Closed pnkfelix closed 2 years ago
I looked a little bit further, and I'm pretty close to being sure that this some sort of compiler optimization (maybe to pattern match certain things and remodel it as a more standard JS object) going awry.
Here is a revision of my test that I think makes it very clear that the behavior I expected is directly expressible. This example is just like the one I put in the description, except that I now have factored the method(self, x): if x == 0: true else: self.odd(x - 1) end end
into its own separate definition, named m-even
, outside of norm
. (And norm
now just reuses that definition for m-even
.)
Then, I made two child objects: child-1
that pulls its even field from m-even
, and child-2
that pulls its even field from norm.even
. The two children should in theory behave identically, but they do not.
# `norm` behaves in the usual way you would expect:
m-even = method(self, x): if x == 0: true else: self.odd(x - 1) end end
norm = {
even: m-even,
odd: method(self, y): if y == 0: false else: self.even(y - 1) end end
}
# `strange` is deliberately "broken"
strange = {
even: method(it, x): if x == 0: true else: it.odd(x - 1) end end,
odd: method(it, y): false end
}
child-1 = {
even: m-even,
odd: strange.odd,
}
# `child` is mixing together methods from `norm` and `strange`.
child-2 = {
even: norm.even,
odd: strange.odd
}
check:
norm.even(0) is true
norm.odd(1) is true
norm.even(2) is true
norm.odd(2) is false
end
check:
strange.even(0) is true
strange.odd(1) is false
strange.even(2) is false
strange.odd(2) is false
end
check:
child-1.even(0) is true
child-2.even(0) is true
child-1.odd(1) is false
child-2.odd(1) is false
child-1.even(2) is false
child-2.even(2) is true # ??? why?
end
Note those last two lines in the last check block. child-1
and child-2
have different behavior. I believe child-1
matches the behavior I would expect based on my understanding of dynamic dispatch. I think child-2
's behavior is explainable via some premature compiler optimization (i.e. assuming that it can eagerly resolve the recursive calls between the even
and odd
methods of child-2
), but it does not seem like it is correct behavior in terms of Pyret semantics.
This semantics is intentional. In our homepage (https://www.pyret.org/), we have the following section:
Embracing Substitutability
A design goal of Pyret's syntax and semantics is to embrace the substitutability of equivalent expressions as much as possible. This is in contrast to, for example, some scripting languages, in which what looks like binding an expression to a temporary name changes program behavior.
o = {
method my-method(self, x): self.y + x end,
y: 10
}
method-as-fun = o.my-method
check:
o.my-method(5) is 15
method-as-fun(5) is 15
end
o = {
method my-method(self, x): self.y + x end,
y: 10
}
method-as-fun = o.my-method
check:
o.my-method(5) is 15
method-as-fun(5) is 15
end
Okay... so how is one supposed to explain the distinction between child-1
and child-2
that I provided above, in terms of pedagogy with students?
On a more pragmatic level: How is one supposed to implement the template-and-hook pattern where one wants to inject new behavior (which I would expect to do by overriding a specific method, and trusting that calls to that method will be resolved dynamically)?
I am inferring the idea put forth by Pyret is that the very act of first storing m-even
into norm
, and then later extracting it as norm.even
, is not producing the same method value that m-even
holds?
(And instead, m-even
is getting bundled up with its associated receiver when we plug it into norm
, and the later extracting of norm.even
is producing that bundled up function that can now be applied on its own...?)
In any case, I would suggest that you can have your cake and eat it too here: In particular, I don't object to allowing people to extract method-as-fun
in the way that you suggest.
However, I do suggest that when method-as-fun
is plugged into another object, the previous receiver should be forgotten, and the method should be recoupled with its new owner.
(In any case, my real problem here is that I couldn't find any discussion of what the semantics are here in the manual. I admit that I overlooked the "Embracing Substitutability" section, but even that example won't really explain what behavior one can expect to see in cases like the one I outlined above.)
Okay after reflecting on it, I think I can come up with a plausible mental model here.
Perhaps ironically, it was my own "embracing substitutability" that led me to think that I had a iron-clad argument up above: after all, all I was doing was extracting the method expression and putting it into its own bound name, and referencing that name instead of norm.even
in the definition of child-1
. So, how can that kind of "refactoring" cause a change in behavior...
But I can now see a way to describe things, where plugging a method into a structural value causes it to eagerly bundle it with its associated self
, and one cannot unbundle it from that point on. This mental model leads one to say "if you want to make interesting OOP hierarchies, you can do it, but you need to make sure you keep the methods separated until you're ready to bundle them; you cannot just mix and match things by pulling them out of existing objects."
(I guess my remaining question is: Is that the mental model that Pyret wants ? And, where in the docs can I help provide some discussion of this?)
Okay, I have now read https://www.pyret.org/docs/latest/Expressions.html#%28part._s~3adot-expr%29 carefully, and I can see that it specifies the semantics as it stands today.
The act of doing norm.even
is causing the method to immediately (and irrevocably) bind self
to norm
within the function value that it returns.
So it is an anti-pattern to try to mix methods together in the manner that I described in child-2
.
I will see if I can add a teeny bit to that part of the docs to explain this distinction.
One of the reasons for Pyret's model is the bizarre behavior of JS, wherein o.m(a)
does not mean the same as (o.m)(a)
-- method call syntax in JS is its own primitive syntax, despite looking like a contraction of dot-access and function-call syntax.
A long while ago, we had had an ability to extract a method value from an object without binding its self
parameter. We removed that ability as (a) we had no pedagogic use for it, and (b) it imposed implementation constraints that we didn't like. So it wasn't pulling its weight, and we tossed it overboard.
Having anonymous method
values akin to lambda
values is now oddly disconnected, and we may or may not keep that syntax. (It doesn't cost us much in terms of implementation difficulty, so there's not reason to jettison it yet.)
The template-and-hook pattern works just fine using functional extension of objects:
base = {
method foo(self): self.x() end,
method x(self): raise("NYI") end
}
der = base.{ method x(self): 5 end }
check:
der.foo() is 5
base.foo() raises "NYI"
end
This is essentially prototype-based inheritance.
I wanted to learn about how Pyret is modelling method dispatch. I looked through the manual, but didn't see much discussion of it.
So I tried making a small direct example to see if it behaved in the manner I would expect (in terms of modelling delayed resolution of a method-name to its associated code based on what specific receiver is used for the method)
Here is what I tried doing:
If I were trying to teach students about Object Oriented Programming, I would probably try to explain that a big part of the goal is to enable dynamic dispatch, i.e. delaying method resolution to the point where a concrete object actually receives a method call, rather than trying to resolve it at the point where the method itself is defined.
So, when I see a method expression like:
method(it, x): if x == 0: true else: it.odd(x - 1) end
I personally would assume that theit.odd(x - 1)
invocation there is going to use the dynamic value ofit
to resolve the definition ofodd
.But, in the above code, it doesn't seem like this is happening:
child.even(2)
is returningtrue
; I would expect, as an OOP practitioner, the definition ofeven
there (taken fromnorm
) to recursively call into the definition ofodd
that was provided bystrange
, and thus end up with the valuefalse
.Of course, I can explicitly model this sort of delayed method resolution, it doesn't have to be baked into the language.
But what are the semantics that Pyret is using for resolving method invocations, such as
it.odd(x - 1)
in my example above?