- Type inference
- With type restrictions
- Without type restrictions
- 1. Assigning a literal value
- 2. Assigning the result of invoking the class method
new
- 3. Assigning a variable that is a method argument with a type restriction
- 4. Assigning the result of a class method that has a return type restriction
- 5. Assigning a variable that is a method argument with a default value
- 6. Assigning the result of invoking a
lib
function - 7. Using an
out
lib expression - Other rules
Type inference
Crystal’s philosophy is to require as few type restrictions as possible. However, some restrictions are required.
Consider a class definition like this:
class Person
def initialize(@name)
@age = 0
end
end
We can quickly see that @age
is an integer, but we don’t know the type of @name
. The compiler could infer its type from all uses of the Person
class. However, doing so has a few issues:
- The type is not obvious for a human reading the code: they would also have to check all uses of
Person
to find this out. - Some compiler optimizations, like having to analyze a method just once, and incremental compilation, are nearly impossible to do.
As a code base grows, these issues gain more relevance: understanding a project becomes harder, and compile times become unbearable.
For this reason, Crystal needs to know, in an obvious way (as obvious as to a human), the types of instance and class variables.
There are several ways to let Crystal know this.
With type restrictions
The easiest, but probably most tedious, way is to use explicit type restrictions.
class Person
@name : String
@age : Int32
def initialize(@name)
@age = 0
end
end
Without type restrictions
If you omit an explicit type restriction, the compiler will try to infer the type of instance and class variables using a bunch of syntactic rules.
For a given instance/class variable, when a rule can be applied and a type can be guessed, the type is added to a set. When no more rules can be applied, the inferred type will be the union of those types. Additionally, if the compiler infers that an instance variable isn’t always initialized, it will also include the Nil type.
The rules are many, but usually the first three are most used. There’s no need to remember them all. If the compiler gives an error saying that the type of an instance variable can’t be inferred you can always add an explicit type restriction.
The following rules only mention instance variables, but they apply to class variables as well. They are:
1. Assigning a literal value
When a literal is assigned to an instance variable, the literal’s type is added to the set. All literals have an associated type.
In the following example, @name
is inferred to be String
and @age
to be Int32
.
class Person
def initialize
@name = "John Doe"
@age = 0
end
end
This rule, and every following rule, will also be applied in methods other than initialize
. For example:
class SomeObject
def lucky_number
@lucky_number = 42
end
end
In the above case, @lucky_number
will be inferred to be Int32 | Nil
: Int32
because 42 was assigned to it, and Nil
because it wasn’t assigned in all of the class’ initialize methods.
2. Assigning the result of invoking the class method new
When an expression like Type.new(...)
is assigned to an instance variable, the type Type
is added to the set.
In the following example, @address
is inferred to be Address
.
class Person
def initialize
@address = Address.new("somewhere")
end
end
This also is applied to generic types. Here @values
is inferred to be Array(Int32)
.
class Something
def initialize
@values = Array(Int32).new
end
end
Note: a new
method might be redefined by a type. In that case the inferred type will be the one returned by new
, if it can be inferred using some of the next rules.
3. Assigning a variable that is a method argument with a type restriction
In the following example @name
is inferred to be String
because the method argument name
has a type restriction of type String
, and that argument is assigned to @name
.
class Person
def initialize(name : String)
@name = name
end
end
Note that the name of the method argument is not important; this works as well:
class Person
def initialize(obj : String)
@name = obj
end
end
Using the shorter syntax to assign an instance variable from a method argument has the same effect:
class Person
def initialize(@name : String)
end
end
Also note that the compiler doesn’t check whether a method argument is reassigned a different value:
class Person
def initialize(name : String)
name = 1
@name = name
end
end
In the above case, the compiler will still infer @name
to be String
, and later will give a compile time error, when fully typing that method, saying that Int32
can’t be assigned to a variable of type String
. Use an explicit type restriction if @name
isn’t supposed to be a String
.
4. Assigning the result of a class method that has a return type restriction
In the following example, @address
is inferred to be Address
, because the class method Address.unknown
has a return type restriction of Address
.
class Person
def initialize
@address = Address.unknown
end
end
class Address
def self.unknown : Address
new("unknown")
end
def initialize(@name : String)
end
end
In fact, the above code doesn’t need the return type restriction in self.unknown
. The reason is that the compiler will also look at a class method’s body and if it can apply one of the previous rules (it’s a new
method, or it’s a literal, etc.) it will infer the type from that expression. So, the above can be simply written like this:
class Person
def initialize
@address = Address.unknown
end
end
class Address
# No need for a return type restriction here
def self.unknown
new("unknown")
end
def initialize(@name : String)
end
end
This extra rule is very convenient because it’s very common to have “constructor-like” class methods in addition to new
.
5. Assigning a variable that is a method argument with a default value
In the following example, because the default value of name
is a string literal, and it’s later assigned to @name
, String
will be added to the set of inferred types.
class Person
def initialize(name = "John Doe")
@name = name
end
end
This of course also works with the short syntax:
class Person
def initialize(@name = "John Doe")
end
end
The default value can also be a Type.new(...)
method or a class method with a return type restriction.
6. Assigning the result of invoking a lib
function
Because a lib function must have explicit types, the compiler can use the return type when assigning it to an instance variable.
In the following example @age
is inferred to be Int32
.
class Person
def initialize
@age = LibPerson.compute_default_age
end
end
lib LibPerson
fun compute_default_age : Int32
end
7. Using an out
lib expression
Because a lib function must have explicit types, the compiler can use the out
argument’s type, which should be a pointer type, and use the dereferenced type as a guess.
In the following example @age
is inferred to be Int32
.
class Person
def initialize
LibPerson.compute_default_age(out @age)
end
end
lib LibPerson
fun compute_default_age(age_ptr : Int32*)
end
Other rules
The compiler will try to be as smart as possible to require less explicit type restrictions. For example, if assigning an if
expression, type will be inferred from the then
and else
branches:
class Person
def initialize
@age = some_condition ? 1 : 2
end
end
Because the if
above (well, technically a ternary operator, but it’s similar to an if
) has integer literals, @age
is successfully inferred to be Int32
without requiring a redundant type restriction.
Another case is ||
and ||=
:
class SomeObject
def lucky_number
@lucky_number ||= 42
end
end
In the above example @lucky_number
will be inferred to be Int32 | Nil
. This is very useful for lazily initialized variables.
Constants will also be followed, as it’s pretty simple for the compiler (and a human) to do so.
class SomeObject
DEFAULT_LUCKY_NUMBER = 42
def initialize(@lucky_number = DEFAULT_LUCKY_NUMBER)
end
end
Here rule 5 (argument’s default value) is used, and because the constant resolves to an integer literal, @lucky_number
is inferred to be Int32
.