Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proposal: language: compute interface method sets based on values instead of types #47429

Closed
chewxy opened this issue Jul 28, 2021 · 5 comments
Closed
Labels
FrozenDueToAge LanguageChange Suggested changes to the Go language Proposal Proposal-FinalCommentPeriod v2 An incompatible library change
Milestone

Comments

@chewxy
Copy link

chewxy commented Jul 28, 2021

What version of Go are you using (go version)?

$ go version
go version go1.16.5 linux/amd64

Does this issue reproduce with the latest release?

Yes

What did you do?

Situation: I'm teaching someone Go, and we came up with a weird corner case that isn't quite covered in the language specification.

Consider the following code:

// Std is a type that implements Standard, as well as `gener`.
type Std struct{}
func (Std) isMachine() {}
func (Std) Add(a, b int) int { return a + b }
func (Std) Sub(a, b int) int { return a - b }
func (Std) gen() int { return 4 } // guaranteed to be random

type Machine interface{
	isMachine()
}

type Adder interface {
	Add(a, b int) int 
}

type Suber interface {
	Sub(a, b int) int
}

type Standard interface{
	Machine
	Adder
	Suber
}

type gener interface{
	gen() int
}

Now consider the following types:

type Foo struct {
	Standard
}

func New(m Standard) *Foo {
	if m == nil {
		m = Std{}
	}
	return &Foo{m}
}

Syntactically, we can see that Foo and *Foo implements Standard. This is because Standard is embedded in Foo. All good.

Now consider this:

var m Machine = New(nil)   

m has *Foo as an underlying type, which implements Standard by means of the embedded Standard in the struct field. The embedded Standard has a Std as its underlying type. The methodset of m at compile time is that of a Standard:

  • isMachine()
  • Add(a, b int) int
  • Sub(a, b int) int

So far so clear. What is not clear (at least in the langspec) is the following runtime behaviour:

switch m.(type) {
case gener:
	fmt.Println("m implements gener")
case Standard:
	fmt.Println("m implements Standard")
case *Foo:
	fmt.Println("m is *Foo")
}

Here, you'll see it print "m implements Standard".

However, if you do this:

switch m.(*Foo).Standard.(type) {
case gener:
	fmt.Println("m is *Foo, whose `Standard` implements `gener`")
case Standard:
	fmt.Println("m is *Foo, whose `Standard` is well, `Standard`")
case Std:
	fmt.Println("m is *Foo, whose `Standard` is `Std`")
}

Here, you'll see it print "m is *Foo, whose ``Standard`` implements ``gener``".

This all rather makes sense from a syntactic/compilation point of view. We only need to generate methodsets for a given type, and then it's a quick lookup when doing type switches at run time.

The Question

The question raised by my student, which I feel makes sense is this:

why isn't the methodset of m calculated at runtime to also include that of the underlying types?

The question can be asked alternatively as:

Why isn't m reified to be something like &Foo{Std{}}, instead of &Foo{Standard}

The Changes Proposed

A proposed change is how the methodset of a value is computed at runtime. Currently, the methodset is computed at runtime based on the surface types. Foo as a type embeds Standard. But the actual value at runtime embeds a Std. So we should compute the methodset based on value. This can be done based on heuristics - For things that are known and analyzable at compile time, we can generate the methodsets AOT (or for large interfaces, generate all possible combinations); and a dynamic lookup to be cached somewhere in the runtime system for things that are dynamic.

This change makes sense, as programmers can now do type assertions based on the behaviours of the concrete types that underlie the interfaces, as follows:

switch m.(type) {
case gener:
	fmt.Println("m implements gener") // yay! 
case Standard:
	fmt.Println("m implements Standard")
case *Foo:
	fmt.Println("m is *Foo")
}

Why?

There is an argument to be made that the point of interfaces and type switches are that it allows adhoc, dynamic (i.e. at run time) calling of methods. The compile-time strictness can be left to things like contracts or type classes.

Furthermore it's actually more consistent in behaviour - runtime.I2I should work for all interfaces.

@chewxy chewxy changed the title [Language Change?] Runtime Type Assertion of Embedded Interfaces in Embedded Structs Language Change: Compute interface methodsets based on values instead of types Jul 28, 2021
@ianlancetaylor
Copy link
Contributor

why isn't the methodset of m calculated at runtime to also include that of the underlying types?

All method sets are fully known at compile time. This is a feature.

A type switch or type assertion permits the program to ask whether the dynamic type of an interface value is some type or whether it implements some interface. When you use a type switch to ask whether m implements gener you are asking whether it has the gen method. The dynamic type of m is *Foo. *Foo implements various methods, but it does not implement gen. Given a value f of type *Foo, a call to f.gen() will be a compilation error.

The field Foo.Standard is itself an interface type. The type switch on m.(*Foo).Standard.(type) examines the dynamic type of the Foo.Standard field, which in this example is Std. The Std type has a gen method, so it implements gener.

I think you are suggesting that when a struct embeds an interface type the methods of the dynamic type of the interface value should become methods of the struct. However, those methods will not be available at compile time. They clearly can't be, as the compiler can't possibly know in general the dynamic type of Foo.Standard. So they would only be available at run time, for use in a type assertion or type switch to an interface type.

