golang / go

The Go programming language
https://go.dev
BSD 3-Clause "New" or "Revised" License
122.99k stars 17.54k forks source link

proposal: spec: tuples as sugar for structs #63221

Open jimmyfrasche opened 12 months ago

jimmyfrasche commented 12 months ago

Updates:

This proposal adds basic tuples to Go with one piece of sugar and two builtins.

The sugar is for struct(T0, T1, …, Tn) to be shorthand for a struct type with n fields where the ith field has type Ti and is named fmt.Sprintf("F%d", i). For example, struct(int, string) is a more compact way to write struct { F0 int; F1 string }.

This gives us a tuple type notation for and answers all questions about how they behave (as the desugared struct they are equivalent to). By naming the fields F0 and so on, this both provides accessors for the individual elements and states that all tuple fields are exported.

The variadic pack builtin returns an anonymous tuple, with the appropriate types and values from its arguments. In particular, by ordinary function call rules, this allows conversion of a function with multiple returns into a tuple. So pack(1, "x") is equivalent to struct(int, string){1, "x"} and, given func f() (int, error), the statement t := pack(f()) produces the same value for t as the below:

n, err := f()
t := struct(int, error){n, err}

The unpack builtin takes any struct value and returns all of fields in the order of their definition, skipping _ fields and unexported fields from a different package. (This has to be somewhat more generally defined as tuples aren't a separate concept in the language under this proposal.) This is always the inverse of pack. Example:

// in goroutine 1
c <- pack(cmd_repeat, n)

// in goroutine 2
cmd, payload := unpack(<-c)

The struct() sugar let's us write pairs, triples, and so on for values of mixed types without having to worry about names. The pack and unpack builtins make it easier to produce and consume these values.

No changes are needed to the public API of reflect or go/types to handle tuples as they're just structs, though helpers to determine if a given struct is "tuple-y" may be useful. go/ast would need a flag in StructType noting when a struct used the tuple syntax but as long as the implicit field names are explicitly added by the parser. The only place this would be needed is for converting an AST back to a string.

The only potential danger here is unpack. If it's used on a non-tuple struct type from a different package it would be a breaking change for that package to add an additional exported field. Go 1 compat should be updated to say that this is a acceptable just as it says that adding a field that breaks an unkeyed struct literal is acceptable. Additionally, a go vet check should be added that limits unpack to structs with exclusively "F0", "F1", …, "Fn" field names. This can be relaxed at a later time.

This is a polished version of an earlier comment of mine: https://github.com/golang/go/issues/33080#issuecomment-612543798 In the years since I've written many one-off types that could have just been tuples and experimented with generics+code generation to fill in the gap. There have been multiple calls for tuples in random threads here and there and a few proposals:

ianlancetaylor commented 11 months ago

I am troubled by calling unpack on a type defined in a different package, as typically that will break if the type changes: if it adds or removes a field, or even if it rearranges the fields. This restriction is similar to the current vet check on using a type defined in a different package in a composite literal: we require that they composite literal be keyed. If we don't have a similar requirement for unpack, then any change to a struct type may be a breaking change. I don't see how to resolve that except to say that vet should warn about using unpack with any type defined in a different package. How troublesome would such a vet check be in practice?

earthboundkid commented 11 months ago

I think there would need to be some kind of comment you could add to a struct that means “I promise not to add fields to this struct, so it’s okay to unpack it if you want”. The default would be that it’s not okay to unpack across packages, of course.

jimmyfrasche commented 11 months ago

@ianlancetaylor using the usual exported/unexported rules would be fine by me.

So is a vet check no matter how strict (it can always be loosened with experience).

If there had been a way to make it strictly the inverse of pack I would have. It being necessarily more powerful than that does create hypothetical issues. Again, though, I don't believe there would be much misuse in practice. Objectionable uses don't seem especially useful and vice versa. That may be lack of imagination on my part, of course.

DeedleFake commented 11 months ago

If there had been a way to make it strictly the inverse of pack I would have.

There was a suggestion up above to automatically add tags to the fields of a tuple. What if unpack() only unpacked, in order, all the fields of a struct that had a certain tag on them, and then just make the tuple syntax sugar add that tag? That way the usage is opt-in per struct field, new fields can be added without breaking it, and it which fields are unpacked should, I think, even be checkable by the compiler and not have any overhead from it.

jimmyfrasche commented 11 months ago

@DeedleFake that sounds awkward to spec. A vet check is a simpler approach, if anything needs to be done here.

urandom commented 11 months ago

I'm curious how this proposal works alongside what go already calls tuples - function parameters and return values. Would this proposal cause confusion, such that people might be trying to define tuples as (int, string) like in function definitions instead of specifying the struct type beforehand? Would there be an assumption that these tuples are the same?

jimmyfrasche commented 11 months ago

That's a complicated question.

I don't think it would never happen, but I don't think it would happen that many times in a row, and I do think that anyone who runs into that would learn the lesson fairly quickly. A small bump in the road but nothing that will slow you down too much.

With the exception of some functional language families, most languages have more of a difference between "function tuples" and "value tuples" than you'd think. Using the same syntax kind of papers over those difference at the expense of a complicated grammar and leaving lots of little special cases all over the place.

Being explicit about construction, destructuring, and type construction avoids those special cases and makes tuple operations easier to spot when reading code. On the whole, I think being explicit would make tuples easier to use and fit into Go better.

atdiar commented 11 months ago

That's interesting. Have you thought about how to handle generic code?

Notably for functions, if one wants to abstract over them, tuple will probably be required to describe function arguments and function returns.

So somehow, it might require some more work to make them types. (the return value needs to be assignable to some kind of variable too which has to be of a given "tuple" type)

But then some other questions may arise... What to do with functions which are variadics. What's the arity? i.e. What's the actual size of the tuple? Infinite might be problematic.

Some of the questions are far reaching so I don't necessarily expect an answer but it might be relevant when considering a new feature such as this.

jimmyfrasche commented 11 months ago

@atdiar that is all far, far out of scope for this proposal.

atdiar commented 11 months ago

Well it would still be a good litmus test to think about it I believe.

Features tend to interact with each other and given that people seem to think about variadic generics too, might be good to take it into consideration as well.

afocus commented 11 months ago

more and more like this proposal, it can generate a lot of new associations such as lambda expression, error handle, etc...

DeedleFake commented 11 months ago

Just today I find myself creating a Result struct just to pass two values through a channel and immediately thought of how much nicer it would be with this proposal.

How feasible would it be to expand this proposal to allow an unpack(<-c) in a select case? Calling a function like that does not currently work.

jimmyfrasche commented 11 months ago

I wouldn't know about the feasibility of that but it feels unlikely. Is there any precedent for builtins being used in that way?

Not having it certainly isn't ideal but doesn't seem too bad:

case v := <-c:
  x, y, z := unpack(v)
apparentlymart commented 11 months ago

The fact that a select case can support a value, ok assignment to detect if the channel has been closed seems to complicate that.

But with that said, reading out the tuple and then passing it to unpack as a separate step still seems considerably better than the hand-written-every-time boilerplate that's required today. A shorthand that allows reading and destructuring at the same time would be nice, but even without it I'd still like to have what is proposed here.

awilliams commented 11 months ago

I'm wondering how the following use of unpack would work:

type B struct{ F0 int }

type A struct{ B }

a := A{
    // The following should work because this works today:
    //  B: struct {
    //      F0 int
    //  }{
    //      F0: 42,
    //  }
    B: pack(42),
}

v := unpack(a)
fmt.Printf("%v", v) // Is this '42' or '{42}'?
jimmyfrasche commented 11 months ago

Since, in this case, pack(42) is equivalent to struct{F0 int}{42} and unpack(a) is equivalent to a.B, it prints "{42}": https://go.dev/play/p/PgPtYqH7Jhy

jimmyfrasche commented 10 months ago

I've updated the proposal to include language/spec changes and to endorse a vet rule.

Language: Allow unpack to unpack any struct. unpack always skips _ fields. Unexported fields of a struct from a different package are also skipped. [this is slightly different than what @ianlancetaylor suggested so removed reference incorrectly implying otherwise]

That is, given:

package X
var V struct {
  A int
  b bool
  C string
  _ struct{}
}

the statement

a, c := unpack(X.V)

is equivalent to

a, c := X.V.A, X.V.C

Vet: There are many complicated rules we could derive to cover as many cases as possible but there are also simple rules that may be good enough:

option 1: do not allow unpack on any struct defined in a different package.

option 2: only allow unpack on a struct struct defined a different package iff all of its fields are exported.

option 3: limit unpack to structs that could be written as a struct() (that is, all fields are named F%d and the number corresponds to the field position)

option 1 is certainly appealing but then that would disallow use with a defined tuple type from a different package. This would break the symmetry of pack and unpack so it's a no go.

option 2 is my mild preference as it basically just enforces the old rule that you can't use it with unexported fields by covert means.

option 3 is probably the safest bet since it in effect only allows unpack to be used with tuples. This is the one that I have included in the proposal.

Regardless of which vet option is taken, I think it's important to add unpack to https://go.dev/doc/go1compat next to the carve out for struct literals to make it official that changes that break unpack are not considered breaking changes. I have made that more explicit.

apparentlymart commented 10 months ago

unpack returning a different number of results depending on whether you use it from the package where the struct is defined seems like it would be surprising the first time someone encounters it, but I think it could be okay if combined with an actionable error and/or vet message when done, as you've proposed. I might've gone a little further and said that unexported fields are always skipped regardless of who's calling unpack, but I can't really defend that as any less surprising than what you proposed, just different.


If the number of results varies depending on the callsite then I expect a likely error is using the wrong number of symbols on the left side of the assignment:

a, b, c := unpack(X.V)

Is the error message that's returned for the wrong number of symbols able to vary depending on what's on the right side of the assignment, or is that just handled generally for all assignments? Ideally I'd like to see the above situation say something specific about how only two results are returned because the unexported fields are excluded, so that there's a hook for someone who encounters this problem to find the relevant section of the docs/spec.

(Perhaps the vet checks are sufficient, but I'm worried about someone who isn't using vet for some reason and so is relying entirely on the direct compiler errors.)

jimmyfrasche commented 10 months ago

@apparentlymart it only unpacks fields it can see per the standard visibility rules. That seems like the least confusing way to me, but perhaps I am wrong. If you think of desugaring unpack into regular field accessors it helps because the right hand side can then only contain legal references.

Having special error messages for unpack would probably be a good idea in general as it could relate the number of returns and their types to the result of the expression in unpack as though it were desugared. That is of course up to the various compiler writers, though.

urandom commented 10 months ago

This might be a bad question, but what if there is no unpack? It at least none in this proposal.

Instead, what if a tuple, or a packed struct, is always destructed on assignment? This would look like this:

// in goroutine 1
c <- pack(cmd_repeat, n)

// in goroutine 2
cmd, payload := <-c

This would leave the ability to assign a tuple to a variable to be done only if you pack it before assignment.

zephyrtronium commented 10 months ago

@urandom u, v := <- c already has the meaning of checking whether c is closed. Similarly for maps. That would be an incompatible change.

I'm also not sure how to consistently represent the idea of forced destructuring except when the assignment is to pack. The assignment you're doing in goroutine 2 in your example is already the result of pack, it just went through a channel first.

DeedleFake commented 10 months ago

@jimmyfrasche

In terms of definition, I think not trying to differentiate between the current package and other packages is simpler. For example, the godoc comment in builtin might say something like

unpack returns all of the exported fields of its argument, which must be a struct, in the order in which they appear in the struct type's definition.

Rewording that to try to allow unexported fields, too, but only if they are accessible from where it's called only makes it more complicated.

unpack returns fields of its argument, which must be a struct, in the order in which they appear in the struct's type definition. It returns all fields which are accessible from its call site, meaning that if the struct type was declared in another package, it only returns its exported fields, but if it was declared in the local package it returns all of it's fields.

I think it makes the most sense to just make it consistently skip unexported fields.

jimmyfrasche commented 10 months ago

@DeedleFake unpack's specifics are certainly the stickiest bit. To confess, I really don't care what those specifics are as long as they contain the inverse of pack. Anything sensible is fine by me.

That said, I will make this argument for the current revision: it's always exactly the most you could write if you manually desugared it. It's always all the fields that you can access normally. If you can write s.f in normal code then unpack(s) includes s.f in the appropriate place. If you can't write s.f, then unpack(s) doesn't unpack it. It unpacks as much of s as you can, no more, no less.

earthboundkid commented 10 months ago

I think it should just be a compiler error to unpack a struct with unexported fields. And a vet error to unpack a struct across packages if it doesn't have some kind of //go:unpack magic comment or struct tag.

jimmyfrasche commented 10 months ago

Forbidding unpack on structs with unexported fields is fine by me. I don't endorse that plan and I'd prefer the rule I wrote, but I have no objection to it.

I do object to requiring directives, however. That's noisy and requires perfectly good code to be updated just to opt in to something that's technically allowed means there will be a lot of cases where you can't use it because no one got around to the ceremony or you end up having to vet a dependency update only to find out it just contains blessing for some structs. That's not a good use of anyone's time.

jimmyfrasche commented 10 months ago

One problem with it being an error to fail if the struct has unexported fields:

If you have a dependency with this code at version N

package p
var S struct {
  X int
}

and this code at version N+1

package p
var S struct {
  X int
  y string
}

then unpack(p.S) is valid when written against version N but you update to N+1 and it's now illegal.

With the rule that only accessible fields are accessed unpack(p.S) is equivalent to writing p.S.X in either case. While it's true there would be an error if S had added a new exported field (or removed X), it is at least immune to changes that it cannot see.

Since S cannot be written with struct() syntax it would fail my proposed vet rule, regardless.

apparentlymart commented 10 months ago

I don't love it being an error to unpack with unexported fields any more than I love the other two possibilities, but it is the one option of the three that allows changing plans later: future versions of Go could make it valid in either of the other two ways specified, if practical experience suggests it would be valuable to do so.

I think it's also worth keeping in mind that unpack is there primarily for reversing what pack does, and that its acceptance of arbitrary other types in this proposal is in the interests of a more straightforward spec, not because it's a recommended thing to do in general.

I would still like to see a vet rule that warns against using unpack with any type that doesn't match the conventions produced by pack, to help reinforce that doing so risks your code becoming broken if the struct changes to include an unexported field in future (and, viewed from the other direction, that adding an unexported field to a struct that was previously entirely exported should not be treated as any more bothersome as it is today).

jimmyfrasche commented 10 months ago

@apparentlymart the current vet check in the top post is exactly that.

apparentlymart commented 10 months ago

Indeed, sorry my intent was to say: even if it were made invalid to unpack a struct type with unexported fields I think the vet check remains important.

In other words, I don't think making unexported fields be an error would replace the need for the vet check, for the compatibility-related reasons we were discussing. (I think I'm just agreeing with you, as far as I can tell)

