Help with language change proposal

150 views
Skip to first unread message

Landon Cooper

unread,
Mar 14, 2023, 9:32:11 PM3/14/23
to golang-nuts
Hi gophers, I'm new here .. I found the link in the language change proposal template.

I've had a start on something, but it's opened so many doors I'm suspicious I've missed something very obvious.

If anyone has time, I appreciate any feedback.


-------------------------

  • Would you consider yourself a novice, intermediate, or experienced Go programmer?

intermediate

 

  • What other languages do you have experience with?

In order of experience … C#, Smalltalk, Javascript, Java, C, python, Haskell

 

  • Would this change make Go easier or harder to learn, and why?

Harder – one more concept to understand.

 

  • Has this idea, or one like it, been proposed before?

Yes, because it potentially mitigates problems outlined in several other issues:

 

  • If so, how does this proposal differ?

This proposal is different in implementation. It does not match the solution from the linked issues, but I believe it mitigates the underlying problems.

 

  • Who does this proposal help, and why?

go programmers who want to reduce boiler plate code.

 

  • What is the proposed change?

interfaces and named aliases would become allowable receivers. The change would just be syntactic sugar for functions that accept this interface.

1type MyInterface interface {} 2 3// currently dissalowed 4func (MyInterface) FunctionB() { 5 // ... 6} 7func main() { 8 t := MyInterface(123) 9 t.FunctionB() 10} 11 12--- 13// would compile as 14func FunctionB(MyInterface) { 15 // ... 16} 17func main() { 18 t.FunctionB(MyInterface(123)) 19}

There are some restrictions:

R1: Just like normal methods, “interface methods” can only be defined within the package the interface is defined. This prevents imported code from potentially breaking or overriding the intended functionality.

R2: interface methods cannot override existing methods. Attempting to cast an object to an interface that it already fulfills or partially fulfills would be a compile time error. This prevents interfaces from becoming self-fulfilling, or overriding expected functionality. The compile time error is necessary so future updates to packages don’t change functionality unexpectedly.

 

My motivation for this feature is to mitigate limitations on dynamic types. Consider the typical approach to handling dynamic json.

1dj := make(map[string]interface{}) 2json.Unmarshal(bytes, &dj) 3 4var maybeC *int 5if a, ok := dj["a"].(map[string]interface{}); ok { 6 if b, ok := a["b"].(map[string]interface{}); ok { 7 if c, ok := b["c"].(float64) { 8 t := int(c) 9 maybeC = &c 10 } 11 } 12}

Access could be simpler through a hypothetical jnode package.

1dj := jnode.Unmarshal(bytes) 2cval := dj.At("a").At("b").At("c").AsInt() 3 4--- 5package jnode 6 7type node any 8 9func Unmarshal(b []byte) node { 10 n := make(node) 11 json.Unmarshal(b, &n) 12 return n 13} 14 15func (n node) At(s string) node { 16 if n == nil { 17 return nil 18 } 19 if nd, ok := n.(map[string]any); ok { 20 return node(nd[s]) 21 } 22 return nil 23} 24 25func (n node) AsInt() int { 26 if n == nil { 27 return nil 28 } 29 if i, ok := n.(int); ok { 30 return i 31 } 32 return nil 33} 34 35// ...

 

It’s possible that these “extension methods” could become part of the new object’s method set. This could lead to a kind of simple adapter pattern like so (excuse my bad example, I was trying to use the built in types so everyone could relate, though this would be much more useful in real applications)

1package str2err 2 3type Adapt interface { 4 String() string 5} 6 7func (e error) Error() string { 8 return e.String() 9} 10 11--- 12func itsComplicated(fs ...func() error) error { 13 var b strings.Builder 14 for i := 3; i >= 1; i-- { 15 fmt.Fprintf(&b, "%d...", i) 16 } 17 b.WriteString("error") 18 return str2err.Adapt(b) 19}

