Why the free variables in function body don't get bound at function definition time?

My update example was meant to be pass by value (for simplicity); thanks to @Benny for pointing out - it would have to be like this (unless in some lang global a could mean to only apply to the var instance next to it… but let’s keep it familiar):

function update(anew, increment) 
   global a = anew+increment # could be `outer a` in a local scope, depending on need
end
# call:
update(a, 17)   # or maybe `update(global a, 17)`

In such a lang, classical OOP could still be done without breaking the main idea, because

obj.method()

can be interpreted as obj being an implicit first argument passed to method() to bind some self parameter to represent the object at call time. Thus, even though such a method could have different effects at different calls, those can still be considered to be transparently depending on obj.

Yet, depending on what data structures are available, it would still be possible to hack similar OOP constructions to the one shown by @bertschi above:

obj = (let A = [1]
    inc() =   obj.A[1]+=1
    dec() =   obj.A[1]-=1
    get() =   obj.A[1]
    (; inc, dec, get, A)
end)
obj.get() # 1
obj.inc(); obj.inc(); obj.dec(); obj.inc();
obj.get() # 3

The way this would work is by having the following rule: if, at the time of function definition, the free variable that needs to be accessed is itself under construction/definition (like what happens to obj above), then the resolution of that free variable if left to the call time.

EDIT: (after reading @bertschi reply below … )
Don’t take the last rule seriously (just in case you take seriously anything in this topic :wink: ). I made it up just to make that hack work. But neither the hack or this rule is important.
Here’s another hack that doesn’t depend on it and should force the evaluation of state to be done at call time:

obj = (let A = [1]
    inc(dummy=1) =   A[dummy]+=1
    dec(dummy=1) =   A[dummy]-=1
    get(dummy=1) =   A[dummy]
    (; inc, dec, get)
end)
obj.get() # 1
obj.inc(); obj.inc(); obj.dec(); obj.inc();
obj.get() # 3

Yes, it would have to apply to function calls within the function body as well, like this:

g() =1 ; 
f()= g()
f() # 1 
g() = 2;
f() # 1

# But to achieve different outputs:
f(h) = h()  
g()=1; f(g) # 1
g()=2; f(g) # 2

The general principle would be that the effect of a function call (be it return of value or some side-effect actions) should only depend on the supplied arguments (if any).
Hence any free variables (thus including function variables) in the body would have to be resolved to their values once and for all – thus it seems natural to be at function definition time .

Ok, let’s step back a bit … making these rules dependent on global context – whether a variable exists already or not – sounds like a recipe for confusion.
Here are some arguments, why I think it might not be a good idea in the first place:

  1. Usually functions are bound globally and (as you acknowledge) your rule would apply to functions too. This would be very annoying for interactive work, i.e., define g, define f using g, test f and see its wrong (problem was in g already), fix g, try f again (success).
    Note that with your rule, the old definition of g would still stick around and f would need to be redefined as well to see the change – as you can imagine this gets messy very quickly with any kind of interactive workflow. If you ever worked with Python it is annoying enough that old objects stick around when you redefine a class and languages designed for interactive use, e.g., Smalltalk, Common Lisp, can even handle this case gracefully.
    Some compiled languages could get away with this, e.g., Haskell which only allows a single global definition anyways.

  2. Your rule would also not solve the fundamental problem of “spooky action at a distance” due to global variables but simply shift the problem:

    glob_a = 10
    
    # Lot's of other important stuff
    
    f(x, y) = x * glob_a + y
    
    # Some more scrolling here
    
    glob_a = 20
    
    # Further down the lines
    
    f(2, 3)  # What will/should this be? 
    

    Local reasoning does not work in any case …

  3. Finally, it would fundamentally change the semantic of functions which don’t evaluate their body until called (part of it would be evaluated by your rule already at definition time). You’re right that closures do keep a reference to their defining environment, but this was actually introduced to restore local reasoning for higher-order functions:

    function adder(x)
        fun(y) = x + y  # x is free here
        fun
    end
    x = 10
    a1 = adder(1)
    a1(x) # 11
    

    You can try that this breaks in Emacs Lisp being one of the few languages still around with dynamic binding (modern language do provide dynamic variables as an option though, including Julia):

    (defun adder(x)
        (lambda (y) (+ x y)))
    (setq x 10)
    (setq a1 (adder x))
    (funcall a1 x)  // 20
    

    Dynamic variables – or simply redefining a global variable – can be very handy for (locally) changing configuration options

    somefile = open(...)
    redirect_stdout(somefile) do
        print("Hallo")
    end
    

    which would not be possible when following your rule.