jimmyfrasche commented 10 months ago

If it's feasible to spec that you can only unpack a struct that can be written with the struct() syntax that would allow the most leeway for future expansion and would require no vet checks and doesn't introduce any new ways to make a breaking change. I'm not sure if that's an option but if it is it may cause the least unease.

urandom commented 10 months ago

u, v := <- c already has the meaning of checking whether c is closed. Similarly for maps. That would be an incompatible change.

@zephyrtronium Even the current spec states that this form yields an additional untyped boolean result . It doesn't state it is the second value. This should also not be an incompatible change, as u, v := <-c would cause destructing only if the value was packed in the first place. Thus, without even changing the spec, one could still write the special form as u, v, ok := <-c

At least that's my interpretation of it

DeedleFake commented 10 months ago

@urandom

What if you don't want to unpack? And how is a tuple detected, then? The proposal wants pack() to just be a convenience for creating an instance of an otherwise normal anonymous struct type. With your suggestion, using pack() would creating a struct that was uniquely identifiable as having been created from pack(). Unless I'm missing something.

urandom commented 10 months ago

What if you don't want to unpack? And how is a tuple detected, then? The proposal wants pack() to just be a convenience for creating an instance of an otherwise normal anonymous struct type. With your suggestion, using pack() would creating a struct that was uniquely identifiable as having been created from pack(). Unless I'm missing something.

