rubyerblogical-operators

Why do Ruby ERB if statements set variables to nil?


I'm using ERB to generate a set of instructions with location data, and want to accept an offset in my input. When I try to check if that offset is present however, it sets it's value to nil. If anyone can help explain what's going on it'd be greatly appreciated!

Here's the code I ran to test this, once I'd narrowed down the problem:

<%
    # apply a default value of 0 to the offset hash
    p offset.nil?
    p offset
    p offset.nil?
    p offset

    if offset.nil?
        p "I ran the code inside the if statement"
        offset = {}
        p "offset has been initialized, since it was nil"
    end

    p offset.nil?
    p offset
%>

and the output:

false
{"z"=>-10}
false
{"z"=>-10}
true
nil

If I delete the if statement, I get the expected result of:

false
{"z"=>-10}
false
{"z"=>-10}
false
{"z"=>-10}

but having that if statement (or one like it) is kind of important. I've figured out a workaround with a begin-rescue block that basically does what the If was supposed to do, but I'm very curious why this was happening.


Solution

  • I assume that at the beginning, when calling offset you are calling a method which returns the {"z"=>-10} value, e.g. method in an included helper module.

    Now, in Ruby local variables have precedence before methods with the same name. Thus, if there is a local variable named offset, when referencing this name, you are getting the value of that variable rather than the result of calling the method of the same name.

    Now, in Ruby when you define any code which may set a variable, this variable is initialized with nil, even if the code which sets an initial value is never actually run. This is similar to variable hoisting in JavaScript (but not entirely):

    defined?(some_variable)
    # => nil
    
    if false
      some_variable = 'value'
    end
    
    defined?(some_variable)
    # => "local-variable"
    

    Thus, because you set offset = {} in the body of your if block, afterwards, you have a local offset variable in existence which shadows the offset method.

    If you want to always call the offset method, you can instruct Ruby to call the method by adding parentheses (which are otherwise optional)

    p offset
    # => {"z"=>-10}
    
    if offset.nil?
      offset = {}
    end
    
    p offset
    # => nil
    p defined?(offset)
    # => "local-variable"
    
    p offset()
    # => {"z"=>-10}
    p defined?(offset())
    # => "method"
    

    To implement your actual use-case, you could also use something like this instead of your if statement in order to set the offset variable as either the result of the offset method or an empty hash if the method returns nil or false:

    offset = offset() || {}