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: spec: allow embedding interfaces with conflicting methods #45405

Closed
bcmills opened this issue Apr 6, 2021 · 12 comments
Closed

proposal: spec: allow embedding interfaces with conflicting methods #45405

bcmills opened this issue Apr 6, 2021 · 12 comments
Labels
Milestone

Comments

@bcmills
Copy link
Contributor

bcmills commented Apr 6, 2021

Background

(This proposal is forked from #45346 (comment).)

Today, the Go compiler rejects programs that declare unsatisfiable interface types — that is, interface types that require the same method name with two different signatures (https://play.golang.org/p/xSzi4tLp4q4).

However, such interfaces can arise in real programs as they evolve over time, due to otherwise-irrelevant incompatible changes in upstream dependencies.

Consider

import (
	"example.com/foo"
	"example.com/bar"
)

type FooBarBazzer interface {
	foo.FooBarrer
	bar.BarBazzer
}

// OldF implements the old version of F.
//
// Deprecated: use NewF instead. A FooBarBazzer can no longer be constructed
// as of foo v0.4.0 and bar v0.6.0.
func OldF(x FooBarBazzer) {
	foo.Consume(x)
	bar.Consume(x)
}

// NewF implements the same functionality as OldF without using package foo.
func NewF(x bar.BarBazzer) {
	substituteFoo(x)
	bar.Consume(x)
}

This package may well continue to compile and work with older versions of example.com/foo and example.com/bar, but will fail to build when those dependencies are updated, even if the callers have all migrated to NewF. The OldF function can only be removed as a breaking change, which the maintainer of the package might not want to do at this point.

However, if the FooBarBazzer interface were allowed to decay to an unsatisfiable interface, then the package would continue to compile without error, and only the callers of the deprecated OldF function would break when they upgrade to incompatible versions of foo and bar.

Proposal

I propose that the check for conflicting methods on embedded interfaces be removed.

In order for programs to usefully compile with this restriction removed, we would need a few additional changes. For an interface type Conflict that embeds conflicting interfaces:

  • A variable of type Conflict may be declared and used. Its only valid value is nil.

    • The program may invoke any method on the variable except for the methods with conflicting definitions.
      • Calls to conflicting methods are disallowed by analogy to accessing a conflicting embedded field of a struct.
      • We could instead allow the call to succeed using any definition of the method, but that seems subtle, unnecessary, and difficult to implement. (A caller who wants a specific version of the method could assign the variable to an unambiguous interface type and call the method on that, but there's no point in doing so because it will nil-panic anyway.)
    • All allowed method invocations necessarily panic (because the value is the nil interface value).
  • The method set of Conflict contains all of the conflicting signatures for the method. Thus, it continues to implement all of the interfaces it embeds, and is assignable to each of those interfaces.

  • The conflicting method definitions can be enumerated by reflect.Type and reflect.Value, but cannot be looked up by name.

    • For reflect.Type.NumMethod and reflect.Value.NumMethod, each distinct signature for the method is considered to be a different method.
    • reflect.Type.Method and reflect.Value.Method enumerate each distinct signature independently.
    • However, reflect.Type.MethodByName returns Method{}, false and reflect.Value.MethodByName returns the zero Value for any method with conflicting definitions.

Go 2 Language Change Template

  • Would you consider yourself a novice, intermediate, or experienced Go programmer?
    • Experienced.
  • What other languages do you have experience with?
    • C, C++, Python, Java, OCaml, SML, bash, and small doses of Rust and Haskell
  • Would this change make Go easier or harder to learn, and why?
    • Likely neutral. It adds a weird case to deal with (unusable interfaces), but also removes a special case (conflicting embedded methods).
  • Has this idea, or one like it, been proposed before?
  • Who does this proposal help, and why?
    • Consumers of packages that have made breaking changes to interfaces — especially consumers who themselves publish a package for external consumption. This proposal allows consumers to migrate and adapt to new method signatures incrementally, instead of forcing all packages to update simultaneously when a breaking change occurs.
  • What is the proposed change?
    • (See the previous section.)
  • Is this change backward compatible?
    • Yes. This proposal allows programs that are rejected today to compile and run, but does not change the behavior of any program that already compiled successfully.
    • However, it weakens an invariant in the reflect package. Specifically, it is no longer the case that every method returned by Method can be retrieved using MethodByName.
  • Show example code before and after the change.
    • (See the example in the Background section.)
  • What is the cost of this proposal? (Every language change has a cost).
    • Implementation work in cmd/compile and gccgo to accept and propagate methods with conflicting definitions.
    • Some amount of work in static analyzers (such as staticcheck) to accept and process programs with conflicting methods.
    • How many tools (such as vet, gopls, gofmt, goimports, etc.) would be affected?
    • What is the compile time cost?
      • Negligible. (Perhaps some memory overhead from changed data structures for describing methods.)
    • What is the run time cost?
      • Negligible. (The MethodByName methods in the reflect package may need new conditional branches, but for all existing programs those branches are 100% predictable.)
  • Can you describe a possible implementation?
    • I'm happy to discuss that as a followup, but I believe the implementation is straightforward.
    • Do you have a prototype? (This is not required.)
      • No.
  • How would the language spec change?
    • (See the previous section.)
  • Orthogonality: how does this change interact or overlap with existing features?
  • Is the goal of this change a performance improvement?
    • No.
  • Does this affect error handling?
    • Not significantly, no.
  • Is this about generics?

CC @Merovius @ianlancetaylor @griesemer

@gopherbot gopherbot added this to the Proposal milestone Apr 6, 2021
@bcmills bcmills added v2 A language change or incompatible library change LanguageChange labels Apr 6, 2021
@Merovius
Copy link
Contributor

Merovius commented Apr 6, 2021

I'm not convinced by the case you motivate this with. ISTM that a change in foo.FooBarrer or bar.BarBazzer that would cause a conflict is a breaking change (as is any change to any public method of an exported interface, AFAICT). As such, foo or bar (whichever breaks) should bump their major version when doing that change. At that point, SIV means that FooBarBazzer still refers to the old version. Updating it to the types from the new major version is, again, a breaking change. All of these breakages are regardless of whether or not we allow conflicting method definitions. So maybe I misunderstand what you are getting at, but to me, this doesn't seem to really help in the situation you outline.

@bcmills
Copy link
Contributor Author

bcmills commented Apr 6, 2021

@Merovius

ISTM that a change in foo.FooBarrer or bar.BarBazzer that would cause a conflict is a breaking change (as is any change to any public method of an exported interface, AFAICT).

Correct: adding a method to an existing, exported interface is a breaking change. Part of the point of this proposal is to contain and isolate the damage from such a break.

As such, foo or bar (whichever breaks) should bump their major version when doing that change. At that point, SIV means that FooBarBazzer still refers to the old version.

Note that my example involves breaking changes in v0 releases, which do not require a major-version increment and thus do not require a semantic import path change.

We don't recommend making breaking changes like that, but they can sometimes be useful — especially during early development of a public package, or during ongoing development of packages with limited users (such as private code in use by multiple projects within an enterprise).

@bcmills
Copy link
Contributor Author

bcmills commented Apr 6, 2021

The other place where this may come up is in generic code, in which user-supplied type arguments may cause some generic interface to be unsatisfiable. If the unsatisfiable interface is only used as, say, an input to a method that the user does not intend to call, then the fact that it has no usable implementation is arguably not important.

This also parallels some of the discussion in #45346: if we argue that it isn't important to diagnose interfaces whose type-sets are empty due to conflicting concrete-type constraints, then why is it important that we diagnose interfaces whose type-sets are empty due to conflicting method constraints?

@Merovius
Copy link
Contributor

Merovius commented Apr 6, 2021

if we argue that it isn't important to diagnose interfaces whose type-sets are empty due to conflicting concrete-type constraints, then why is it important that we diagnose interfaces whose type-sets are empty due to conflicting method constraints?

FTR I wasn't arguing that it's "not important", but that the benefits are low compared to the complexity. It is not hard to detect if an interface is not satisfiable, but it's very hard to detect if a type set is empty :)

@baha-ai
Copy link

baha-ai commented Apr 9, 2021

You can still do this today without any change to the language, see example below (removed imports so you can directly compile the code):

type FooBarrer struct{}
func (f *FooBarrer) fn() {
	
}

type BarBarrer struct{}
func (b *BarBarrer) fn() {
	
}

type FooBarBazzer interface {
	FooBarrer
	BarBarrer
}

type NewFooBarBarrer struct {
	FooBarBazzer
}
func (e *NewFooBarBarrer) fn() {
	e.FooBarBazzer.(*BarBarrer).fn()
}

The new NewFooBarBarrer implementation of FooBarBazzer calls BarBarrer's fn() function while both old and new implementations coexist.

GoLang doesn't need to add complex function/operation overloading, unlike other OO languages, it's focused on basic interface-implementation interactions to simplify APIs.

@bcmills
Copy link
Contributor Author

bcmills commented Apr 9, 2021

@Baha-sk, you can embed conflicting interfaces in a struct type today. However, you cannot do the same with an interface type. This proposal is to change the interface behavior to more closely match the struct behavior.

@baha-ai
Copy link

baha-ai commented Apr 9, 2021

That's the whole point @bcmills, when overriding, you're doing it on the struct that specifies the implementation, not the interface.

Although FooBarBazzer embeds 2 conflicting implementations (or even interfaces), it's the implementation of it that decides which member to call.

For the sake of clarity, here's the updated example with embedded interfaces (and their respective implementations):

type Foo interface {
	fn()
}

type FooBarrer struct{}

func (f *FooBarrer) fn() {
	fmt.Println("old fn")
}

type Bar interface {
	fn()
}

type BarBarrer struct{}

func (b *BarBarrer) fn() {
	fmt.Println("new fn")
}

type FooBar interface {
	Foo
	Bar
}

func NewFooBarBarrer() FooBar {
	return &FooBarBarrer{
		Foo: &FooBarrer{},
		Bar: &BarBarrer{},
	}
}

type FooBarBarrer struct {
	Foo
	Bar
}

func (e *FooBarBarrer) fn() {
	e.FooBar.(*BarBarrer).fn()
}

FooBar doesn't care one bit how implementations call fn() as long as it's defined and can be resolved to a concrete implementation. Instantiating ForBarBarrer dictates which one to call.

If say the client has a function referencing FooBar like : CallMe(fb FooBar), then I don't expect the client to create a separate CallMe clone to specify which interface should call fn(). The client will be provided with an updated struct implementation (eg FooBarBarrer) that does the job for them.

@antichris
Copy link

I think you've missed the point of this proposal entirely, @Baha-sk. I believe this proposal isn't about being stuck on the details of concrete implementations, but rather about changing the language to make code like this compile and run as is.

@baha-ai
Copy link

baha-ai commented Apr 12, 2021

Even though FooBarBazzer and bar_BarBazzer both have Fn() defined as a member function, they are not the same type definitions.

It's a mistake to allow swapping interface types in arguments or any field, this will introduce inconsistencies in the language.

I think the problem lies in the design of FooBarBazzer and OldF/NewF calls. Typically updating an API change will require clients to migrate from an old version to a newer one that's not backward compatible. That's part of life.

I think as much as we'd like public APIs not to break old clients when migrating, keeping the language clean and consistent is more important. The type struct -> interface relationship is well established and understood. Swapping of interfaces with same function definitions will require additional checks on the interface definition instead of just verifying the type.

For me, clarity and consistency overweigh seemless migration of APIs. Don't you agree?

That's just my 2 cents, I may be wrong and your case is probably a valid one, the Go Authors/Experts might have a stronger opinion.

@ianlancetaylor
Copy link
Contributor

This proposal suggests that we add complexity to the language in order to support a complex code base. While the language should support complex code bases, this seems like an inappropriate approach of adding complexity to the language rather than removing complexity from the code base.

Also there doesn't seem to be strong support for this proposal.

Therefore, this is a likely decline. Leaving open for four weeks for final comments.

@ianlancetaylor
Copy link
Contributor

No further comments.

@gudvinr
Copy link

gudvinr commented Nov 6, 2021

I was writing parser for 3rd party schema which might not define fields for types (and even whole types) yet data provider still can send them.

Missing types isn't a problem since I could just add them to parent interface and embed generated one inside.

However, if there's field in existing type that isn't defined in schema, that leads to situation where I can't safely override interface method for new type that embeds old one.
Other options mostly look like janky workarounds: either add hardcoded replacements in generator or create new interface method for wrapper type.
Second option leaves unused method in interface that won't be used.

This looks somewhat like this:

// generated code

type SomeEvent struct {
    EventID string `json:"event_id"`
}

type generatedHandler interface {
    SomeEvent(SomeEvent)
}

var generatedDefs = map[string]reflect.Type{
    "some_event": reflect.TypeOf(SomeEvent{}),
}

func newEvent(name string) interface{} {
    return reflect.New(generatedDefs[name]).Interface()
}
// wrapper code

type SomeEventShim struct {
    SomeEvent
    AnotherField int `json:"another_field"`
}

type Handler interface {
    generatedHandler
    SomeEvent(SomeEventShim) // I'd like that
}

func init() {
    // so I can use that
    generatedDefs["some_event"] = reflect.TypeOf(SomeEventShim{})
}

// and that
type HandlerImpl struct {}
func (HandlerImpl) SomeEvent(SomeEventShim) {}

@golang golang locked and limited conversation to collaborators Nov 6, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

No branches or pull requests

7 participants