Detection should be possible if pack creates these anonymous struct types that also implement some sort of interface. If one doesn't want to unpack, I was thinking that it might be possible to pack the tuple again on assignment, something like:

tuple := pack(...)

This would however require some special handling in order to be able to produce the special assignment forms for channels and maps. This problem could be postponed for now, by simply not supporting fetching a packed struct in the special forms. This may turn out to be just a slight inconvenience in the end.

One reason I'm suggesting this is, to me it seems like the unpack is the harder part, with a lot more edge cases, according to the discussions here. So perhaps avoiding the unpack altogether for this proposal might be easier as a start. It might also simplify the spec change, since that already mentions tuples in this context:

A tuple assignment assigns the individual elements of a multi-valued operation to a list of variables
DeedleFake commented 10 months ago

I don't think that it's harder, per se. It's just that it has several different ways to do it that all seem valid and people have different opinions on which is best. I think that removing it in favor of making channel receives attempt to destructure pack()ed structs has far more edge cases and complications.

jimmyfrasche commented 10 months ago

@urandom

Detection should be possible if pack creates these anonymous struct types that also implement some sort of interface.

The tuples are defined to be identical to an anonymous struct. This proposal doesn't really work if they're not.

Also you're not avoiding unpack: you're making it implicit. That doesn't make any of the issues go away it just adds more, unfortunately.

