The usage of method call syntax for regular functions as a way to have "generic methods"

166 views
Skip to first unread message

Mark Mavzon

unread,
Oct 29, 2024, 12:04:20 AMOct 29
to golang-nuts
As we all know Go doesn't have generic methods. The FAQ states:
"We do not anticipate that Go will ever add generic methods".
Also the FAQ lists 4 options out of which the last one seems reasonable:
Define that generic methods cannot be used to satisfy interfaces at all.
But for some unknown reason it is considered a "bad design".
And so I was thinkinking that maybe the regular functions can be adapted for the usage as methods? Consider this:
type Maybe[A any] {
    value any
}
func Just[A any](value A) Maybe[A] { return Maybe[A]{value} }
func Map[A, B any](_m Maybe[A], f func(A) B) Maybe[B] {
    if _m.value == nil {
        return Maybe[B](_m)
    }
    value := f(_m.value.(A))
    return Maybe[B]{value}
}
And then the usage:
maybeInt := Just(123)
maybeInt.Map(func(i int) { return fmt.Printf("%v", i) }).Map(...).Map(...)

Can this break anything? What are the chances of such a proposal to be approved by the Go Team? Specifically I propose that if a regular function is defined with the first parameter name starting with the underscore we should be able to use method call syntax with this function. And at the same time this function should not take part in interface satisfaction in any way. And if there's a method with the same signature this method should be prioritized.
So what do you think?

Axel Wagner

unread,
Oct 29, 2024, 1:26:35 AMOct 29
to Mark Mavzon, golang-nuts
The chained syntax does not contain a package name. There are three possibilities
1. It only works if the function is defined in the same package as its used, or you'd need to use dot-imports. Neither is likely to be acceptable.
2. There would be an ambiguity, if multiple imported packages define a `Map` function with the same signature. Definitely unacceptable.
3. You'd need to modify the syntax, to include a package-qualifier. That's possible, but the result seems like a mess that is even harder to read than chaining syntax in general and I don't think it is what people who have strong opinions in favour of the chaining syntax would like either.

This suggestion (and many variations trying to address this short fall) have come up in #49085 as well. You can read the details there yourself. My personal summary is "this is not going to happen".

--
You received this message because you are subscribed to the Google Groups "golang-nuts" group.
To unsubscribe from this group and stop receiving emails from it, send an email to golang-nuts...@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/golang-nuts/8ac5ee09-a68f-40b2-b311-94808304fd6en%40googlegroups.com.

Axel Wagner

unread,
Oct 29, 2024, 3:22:36 AMOct 29
to golang-nuts
From an off-list response:

1. It only works if the function is defined in the same package as its used, or you'd need to use dot-imports.
The dot-import in this case seems redundant because we have a variable with which we try to use a method syntax and this variable has a specific type and this type was defined in a specific package and all of this is known at compile time. So can't the compiler just perform a check along the lines of "if we use the method call syntax on this variable and if the type of this variable is not an interface and if there's no method with the required signature then check if there's a regular function with the required signature defined in the same package as the type of the variable"?

What you are describing here are effectively methods. You are just saying that, in addition to a method being declared as "func (T) M()`, it can also be declared as `func M(T)`. But nothing changes about the fundamental association between the body and the type, that characterizes a method. So it comes with exactly the same downsides as regular methods, with the added downside that we now have two different kinds of methods, that behave subtly differently (in that only one kind participates in interface implementation). It also means that a package can not define the generic `Map` method people seem to want on more than one type. Which, again, is unlikely to be what people want.

I really encourage you to read #49085, if you find the FAQ entry unsatisfactory. It contains a lot of additional discussion and in particular this suggestion and probably every variation of it you can think of has been discussed there already.

Mark Mavzon

unread,
Nov 2, 2024, 11:52:02 PMNov 2
to golang-nuts
Axel Wagner, I've "read" the thread you linked (read some posts and skimmed through others) and also read the following article (which I should've read sooner):
and now I understand the issue a little bit better (hopefully). As a result I have the only suggestion left for now in regards to generic methods:
1. For an interface's generic methods disallow the use of "any" constraint for type parameters.
type HasIdentity interface {
    Identity[T int64 | float64](T) T // ok
//  Identifoo[T any](T) T // not allowed
}
2. For a type's generic methods there should be no restrictions for constraints.
type Foo struct {...}
func (foo Foo) Identity[T any](v T) T { return v } // ok
3. Generic methods that are invoked on variables of concrete types should behave just like regular generic functions.
foo := Foo{}
v := foo.Identity(123) // the same as calling Identity[T any](foo Foo, v T) T with T = int and the same body as the method
4. Types' generic methods should only be able to satisfy interfaces' generic methods. Types' non-generic methods should only be able to satisfy interfaces' non-generic methods.
5. When the whole program code is analyzed by the compiler we should know exactly which types satisfy which interfaces and which types satisfy which constraints. So...
5a. Every generic method in every interface should be turned into several non-generic methods with their type parameters substituted for every possible concrete types according to their constraints. For the HasIdentity example above we'll have:
type HasIdentity interface {
    Identity[int64](int64) int64
    Identity[float64](float64) float64
}
5b. For every type that has generic methods and that satisfies some interface the corresponding constraints from the interfaces' generic method type parameters should be used to generate all possible method variations for this type. So for the Foo struct above we know that according to its generic Identity method signature it should satisfy the interface HasIdentity so we take the corresponding type parameter constraint from HasIdentity's method (int64 | float64) and use it to generate the needed methods:
type Foo struct{}
func (foo Foo) Identity[int64](v int64) int64 { return v }
func (foo Foo) Identity[float64](v float64) float64 { return v }