But that's not how Go works. Go doesn't construct method sets dynamically. And I don't see why it would be a good idea to construct method sets dynamically at run time.

@ianlancetaylor ianlancetaylor changed the title Language Change: Compute interface methodsets based on values instead of types proposal: language: compute interface method sets based on values instead of types Jul 28, 2021
@gopherbot gopherbot added this to the Proposal milestone Jul 28, 2021
@ianlancetaylor ianlancetaylor added v2 An incompatible library change LanguageChange Suggested changes to the Go language labels Jul 28, 2021
@mvdan
Copy link
Member

mvdan commented Jul 28, 2021

Perhaps worth noting that, if you really want to create named types with arbitrary methods at run-time, that should be possible at some point: #16522

But I fully agree with Ian that a type known at compile-time having a fixed set of methods is a feature. A lot of tooling and code is built on that principle.

@bjorndm
Copy link

bjorndm commented Jul 28, 2021

https://play.golang.org/p/dgzYDBbYiHM

This is not a proposal but rather I question I feel, but nevertheless, I will try to answer it. In your case m, and Machine do not implement gener because it is only an /incident/ that the Standard member happens to be filled in with something that also implements gener. I we were to promote the method set of the real Standard member to the top level Machine, then machine could both implement and not implement the gener interface, depending on the value contained, which is contradictory.
To avoid such contradictions, it's very important that the mehod set of a value depends on the method set of a type, and that this is determined at compile time (if possible).

This is also important to maintain type safety. While there are some other programming languages that allow individual instances to be customized with additional methods, this is not a good idea, because it makes it difficult to reason about what you can do with a specific value, and type safety is lost.

Example:

package main

import (
	"fmt"
)

// Std is a type that implements Standard, as well as `gener`.
type Std struct{}

func (Std) isMachine()       {}
func (Std) Add(a, b int) int { return a + b }
func (Std) Sub(a, b int) int { return a - b }
func (Std) gen() int         { return 4 } // guaranteed to be random

// StdNogen is a type that implements Standard but not gener.
type StdNogen struct{}

func (StdNogen) isMachine()       {}
func (StdNogen) Add(a, b int) int { return a + b }
func (StdNogen) Sub(a, b int) int { return a - b }

type Machine interface {
	isMachine()
}

type Adder interface {
	Add(a, b int) int
}

type Suber interface {
	Sub(a, b int) int
}

type Standard interface {
	Machine
	Adder
	Suber
}

type gener interface {
	gen() int
}

type Foo struct {
	Standard
}

func NewFoo(m Standard) *Foo {
	if m == nil {
		m = Std{}
	}
	return &Foo{m}
}

func main() {
	var m Machine = NewFoo(nil)
	var n Machine = NewFoo(StdNogen{})

	// m is of type Foo, which implements the same methods as Standard does.
	// That is why it is not a gener, even at runtime, Standard only implements
	// the methods of Machine, Adder and Suber.
	switch m.(type) {
	case *Foo:
		fmt.Println("m is *Foo")
	}

	switch m.(type) {
	case gener:
		fmt.Println("m implements gener")
	}

	switch m.(type) {
	case Standard:
		fmt.Println("m implements Standard")
	}

	// n is also of type Foo, even though Standard has been filled in differently.
	// It only implements Standard and not gener also.
	switch n.(type) {
	case *Foo:
		fmt.Println("n is *Foo")
	}

	switch n.(type) {
	case gener:
		fmt.Println("n implements gener")
	}

	switch n.(type) {
	case Standard:
		fmt.Println("n implements Standard")
	}

	// In this case, at run time, incidentally, Standard is Std, for m,
	// which does implement gener  as well as the Std methods.
	switch m.(*Foo).Standard.(type) {
	case gener:
		fmt.Println("m is *Foo, whose `Standard` implements `gener`")
	}

	switch m.(*Foo).Standard.(type) {
	case Standard:
		fmt.Println("m is *Foo, whose `Standard` is well, `Standard`")
	}

	switch m.(*Foo).Standard.(type) {
	case Std:
		fmt.Println("m is *Foo, whose `Standard` is `Std`")
	}

	// In this case, incidentally, Standard is StdNogen, for n,
	// which does not implement gener.
	switch n.(*Foo).Standard.(type) {
	case gener:
		fmt.Println("n is *Foo, whose `Standard` implements `gener`")
	}

	switch n.(*Foo).Standard.(type) {
	case Standard:
		fmt.Println("n is *Foo, whose `Standard` is well, `Standard`")
	}

	switch n.(*Foo).Standard.(type) {
	case StdNogen:
		fmt.Println("n is *Foo, whose `Standard` is `StdNogen`")
	}

}

@ianlancetaylor
Copy link
Contributor

Based on the discussion above this is a likely decline. Leaving open for four weeks for final comments.

@ianlancetaylor
Copy link
Contributor

No further comments.

@golang golang locked and limited conversation to collaborators Sep 8, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
FrozenDueToAge LanguageChange Suggested changes to the Go language Proposal Proposal-FinalCommentPeriod v2 An incompatible library change
Projects
None yet
Development

No branches or pull requests

5 participants