I do understand where you want to go with that but you need to start from a different place to get there. I think you'd both need to introduce tuples as a separate kind of type unrelated to structs and define general destructuring. I like many languages with those things but the more explicit mechanisms in this proposal seem the most likely way to do this in Go.

jimmyfrasche commented 9 months ago

@griesemer filed a sketch for tuples in #64457. That's not a proposal that is intended to be accepted, just documentation of a potential design. I evaluate it versus this proposal.

There are two bits to each of these proposals: syntax and semantics. They are largely separate and you could bolt the syntax of this proposal onto the semantics of that one and vice versa.

Syntax

The sketch uses the more common (x, y, z) syntax, for both types and values.

It notes that this "directly matches the commonly used mathematical notation for tuples". I don't think that's strictly true. Not all mathematicians use that notation for tuples, though it is pretty much standard in computer science. Regardless, matching mathematical notation isn't a goal in itself or we'd use juxtaposition for multiplication or at the very least write string concatenation with * rather than +. It does however match the syntax used by many languages with tuples, which is a big, big :+1: in its favor.

That does introduce some cons, though

  1. a 1-tuple requires a trailing comma: (1,)
  2. a function returning only an anonymous tuple requires double parens: func() ((int, error))
  3. there is no equivalent to this proposal's pack(f()) where f returns multiple values
  4. it requires a new type in go/ast to represent a tuple type/value

