Skip to content

Add Map curry/uncurry utils - #1239

Open
alexfmpe wants to merge 1 commit into
haskell:masterfrom
alexfmpe:curry-map
Open

Add Map curry/uncurry utils#1239
alexfmpe wants to merge 1 commit into
haskell:masterfrom
alexfmpe:curry-map

Conversation

@alexfmpe

Copy link
Copy Markdown
Contributor

I've been using a curryMap on a project of mine and realised at some point it could be made O(n).
Given it's non-trivial how to do it right, upstreaming seems like it makes sense.


prop_curryMap :: Map Int (Map Int A) -> Property
prop_curryMap m = m' === uncurryMap (curryMap m')
where m' = uncurryMap m

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to simply have

prop_curryMap :: Map (Int, Int) A -> Property
prop_curryMap m = m === curryMap (uncurryMap m)

but that yields • No instance for ‘IsInt (Int, Int)’ for some reason.

Comment thread containers/src/Data/Map/Internal.hs Outdated
--
-- @since FIXME
curryMap :: (Ord a, Ord b) => Map (a,b) c -> Map a (Map b c)
curryMap m = fmap (fromDescList . ($ [])) $ fromAscListWith (.) $ fmap (\((a,b),c) -> (a, ((b,c):))) $ toAscList m

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surprisingly to me this consistently comes up slightly faster and with significantly lower allocations

  curryMap:   OK
    262  μs ±  15 μs, 2.3 MB allocated, 116 KB copied,  23 MB peak memory

than

  curryMap:   OK
    273  μs ±  13 μs, 2.4 MB allocated, 142 KB copied,  23 MB peak memory

curryMap m = fmap M.fromDescList $ M.fromAscListWith (<>) $ fmap (\((a,b),c) -> (a, [(b,c)])) $ M.toAscList m

on cabal bench map-benchmarks --benchmark-options "-p curryMap +RTS -T".

I guess it's because we skip the pattern match on singleton lists, but I expected the overhead of a closure to be worse. I guess GHC gets to optimize this case a lot more.

Comment on lines +1486 to +1487
curryMap :: (Ord a, Ord b) => Map (a,b) c -> Map a (Map b c)
curryMap m = fmap (fromDescList . ($ [])) $ fromAscListWith (.) $ fmap (\((a,b),c) -> (a, ((b,c):))) $ toAscList m

@Ericson2314 Ericson2314 Aug 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
curryMap :: (Ord a, Ord b) => Map (a,b) c -> Map a (Map b c)
curryMap m = fmap (fromDescList . ($ [])) $ fromAscListWith (.) $ fmap (\((a,b),c) -> (a, ((b,c):))) $ toAscList m
curry :: (Ord a, Ord b) => Map (a,b) c -> Map a (Map b c)
curry m = fmap (fromDescList . ($ [])) $ fromAscListWith (.) $ fmap (\((a,b),c) -> (a, ((b,c):))) $ toAscList m

I think just this because the intended use-case would be to import qualified (Data.Map.curry) and use that for disambiguation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I forgot to change the names when upstreaming

@meooow25

Copy link
Copy Markdown
Contributor

This is interesting to consider for sure, but I am not not seeing the utility. In what situation do you use it in your project?

@alexfmpe

Copy link
Copy Markdown
Contributor Author

This is interesting to consider for sure, but I am not not seeing the utility. In what situation do you use it in your project?

I am given a Map (a,b) Word and want to then compute some stats over it keyed by a so I do stats . curryMap. e.g.

stats :: Map a (Map b Word) -> Map a (Word, Word)
stats = fmap $ (toEnum . length) &&& sum

I added uncurry purely out of symmetry

IIRC @Ericson2314 also had a FRP use-case? curry-ing Map to create hierarchical UI lists or so?

@meooow25

Copy link
Copy Markdown
Contributor

How do you have a Map (a,b) Word in the first place? I think that's the part which seems most like a wart to me. Almost all functions in the interface are polymorphic on the key type, which is a good property to have since the users can use their types as keys.

Consider that somebody wants

data Foo = Foo !Bar !Baz deriving Ord

curryFooMap :: Map Foo a -> Map Bar (Map Baz a)
curryFooMap = ??

but they are unable to use curryMap because it's not (a,b), though they should ideally be able to use what is offered from the Map interface.

@alexfmpe

alexfmpe commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

It goes something like this

  1. build a Map ByteString c
  2. parse the ByteString into (a,b)
  3. call stats from above comment