So the usage can be:
var a any = Foo{}
// the HasIdentity method table has 2 entries
// which map to the corresponding 2 entries in the Foo method table
h := a.(HasIdentity)
i := h.Identity(123)
f := h.Identity(1.0)

Hopefully I conveyed the main idea. Looking forward to a feedback.

Axel Wagner

unread,
Nov 3, 2024, 2:00:39 AMNov 3
to Mark Mavzon, golang-nuts
On Sun, 3 Nov 2024 at 04:52, Mark Mavzon <marku...@gmail.com> wrote:
1. For an interface's generic methods disallow the use of "any" constraint for type parameters.
type HasIdentity interface {
    Identity[T int64 | float64](T) T // ok
//  Identifoo[T any](T) T // not allowed
}

I'll note that this is extremely unlikely to be accepted. It's a pretty ugly and arbitrary restriction.

I also don't think it really fixes the problems. Whenever someone uses `any` in the relevant examples, they might as well add `SomeOtherwiseUnrelatedMethod()` to all the relevant interfaces for much of the same result (for example, instead of type-asserting from any to io.Closer, you'd type-assert from io.Reader to io.ReadCloser). It does *somewhat* reduce the issue, because it at least means only types which have at least that one method are relevant. But the basic issue remains.

5. When the whole program code is analyzed by the compiler we should know exactly which types satisfy which interfaces and which types satisfy which constraints.

There are circumstances in which this is not the case. For example, when using -buildmode=shared or -buildmode=plugin.
 
So...
5a. Every generic method in every interface should be turned into several non-generic methods with their type parameters substituted for every possible concrete types according to their constraints. For the HasIdentity example above we'll have:
type HasIdentity interface {
    Identity[int64](int64) int64
    Identity[float64](float64) float64
}
5b. For every type that has generic methods and that satisfies some interface the corresponding constraints from the interfaces' generic method type parameters should be used to generate all possible method variations for this type. So for the Foo struct above we know that according to its generic Identity method signature it should satisfy the interface HasIdentity so we take the corresponding type parameter constraint from HasIdentity's method (int64 | float64) and use it to generate the needed methods:
type Foo struct{}
func (foo Foo) Identity[int64](v int64) int64 { return v }
func (foo Foo) Identity[float64](v float64) float64 { return v }

So the usage can be:
var a any = Foo{}
// the HasIdentity method table has 2 entries
// which map to the corresponding 2 entries in the Foo method table
h := a.(HasIdentity)
i := h.Identity(123)
f := h.Identity(1.0)

Hopefully I conveyed the main idea. Looking forward to a feedback.

On Tuesday, October 29, 2024 at 10:22:36 AM UTC+3 Axel Wagner wrote:
From an off-list response:

1. It only works if the function is defined in the same package as its used, or you'd need to use dot-imports.
The dot-import in this case seems redundant because we have a variable with which we try to use a method syntax and this variable has a specific type and this type was defined in a specific package and all of this is known at compile time. So can't the compiler just perform a check along the lines of "if we use the method call syntax on this variable and if the type of this variable is not an interface and if there's no method with the required signature then check if there's a regular function with the required signature defined in the same package as the type of the variable"?

What you are describing here are effectively methods. You are just saying that, in addition to a method being declared as "func (T) M()`, it can also be declared as `func M(T)`. But nothing changes about the fundamental association between the body and the type, that characterizes a method. So it comes with exactly the same downsides as regular methods, with the added downside that we now have two different kinds of methods, that behave subtly differently (in that only one kind participates in interface implementation). It also means that a package can not define the generic `Map` method people seem to want on more than one type. Which, again, is unlikely to be what people want.

I really encourage you to read #49085, if you find the FAQ entry unsatisfactory. It contains a lot of additional discussion and in particular this suggestion and probably every variation of it you can think of has been discussed there already.

--
You received this message because you are subscribed to the Google Groups "golang-nuts" group.
To unsubscribe from this group and stop receiving emails from it, send an email to golang-nuts...@googlegroups.com.

Axel Wagner

unread,
Nov 3, 2024, 2:05:06 AMNov 3
to Mark Mavzon, golang-nuts
I'll make one more note, in an attempt to show that this problem is not easy to solve:
Rust has generic methods. But it does not allow to use them in a "trait object" (it calls that "object safety"). Go's interfaces are, primarily, equivalent to Rust trait objects.
The fact that Rust has not figured out how to do this either should be an indication, that this problem is hard.

Personally, I think that's the only way we could really get generic methods: By making any interface that contains them only usable as a constraint, just like we currently do with union elements. But it should be noted that we really want to *remove* that distinction between constraint- and general interfaces (by using union elements as union types), not widen the gulf.
Reply all
Reply to author
Forward
0 new messages