(1,) is inaesthetic but common. The double parens on returns is less common and could cause annoyance fixing compiler errors when forgotten, but that's something I could easily get used to.

The lack of a way to turn a multiple-return into a tuple is a major :-1: against the sketch, at least in my book. It could be rectified by introducing additional syntax specifically for the task but that just adds to the complexity and the number of things to learn.

Requiring a new type in go/ast means that all go tooling that parses source needs to be made aware of the new production making deployment of the feature more costly.

The sketch writes unpack(t) as t... which is shorter.

Semantics

Unlike this proposal, the sketch makes tuples an entirely new kind of type. This means that they can diverge from structs semantics. There is very little in the sketch that does, however, other than making tuple fields inaddressable. The major gain is that ... need not be defined for general structs.

A very large complication of introducing a new kind of type is the reflect package.

Say you have a tuple t and a third party dependency pkg with a function func F(any) and F both uses reflection and was written before reflect.Tuple become a valid reflect.Kind. What happens when you call pkg.F(t)? Well, anything. It could panic, or loop, or just do something slightly wrong that takes forever to trace back to its source.

One thing the sketch has that this proposal doesn't is rules for converting between tuples and structs. Roughly, you can convert a tuple to any struct that has the same types in the same order and vice versa.

That could be added to this proposal if unpack is always allowed and as a special case S{unpack(t)} is allowed. Then you could convert a tuple t to a struct type S with S{unpack(t)}. You could also convert a struct value s into a tuple with pack(unpack(s)). I'm not really sure how useful it is to convert between tuples and structs, though. Generally if I have a struct I'll just use that and the tuple is when I neither have nor need a struct.

Conclusion

I'd be happy with the sketch if it adds some simple way to lift the multiple returns a function invocation into a tuple. Other than that they are essentially the same mechanism. I still have a strong preference for this proposal as it is much simpler.

dsnet commented 9 months ago

Personally, I see this proposal treating tuples under the hood as a Go struct as a feature. As the author of several reflect-based packages, I'd rather not think about how to support tuples for the most part. The differences between a tuple and a struct are not sufficiently different that it justifies a separate Go kind. That said, I would still like a single bit in the reflect information to at least distinguish between explicitly created Go struct types versus the implicit structs from tuple-like syntactic sugar if I do happen to care about that distinction in the reflect code.

jimmyfrasche commented 9 months ago