Implementing obj.method() by methods capturing values (or variables that don’t get reassigned) is not classical OOP at all. Your methods are encapsulated by an object with no nominal class of its own. In classical OOP, a named class defines and encapsulates the methods, and its instances obj or its fields are strictly passed to the methods at call-time, whether by a convention, key word, or argument. An instance can’t exist while the class and its methods are defined, so there are no values to capture. Binding values to methods at definition time just do not help OOP languages do what they do, and it’s unrelated to why Julia is not OOP.

Interestingly, for some macros (@eval, @spawn and others), there is an easy way to use the value, not the name, at the point of definition. E.g.

julia> a = 1
1
julia> @eval f()=println($a)
f (generic function with 1 method)
julia> a = 2
2
julia> f()
1

Of course, macros work at the syntactic level, and the $ expansion is typically done with the let construction suggested above.

A modification to the julia parser could possibly enable such a thing for use outside macros, not that I would recommend it.

OK, let’s rename that argument.

update(anew, inc) = global a=anew+inc

But that syntax is not necessary in Julia 1.11.5:

julia> a=0;
julia> f() = global a=a+1;
julia> f(); f(); a
2
# Also for local variables:
julia> function f()
         a=0
         f()= a=a+1
         f(); f(); print(a);
       end
...
julia> f()
2 

The transparency I meant is on exactly which variables the method depends - explicitlely as arguments, as opposed to implicitely via some free variables in the f.body. If you called same myf(42) repeatedly, at any time, you should be guaranteed to get the same effect (return value or side effect … ).
(Unless myf is getting some sort of external input (file, user…) - though that might be made transparent as well, I haven’t thought enough about that.)

If we’re both talking about the value of a global a at the moment of a method definition that uses that a in the body… then at that moment a has a certain value, regardless of reassignments that happen before or after this function definition time. So I don’t understand the question.
I mean a fundamentally new way that functions/methods would work (not in currently in Julia/Python/C…) by automatically bingind the free variables (be they global or other outer variables) to their current values at f.definition time.

Yes, forcing every dependency to be explicity via arguments will be a bit more verbose; but I thought the transparency (and thus ease of reasoning and thus probably less bugs) would make it worth it.
I think that certain programming patterns related to scope and closures would be made easier – I’ll try to bring examples later.

I think you misunderstood me, I didn’t say that that is classical OOP. When you say “Your methods are encapsulated by an object with no nominal class of its own” it seems that you looked at that hacked example with obj returning (; inc,dec,get,A) ? But that was just a hack , rather for fun, so that, and the specific rule I mentioned at the end to make that hack work is not so important in the big scheme.
But to the rest of what you say:

Yes, indeed, but this is not harmed/impeded/contradicted by binding values to free variables in methods at definition time. Because when defining the methods to act on some future obj, you work with self (or something similar) - a variable to stand for that object that will be instantiated at call time. self is, de facto, a parameter, not a free variable, thus resolved at method call time, as all other parameters.
It’s just that in usual OOP syntax, self is not required to be listed explicitely inside the parameter list.

I think most of your criticism does not apply to the design I mean.
Only dependency on input (information needed to carry out the task) needs to be made explicit via parameters, arguments.
So that whatever the effect will be, it will be the exactly the same if the passed argument values are same.
Hence, the requirement that free vars be determined at fun. def. time.

But not to explicitely track or declare the state that will be changed (like files to be changed, globals to be modified…).

# this would be totally fine;  
# effect at call is always the same:
function f() 
  print(42)  
  global a = 9
end
# to have effect depend on some outer variables `t` and `anew`, would need:
a=0;
function f(t, anew) 
  print(42+t +a)  # this `a` will just interpolate current val of `a`, 0 here
  global a = anew+t 
end
# call 
f(3,a)
# to have effect depend on external user input, perhaps like this, to be strict:
function f2(STDIN, t ) 
   userin=read(STDIN)
   print(userin, t)
end

This one could be true.
If the generated numbers depend on some outside-the-program state (memory box, thus similar to a variable), then in principle yes, the function would need to explicitely declare that as a parameter.
I guess same would be for functions that read user or file input.
Perhaps, as a convenience, these cases – of reading inputs from “outside” the program – might make the only exception to requiring specifying the dependency as input parameter.