Because this is just implemented through code rewriting, I believe there would be divergence in a variable's “compile time type” and its “runtime type”. This could lead to complexity in the compiler and complex interactions I haven’t thought of.

Fortunately, there’s no need to rush into this. We could start by saying extension methods do not contribute to the method set, and therefore do not help fulfill interfaces. Unfortunately, that caveat would fall to programmers to understand, raising the bar for learning go. I believe the ultimate design here would definitely be to allow the extension methods into the type’s method set.

The adapter pattern becomes even more powerful if we can find a way to disambiguate how extension methods would override a struct’s “real” methods.

 

One commonly proposed language change is to support chaining function calls. Consider this code taken from issue #56242

1result := someList. 2 filter(...). 3 map(...). 4 map(...). 5 reduce(...) 6 7Instead of: 8 9i1 := filter(someList,...) 10i2 := map(i1,...) 11i3 := map(i2,...) 12result := reduce(i3,...)

Interface methods could solve this issue like so

1package funslice 2 3type slice[T any] []T 4 5func Slice[T any](s []T) slice[T] { 6 return slice[T](s) 7} 8 9func (slice[T]) Filter[T any](func(T) bool) slice[T] { 10 // ... 11} 12func (slice[T]) Map[T any, U any](func(T) U) slice[U] { 13 // ... 14} 15func (slice[T]) Reduce[T any, U any](reducer func(T, U) U, seed U) U { 16 // ... 17} 18 19--- 20package main 21 22import "funslice" 23 24func main() { 25 t := []int{1, 2, 3, 4} 26 ext := funslice.Slice(t) 27 28eighteen := ext.Filter(twoOrMore). 29 Map(double). 30 Reduce(sum, 0) 31}

The compiler would be responsible for changing this code to

1package funslice 2 3type slice[T any] []T 4 5func Slice[T any](s []T) slice[T] { 6 return slice[T](s) 7} 8 9func Filter[T any](slice[T], func(T) bool) slice[T] { 10 // ... 11} 12func Map[T any, U any](slice[T], func(T) U) slice[U] { 13 // ... 14} 15func Reduce[T any, U any](s slice[T], reducer func(T, U) U, seed U) U { 16 // ... 17} 18 19--- 20package main 21 22import "funslice" 23 24func main() { 25 t := []int{1, 2, 3, 4} 26 ext := funslice.Slice(t) 27 28eighteen := funslice.Reduce( 29 funcslice.Map( 30 funslice.Filter(ext, twoOrMore), 31 double) 32 sum) 33}

This example illustrates how the code rewriting can lead to a kind of support for generic methods.

Theoretically, this could be a starting point for how generic methods might be possible, though there’s a lot of investigation to happen before that’s possible.

 

One area I’m uncertain about is composition of interfaces. This case seems simple to support.

1package pkgA 2 3type Dem interface { 4Left() 5} 6 7func (Dem) Left() { 8 // ... 9} 10 11--- 12package pkgB 13 14type Rep interface { 15Right() 16} 17 18func (Rep) Right() { 19 // ... 20} 21 22--- 23package main 24 25type mix interface { 26 Dem 27 Rep 28} 29 30func main() { 31 t := make(mix) 32 t.Left() // pkgA is driving 33 t.Right() // pkgB gets control 34}

However, if we redefine our interfaces to include a collision, we have to deal with the ambiguity.

1package pkgA 2 3type Dem interface { 4Left() 5Center() 6} 7 8func (Dem) Left() { 9 // ... 10} 11func (Dem) Center() { 12 // ... 13} 14 15--- 16package pkgB 17 18type Rep interface { 19Right() 20Center() 21} 22 23func (Rep) Right() { 24 // ... 25} 26func (Rep) Center() { 27 // ... 28} 29 30--- 31package main 32 33// Is this legal? 34type mix interface { 35 Dem 36 Rep 37} 38 39func main() { 40 t := make(mix) 41 t.Center() // Which extension method should be called? 42}

For now, this “mix” interface could give a compile error, either because you can’t compose interfaces with methods, or specifically because of the conflict.

A possible workaround is to adapt one interface. This would mean interface methods are not commutative through multiple compositions.

1package main 2 3type myDem interface { 4 Dem 5} 6 7func (MyDem) Left() { 8 // ... 9} 10 11type mix interface { 12 myDem // no longer adds Center as an extension 13 Rep 14}

Alternatively, the extension methods could never commute through composition and a developer must always be explicit in the combining. Within interface extension methods, the composing pieces would become accessible, much like a composed struct.

1package main 2 3// on it's own, composition does not add extension methods to mix 4type mix { 5 Dem 6 Rep 7} 8 9// however, we can add them explicitly 10func (m mix) Left() { 11 m.Dem.Left() 12} 13 14func (m mix) Center() { 15 m.Rep.Center() 16}

 

 

to-do

 

  • Is this change backward compatible?

Because defining methods on interface receivers has been illegal, I believe this is a backward compatible change. No existing code will be doing this.

 

  • What is the cost of this proposal? (Every language change has a cost).

There are many costs, not least of all to the complexity of the compiler, especially if we are to include method set extension.

Some tools I know about would need updating: gpls, gofmt, golangci-lint, probably many others.

I don’t believe there is any runtime cost, besides, possibly, more function calls because of the structural changes in programs this would precipitate. I expect most of that cost will be regained through existing compiler optimizations.

 

  • Can you describe a possible implementation?

The simplest implementation I can imagine is just code rewriting. If we expand the scope to include method set extension, I believe this will require the concept of “compile types” which will be different than runtime types.

 

  • Orthogonality: how does this change interact or overlap with existing features?

I think this syntax synergizes very well with existing features. The syntax is familiar and does not introduce new tokens. With set extension, it also behaves quite naturally from a developer's perspective.

  • Does this affect error handling?

I don’t believe so, though it does facilitate more functional ideas, which might lead to a shift in how errors are treated.

 

  • Is this about generics?

Not specifically, though it does incorporate them.

Axel Wagner

unread,
Mar 15, 2023, 3:53:16 AM3/15/23
to Landon Cooper, golang-nuts
Hi,

Some comments in-line:

> > Who does this proposal help, and why?
> go programmers who want to reduce boiler plate code.

I believe you should definitely be more explicit with the problem you are trying to solve. "Today, if you want to do X, you have to write Y. That is bad, because Z".

There are many different kinds of boiler plate, so just saying you help "Go programmers who want to reduce boiler plate" carries little to no information.

> interfaces and named aliases would become allowable receivers. The change would just be syntactic sugar for functions that accept this interface.

I don't have a specific reference handy, but more or less exactly this has been proposed a couple of times in the past and always been rejected.
So, apologies for being harsh, but I would predict that this has an essentially zero chance of being accepted.

I also think one thing we should be clear on, is that methods fundamentally are never "just syntactic sugar for functions" - unless you really are proposing Uniform Function Call Syntax. In particular, your proposal has name spacing implications - it would be possible to have two methods of the same name on different interface types, while it is not possible to have two *functions* of the same name, even if they accept different interfaces as their first argument.

So, this is not just syntactic sugar, it carries semantic meaning. And if it where, it would not be likely to solve the problem you are trying to solve.
 
> There are some restrictions:

> R1: Just like normal methods, “interface methods” can only be defined within the package the interface is defined. This prevents imported code from > potentially breaking or overriding the intended functionality.

I don't think this is a good idea. Interfaces, by their nature, are mostly package-agnostic. That is, I can define an interface
type MyReader interface{ Read(p []byte) (int, error)
and while it is not *identical* to io.Reader, it is mostly equivalent, that is, they are mutually assignable.

I believe it would be confusing for methods on a `MyReader` to disappear, when assigning them to an `io.Reader` and to appear, when assigning an `io.Reader` to a `MyReader`.

Note also, that Go has interface type-assertions. So how would this work, for example?

func (r MyReader) WriteTo(w io.Writer) (int64, error) { … }
type R struct{}
func (R) Read(p []byte) (int, error) { return len(p), nil } // implements io.Reader
func main() {
    _, ok := io.Reader(R{}).(io.WriterTo) // fails
    mr := MyReader(R{})
    _, ok = io.Reader(mr).(io.WriterTo) // succeeds, presumably?
    ch := make(chan io.Reader, 1)
    ch <- mr
    (<-ch).(io.WriterTo) // fails? succeds?
}

That is, a `MyReader` would implement `io.WriterTo`. But if it is intermediately stored in an `io.Reader` (say, by passing it through a channel), the compiler no longer knows if and where to find a `WriterTo` method.

This really is just a corollary/demonstration of why it's confusing for methods to appear/disappear when converting them between io.Reader and MyReader.

> R2: interface methods cannot override existing methods. Attempting to cast an object to an interface that it already fulfills or partially fulfills would be
> a compile time error. This prevents interfaces from becoming self-fulfilling, or overriding expected functionality. The compile time error is necessary
> so future updates to packages don’t change functionality unexpectedly.

How do you deal with a situation where the *dynamic type* of an interface has that method, but not its static type? That is, what if I assign an `*os.File` to a `MyReader`, for example? What if I assign an `*os.File` to an `io.Reader` and then convert that to a `MyReader`?

> My motivation for this feature is to mitigate limitations on dynamic types. Consider the typical approach to handling dynamic json.
> […]

> Access could be simpler through a hypothetical jnode package.
> […]
 
I believe you can functionally achieve the same result if you make `node` a struct type with an `UnmarshalJSON` method and an `any` or `map[string]any` field or something like that.

> Fortunately, there’s no need to rush into this. We could start by saying extension methods do not contribute to the method set, and therefore do not 
> help fulfill interfaces. Unfortunately, that caveat would fall to programmers to understand, raising the bar for learning go. I believe the ultimate
> design here would definitely be to allow the extension methods into the type’s method set.

Note that the resulting confusion of having methods that are not in the method set, when considering interface satisfaction, is why we do not have extra type parameters on methods. I don't believe this proposal has significantly more advantages than allowing that would have (it probably has significantly fewer) so I don't think this proposal would overcome that concern.

> This example illustrates how the code rewriting can lead to a kind of support for generic methods.
> Theoretically, this could be a starting point for how generic methods might be possible, though there’s a lot of investigation to happen
> before that’s possible.

I think it rather demonstrates why it runs into the same fundamental issues that the proposal to support extra type parameters on methods has.
It seems likely to me, that if we could overcome those issues, we'd prefer to support extra type parameters on methods, instead of this proposal.
 
> > Is this change backward compatible?
> Because defining methods on interface receivers has been illegal, I believe this is a backward compatible change.
> No existing code will be doing this.

That depends on a couple of details - specifically questions the kinds of questions I asked above. Depending on how you answer those, the fact that the dynamic method set of a type can change by passing them through interface types could break compatibility.

Landon Cooper

unread,
Mar 15, 2023, 1:47:10 PM3/15/23
to golang-nuts
Thanks very much for the feedback, Axel. I didn't think it was harsh, this is just the kind of information I needed before burning needless effort on the idea.
Getting started with these conversations is quite intimidating because there is so much information and history, it's hard to find what is relevant, let alone digest it all. I appreciate that anyone spends the time to help a newbie.

Just for interest's sake, I wanted to respond to some of your questions. I take your point about some sections of the proposal being incomplete or too sparse, but I don't think there's any point in spending time on that when there are obvious problems.

> I also think one thing we should be clear on, is that methods fundamentally are never "just syntactic sugar for functions" - unless you really are proposing Uniform Function Call Syntax.
I don't understand this, but I do take your word for it. This is probably the "something very obvious" I missed. 

> In particular, your proposal has name spacing implications - it would be possible to have two methods of the same name on different interface types, while it is not possible to have two *functions* of the same name, even if they accept different interfaces as their first argument.
I believe I accounted for this in the restrictions and scratched the surface of this challenge in the section about composition. However, my approach is obviously naive, I expect there's a good name for this problem and it likely has to be dealt with more systematically.

> I believe it would be confusing for methods on a `MyReader` to disappear, when assigning them to an `io.Reader` and to appear, when assigning an `io.Reader` to a `MyReader`.
Agreed, and it only takes passing mr to a func(io.Reader) for the MyReader method information to be lost at compile time. This points out a serious limitation on the idea of implementing through code rewriting; it could only work for locally scoped variables.

> How do you deal with a situation where the *dynamic type* of an interface has that method, but not its static type? That is, what if I assign an `*os.File` to a `MyReader`, for example? What if I assign an `*os.File` to an `io.Reader` and then convert that to a `MyReader`?
Not sure I see your point here. *os.File to a MyReader would be Okay. The type would gain the WriteTo method.
*os.File to io.Reader, of course is okay, and then to a MyReader is also okay. The dynamic type would have only the Read and WriteTo methods.

I do think the proposal of using code rewriting avoids the issue of parametrized methods, but its usefulness is completely destroyed by the limitation you pointed out.

Thanks again Axel!

Axel Wagner

unread,
Mar 15, 2023, 2:52:00 PM3/15/23
to Landon Cooper, golang-nuts
On Wed, Mar 15, 2023 at 6:47 PM Landon Cooper <ldco...@gmail.com> wrote:
Thanks very much for the feedback, Axel. I didn't think it was harsh, this is just the kind of information I needed before burning needless effort on the idea.
Getting started with these conversations is quite intimidating because there is so much information and history, it's hard to find what is relevant, let alone digest it all. I appreciate that anyone spends the time to help a newbie.

Just for interest's sake, I wanted to respond to some of your questions. I take your point about some sections of the proposal being incomplete or too sparse, but I don't think there's any point in spending time on that when there are obvious problems.

> I also think one thing we should be clear on, is that methods fundamentally are never "just syntactic sugar for functions" - unless you really are proposing Uniform Function Call Syntax.
I don't understand this, but I do take your word for it. This is probably the "something very obvious" I missed. 

I don't think it's obvious. I just mean that "syntactic sugar" really means that you can apply a mechanical transformation to remove it. But consider

type A interface{ X() }
type B interface{ Y() }
func (A) M() {}
func (B) M() {}

If you "desugared" this, you'd get
func M(A) {}
func M(B) {}
which obviously doesn't compile. So there *must* be something else going on. My other questions work in a similar vein - they are meant to demonstrate things that make methods semantically different from functions, to show that these differences must be somehow talked about.

And if you actually *could* apply this mechanical transformation - well, then that really *is* just UFCS. Like, almost definitionally, UFCS is the proposition that a.M() and M(a) should be equivalent. Though to be fair, definitions in computer science are sometimes fuzzy, so I maybe shouldn't make this claim authoritatively - it's more a "if it quacks like UFCS…" sorta thing.
 
> In particular, your proposal has name spacing implications - it would be possible to have two methods of the same name on different interface types, while it is not possible to have two *functions* of the same name, even if they accept different interfaces as their first argument.
I believe I accounted for this in the restrictions and scratched the surface of this challenge in the section about composition. However, my approach is obviously naive, I expect there's a good name for this problem and it likely has to be dealt with more systematically.

I don't think you really addressed this. I was referring to my example above. I can't find anything in your E-Mail about it (though I might be overlooking it - unfortunately, I tend to do that occasionally).
 
--
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 on the web visit https://groups.google.com/d/msgid/golang-nuts/72cacb9e-117c-4b5f-8015-1fdd7756f040n%40googlegroups.com.
Reply all
Reply to author
Forward
0 new messages