It's pretty trivial to write a function that most likely tells you if it's a tuple under this proposal: Check if it's a struct and all of its fields match the naming convention and are untagged. That tells you that it is either a tuple or something that could have been written as a tuple. It does not tell you how it happens to have been written, but what important difference is lost there?

DeedleFake commented 9 months ago

Worth noting that Rust also considers its tuples to be structs, but Rust also doesn't have the strict separation between types and data format that Go does. A struct type is defined as

struct Example {
  val: i32,
}

As far as I know, there isn't really an equivalent of Go's type Ex int, only an equivalent of type Ex = int.

there is no equivalent to this proposal's pack(f()) where f returns multiple values

It's not too hard to add one, though. The first thing that comes to mind for me is (f()...). It's a bit weird, but I think it would be pretty consistent.

My personal opinion is that this proposal is closer to the correct approach, but I think that I could be persuaded otherwise.

DeedleFake commented 9 months ago

Oh, another thing that I thought I'd mention: The tuple problem can also be somewhat solved using #56462. For example, it would be possible under that proposal to just do

type Tuple[T ...any] struct { v T }

func Pack[T ...any](v T) Tuple[T] { return Tuple[T]{v} }

func (t Tuple[T]) Unpack() T { return r.v }

func main() {
  c := make(chan Tuple[(int, string)], 1)
  c <- Pack(3, "Example")
  t := (<-c).Unpack()
  // Etc.
}
jimmyfrasche commented 9 months ago

Use cases for tuples:

Communicating multiple values between goroutines

ch <- pack(1, 2)

Storing multiple values in a generic data structure

m := map[struct(string, string)]int{}

Piping multiple values through generic functions that except a single value:

f[struct(int, string)](aTuple)

Lifting a function's multiple returns into a single value (usually in service of one of the above)

v := pack(multipleReturns())

Calling a function with multiple arguments using a single value (from one of the above)

f(unpack(<-ch))

Creating an intermediary group of/index on values within a function/data structure that is necessary for processing but does not escape to the outside world (this is too hard to illustrate with a one liner)

Have I left any out?

Could you use structs instead? Yes, but then you need to come up with a name and put it somewhere. If you're lucky you can come up with good names for things but often the use case is small so it's simpler to just stick to numbering it (Pair.First, etc.) You can use generics to only define these once but then you still need a new one for every number used. For a large module you could probably stick these in an internal/ package. You could also write out the most commonly useful sizes and put it in a public package but then you need to put that somewhere and all the names in it are Pack2, Pack3, etc.

Should you always use tuples? No. They can make things harder to read and use if they're used injudiciously. It's rarely correct to use as part of an API. They're best used inside an encapsulation barrier when a name is more trouble than its worth and it doesn't matter if they need to be changed since you're in control over all the places that will change. That can still cover a large area and improve readability when you don't have to litter a bunch of structs all over just to stuff 2 or 3 values down a channel. It's easier to say "ah a tuple of two values" then "what's this type? let's see, go to definition…. Ah, just a pair. Where was I?"

gophun commented 9 months ago

You can use generics to only define these once but then you still need a new one for every number used.

While a 2-tuple may be okay, a triple is already a smell, and a 4-tuple is a stench. An internally defined generic 2-tuple type should be enough for anybody.

apparentlymart commented 9 months ago

All of my use-cases for tuples are variations on sending a set of function arguments and/or return values through anything that supports only a single value, as described in https://github.com/golang/go/issues/63221#issuecomment-1837604979. Given that, I would suggest that an N-tuple for any N is a "smell" only if a function with N arguments and/or N return values is also a "smell" for the same N.

I agree that a function with a large number of arguments and/or a large number of return values is a situation that would give me pause, particularly in an exported API. However, by my sensibilities N is greater than 4: a function with 3 or 4 arguments seems reasonable to me. I might start being concerned at 5 or more.

I have more tolerance for larger N for unexported functions because the caller and callee are defined closer together, which permits taking greater design liberties. I agree with @jimmyfrasche that I would use this only inside an abstraction boundary, and I would therefore tolerate a larger N before considering it problematic.

gophun commented 9 months ago

I would suggest that an N-tuple for any N is a "smell" only if a function with N arguments and/or N return values is also a "smell" for the same N.

