Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autodetect types of empty array/hash/splat arguments properly #9774

Open
LeXofLeviafan opened this issue Sep 26, 2020 · 2 comments
Open

Autodetect types of empty array/hash/splat arguments properly #9774

LeXofLeviafan opened this issue Sep 26, 2020 · 2 comments

Comments

@LeXofLeviafan
Copy link

When passing an array argument into a function, the type is autodetected properly even with vague (union) type parameter:

def foo (bar : Array(String | Int))
end
foo([42])

Same thing for hashes:

def foo (bar : Hash(String | Symbol, Int | Nil))
end
foo({:answer => 42})

However, regardless of whether or not we use union type parameters in the function signature, an attempt to pass empty array or hash without explicitly specifying the type outright fails:

def foo (bar : Array(String | Int))
end
foo([]) # Error: for empty arrays use '[] of ElementType'
def foo (bar : Hash(String, Int))
end
foo({}) # Error: for empty hashes use '{} of KeyType => ValueType'

Considering that all the required type information is already available, this is rather annoying.

Another rather similar issue comes with splat arguments. Suppose we have a recursive Tree class, with each node being either another Tree or a String, with parameters passed as a splat:

class Tree
  property children : Array(String | Tree)
  def initialize (*children)
    @children = children.map {|x| x.as(String | Tree)}.to_a # must use .map with .as to ensure matching Array type
  end
end
Tree.new "42" # #<Tree:0x7fd94e903e80 @children=["42"]>

.map {|x| x.as(String | Tree)}.to_a produces an Array(String | Tree) while retaining type safety (trying to pass a number results in “can't cast Int32 to (String | Tree)” error).
When trying to pass an empty splat, however, that approach fails:

Tree.new # Error: instance variable '@children' of Tree must be Array(String | Tree), not Array(NoReturn)

Incidentally, when passing to class methods (regardless if you're passing *children : String | Tree, children : Array(String | Tree), or @children), it also invariably rejects union subtypes (and empty splats as well):

class Tree
  property children : Array(String | Tree)
  def initialize (@children)
  end
end
Tree.new ["42"] # Error: instance variable '@children' of Tree must be Array(String | Tree), not Array(String)
class Tree
  property children : Array(String | Tree)
  def initialize (*children : String | Tree)
    @children = children.to_a
  end
end
Tree.new # Error: no overload matches 'Tree.new'

(Also, when using in class property type, it claims that Int is not supported in unions.)

@HertzDevil
Copy link
Contributor

The first two examples are not supposed to work if we go down the route of making generic type arguments invariant by default. (They may still work if autocasting of array and hash literals is supported, see #10188.)

The empty [] can probably be allowed. The {} won't happen as it is ambiguous with the empty tuple and the empty block.

The splat-related issues are a duplicate of #9184.

@HertzDevil
Copy link
Contributor

HertzDevil commented Aug 13, 2021

Informally speaking, if the type of [] in Crystal is Array(T) forall T, with the free variable attached to the type itself, then it will unify with restrictions just fine, except that both parameters and arguments may now have their own free variables:

def foo(x : Array(Int32)); end
foo([]) # T = Int32

def bar(x : Array(U)) forall U; end
bar([]) # T = U (undefined constant U)

def baz(x : U, y : Array(U)) forall U; end
baz('a', []) # T = U = Char

In fact this is what certain FP languages such as Haskell do to their empty lists, but it is certainly overkill to introduce universally quantified types to Crystal just because of [], and also very difficult to make type inference work on things like [] << 1. So empty arrays should be limited to autocasts and the macro language (see #10195), with the following semantics for the former:

  • [] has no exact matches, not even Array(NoReturn).
  • [] partially matches Array(T) for any type T, if that type can be instantiated (e.g. not Array(Array)); the autocast expression is simply Array(T).new.
  • [] does not partially match subclasses of Array, should one attempt to subclass it.
  • [] does not partially match Array(T) if T is a free variable for a def restriction.
  • The existing ambiguity checks for autocast literals also apply to [], thus it is a compilation error to match it against Array(Int32) | Array(Char).
  • [] cannot be assigned to a constant, because constants cannot have a restriction.

Any method call with a [] argument, therefore, always fails to match before autocasts are considered.


For the last example in the OP, one way to write it is as follows:

class Tree
  property children : Array(String | Tree)

  def initialize
    @children = [] of String | Tree
  end

  def initialize(*children : String | Tree)
    @children = [*children] of String | Tree
  end
end

If [] autocasts, one may instead write:

class Tree
  property children : Array(String | Tree) = []

  def initialize
  end

  def initialize(*children : String | Tree)
    # okay, `Array#concat` accepts covariant `Array` arguments
    @children.concat(children)
  end
end

(The "no overload matches" error isn't really related to type covariance; a splat parameter with a non-splat restriction always requires at least one argument.)

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

No branches or pull requests

3 participants