crystal icon indicating copy to clipboard operation
crystal copied to clipboard

Undefined variables when declared within macro expression

Open waj opened this issue 6 years ago • 15 comments

The following code doesn't compile, complaining about undefined local variable or method 'x' for top-level

{% if flag?(:foo) %}
  x = 1
{% else %}
  x = 2
{% end %}

puts x

A similar issue also exists for instance variables. For example this code gives a instance variable '@x' of Foo was not initialized...:

class Foo
  @x : Int32

  def initialize
    {% if flag?(:foo) %}
      @x = 1
    {% else %}
      @x = 2
    {% end %}
  end
end

waj avatar Jul 19 '19 14:07 waj

https://github.com/crystal-lang/crystal/issues/7767#issuecomment-491532061 🤷‍♂

Blacksmoke16 avatar Jul 19 '19 14:07 Blacksmoke16

Yeah... The parser knows whether x is a variable or a call based on lexical information before that: if x was assigned something then it's a local variable, otherwise it's a call.

One way to solve this would be to check, on the semantic pass, if a call has no arguments and no parentheses and there's a local variable with that name. In that case the call would be replaced with a variable.

asterite avatar Jul 19 '19 15:07 asterite

But for instance variables, I don't know. Maybe if an initialize has macro code then we could assume it initializes all variables and give an error later on.

There are the ugly bits of macros...

asterite avatar Jul 19 '19 15:07 asterite

Is there any way that the check for instance variable initialization in a later phase of the compiler, once macros have been expanded? If initialization is checked later, maybe indirect initialization could also be supported?

mattrbeck avatar Aug 30 '22 21:08 mattrbeck

For the local variable case I think it is good to be explicit here. We could declare x to be a variable with no fixed type:

x : _

{% if flag?(:foo) %}
  x = 1
{% else %}
  x = 2
{% end %}

puts x # okay, `x` is a `Var`
x = "" # okay, `x`'s type is not frozen

The usual read-before-write errors apply:

x : _
puts x # Error: read before assignment to local variable 'x'
x : _
if true
  x = 1
end
puts x # okay, the end of the if-expression creates a new `x`

Those variable declarations would disallow initializers because that would be the same as not using the : _ at all.

For instance variables this is harder because the main phase must occur after all instance variables are finalized.

HertzDevil avatar Aug 30 '22 21:08 HertzDevil

@HertzDevil Why is it good to be explicit? I think the first example is perfectly fine without x : _. It makes sense semantically, the local variable is defined in all branches, so it definitely exists after the macro is expanded.

straight-shoota avatar Aug 30 '22 21:08 straight-shoota

Apart from semantic correctness, this has the benefit that parsers and tools which don't rely on semantic analysis may determine whether the x in the puts call is a variable or a call.

On the other hand semantic analysis might be needed after all if x could really be either a variable or a call:

{% if flag?(...) %}
  x = 1
{% else %}
  def x; 2; end
{% end %}

puts x # Error: undefined local variable or method 'x' for top-level

If we add x : _ at the top and the def branch is picked, the variable would shadow the def and read-before-write would be reported, so a purely syntactical approach wouldn't address the ambiguity here. I don't know if this is a significant use case, though.

HertzDevil avatar Aug 30 '22 22:08 HertzDevil

Somewhat related but I ran into an interesting scenario where being explicit actually resolved (or worked around it).

class Foo
  @some_other_obj = 0
  
  forward_missing_to @some_other_obj
  
  def run(value : Int32)
    {% if true %}
      v = value.abs
    {% else %}
      v = value
    {% end %}
  
    pp v + 10
  end
end

Foo.new.run 10

This currently results in:

There was a problem expanding macro 'method_missing'

Code in macro 'forward_missing_to'

 1 | macro method_missing(call)
     ^
Called macro defined in macro 'forward_missing_to'

 1 | macro method_missing(call)

Which expanded to:

 > 1 | @some_other_obj.v
                       ^
Error: undefined method 'v' for Int32

However adding a v : Int32 before the macro conditional makes it work as expected.

Blacksmoke16 avatar Sep 05 '22 16:09 Blacksmoke16

What about?

  def run(value : Int32)
    v = {% if true %}
      value.abs
    {% else %}
      value
    {% end %}
  
    pp v + 10
  end

asterite avatar Sep 05 '22 16:09 asterite

Oh right, good call. I forgot about that :sweat_smile:. That definitely works too.

Blacksmoke16 avatar Sep 05 '22 16:09 Blacksmoke16

This is why I think there's a minor issue. If you need a variable to be defined and you also need its value to depend on a compile-time value, you can put just the value inside macro. This applies to all of the examples in this GitHub issue.

asterite avatar Sep 05 '22 16:09 asterite

@asterite How about this case? How would you go about this with this line removed?

mattrbeck avatar Sep 05 '22 17:09 mattrbeck

@mattrberry If you inline update_all_fields in initialize instead of calling it, it works. That problem is initializing instance vars indirectly. Maybe if you could call a macro method at compile-time and dump the result into the current macro, it would be simpler/easier.

asterite avatar Sep 05 '22 17:09 asterite

Is there a way today to inline update_all_fields other than just copying its implementation to both of its call sites?

mattrbeck avatar Sep 05 '22 17:09 mattrbeck

No. There's an open issue about being able to call macro methods at compile time, but even with that it's not clear how it would improve this situation.

asterite avatar Sep 05 '22 18:09 asterite