Functions and their parameters almost always have names, whereas tuples and their fields don't. Multiple return values usually have names starting at 3 or when the second parameter is not an error. That's what makes the difference in "smelliness".

jimmyfrasche commented 9 months ago

I've used 3 and 4 tuples within a function where nothing would have been clarified by using anything else. There is nothing wrong with that. I have certainly seen tuple-mad code and agree that's bad but that's not because of the idea of tuples it's due to their use when something better made more sense.

A pair is the most common n to the point where it would cover a majority of the times you need a tuple (75%+ easy) and I think it's fair to consider only supporting a pair on that basis alone, though I'd prefer a more general mechanism.

griesemer commented 9 months ago

Just some comments regarding #64457: It does mention (end of comment) that a possible implementation of tuples would simply consider them as syntactic sugar for light-weight structs. In many ways this seems like the preferred approach because it would be fairly light-weight.

The concern I have with such an approach though is that the respective struct fields will have to be given "automatic" names that are always the same. This leads exactly to the kind of readability problems that others have pointed out with tuples - fields like F0 and F1 etc. are just not very informative. In such cases one should really use a struct with telling field names.

The approach I had investigated was mapping tuples to structs with all blank (_) fields. That would make those fields inaccessible but retain the benefit of the mapping. Because individual fields are inaccessible, there's a reduced danger of using tuples for building data structures where proper structs are the better tool.

Unfortunately this falls apart because at some point in the past we decided that fields are ignored when comparing structs. Thus such tuples couldn't be compared. (As an aside, in retrospect, adding the rule that fields are ignored when comparing structs seems like a mistake to me: it ties properties of blank field names together with properties of structs and thus connects these in non-orthogonal ways, violating one of the first rules for Go language features.)

jimmyfrasche commented 9 months ago

If _ for the names worked, I'd be fine with that. Most languages offer some way to get to just the nth field of a tuple but it's not universal and it's rare to have a legitimate reason to access the fields individually. You generally either create one whole or break one into all it's pieces at once (pack/unpack) and if you need to do more you should probably be using something else.

Simulating unlabeled types via a labeling scheme may not be ideal and probably shouldn't be considered for a new language but it is a pragmatic solution for working it into an existing language and for the most part the names can just be ignored.

If I'm writing a package now that needs a single pair I'll just write the struct out and probably give the fields good names, but if I need multiple kinds of pairs I'll make the struct generic and name the fields something like first and second (if not fst and snd) as the only sensible name is its index into the container and not much is gained over F0, F1.

Of course, the Fn pattern is just what I've been using. Maybe FieldN would be more palatable? It could be anything as long as it's

apparentlymart commented 9 months ago

I agree that, for my purposes, accessing the individual fields by name is not important at all. The only parts of this idea that I really care about are:

Neither of the above require writing down individual field names anywhere, or even knowing that there are field names.

dsnet commented 9 months ago

I want to re-emphasize the importance of making existing reflection-based code work well with tuples.

If fields were not individually accessible, it implies that there must be new reflect API to extract all of the elements of a tuple (functionally something that matches pack and unpack in reflection). One possible API would be something like:

package reflect

func Pack(...reflect.Value) reflect.Value
func Unpack(reflect.Value) []reflect.Value

but I suspect this API will perform poorly as it will incur allocations. Furthermore, it goes against my prior comment that reflection should require close to zero changes to support this feature.

One of the benefits of type parameters is that it had no material impact to Go reflection, thus avoiding a large overhaul of reflection packages to support it. It is a noble goal if tuples, type unions, or any language addition avoid changes to reflection (if possible).

The concern I have with such an approach though is that the respective struct fields will have to be given "automatic" names that are always the same.

There is already an inherent "namespace" for the elements of tuple in that ordering is inherent to the structure of a tuple. Using a numeric index is the most natural representation for ordering. The choice of a "F" prefix in the name is fairly arbitrary, but at least the number portion is a natural outflow of the tuple structure itself.

dsnet commented 9 months ago

As a litmus test, someone using the go-cmp module should not need to upgrade the module to handle tuples (nor should the author of go-cmp need to teach it about tuples). Treating tuples as either 1) a different Go kind, or 2) Go structs with distinctly different behavior than regular Go structs (e.g., all fields are _) will result in either panics or the logic doing something contrary to user expectation by ignoring all the _ fields. In the original proposal of having elements be named fields of F%d, the cmp package will operate as users expect.