Skip to content

Add at' and (!~), strict variants of at and (.~) - #1119

Open
jozanek wants to merge 3 commits into
ekmett:masterfrom
jozanek:strict-at-944
Open

Add at' and (!~), strict variants of at and (.~)#1119
jozanek wants to merge 3 commits into
ekmett:masterfrom
jozanek:strict-at-944

Conversation

@jozanek

@jozanek jozanek commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Closes #944.

Problem

ix/at can leave an unevaluated thunk in a strict Data.Map.Strict/Data.HashMap.Strict, because Data.Map.Strict.Map and Data.Map.Lazy.Map are the same type — the Ixed/At instances can't tell which strictness was intended and use lazy inserts. Switching those instances to the strict modules (proposed in #1093) would change behavior for code relying on the current laziness, so this adds opt-in strict combinators instead.

Approach

at' is a strict at: setting through it evaluates the new value (to weak head normal form) instead of storing an unevaluated thunk, so a strict container stays thunk-free. It works for any At type, affects only writes (not reads), and still obeys the lens laws. (!~) is the same idea for (.~).

Two combinators are needed because a strict (.~) alone can't fix at: at's value is wrapped in a Maybe, and evaluating the Just doesn't touch the value inside it, so at' forces that value itself. (!~), for its part, forces its argument before set runs, so it forces even when the optic matches nothing.

For the original ix case, the cleanest fix forces the value only when the key is present (like Data.Map.Strict.adjust): since ix k is at k . traverse, write at' k . traverse .~ v. The Data.Map.Lens docs point to this.

@RyanGlScott RyanGlScott left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, aside from two minor suggestions.

Comment thread tests/hunit.hs Outdated
Comment on lines +355 to +357
throws :: a -> IO Bool
throws thunk = do
r <- try (evaluate thunk >> return ()) :: IO (Either SomeException ())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Slightly simpler:

Suggested change
throws :: a -> IO Bool
throws thunk = do
r <- try (evaluate thunk >> return ()) :: IO (Either SomeException ())
throws :: forall a. a -> IO Bool
throws thunk = do
r <- try (evaluate thunk) :: IO (Either SomeException a)

(Requires enabling ScopedTypeVariables)

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.

done

Comment thread tests/hunit.hs Outdated
Comment on lines +355 to +357
throws :: a -> IO Bool
throws thunk = do
r <- try (evaluate thunk >> return ()) :: IO (Either SomeException ())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This catches SomeException, which means that any exception will be caught. Given that the tests below specifically aim to catch uses of undefined, I wonder if we should instead use a more specific exception type like ErrorCall. (It's unlikely to matter too much in these specific tests, but this might avoid some confusion down the road if we expand the test coverage to include other types of exceptions in the future.)

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.

udpated

Comment thread src/Control/Lens/At.hs
-- >>> at' 1 ?~ "hello" $ Map.empty
-- fromList [(1,"hello")]
at' :: At m => Index m -> Lens' m (Maybe (IxValue m))
at' i f = at i (fmap forceJust . f)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A problem with this is that at' behaves differently from e.g. Data.Map.Strict.insert. In particular, if the code checks for thunks with nothunks i'm quite sure that this at' will still create a (trivial but nevertheless) thunk inside the container.

I'm not sure this is "the" strict at people really want, IMO this isn't it.

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.

Thanks for taking a look! I tried to reproduce the thunk with nothunks and it comes back clean. at' looks identical to Data.Map.Strict.insert:

λ> import Control.Lens
λ> import NoThunks.Class
λ> import Control.Exception (evaluate)
λ> import qualified Data.Map.Strict as M
λ> let base = M.fromList [(1,0)] :: M.Map Int Int
λ> m1 <- evaluate (M.insert 1 (id (1+1)) base)       -- Data.Map.Strict.insert
λ> m2 <- evaluate (base & at' 1 .~ Just (id (2+2)))  -- at'
λ> m3 <- evaluate (base & at  1 .~ Just (id (3+3)))  -- lazy at (control)
λ> mapM (noThunks []) [m1, m2, m3]
[Nothing,Nothing,Just (ThunkInfo {thunkContext = ["Int","Map"], thunkInfo = Nothing})]

at' and Strict.insert are both Nothing. The lazy at is correctly flagged, so the check is genuinely detecting thunks. The reason at' works: at k is alterF, which has to force the result Maybe to WHNF to choose insert vs delete, and at''s Just $! v rides on that.So the value is forced exactly as it's stored (same for IntMap/HashMap).

That said, your larger point stands: at' is strict by derivation, not by contract. Generic seq over the lazy alterF rather than the container's own Data.Map.Strict.alterF. The fully transparent fix is #1093 (strict Ixed/At instances), but that changes behavior for code relying on the current laziness, so I went opt-in here. If you'd prefer strict-by-contract I can back at' with the per-container strict alterFs. Otherwise, given it's demonstrably thunk-free, I'm happy to leave the generic version. Your call.

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.

ix is able to insert undefined in strict (hash)map

3 participants