brownplt / pyret-lang

The Pyret language.
Other
1.07k stars 110 forks source link

What are Pyret's method dispatch semantics? #1661

Closed pnkfelix closed 2 years ago

pnkfelix commented 2 years ago

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:

# `norm` behaves in the usual way you would expect: 
norm = {
  even: method(it, x): if x == 0: true else: it.odd(x - 1) end end,
  odd: method(it, y): if y == 0: false else: it.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` is mixing together methods from `norm` and `strange`.
child = {
  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.even(0) is true
  child.odd(1) is false
  child.even(2) is true # ??? why?
end

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 the it.odd(x - 1) invocation there is going to use the dynamic value of it to resolve the definition of odd.

But, in the above code, it doesn't seem like this is happening: child.even(2) is returning true; I would expect, as an OOP practitioner, the definition of even there (taken from norm) to recursively call into the definition of odd that was provided by strange, and thus end up with the value false.


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?

pnkfelix commented 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.

sorawee commented 2 years ago

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
pnkfelix commented 2 years ago

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.

pnkfelix commented 2 years ago

(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.)

pnkfelix commented 2 years ago

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?)

pnkfelix commented 2 years ago

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.

blerner commented 2 years ago

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.