You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Incorrect overload selected for Array(Array(T)) vs Array(T) #8973
Not sure if it's relevant, but the bug appears inconsistent.
def foo(a : Array(T)) forall T
a.each_with_index do |a, i|
p "first call for #{a} of class #{a.class}"
foo(a)
end
end
def foo(a : T) forall T
p "second call for #{a} of class #{a.class}"
p a
end
foo([1, [2, [[3]], [4, [[5]]]]])
"first call for 1 of class Int32"
"second call for 1 of class Int32"
1
"first call for [2, [[3]], [4, [[5]]]] of class Array(Array(Array(Array(Int32)) | Int32) | Array(Array(Int32)) | Int32)"
"first call for 2 of class Int32"
"second call for 2 of class Int32"
2
"first call for [[3]] of class Array(Array(Int32))"
"second call for [[3]] of class Array(Array(Int32))"
[[3]]
"first call for [4, [[5]]] of class Array(Array(Array(Int32)) | Int32)"
"first call for 4 of class Int32"
"second call for 4 of class Int32"
4
"first call for [[5]] of class Array(Array(Int32))"
"first call for [5] of class Array(Int32)"
"first call for 5 of class Int32"
"second call for 5 of class Int32"
5
I'm not enough of a mathematician to do this correctly, but bear with me.
So numbers belong to the set N. [[3]] and [[5]] belong to the same set or type A for N ⊂ A. [2, [[3]]] and [4, [[5]]] would also belong to the same set or type B for which (A | N) ⊂ B.
So the array [2, [[3]], [4, [[5]]] is a collection C which is B(N | B). When the compiler sees this pattern it determines that N | B is the unit type T. When calling the first method on C it will call the second method on any B that is not B(B). Because [[3]] is A and is not B, the second method is called. Because [4, [[5]]] is B, the first method is called. For this instance the unit type T is no longer N | B, but rather N | A. Because A is defined as containing N but not itself, N is the unit type T for the first method called on A.
If any of that makes sense and is factual, the compiler's actions are surprising but correct. The expected output would only be possible if the compiler waits to define the unit type T until all possible recursion is done.
Recursion might be the wrong term for the algorithm process, but it does seem to have something to do with the recursive union type.
B(A|B) triggers it when C(A|B) does not.
Doing some old-fashioned puts debugging in the compiler and discovered this when I ran it against @stevensonmt's example code:
Crystal::Matches(
@cover=true,
@matches=
[#<Crystal::Match:0x10cf32330
@arg_types=[Array(Array(Int32))],
@context=
#<Crystal::MatchContext:0x10cf52900
@def_free_vars=["T"],
@defining_type=main,
@free_vars={"T" => Array(Int32)},
@instantiated_type=main,
@strict=false>,
@def=
def foo(a : Array(T)) forall T
a.each_with_index do |a, i|
p("first call for #{a} of class #{a.class}")
foo(a)
end
end,
@named_arg_types=nil>,
#<Crystal::Match:0x10cf32300
@arg_types=[(Array(Array(Int32)) | Int32)],
@context=
#<Crystal::MatchContext:0x10cf52800
@def_free_vars=["T"],
@defining_type=main,
@free_vars={"T" => (Array(Array(Int32)) | Int32)},
@instantiated_type=main,
@strict=false>,
@def=
def foo(a : T) forall T
p("second call for #{a} of class #{a.class}")
p(a)
end,
@named_arg_types=nil>],
@owner=main,
@success=true)
Crystal::Type#lookup_matches_without_parents is correctly matching Array(Array(Int32)) (the [[3]]) to the first method but also lumping it in with Int32 in a second match. I haven't figured out how that match is used yet, but it seems to be choosing the second match somewhere.
deffoo(a : Array(T)) forallT
a.each_with_index do |a, i|
p"first call for #{a} of class #{a.class}"
foo(a)
endenddeffoo(a : Array(Array(Int32)))
a.each_with_index do |a, i|
p"first call for #{a} of class #{a.class}"
foo(a)
endenddeffoo(a : T) forallTp"second call for #{a} of class #{a.class}"p a
end
foo([1, [2, [[3]], [4, [[5]]]]])
Then I get this:
"first call for 1 of class Int32"
"second call for 1 of class Int32"
1
"first call for [2, [[3]], [4, [[5]]]] of class Array(Array(Array(Array(Int32)) | Int32) | Array(Array(Int32)) | Int32)"
"first call for 2 of class Int32"
"second call for 2 of class Int32"
2
"first call for [[3]] of class Array(Array(Int32))"
"first call for [3] of class Array(Int32)"
"first call for 3 of class Int32"
"second call for 3 of class Int32"
3
"first call for [4, [[5]]] of class Array(Array(Array(Int32)) | Int32)"
"first call for 4 of class Int32"
"second call for 4 of class Int32"
4
"first call for [[5]] of class Array(Array(Int32))"
"first call for [5] of class Array(Int32)"
"first call for 5 of class Int32"
"second call for 5 of class Int32"
5
The problem is that this is a bug where the problem is not the code. We need to define how we want it to work first.
Here's the code again:
classGen(T)
enddeffoo(a : Gen(T)) forallTputs"one"enddeffoo(a)
puts"two"end
z =Gen(Int32).new.as(Gen(Gen(Int32)) |Gen(Int32))
foo(z)
The type of z is a union of:
Gen(Gen(Int32))
Gen(Int32)
When the compiler tries to match that union type against Gen(T) it will do this:
Take the first type in the union: Gen(Gen(Int32)). It matches against Gen(T). T becomes Gen(Int32).
The the second type in the union: Gen(Int32). Try to match it against Gen(T). However, we said T is Gen(Int32). So it's matching against Gen(Gen(Int32)). No match.
The only match is the first one, Gen(Gen(Int32)), where T is Gen(Int32).
Then the next overload is considered, and the entire union matches.
That's why we are seeing that behavior.
How it should work? Should T become the union of those types? But if T is Gen(Int32) | Int32 it would mean we had Gen(Gen(Int32) | Int32) in the first place, which is not the case.
We basically need to dispatch the union type to two different instantiations of the same overload. But the code never does that in any place of the compiler.
So this is a huge change, if we want to implement it.
Or... other ideas of how this should match?
Reacted by stevensonmt, Faustino Aguilar, ~kb and Caspian Baska
This makes me question whether I understand forall. My mental model is that it instantiates a new overload for every single type it was called with, including splitting unions. For example:
deffoo(a : T) forallTend
foo [1, "b"].first
In this example, it seems that there would be 2 overloads for foo — one for Int32 and one for String. Is that correct or is there just one for Int32 | String?
It works exactly the same way without forall. forall doesn't change which instantiations occur, it just increases the expressiveness of type restrictions.
Exactly. forall just lets you talk about that T and use it in code later on. But it doesn't change how things match.
That said, in my mind forall should also expand union types and provide one overload for each, but that's not the case. And maybe we shouldn't do it. Should we do it for each subclass of a type hierarchy too?
When the compiler tries to match that union type against Gen(T) it will do this:
Take the first type in the union: Gen(Gen(Int32)). It matches against Gen(T). T becomes Gen(Int32).
The the second type in the union: Gen(Int32). Try to match it against Gen(T). However, we said T is Gen(Int32). So it's matching against Gen(Gen(Int32)). No match.
The only match is the first one, Gen(Gen(Int32)), where T is Gen(Int32).
Then the next overload is considered, and the entire union matches.
That's why we are seeing that behavior.
How it should work? Should T become the union of those types? But if T is Gen(Int32) | Int32 it would mean we had Gen(Gen(Int32) | Int32) in the first place, which is not the case.
We basically need to dispatch the union type to two different instantiations of the same overload. But the code never does that in any place of the compiler.
So this is a huge change, if we want to implement it.
Or... other ideas of how this should match?
Thanks for the clear delineation of the problem. I never expected to uncover such a fundamental bug. How important is this corner case?
There's lucky framework, amber, athena and thousand of shards out there. We have a compiler. There's the standard library with a lot of features.
It's a bug but it's not preventing people from creating wonderful things. I think this is a very low priority bug. It's pretty rare to have to match against Array(T) and wanting to get that T. It's a bit leaky. And if you really need to do that, you can do it by doing typeof(array.first) or something like that too. So there are workarounds.
It works exactly the same way without forall. forall doesn't change which instantiations occur, it just increases the expressiveness of type restrictions.
Exactly. forall just lets you talk about that T and use it in code later on. But it doesn't change how things match.
Ahh, okay, similar to the idea of &block vs yield (purposely ignoring the block inlining for yield for this analogy), where they both match the exact same way but the &block version gives you a reference to the block itself but there's only one instantiation of the &block overload?
as dynamic dispatch would generate separate overloads for T = Int32 and T = Char. IMO this is undesirable as it leads to semantic consequences like the following:
So I am inclined towards not allowing dynamic dispatch here; when one writes Gen(T) forall T, the static type of the argument must never be a union. Instead there is the following way to perform dynamic dispatch on a static union type:
classGen(T)
enddeffoo_impl(a : Gen(T)) forallTpTenddeffoo(x : T) forallT
{%begin%}
case x
{%for t inT.union_types %}
in {{ t }} then foo_impl(x)
{%end%}
end
{%end%}
end
foo(Gen(Int32).new ||Gen(Char).new) # => Int32
foo(Gen(Char).new ||Gen(Int32).new) # => Char
foo(1) # Error: no overload matches 'foo_impl' with type Int32
Perhaps we could extract this idiom into a pseudo-method. A regular method probably wouldn't work; if we replace foo_impl(x) with yield x, the static type of the block argument would still be a union of Gens.
Reacted by Ary Borenszweig, Julien Reichardt and Caspian Baska
If we follow the no dispatches approach, both calls should print 3, because Array(Array(T)) forall T and Array(T) forall T never match the static union type. But if the first overload doesn't involve a free var:
deffoo(x : Array(Array(Int32)))
puts"1, #{Int32}"enddeffoo(x : Array(T)) forallTputs"2, #{T}"end
foo([[1]] || [1]) # Error: no overload matches 'foo' with type (Array(Array(Int32)) | Array(Int32))
foo([1] || [[1]]) # Error: no overload matches 'foo' with type (Array(Array(Int32)) | Array(Int32))
then the calls should print 1 and 2 respectively; that is, we still want to perform union dispatch in the same way the following is supported:
We could perform dispatch over free variables only when the usual unification fails. More specifically, suppose we have:
deffoo(x : Array(T), y : Array(T)) forallTend
If a type like Array(Int32) | Array(String) is used then unification will fail. In this case, we expand each restriction to a fictitious Union of copies of the original restriction, with the free variables in each copy renamed:
deffoo(
x : Array(T_11) |Array(T_12) |Array(T_13) | ... ,
y : Array(T_21) |Array(T_22) |Array(T_23) | ...
) forallT_ij...
end
These Unions are matched against the argument types and every T_*j produces a different overload to be dispatched, with the additional requirement that if the same free variable is used in multiple parameters, only the common matches that appear on all such parameters (i.e. T_1* & T_2* & T_3* & ...) count as successful substitutions:
# T_1* = {Int32}# T_2* = {Int32}# T = {Int32}
foo([1], [1]) # note that normal unification should also succeed here# T_1* = {Int32, String}# T_2* = {Int32, String}# T = {Int32, String}# missing overloads for `foo([1], [""])` and `foo([""], [1])`
foo([1] || [""], [1] || [""])
# T_1* = {Int32, Char}# T_2* = {Int32, String}# T = {Int32}# missing overloads for `foo([1], [""])`, `foo(['a'], [1])`, and `foo(['a'], [""])`
foo([1] || ['a'], [1] || [""])
# T_1* = {Int32 | String}# T_2* = {String}# T = ø# missing overloads for everything
foo([1||""], [""])
defbar(x : Array(T), y : Array(U)) forallT, Uend# T_1* = {Int32, String}# U_1* = {Int32, String}# T × U = {(Int32, Int32), (Int32, String), (String, Int32), (String, String)}
bar([1] || [""], [1] || [""])
# T_1* = {Int32 | String}# U_1* = {String}# T × U = {((Int32 | String), String)}
bar([1||""], [""])
At this stage any bare free variable restrictions expand union types to their power sets: (a similar treatment could be done for Tuple(T) and NamedTuple(a: T) provided they don't appear within other generics)
The standard library, to my knowledge, doesn't do this outside the Atomic primitives, but these also have Pointer(T) restrictions so matching would remain unaffected.
Hm, I'm wondering if this would actually be correct.
I would interpret def foo(x : T, y : T) forall T that both arguments are supposed to have identical types. And not that the compiler should construct a union type to fit different types.
On the other hand, you can also just cast both arguments to Int32 | String explicitly. Maybe having the compiler do that implicitly wouldn't be too bad. But it still seems unintuitive.
What's the point in restricting both arguments to the same type then, if any pair of types would be acceptable?
Activity
asterite commentedon Mar 29, 2020
Without the dependency on
Array:stevensonmt commentedon Mar 29, 2020
Not sure if it's relevant, but the bug appears inconsistent.
stevensonmt commentedon Mar 30, 2020
I'm not enough of a mathematician to do this correctly, but bear with me.
So numbers belong to the set N.
[[3]]and[[5]]belong to the same set or type A for N ⊂ A.[2, [[3]]]and[4, [[5]]]would also belong to the same set or type B for which (A | N) ⊂ B.So the array
[2, [[3]], [4, [[5]]]is a collection C which is B(N | B). When the compiler sees this pattern it determines that N | B is the unit type T. When calling the first method on C it will call the second method on any B that is not B(B). Because[[3]]is A and is not B, the second method is called. Because[4, [[5]]]is B, the first method is called. For this instance the unit type T is no longer N | B, but rather N | A. Because A is defined as containing N but not itself, N is the unit type T for the first method called on A.If any of that makes sense and is factual, the compiler's actions are surprising but correct. The expected output would only be possible if the compiler waits to define the unit type T until all possible recursion is done.
asterite commentedon Mar 30, 2020
It might be a correct guess but there's no recursion going on.
It works wrong because we have a bug in our code.
stevensonmt commentedon Mar 30, 2020
Recursion might be the wrong term for the algorithm process, but it does seem to have something to do with the recursive union type.
B(A|B) triggers it when C(A|B) does not.
jgaskins commentedon Mar 31, 2020
Doing some old-fashioned puts debugging in the compiler and discovered this when I ran it against @stevensonmt's example code:
Crystal::Type#lookup_matches_without_parentsis correctly matchingArray(Array(Int32))(the[[3]]) to the first method but also lumping it in withInt32in a second match. I haven't figured out how that match is used yet, but it seems to be choosing the second match somewhere.faustinoaq commentedon Mar 31, 2020
Interesting bug, if I try this: https://carc.in/#/r/8t59
Then I get this:
jgaskins commentedon Mar 31, 2020
Looks like the bug is somewhere in this file. Haven't found it yet. I'll look some more after work today.
asterite commentedon Mar 31, 2020
The problem is that this is a bug where the problem is not the code. We need to define how we want it to work first.
Here's the code again:
The type of
zis a union of:Gen(Gen(Int32))Gen(Int32)When the compiler tries to match that union type against
Gen(T)it will do this:Gen(Gen(Int32)). It matches againstGen(T).TbecomesGen(Int32).Gen(Int32). Try to match it againstGen(T). However, we saidTisGen(Int32). So it's matching againstGen(Gen(Int32)). No match.The only match is the first one,
Gen(Gen(Int32)), whereTisGen(Int32).Then the next overload is considered, and the entire union matches.
That's why we are seeing that behavior.
How it should work? Should
Tbecome the union of those types? But ifTisGen(Int32) | Int32it would mean we hadGen(Gen(Int32) | Int32)in the first place, which is not the case.We basically need to dispatch the union type to two different instantiations of the same overload. But the code never does that in any place of the compiler.
So this is a huge change, if we want to implement it.
Or... other ideas of how this should match?
jgaskins commentedon Mar 31, 2020
This makes me question whether I understand
forall. My mental model is that it instantiates a new overload for every single type it was called with, including splitting unions. For example:In this example, it seems that there would be 2 overloads for
foo— one forInt32and one forString. Is that correct or is there just one forInt32 | String?RX14 commentedon Mar 31, 2020
@jgaskins there is just one, for the union type.
It works exactly the same way without
forall.foralldoesn't change which instantiations occur, it just increases the expressiveness of type restrictions.asterite commentedon Mar 31, 2020
Exactly.
foralljust lets you talk about thatTand use it in code later on. But it doesn't change how things match.That said, in my mind
forallshould also expand union types and provide one overload for each, but that's not the case. And maybe we shouldn't do it. Should we do it for each subclass of a type hierarchy too?Another thing to consider:
Currently fails:
It's because
Gen(Char)matches,TbecomesChar, thenGen(Int32)doesn't match and there's no overload to cover that.The entire situation is very confusing.
My advice is to avoid using
forallif not needed. In the example in the forums theTwasn't needed at all.stevensonmt commentedon Mar 31, 2020
Thanks for the clear delineation of the problem. I never expected to uncover such a fundamental bug. How important is this corner case?
asterite commentedon Mar 31, 2020
There's lucky framework, amber, athena and thousand of shards out there. We have a compiler. There's the standard library with a lot of features.
It's a bug but it's not preventing people from creating wonderful things. I think this is a very low priority bug. It's pretty rare to have to match against
Array(T)and wanting to get thatT. It's a bit leaky. And if you really need to do that, you can do it by doingtypeof(array.first)or something like that too. So there are workarounds.jgaskins commentedon Mar 31, 2020
Ahh, okay, similar to the idea of
&blockvsyield(purposely ignoring the block inlining foryieldfor this analogy), where they both match the exact same way but the&blockversion gives you a reference to the block itself but there's only one instantiation of the&blockoverload?HertzDevil commentedon Jan 30, 2021
If we dispatch here, the same can be said about a plain free var restriction:
as dynamic dispatch would generate separate overloads for
T = Int32andT = Char. IMO this is undesirable as it leads to semantic consequences like the following:So I am inclined towards not allowing dynamic dispatch here; when one writes
Gen(T) forall T, the static type of the argument must never be a union. Instead there is the following way to perform dynamic dispatch on a static union type:Perhaps we could extract this idiom into a pseudo-method. A regular method probably wouldn't work; if we replace
foo_impl(x)withyield x, the static type of the block argument would still be a union ofGens.asterite commentedon Jan 30, 2021
Great analysis, thank you! And I agree with you.
j8r commentedon Jan 30, 2021
This issue is probably the same as mine, which does not involve generics:
As @HertzDevil suggested, I did have to implement a method to de-unionize.
HertzDevil commentedon Feb 13, 2021
So if
Genisn't equivalent toGen(T) forall Tin type restrictions, which one shouldGen(_)be equivalent to?HertzDevil commentedon Mar 10, 2021
There is also the following:
If we follow the no dispatches approach, both calls should print 3, because
Array(Array(T)) forall TandArray(T) forall Tnever match the static union type. But if the first overload doesn't involve a free var:then the calls should print 1 and 2 respectively; that is, we still want to perform union dispatch in the same way the following is supported:
HertzDevil commentedon Sep 1, 2021
We could perform dispatch over free variables only when the usual unification fails. More specifically, suppose we have:
If a type like
Array(Int32) | Array(String)is used then unification will fail. In this case, we expand each restriction to a fictitiousUnionof copies of the original restriction, with the free variables in each copy renamed:These
Unions are matched against the argument types and everyT_*jproduces a different overload to be dispatched, with the additional requirement that if the same free variable is used in multiple parameters, only the common matches that appear on all such parameters (i.e.T_1* & T_2* & T_3* & ...) count as successful substitutions:At this stage any bare free variable restrictions expand union types to their power sets: (a similar treatment could be done for
Tuple(T)andNamedTuple(a: T)provided they don't appear within other generics)These unions and power sets are only needed for a formal definition; the compiler probably won't create them.
I would like to add that the following should unify normally for any pairs of argument types (it currently doesn't):
The standard library, to my knowledge, doesn't do this outside the
Atomicprimitives, but these also havePointer(T)restrictions so matching would remain unaffected.straight-shoota commentedon Sep 1, 2021
Hm, I'm wondering if this would actually be correct.
I would interpret
def foo(x : T, y : T) forall Tthat both arguments are supposed to have identical types. And not that the compiler should construct a union type to fit different types.On the other hand, you can also just cast both arguments to
Int32 | Stringexplicitly. Maybe having the compiler do that implicitly wouldn't be too bad. But it still seems unintuitive.What's the point in restricting both arguments to the same type then, if any pair of types would be acceptable?
HertzDevil commentedon Jan 7, 2022
Revisiting this issue and you're probably right that
Tshould restrict exactly to the argument type. In that case we have: