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: builtin: extend make() to be a general-purpose non-nil zero value creator #52151

Closed
DeedleFake opened this issue Apr 5, 2022 · 5 comments
Labels
Milestone

Comments

@DeedleFake
Copy link

Problem

Currently in Go, make() serves as a general instantiator for the more complex of the builtin data structures. If you want to allocate a pointer to a zero-value, on the other hand, new() is the function to use. However, while both of these functions take a type as the first argument, they do so quite differently. new(T) results in a non-nil *T, but make(T) results in a non-nil T directly. In other words, the type passed to make() is the type that it returns, while new() returns a pointer to the type that it is passed instead.

In most situations, this difference is not a particular issue, but with the introduction of generics it can cause some minor problems. For example, I recently wanted to be able to instantiate zero-values of a type that I was passed. The function, slimmed down into a contrived variant, is having trouble trying to do the following:

type Constraint interface {
  M()
}

func Example[T Constraint]() T {
  return ???
}

func main() {
  t1 := Example[ImplOne]()
  t2 := Example[*ImplTwo]()
}

In the case of the first call to Example(), I could easily just create an instance of T by doing var t T; return t, but that doesn't work for the second as it will return nil. Similarly, I can't actually use new(T) as doing so creates a pointer to the type that it is passed, so it will try to return *ImplOne for the first call and **ImplTwo for the second, which is worthless. Worse, while the first case can be manually dereferenced after the call to new(T), the second will result in returning a nil pointer, putting the whole thing back at square one.

I wound up solving the issue by writing the following function:

func deepZero[T any]() (v T) {
  t := reflect.TypeOf(v)
  if t.Kind() != reflect.Pointer {
    return v
  }
  return reflect.New(t.Elem()).Interface().(T)
}

I'm not thrilled with the need to use reflect for this solution, but it works.

Proposal

I propose to extend the make() builtin to return a non-nil zero value for non-interface types, excluding unsafe.Pointer and function types. In other words, for non-nillable types, it will simply return their regular zero value. For nillable types, on the other hand, it will return an empty, non-nil variant of whatever they represent. It already does this for slices, maps, and channels, so this proposal essentially just extends that behavior to most other concrete type.

To allow for the usage of this new behavior with generics, usage of make(T) where T is an unsafe.Pointer, function type, or interface would return nil instead of being a compile-time error. Alternatively, this could be made to panic. This would mean that some code would be unsafe despite compiling, but it would also leave the door open for the behavior to be changed later on without breaking the compatibility guarantee.

make([]T, 3) // No change.
make(map[K]V) // No change.
make(int) // Returns 0.
make(string) // Returns an empty string.
make(*int) // Returns the same thing as new(int).
make(any) // Since there is no obvious non-nil zero value for interfaces, it returns nil.
make(func()) // Also returns nil.

This would let the above function be rewritten as

func Example[T Contraint]() T {
  return make(T)
}

Concerns

One issue that I have with this is that it adds yet another way to instantiate pointers. For structs, there's already an overlap between new(T) and &T{}. I think that the benefit is worth it, though.

I'm also not thrilled with either solution to the issue of unsafe.Pointer, function types, and interfaces. I think that returning nil is probably fine, but that would mean that it would be stuck that way. I don't like the idea of it panicking, but plenty of other simple operations do that, too, such slice-to-array conversions. Those are easier to check for safety in advance at runtime, however.

@gopherbot gopherbot added this to the Proposal milestone Apr 5, 2022
@earthboundkid
Copy link
Contributor

Still thinking about the proposal but FWIW, deepZero will panic if it receives a nil interface: https://go.dev/play/p/aH7nKUIM-W_l

Do this instead: https://go.dev/play/p/hcxatH0Nkfy

@ianlancetaylor
Copy link
Contributor

Can you expand a bit on the case where this arises? If I understand correctly, for a pointer type *T you want new(T), but otherwise you want the zero value of T. When does this arise?

Separately, what should make(T) return if T is a slice type? Currently that is invalid: make with a slice type requires a length.

@ianlancetaylor ianlancetaylor added LanguageChange v2 A language change or incompatible library change labels Apr 5, 2022
@DeedleFake
Copy link
Author

DeedleFake commented Apr 5, 2022

@carlmjohnson

Ah, that's the same issue that I worried about in #50741. Whoops. Thanks.

Edit: I think the reflect.ValueOf() is actually unnecessary. You can just do reflect.TypeOf(&v).Elem() instead.

@ianlancetaylor

The specific case has something that is dynamically managing several objects for the user. A given instance of the type always deals with the same type of object, so it can be generecized, improving the ergonomics of the functions that the user provides to which the objects are passed. In other words, it's something like

type Object interface { ... }

type Manager[T Object] struct {
  Create func() T
  Modify func(T)
}

func (m *Manager[T]) register(obj T) { ... }
func (m *Manager[T]) fromThirdParty(tp thirdparty.Type) T { ... }

func (m *Manager[T]) Manage() {
  thirdparty.DoSomething(
    func() thirdparty.Type {
      t := m.Create()
      m.register(t)
      return t.ThirdParty()
    },
    func(tp thirdparty.Type) {
      m.Modify(m.fromThirdParty(tp))
    },
  )
}

However, Manager.Create is just func() Object { return &impl{} } or func() Object { return impl{} } essentially 100% of the time. I could remove the need for the user to provide it completely if I could genericly instantiate a non-nil zero value:

c := func() T { return make(T) }
if m.Create != nil {
  c = m.Create
}
t := c()
return t.ThirdParty()

As for make([]T), to be honest I completely missed it. I think that following the idea of returning a non-nil zero value, though, simply making the second argument optional for slices too and then making make([]T) the equivalent of []T{} makes sense.

@ianlancetaylor
Copy link
Contributor

It seems hard to justify the fact that make would return the zero value of most types but not pointer, map, channel, or slices types. And why should make([10]*int) return an array of 10 nil pointers rather than allocating those pointers?

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

-- for @golang/proposal-review

@ianlancetaylor
Copy link
Contributor

No further comments.

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

4 participants