RFC: variable declaration syntax using `:=`

Currently, declaring vars happens either transparently through a normal assign (but with the downside of allowing the type to be flexible), or via a type declaration (which requires specifying the type explicitly).

I think it could be useful to pull in the variable declaration syntax from Odin (:=), with the behavior that the type will be inferred, but cannot be changed from whatever the type is originally inferred to be. I think this could be clearer when reading code the intention of the original developer, and can prevent subtle issues (like accidentally reusing the same variable within the same scope).

class MyClass
  @my_ivar = 1

  def update(float : Float64)
    # oops the type of @my_ivar is now (Int32 | Float64),
    # and this can happen anywhere
    @my_ivar = float
  end
end

vs

class MyClass
  @my_ivar := 1

  def update(float : Float64)
    @my_ivar = float
    # ^^^^^^^^^^^^^^ error: `@my_ivar` inferred to be type `Int32`, not `Float64`
  end
end

As well, trying to re-declare the same variable should produce a syntax / semantic error:

my_var := 1

# bunches of lines of code

if my_var := read_string
   # ^^^^^^^ error: Cannot redeclare variable `my_var`
end
7 Likes

I really like this!!

I often find myself preferring explicitly typed variables, but that is a bit bothersome currently.

Futhermore, explicit typing for user-made classes etc. can get a bit unwieldy, even more so if modules are involved.

class Pokemon
end

snorlax = Pokemon.new
snorlax = 6 # Valid

jigglypuff : Pokemon = Pokemon.new
jigglypuff = 6 # Compile time error

The proposed addition would allow us to leave the type restriction to the compiler

class Pokemon
end

jigglypuff := Pokemon.new # Type is Pokemon
2 Likes

To pin the type of a local variable you can use

v : String
v = 1
# Error: type must be String, not Int32

ivars are slightly pined already by the type inference rules.

1 Like

I suppose x := a could perhaps be implemented as syntax sugar for x : typeof(a) = a.

You can experiment with that using a macro:

macro let(assign)
  {{ assign.target }} : typeof({{assign.value}}) = {{ assign.value }}
end

let x = 1
x = "foo" # Error: type must be Int32, not (Int32 | String)

Note: This won’t work for ivars though because typeof is not allowed for ivar type declarations (maybe it should?). So this currently cannot satisfy the first example.

Maybe such a macro could also be an alternative syntax instead of := which would make implementation not depend on compiler support?

5 Likes

The benefit of this is not needing to explicitly specify the type while also restricting it to that type.

Ivars are restricted from changes externally, but can be changed within the same class unless the restriction is specified.

That’s exactly it! I think that’d work perfectly. I personally prefer := to let as it’d be cleaner when doing getter:

class MyClass
  getter name := "George"
  getter let age = 27
end

There is the benefit of let not requiring parser changes though.

Would that macro call the value twice? Maybe slight improvement:

macro let(assign)
  %value = {{ assign.value }}
  {{ assign.target }} : typeof(%value) = %value
end

let x = 1
x = "foo" # Error: ...
1 Like

Yes, your variant with caching the value in a local variable should be better. I originally omitted that for brevity.

As I mentioned above, this macro won’t work with instance variables, so at the moment it can’t be combined with getter either.

1 Like

Ah! Disregard my “improvement”, the expressions inside typeof aren’t evaluated, only checked for their return types, so you’re original example would work fine. typeof - Crystal

I couldn’t find any issues about typeof initialising instance vars with a quick search, but I imagine it could be problematic if you did this:

class MyClass
  @ivar1 : typeof(@ivar2)
  @ivar2 : typeof(@ivar1)
end

Maybe the compiler could do a best-effort to evaluate the type, but if it can’t then throw an error(?).

For what my opinion is worth, I think the

class MyClass
  getter name := "George"
end

syntax is very smexy and reads super well. I’d prefer it over let too.

The problem is that the compiler doesn’t allow typeof in a type declaration for an instance variable:

class Foo
  @var : typeof(1) # Error: can't use 'typeof' here
end

That’s the problem I was referring to - maybe it’d be feasible to change that to allow for typeof() for instance vars, but in a limited capacity, and I can see why they were disabled.

The original macro example would not execute duplicate code, but the compiler would still have to type the same code twice. Storing the value in a fresh local variable allows it to reuse the result of semantic analysis. This can be relevant when the value is a complex expression (instead of a simple literal for example).

1 Like

I’m wouldn’t even be quite sure whether that’s an intentional limitation. I don’t see why it shouldn’t be possible. Sure some expressions might not be able to produce a valid type, but that’s true for typeof anywhere.

1 Like

typeof() also can’t be used in parameter type restrictions:

In test.cr:4:15

 4 | def check(var := :symbol)
                   ^
Error: can't use typeof in type restrictions

Started experimenting with this on a branch (GitHub - nobodywasishere/crystal at nobody/var-declaration-experiment) just to see what’s feasible and get a feel for the syntax.

Just pointing out this is also a feature in Golang. It might be worth discussing some of the nuances of where the := operator is valid, like multi-assignment.

I would expect this to be an error, for instance.

if a := some_function
  a = func_with_different_type # compile type error
end

Go’s treatment of multi-assignment with := is interesting, because existing variables can be re-assigned but not re-declared as long as at least one new variable exists on the left hand side:

func main() {
	a, b := 0, 0

	a, b, c := 1, 2, "c"
	// same as
	a, b = 1, 2
	c := "c"
}

1 Like

That is interesting. Odin does not allow that. := is uniquely a declaration, which I think makes more sense than the hybrid approach that golang follows

main :: proc() {
    a, b := 2, 3
    a, b, c := 1, 2, "c" // Error due to a redeclaration of 'a' and 'b'
}

Following up on that, IMO assignments should never use :=, only =.

1 Like

I think the main reason Go allows reassignment with := is for more ergonomic handling of errors. result, err := action() is commonly repeated several times in a Go function, and it would be a burden on the programmer to give a unique name to the error variable every time.

Crystal doesn’t use error results in the same way, so I agree that we shouldn’t support reassignment with :=.

2 Likes

From my limited testing, while typeof(1) and typeof("hello") work for type decl of instance vars (having disabled the check), trying to use typeof(Array(String).new) throws errors about instance vars of Array(T) not being explicitly typed. One of the ivars, even though it was explicitly typed, still threw an error (probably due to it being a generic type). My hypothesis is that this is due to when / how type declarations are done for ivars.

We could probably allow := for literals when declaring an instance var, but doing others may require significant changes to how semantic analysis is done (or getting the type more lazily).

1 Like

My first reaction was positive. But then I got reminded on how many things are nice per se, but when considered as a bunch, they can be quite burdensome for newcomers.

I’m not saying we shouldn’t do it, I just think this has low priority over improving the language as is today. Getting typeof to work in ivars has more priority to my eyes :slight_smile:

That said, playing around and finding out what to do in the edge cases (like multiple assignments) is a great first step.

5 Likes