chapel-lang / chapel

a Productive Parallel Programming Language
https://chapel-lang.org
Other
1.78k stars 419 forks source link

how to make a method call with a particular interface? #21343

Open mppf opened 1 year ago

mppf commented 1 year ago

We can use interfaces to resolve a problem with special method naming (see #22618, #19038) but that will require having the ability to call a method from a particular interface, so that there could be another method with the same name outside of the interface.

(Spin-off from discussion in #19050).

This was brought up before in https://chapel-lang.org/releaseNotes/1.21/06-ongoing.pdf slide 29.

Here is a list of my understanding of the current proposals. In the discussion we assume that element is a value and Hashable is an interface containing hash and that the type of element implements Hashable.

Each proposal will start with the example syntax and then discuss.

Universal Method Call

Hashable.hash(element)

Could introduce universal method call syntax (which is how Rust solves this) -- so you would be able to equivalently write receiver.someMethod() and someMethod(receiver) in general; and in this specific case you could write Hashable.hash(element). I do not remember if we have seriously considered this idea before. However, it might make language elements that treat method receivers differently from other arguments less appealing. For example, record method receivers are ref-or-const-ref depending on whether or not this is modified. Would it now apply to the 1st argument of all free functions as well?

Cast Syntax

(element:Hashable).hash()

The idea here is to cast to the interface type to forget other information about the type of element & so call the hash required by the interface.

@dlongnecke-cray does not like this form b/c it doesn't make a new value while most casts do.

use a constrained-generic wrapper method

proc hashIt(arg: Hashable) { arg.hash(); }
hashIt(element);

The idea is that the constrained-generic wrapper method forgets the type of element except that it is Hashable, and so calls the hash required by the interface.

Use .InterfaceName

element.Hashable.hash()

This one would imply that types that implement an interface can't make fields or parenless methods with the same name. That might be a problem because you should be able to implement an interface with a type on behalf of that type, when the type is in a 3rd party library, say.

@e-kayrakli proposed this idea but then then said

but let's please not do this this syntax should read as "get Hashable of this and call hash on it". But that's not what it means.

And see also https://github.com/chapel-lang/chapel/issues/21343#issuecomment-1612085087

with clause

with Hashable(element.type) {
  element.hash();
}

(this idea from #19074)

use statement

{
  use Hashable(element.type);
  element.hash();
}

@ syntax

element.hash@Hashable()

Concerns with @ syntax: no precedent in other languages, @ is already used for attributes

@ syntax variation 2

element@Hashable.hash()

@benharsh likes this better than the earlier @ variation because it's odd to have Hashable be the last thing before the argument list.

:: Syntax

element::Hashable.hash()

Square bracket Syntax

Hashable[element].hash()

as syntax

element.as(Hashable).hash()

or perhaps

x as Hashable.hash()

dlongnecke-cray commented 1 year ago

I am a fan of the with clause idea or more specifically some sort of block syntax that allows you to prioritize a specific interface over others for a value referenced within its scope. However I think we also need a short form that can adorn specific callsites in order to provide more granularity.

I am not a fan of hash@Hashable() for the same reason I am not a fan of ~hash~() or hash@() in #19050.

I am a fan of universal method calls, however I think that has big implications for Chapel as it stands which might make it intractable. [edit: Maybe we can just do UMC for interfaces, only?]

As it stands I think I would be OK with (x:Hashable).hash(). It's a shame that we can't get rid of the () in this case (actually I wonder if we can, since Hashable.hash() is not a value...).

However I think that the combination of some different ideas together can help:

interface MyHashable { ... }
interface Hashable { ... } // In ChapelBase...
implement MyHashable for int; // Assume int satisfies this interface.

var x, y: int;

// With syntax...prioritize names from Hashable for `x`, names from MyHashable for `y`
with x as Hashable, y as MyHashable {
  writeln(x.hash() == y.hash());
}

// "Ephemeral" cast-to-interface
writeln((x:Hashable).hash() == (y:Hashable).hash());

// Formals with interface type
proc writeEquals(x: Hashable, y: MyHashable) {
  writeln(x.hash() == y.hash());
}

Additionally, I am wondering if we can repurpose the let statement as a way to preserve an "ephemeral cast" (this is what I've been calling this new sort of "interface cast" - historically casts always create a new value, but this new kind of cast does not).

E.g., we could have:

let yToMyHashable = y:MyHashable;
writeln(x.hash() == yToMyHashable.hash());
e-kayrakli commented 1 year ago

I like "use a constrained-generic wrapper method", and wonder if the idea can be used to enable interface creators to emulate the universal call idea without changing the language to support them.

I don't think this fits the design we have but:

interface Fooable {
  proc foo() { }

  proc type foo(arg: this.type) { arg.foo(); }
  // IIRC, this would require the implementing type to define
  // a type method named foo, which makes sense.
  // but what if that (or likely a different syntax) would mean
  // a type method definition on the Fooable "type" directly
}

Something that may get close is that interface creators can be encouraged to provide standalone functions that does something like this.

module Fooable {
  interface Fooable { // assuming we can have module and interface name the same,
                      // but it is somewhat irrelevant
    proc foo() { }
  }

  proc foo(arg: Fooable) { arg.foo(); }
}

// then
module Main {
  import Fooable;

  Fooable.foo(myFooable); 
}

This is pretty much what the wrapper method idea suggests. But I would like to consider if we can make it a suggestion than a standard language provides as a rule.

dlongnecke-cray commented 1 year ago

Another syntax that just popped into my head I'd be OK with:

var a = x.as(Hashable).hash();
var b = x as Hashable.hash();
var c = x.as.Hashable.hash();
dlongnecke-cray commented 1 year ago

I'm not a fan of the cast syntax (foo:Hashable). I think that Chapel has a pretty strong definition of cast: it is an operator that takes a value and a type and produces a new value.

This new mechanism is not creating a value, it is temporarily re-interpreting an existing value as an "abstract" interface type, which is not itself concrete and can't have any instances constructed (I can never make a new Hashable). This mechanism is much more akin to a C++ reinterpret cast.

I think using the cast operator in this case would be deceiving given how it functions in the rest of the language.

bradcray commented 1 year ago

I'm not a fan of the cast syntax (foo:Hashable). I think that Chapel has a pretty strong definition of cast: it is an operator that takes a value and a type and produces a new value.

Note that in the context of classes, casting from a superclass to a subclass (or vice-versa) behaves more like a re-interpretation of the existing value than the creation of a new value (though that's because the object is common to the two variables; technically a new variable is being created, it's just a new pointer-to-class-object variable). The use of the cast approach here seems similar to that to me, even though it's not an exact match.

dlongnecke-cray commented 1 year ago

Note that in the context of classes, casting from a superclass to a subclass (or vice-versa) behaves more like a re-interpretation of the existing value than the creation of a new value (though that's because the object is common to the two variables; technically a new variable is being created, it's just a new pointer-to-class-object variable).

Aren't we slicing into the subclass reference to create a reference that is a view of the superclass memory? So I think there is a runtime value.

In the interface case, I don't think we are performing such slicing at all. For the given examples, I at least, was envisioning that this cast would reinterpret the type of a value completely at compile-time, for some scope (either a block or a single call, or etc). The interface type + the originating type would be used to select the interface implementation to use.

The only time where I think the two would be analogous is if we created a "runtime interface" type. In which case I expect we'd store a pointer to the static type along with the vtable pointer for the interface implementation (as my naive first guess).

mppf commented 1 year ago

I have been thinking about element.Hashable.hash() as a way to do this. To me, it is very similar to element.super.foo() which we would allow for classes and has a similar nature in that it's reinterpreting the element as a slightly different type. We might be able to build on this idea as a way to declare such functions (see https://github.com/chapel-lang/chapel/issues/22618#issuecomment-1611459858 ).

@e-kayrakli proposed this idea but then then said

but let's please not do this this syntax should read as "get Hashable of this and call hash on it". But that's not what it means.

@e-kayrakli -- somehow this does not bother me. element.Hashable.hash() is getting the Hashable aspect of element (and calling hash on it) just as element.super.foo() is getting the superclass aspect of element (and calling foo on it). Do you feel the same way about element.super ?

dlongnecke-cray commented 1 year ago

I think I'm on board that element.Hashable.hash() is the most elegant way to implement this feature. It's not cast syntax, it doesn't introduce any new syntax into the language (as my element.as(Hashable) idea would), and we can already parse it today. All it requires is that we know how to evaluate interface type receivers in dot expressions, which shouldn't be terribly hard (relative to the cost of implementing interfaces as a whole...).

The comparison with super is fantastic. The super keyword already functions as a sort of "reinterpret compile-time cast". ~You can't write var x = super;, so there's no way to materialize that cast at runtime~. I expect we'd say the same thing for interface casts.

Edit: Looks like I was wrong, you can do var x = super;.

However another good illustration here of how dot expressions are already super overloaded is FooModule.bar(). Here the type of action and when we make it (fetching a method from a module, entirely at compile-time), changes depending on the qualified type of the receiver.

DanilaFe commented 1 year ago

I think I'm with Engin here -- I don't think that element.Hash makes sense at a glance. If anything, I'd argue against x.super, too. It makes sense in a language where you have a clearer understanding of how subclassing works, but not for Chapel. For instance, in C++, if I write something like this:

struct A : B {};

What I'm effectively writing is

struct A {
  B super;
}

So a.super would make sense (though it's not valid C++) to me in this context. In Chapel, the language is high level enough that when I write class hierarchies, I don't think of the data layout, or how the child "contains" the parent. In fact, when I was learning Chapel, I was somewhat concerned that x.super would return a copy of the parent object / do slicing (rather than re-interpreting the reference). In my opinion x.InterfaceName is generalizing an already fairly confusing syntax to be even more potentially confusing.

The . operator as "content access".

Some might argue that the . operator is already quite overloaded in Chapel; however, although it certainly does different things depending on the context (M.x vs x.field vs x.method), I think in all cases it can be though of as accessing a piece of something else. This is true for module access (x is somehow contained in M), field access (field is a part of x), and method access (except in the tertiary method case). Even for super, this intuition roughly holds, if children are considered as "supersets" of the parent's data (as in the C++ example above).

For x.Hashable, I don't think this intuition works. I just can't think of an interface implementation as being "contained" inside of x. To me, it's reminiscent of maybe getting a type parameter from x, but certainly not converting it to an interface.

Precedent

I would also note that no other languages use the dot operator for interface selection. Rust uses Trait::method(bla), which would be closer to Hash.hash(x) (though it's different for us, since we have no precedent for being able to call a method without the dot). No other language that I know allows multiple interfaces to defined a method with the same name, or for an interface and non-interface method to coexist (requiring disambiguation).

mppf commented 1 year ago

I think that the universal method call strategy Hashable.hash(element) could use some more discussion.

The issue I brought up about ref-maybe-const for record method receivers is something we are considering removing. But, it might be OK even if we don't remove it. Are there other concerns with it? It looks like it is available in D, has been proposed in C++, and Rust uses it in one direction only:

because it's possible to have several traits defining the same method implemented on the same struct, a mechanism is needed to disambiguate which trait should be used.

Member functions can also be used as free functions through a qualified (namespaced) path.

The term UFCS is incorrect for these uses, as it allows using methods as (namespaced) free functions, but not using free functions as methods.

If we were to mimic Rust in this regard, it would mean that Hashable.hash(element) could work to indicate we want the hash method from the Hashable interface. But it would only apply to Interface.methodname() and ModuleName.methodname() type calls -- that is, it would only apply to these kinds of qualified method calls. In particular, we could not call proc R.foo() { } with foo(myR) and we could not call proc bar(arg: R) { } with myR.bar().

mppf commented 1 year ago

Regarding element.hash@Hashable(), @dlongnecke-cray said he was concerned about the @ meaning attributes, which it does (now that we have attributes), but here it is more like an email address, where the @ sign is infix. I think that's sufficiently different that it would be OK.

mppf commented 1 year ago

No other language that I know [other than Rust] allows multiple interfaces to defined a method with the same name, or for an interface and non-interface method to coexist (requiring disambiguation).

Indeed, Swift does not seem to support it. The following Swift program does not compile: it gives "Invalid redeclaration of 'foo()'".

protocol Fooable {
    func foo()
}

struct MyStruct {
    var field = 1
    func foo() {
        print("In MyStruct.foo")
    }
}

extension MyStruct : Fooable {
    func foo() { // error here
        print("In MyStruct Fooable Extension foo")
    }
}
var s = MyStruct()
s.foo()
dlongnecke-cray commented 1 year ago

I think it would be nice to have a different syntax so that we can better distinguish between "create a value of type InterfaceName" and "call an implementation method for interface InterfaceName with type foo".

Especially in a world where we have interface types, it becomes harder to harder to see (x:Hashable) as anything but "create a value of type Hashable out of x". While it's true that we can rationalize the specific case of (x:Hashable).hash() as saying "the compiler optimized the temporary Hashable away and issued a static call to foo.Hashable.hash()", I think we would be better served by having syntax that separates the two ideas more clearly:

I like the idea of Hashable.hash(x) the Rust form where we unfold the method call and pass in the receiver, but it does make we wish that we could just do that for any method call.

lydia-duncan commented 1 year ago

I'd thought we had decided we'd use implements both for declaring that a type supports the interface and for indicating that an argument requires that interface, but I didn't dig deep into the interface design discussions to verify that. It seems like it would be a viable candidate for what you're talking about, at a glance?

dlongnecke-cray commented 1 year ago

The CHIP states that the below two forms are equivalent:

proc foo(x: Hashable) {}
proc foo(x) where x implements Hashable {}

I've been meaning to challenge this but I think that's for a different thread.

Do you mean that whatever call syntax we use could make use of the implements keyword? E.g, implements(x, Hashable).hash()?

mppf commented 1 year ago

I'd thought we had decided we'd use implements both for declaring that a type supports the interface and for indicating that an argument requires that interface, but I didn't dig deep into the interface design discussions to verify that. It seems like it would be a viable candidate for what you're talking about, at a glance?

Yes, that's kindof what the "use a constrained-generic wrapper method" approach is leaning on.

We've been interested in this issue since it is related to special method naming, but I think we will not need a solution to it in the near term for that purpose.

In the long term, it will be possible to have a constrained generics method that is working with an argument implementing multiple interfaces where those interfaces might use the same method name. That is the case that this would help. But it still can be addressing with a wrapper method. Here is a sketch of an example (but not necessarily a compelling use case):

interface Hashable {
  proc Self.hash(): uint; // TBD if Self will exist
}
interface IntHashable {
  proc Self.hash(): int;
}
proc foo(arg) where arg implements Hashable && arg implements IntHashable {
  arg.hash(); // this is ambiguous
  helpCallIntHash(arg); // work around: use a helper method
}
proc helpCallIntHash(arg) where arg implements IntHashable {
  arg.hash(); // this one is not ambiguous
}

This issue proposes a number of other syntaxes that would allow IntHashable.hash to be called without needing to create the wrapper function. I think if we were trying to use the implements keyword it might perhaps be

(arg implements Hashable).hash();

but I'm not so confident that is what you were asking.