Go Programming Experience

Intermediate

Other Languages Experience

Go, Python, Javascript, Java, Elixir, and a smattering of many others

Related Idea

  • [ ] Has this idea, or one like it, been proposed before?
  • [ ] Does this affect error handling?
  • [x] Is this about generics?
  • [x] Is this change backward compatible? Breaking the Go 1 compatibility guarantee is a large cost and requires a large benefit

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

Not that I could find.

Does this affect error handling?

No

Is this about generics?

Yes, as it is about the use of uninstantiated types as function arguments. There is perhaps some conceptual overlap with the idea of discriminated unions, but proposal is much simpler and touches much less.

Proposal

Preface

This topic has almost certainly been covered elsewhere; if that is the case, please direct me to the appropriate discussion and I'll read through it. I did try looking for something that covers this explicitly, and while I found somewhat similar discussions (such as those around creating discriminated unions), I didn't find this exact one.

Overview

TLDR; Disallowing the use of uninstantiated types as function parameters reduces clarity, is surprising, and leads to bugs. Allowing them improves communication within the codebase.

Definition of instantiation, from https://go.dev/blog/intro-generics#type-parameters

Providing the type argument to GMin, in this case int, is called instantiation. Instantiation happens in two steps. First, the compiler substitutes all type arguments for their respective type parameters throughout the generic function or type. Second, the compiler verifies that each type argument satisfies the respective constraint.

This process is required before a type may be used in a function definition. This restricts the ability of the programmer to communicate effectively the behaviors of their functions, and makes the use of generics as function argument types an all-or-nothing proposition: either the function must be "concrete"- that is, instantiated- or any must be used.

Example

Take the following example:

package main

import "fmt"

type Test[T any] interface {
    Get() T
}

type TestImpl[T any] struct {
    value T
}

func (t *TestImpl[T]) Get() T {
    return t.value
}

// This is the problem
func Print(t Test) {
    switch t := t.(type) {
    case Test[string]:
        fmt.Println(t.Get())
    default:
        fmt.Println("not a supported type")
    }
}

func main() {
    Print(&TestImpl[string]{value: "test"})
}

This will not compile, resulting in cmd/types/main.go:17:14: cannot use generic type Test[T any] without instantiation.

Instead, the programmer is left with 2 options:

Option 1

The programmer may "color" the print function (if you're unfamiliar, it's the concept presented here but applied more generally).

func Print[T](t Test[T]) {
    // ....
}

This doesn't really "solve" anything, it means the caller is now faced with the same problem. Worse, if the Print function is attached to a struct, the struct must also be genericized, and the lack of union types means that it can't necessarily be done properly.

Consider a struct that can print Test things:

type Printer[T] struct {}

func (p *Printer[T]) Print(t Test[T]) {
    fmt.Println(t.Get())
}

func main() {
    stringPrinter := Printer[string]{}
    stringPrinter.Print(&TestImpl[string]{value: "test"})

    intPrinter := Printer[int]{}
    intPrinter.Print(&TestImpl[int]{value: 1})
}

Suddenly we need to create a new printer for every type. If the purpose of printer is to print Test things, this approach does not work.

Option 2

Use any as the type parameter.

func Print(t any) {
    switch t := t.(type) {
    case Test[string]:
        fmt.Println(t.Get())
    default:
        fmt.Println("not a supported type")
    }
}

func main() {
    Print(&TestImpl[string]{value: "test"})
}

This is the common approach. The issues is that the any type communicates nothing, and provides no compiler guarantees. The programmer cannot communicate to the caller the expectations of the function, or even the approximate expectations.

Proposal

If the compiler allowed the use of uninstantiated types as function parameters, the programmer could better communicate the expectations of the function to the caller.

func Print(t Test) {
    switch t := t.(type) {
    case Test[string]:
        fmt.Println(t.Get())
    default:
        fmt.Println("not a supported type")
    }
}

This would be backwards compatible since today it would simply result in a compile error.

Preemptive objections and responses

The function parameter still contains no concrete type information, so it is not usable by the function code.

This is true; the programmer would need to use a type assertion to get the instantiated type. In that way, it behaves like any does today.

However, the compiler is still more effectively able to bound the inputs.

For example, the compiler could error like it does with other impossible type assertions:

type I interface {
    Get() string
}

// This is a compiler error in Go
func Coerce(t I) {
    t2 := t.(string) // impossible type assertion: t.(string)
                // string does not implement I (missing method Get)
}

type Test[T any] interface {
    Get() T
}

// This could be a compiler error
func Coerce2(t Test) {
    t2 := t.(string) // impossible type assertion: t.(string)
                // string is not of type Test
}

This wouldn't work on structs, since type assertions are only allowed on interfaces

Even if the compiler only allowed uninstantiated types for interface arguments, this would still be better than just using any today.

It is also not clear to me that this definitely wouldn't be possible for structs. In a sense, an uninstantiated generic struct is like an interface, so there might be a reasonable way to implement it, something like creating implicit interfaces for generic structs. Certainly, from a language perspective, it seems like it would be reasonable to allow uninstantiated struct types.

The programmer still needs to type assert within the function, and it may be non-exhaustive

This is no different from using any today, but still communicates more to the caller and allows additional compiler checks.

The ability to communicate more precisely add clarity with no additional mental cost and is a good thing.

Conclusion

This seems to me like a useful, backwards compatible change.

Because it seems like that, and because the Go team tends to think very carefully about the language, I think I'm probably missing something.

So I would appreciate feedback on this proposal, including any previous proposals that I may have missed and of course issues with doing this.

Thanks!

Language Spec Changes

No response

Informal Change

No response

Is this change backward compatible?

Yes. The code would previously just fail to compile.

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

No response

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

It may make Go slightly easier to learn, as the language would behave a bit closer to expectations.

Cost Description

The time of implementation, including validation.

Changes to Go ToolChain

vet, gopls

Performance Costs

Compile cost is unknown, but should be small, and there should be no runtime cost.

Prototype

No response

Comment From: seankhliao

This doesn't look like something that will be allowed, as it will be ambiguous whether an argument is intended to be boxed via an interface, or passed directly as a generic argument.

Comment From: john-wilkinson

@seankhliao It took a non-trivial amount of time to research and write this proposal. Surely it is worth more than a casual dismissal- at the very least, a short explanation of the situation where you think this would be ambiguous.

I will additionally start a conversation on the mailing list.

Comment From: randall77

@seankhliao It took a non-trivial amount of time to research and write this proposal. Surely it is worth more than a casual dismissal-

It also takes a non-trivial amount of time to evaluate proposals. We're very busy people. We get a lot of proposals. We have to be able to decline proposals we don't think are going to work without spending hours detailing our choices.