(1) is done by streaming lines from a file (with Map.insertWith (+) to count ocurrences)
(2) is done by going via List and mapMaybe because in theory the parse can fail.

ByteString is basically the concatenation of encoded a and b.
Parsing after is way faster than before regardless of whether I accumulate into Map (a,b) c or Map a (Map b c), presumably because by doing it after Map de-dup there's less much less parsing and Ord on ByteString is pretty fast? So I accumulate, then parse, then curry. Roughly, that gives me

  • 50ms - parse before, flat map
  • 48ms - parse before, nested map
  • 35ms - parse after

Btw, as of #1230, (2) can be done with Map.mapAssocsMonotonic and fromJust. mapAssocsMonotonicMaybe anyone?

Almost all functions in the interface are polymorphic on the key type, which is a good property to have since the users can use their types as keys.

I mean, it is polymorphic in a and b. By that logic we'd also reject Map.catMaybes or a one-pass Map.unzip
Some sort of curryMonotonic :: (a -> (b,c)) -> Map a d -> Map b (Map c d) would pass this criteria, but it's decomposable into curryMonotonic f = curry . mapKeysMonotonic f

@meooow25

Copy link
Copy Markdown
Contributor

Thanks, that sheds some light on it. I think it also echoes my point about it being odd to have (a,b) as the key. In this case what is required overall is

foo :: (ByteString -> Maybe (Key1, Key2)) -> Map ByteString Word -> Map Key1 (Map Key2 Word)

and Map (Key1, Key2) Word is an intermediate which is not strictly necessary.

I'm trying to think if there is a nice way to offer this functionality...

By the way, there is a third implementation you could try using fromAscListUpsert (\x -> maybe [x] (x:)), instead of fromAscListWith (.) or fromAscListWith (++). This is on master but not yet released on Hackage.

I mean, it is polymorphic in a and b. By that logic we'd also reject Map.catMaybes or a one-pass Map.unzip

Yes I do feel the same way about catMaybes and unzip :)

@alexfmpe

Copy link
Copy Markdown
Contributor Author

By the way, there is a third implementation you could try using fromAscListUpsert (\x -> maybe [x] (x:)), instead of fromAscListWith (.) or fromAscListWith (++). This is on master but not yet released on Hackage.

Ooooh nice I've wanted exactly this for something else but couldn't find it. I've been able to exploit some structure of my input to go straight to nested maps which then I accumulate with

IntMap.fromListWith (Map.unionWith (+))

but the Map.unionWith (+) felt wasteful since one of them is always a singleton.

IntMap.fromListUpsert (\(e,c) -> maybe (Map.singleton e c) (Map.insertWith (+) e c))

made for 7% speedup on the merging part. Seems to make no difference for curry besides slightly larger allocations:

fromAscListWith:   OK
  256  μs ±  15 μs, 2.3 MB allocated, 116 KB copied,  23 MB peak memory

fromAscListUpsert:   OK
  256  μs ±  14 μs, 2.4 MB allocated, 119 KB copied,  23 MB peak memory

Yes I do feel the same way about catMaybes and unzip :)

Well, the venerable join :: [[a]] -> [a] also fails the "not polymorphic" criteria.
It's easy to get a sequence of sequences, that's how we view text files, as a sequence of lines which are themselves sequences of chars. It's also the output of many group functions.

Map (Key1, Key2) Word is an intermediate which is not strictly necessary.

It's not strictly needed in my use-case, but having a product/record type as Map key happens all the time no? Even if we were confident there are ways to avoid producing it, one cannot control what third-party APIs return.

I'm trying to think if there is a nice way to offer this functionality...

Tricky. At first glance one might think that offering the small pieces covers every use-case, e.g.

foo :: (a -> Maybe (a1, a2)) -> Map a b -> Map a1 (Map a2 b)
foo f = curryMap . mapKeys f

but notice for IntMap the intermediate does not exist. At most there's

module Data.IntMap
curry :: Map (Int,a) b -> IntMap (Map a b)
uncurry :: IntMap (Map a b) -> Map (Int,a) b

This pattern extends far beyond containers: concatMap exists for Text, but join cannot.

@alexfmpe

Copy link
Copy Markdown
Contributor Author

That last point made me notice there's other possible combinations, since IntMap a ~ Map Int a and Set a ~ Map a ()

curry :: Set (a,b) -> Map a (Set b)
uncurry :: Map a (Set b) -> Set (a,b)
curry :: Set (Int,b) -> IntMap (Set b)
uncurry :: IntMap (Set b) -> Set (Int,b)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants