elixir
elixir copied to clipboard
Implement guard-safe `MapSet.is_member/2`
Per discussion on elixir-lang-core, this provides a guard-safe membership check for MapSets without modifying the existing MapSet.member?/2.
It is probably not that relevant to most people, especially with the new type system coming up, but I think this probably breaks Dialyzer's "opaqueness" access rules and will cause bubbling up "this function will never return" warnings when used
@michallepicki: I think this probably breaks Dialyzer's "opaqueness" access rules and will cause bubbling up "this function will never return" warnings when used
Hm, I do indeed get this warning when using this macro copy-pasted into a project:
The guard test is_map_key(_connection@1::any(),'Elixir.MapSet':internal(_) | sets:set(_))
breaks the opaqueness of its argument.
It is probably not that relevant to most people, especially with the new type system coming up, but I think this probably breaks Dialyzer's "opaqueness" access rules and will cause bubbling up "this function will never return" warnings when used
Good point, we could use quote generated: true do to fix this hopefully?
Good point, we could use quote generated: true do to fix this hopefully?
If this causes "bubbling up" no_return warnings to all functions calling this code (I haven't checked), then I think marking it as "generated" wouldn't help (see my proposal to change this Dialyzer behavior: https://github.com/erlang/otp/issues/5503 )
edit: I tried to come up with code that causes more Dialyzer issues and failed. 🎉 So maybe when the opaqueness violation happens in pattern matching like here (and not when constructing or returning data), it doesn't affect Dialyzer that much
It looks like Dialyzer does not bubble up no_returns from this in my experimentation, but it does warn about opaqueness (and generated: true does not fix). Removing MapSet's @opaque internal() type also does not fix because it simply wraps the opaque type :sets.set(value).
Suggestion accepted, and documentation of failure modes added!
Closed questions
I think this PR as-is has answered the finer points:
-
Do we raise for non-mapsets?
Yes, because
MapSet.member?andis_map_keydo -
What do we raise?
ArgumentError -
How do we behave in guards?
We still "raise" (via
is_struct() or :fail)This can lead to suprising behaviour (see: https://github.com/elixir-lang/elixir/pull/7063) but is internally consistent:
- the non-guard version raises because
MapSet.member?andis_map_keydo - so the guard version does as well
- and the well-documented if not well-known behaviour of errors in guards is to bail on the current
whenclause
- the non-guard version raises because
-
Do we document the failure modes?
With the current behaviour, I don't think we need to elaborate any further, despite the footgun.
- guard failures short-circuiting is well-documented
- current
is_member/2docs say it behaves like (not committing to raising the same exception type as)member?/2 member/2does not document its failure mode here, nor exception type, so by not defining this behaviour in documentation, we leave it open to changing without commitment
Neutral questions
Insofar as how we actually do the is_struct check in both guard form and normal form, I have a weak preference (is_struct in both for parity) but there have been a few other preferences presented in code review here (ex. .__struct__ == MapSet in the guard form, and pattern matching on cond in the normal form, by @josevalim).
At the end of the day, I'm happy to ship it with any variant.
Remaining wart
No variant I've tried seems to prevent dialyzer from warning on accessing the underlying opaque type calls, which I don't love, as part of the goal of this PR was to enable folk to do membership testing in guards without worrying about the private underlying type...
But a dialyzer warning is better than requiring folk to use the internal struct keys of MapSet for guard-safety, so I think it's worth the compromise. Dialyzer config can always prevent the warning. I'm not sure what more can be done on this front.
To clairfy the dialyzer warning I am seeing, they are only when:
is_member/2is used (with any struct-checking variant proposed)- inside another
defguard is_xxx - warning at the callsite of where
is_xxxis used
Other usages don't seem to warn.
I'm learning I have more to learn about @opaque types, as this was surprising to me! But I guess it makes sense, the full macro expansion places code that accesses the opaque type fully into a module other than MapSet.
I am sorry folks, but I am still not confident we should merge this. For example, there is no guarantee the most efficient MapSet implementation will always have a single map at the root. Therefore, I will close this.