Ruby forgets local variables during a while loop?

后端 未结 6 825
我在风中等你
我在风中等你 2020-12-09 17:07

I\'m processing a record-based text file: so I\'m looking for a starting string which constitutes the start of a record: there is no end-of-record marker, so I use the start

6条回答
  •  情书的邮戳
    2020-12-09 17:50

    Contrary to some of the other answers, while loops don't actually create a new scope. The problem you're seeing is more subtle.

    To help show the contrast, blocks passed to a method call DO create a new scope, such that a newly assigned local variable inside the block disappears after the block exits:

    ### block example - provided for contrast only ###
    [0].each {|e| blockvar = e }
    p blockvar  # NameError: undefined local variable or method
    

    But while loops (like your case) are different:

    arr = [0]
    while arr.any?
      whilevar = arr.shift
    end
    p whilevar  # prints 0
    

    The reason you get the error in your case is because the line that uses message:

    puts "#{message}"
    

    appears before any code that assigns message.

    It's the same reason this code raises an error if a wasn't defined beforehand:

    # Note the single (not double) equal sign.
    # At first glance it looks like this should print '1',
    #  because the 'a' is assigned before (time-wise) the puts.
    puts a if a = 1
    

    Not Scoping, but Parsing-visibility

    The so-called "problem" - i.e. local-variable visibility within a single scope - is due to ruby's parser. Since we're only considering a single scope, scoping rules have nothing to do with the problem. At the parsing stage, the parser decides at which source locations a local variable is visible, and this visibility does not change during execution.

    When determining if a local variable is defined (i.e. defined? returns true) at any point in the code, the parser checks the current scope to see if any code has assigned it before, even if that code has never run (the parser can't know anything about what has or hasn't run at the parsing stage). "Before" meaning: on a line above, or on the same line and to the left-hand side.

    An exercise to determine if a local is defined (i.e. visible)

    Note that the following only applies to local variables, not methods. (Determining whether a method is defined in a scope is more complex, because it involves searching included modules and ancestor classes.)

    A concrete way to see the local variable behavior is to open your file in a text editor. Suppose also that by repeatedly pressing the left-arrow key, you can move your cursor backward through the entire file. Now suppose you're wondering whether a certain usage of message will raise the NameError. To do this, position your cursor at the place you're using message, then keep pressing left-arrow until you either:

    1. reach the beginning of the current scope (you must understand ruby's scoping rules in order to know when this happens)
    2. reach code that assigns message

    If you've reached an assignment before reaching the scope boundary, that means your usage of message won't raise NameError. If you don't reach any assignment, the usage will raise NameError.

    Other considerations

    In the case a variable assignment appears in the code but isn't run, the variable is initialized to nil:

    # a is not defined before this
    if false
      # never executed, but makes the binding defined/visible to the else case
      a = 1
    else
      p a  # prints nil
    end 
    

    While loop test case

    Here's a small test case to demonstrate the oddness of the above behavior when it happens in a while loop. The affected variable here is dest_arr.

    arr = [0,1]
    while n = arr.shift
      p( n: n, dest_arr_defined: (defined? dest_arr) )
    
      if n == 0
        dest_arr = [n]
      else
        dest_arr << n
        p( dest_arr: dest_arr )
      end
    end
    

    which outputs:

    {:n=>0, :dest_arr_defined=>nil}
    {:n=>1, :dest_arr_defined=>nil}
    {:dest_arr=>[0, 1]}
    

    The salient points:

    • The first iteration is intuitive, dest_arr is initialized to [0].
    • But we need to pay close attention in the second iteration (when n is 1):
      • At the beginning, dest_arr is undefined!
      • But when the code reaches the else case, dest_arr is visible again, because the interpreter sees that it was defined beforehand (2 lines up).
      • Notice also, that dest_arr is only hidden at the start of the loop; its value is never lost.

    This also explains why assigning your local before the while loop fixes the problem. The assignment doesn't need to be executed; it only needs to appear in the source code.

    Lambda example

    f1 = ->{ f2 }
    f2 = ->{ f1 }
    p f2.call()
    # Fails because the body of f1 tries to access f2 before an assignment for f2 was seen by the parser.
    p f1.call()  # undefined local variable or method `f2'.
    

    Fix this by putting an f2 assignment before f1's body. Remember, the assignment doesn't actually need to be executed!

    f2 = nil  # Could be replaced by: if false; f2 = nil; end
    f1 = ->{ f2 }
    f2 = ->{ f1 }
    p f2.call()
    p f1.call()  # ok
    

    Method-masking gotcha

    Things get really hairy if you have a local variable with the same name as a method:

    def dest_arr
      :whoops
    end
    
    arr = [0,1]
    while n = arr.shift
      p( n: n, dest_arr: dest_arr )
    
      if n == 0
        dest_arr = [n]
      else
        dest_arr << n
        p( dest_arr: dest_arr )
      end
    end
    

    Outputs:

    {:n=>0, :dest_arr=>:whoops}
    {:n=>1, :dest_arr=>:whoops}
    {:dest_arr=>[0, 1]}
    

    A local variable assignment in a scope will "mask"/"shadow" a method call of the same name. (You can still call the method by using explicit parentheses or an explicit receiver.) So this is similar to the previous while loop test, except that instead of becoming undefined above the assignment code, the dest_arr method becomes "unmasked"/"unshadowed" so that the method is callable w/o parentheses. But any code after the assignment will see the local variable.

    Some best-practices we can derive from all this

    • Don't name local variables the same as method names in the same scope
    • Don't put the initial assignment of a local variable in the body of a while or for loop, or anything that causes execution to jump around within a scope (calling lambdas or Continuation#call can do this too). Put the assignment before the loop.

提交回复
热议问题