golang/go

proposal: spec: permit eliding the type of struct fields in nested composite literals

neild opened this issue ยท 16 comments

neild commented

Within a composite literal of array, slice, or map type T, elements or map keys that are themselves composite literals may elide the respective literal type if it is identical to the element or key type of T. Similarly, elements or keys that are addresses of composite literals may elide the &T when the element or key type is *T.

When the type of a nested composite literal may derived from its context, that type may be elided in all cases except when the composite literal is the value of a struct field.

type T struct{ V int }

var _ = map[T]T{{1}: {1}}      // type elided from map key and element
var _ = []T{{1}}               // type elided from slice element
var _ = [...]T{{1}}            // type elided from array element
var _ = struct{ F T }{F: T{1}} // type is required

I propose that elision be permitted in this case as well.

var _ = struct{ F T }{F: {1}}
var _ = struct{ F *T }{F: {1}}

I submit that the arguments in favor are the same as for eliding the type in other contexts, as well as conceptually simplifying the language--it is simpler to say that types may always be elided in nested literals (when the type can be derived from context) than to say that types may be elided in all but one case.

Proposed spec change:

Within a composite literal of array, slice, struct, or map type T, ...

This proposal is a subset of #12854, which proposes elision of types in all composite literals, not just nested ones.

Not an argument for or against, but personally I always forget that this case is not allowed because it feels so similar to the cases where I can elide the type.

Also not an argument for or against, just noting that this was discussed when we discussed type elision for array/slice/map literals. At that time we didn't have any experience with this form of type elision and we decided to err on the side of caution: There was a sentiment that type elision in struct literals may be more confusing (read: lead to less readable code) than elsewhere due to the possibly different types of struct fields. In contrast, for arrays, slices, and maps, all the (keys and) elements are of the same type.

neild commented

I'll have to dig up specific examples, but in my experience the lack of elision often leads to stutter in code:

req := service.AddRequest{
  A: 1,
  B: 3,
  // The Options field contains a value of type Options. Quelle surprise!
  Options: service.Options{
    SurrealNumericRegime: true,
  },
}

This is particularly heinous in massive, nested protocol buffer definitions, although I'm sympathetic to the argument that the fault lies in the massive, nested definitions rather than the language literal syntax. :)

rsc commented

When the type of a nested composite literal may derived from its context, that type may be elided in all cases except when the composite literal is the value of a struct field.

Actually the rule is that when the type of all the elided things is the same, the type may be elided. In a struct (in general), fields may have different types, so it's hard to see what the thing being elided is.

We were concerned with structs in particular because interface fields would still need the type, for example if you have an R io.Reader, you have to write R: &bytes.Buffer{} not R: {}, so you can't just wholesale elide them. And in general it's hard to see what type you have. The stutter in other examples is true too. The balance fell the current way back when we considered it last time.

One thing I realized over the weekend is that this has implications for API design. In os/exec we have a SysProcAttr *syscall.ProcAttr field, and the intent was that you have to import "syscall" to fill one out, so that it is explicit you are writing syscall-specific code. If we allow elision, then you can just write SysProcAttr: {Foo: 1} and not realize that the inner struct literal is syscall-specific: there's no syscall import in your program.

Just things to think about, not an argument for or against.

neild commented

I'm not certain that it quite holds that the type of all the elided things is the same--maps permit elision of both keys and values, which are not necessarily the same type. Or if we say that all the keys of a map have the same value, then it is equally true that all the values of field F of a struct have the same value.

I also don't entirely buy the point about interface fields; you can't elide the type from non-struct types in this case either. It's true that most implementations of an interface are usually struct types, but that's not necessarily the case. And, of course, permitting or forbidding elision of struct field types in other cases doesn't change this case at all, since you need to include the type either way.

One thing to note is that the elisions done currently can always be done without impeding readability.

This (and the others in #12854) would be elision(s) that could help or hinder readability depending on the context.

gofmt -s can always perform the current elisions.

This would have to be a choice. As such, gofmt -s could never perform this elision, though it would be a valid simplification.

Being a choice, different codebases, or different sections in the same codebase could make the choice differently. However, this is already true of keyed vs. unkeyed fields in a struct. While keyed literals are generally preferred, unkeyed literals can make equal sense in some situations so there's no absolute rule.

I would prefer the option to make this choice when it would help readability, personally.

If a type is elided in such a way as to hinder readability, or in an inconsistent manner, that's a matter for code review and linting.

neild commented

One thing to note is that the elisions done currently can always be done without impeding readability.

This is a subjective opinion, not a statement of objective fact.

_= T{
  {1, 2, 3, 4}: {"Value"},
}

Is the type elision in this example aiding or impeding readability? I would say that the answer depends on the context of the code, and that opinions will still differ. I do not believe that it is obviously correct that gofmt -s can always perform the current elisions without impeding readability.

Note that the above example is valid under the current elision rules:
https://play.golang.org/p/ccZJAVqV0M

Will this proposal allow me to use anonymous nested structs?

a := struct {
  A int
  B struct {
    C int
  }
} { 
  A: 10,
  B: {
    C: 10,
  },
}
owais commented

@drscre I think it should as IMO it is probably the case that would benefit the most from this proposal. In fact, I think it'd still be a huge win if the proposal was accepted only for the anonymous struct case. I find myself writing nested structs anonymous structs from time to time and am forced to always break them down into multiple independent structs that are the composed together. I agree that is better for most cases but in some cases it feels unnecessary.

For example, recently I wrote something like this but only a a lot more field.

type SubConfigA struct {
	FirstField  string
	SecondField string
}

type SubConfigB struct {
	FieldA string
	FieldB string
}

type Config struct {
	F1 int
	F2 int
	F3 int

	SubA SubConfigA
	SubB SubConfigB
}

With lots of nested fields and lots of nested structs, it would have been nicer to be able to write the whole thing as a single type as it is very much self-contained.

type config struct {
	F1 int
	F2 int
	F3 int

	SubConfA struct {
		FirstField  string
		SecondField string
	}

	SubConfB struct {
		FieldA string
		FieldB string
	}
}

and then be able to use it to define a defualt config instance without having to re-type the entire definition again or split the struct definition into smaller pieces.

defaultCfg := Config{
	F1: "f1",
	SubConfA: {
		FirstField: "first",
	},
	SubConfB: {
		FieldB: "bee",
	},
}

This would be suuuuper useful for configuration structs. When I configure an application, I usually write a config struct like this:

type Config struct {
	Runtime struct {
		Environment string
		Debug       bool
		Admins      []string
		Hostname    string
	}

	GRPC struct {
		Enabled  bool
		Port     int
		Services []string
	}
}

This makes the acceptable values really easy to read.. the anonymous structs just act as namespaces when configuring, so your config file looks something like this:

Runtime:
    Environment: dev
    Debug: true
GRPC:
    Enabled: true

(etc)

But then if you want to fill it out in actual go code, it's kind of awful:

c := Config{
	Runtime: struct {
		Environment string
		Debug       bool
		Admins      []string
		Hostname    string
	}{
		Environment: "dev",
		Debug: true,
 	},
	GRPC: struct  {
		Enabled  bool
		Port     int
		Services []string
	}{
		Enabled: true,
	},
}

The only way around it, would be to make Runtime and GRPC named types, but that then makes it a lot harder to read the type declaration (and thus what actual values are available to be set):

type Runtime struct {
	Environment string
	Debug       bool
	Admins      []string
	Hostname    string
}
type GRPC struct {
	Enabled  bool
	Port     int
	Services []string
}

type Config struct {
	Runtime Runtime
	GRPC GRPC
}

Not only does this make it harder to see what you can set in the Config struct, it also "exposes" the structs internal to Config to the external world, even though they really only exist to namespace values inside the Config struct.

And the annoying thing is that the compiler knows what every type is. Yes, if there's an interface you'd still have to specify, but the compiler would tell you when you forgot to specify that. I don't think "you'd still have to specify a type in one ambiguous case" is a reason not to allow inference the rest of the time.

c := Config{
	Runtime: {
		Environment: "dev",
		Debug: true,
 	},
	GRPC: {
		Enabled: true,
	},
}

With that change, the struct literal becomes almost a perfect duplicate of the type declaration.

Please, let's allow type elision here. It would make a lot of code easier to read, easier to write, and easier to maintain.

This is labelled go2 but it seems to be backwards compatible. Is there something that makes.it not possible to do in go1?

@natefinch We label all language changes as Go2.

jba commented

This would make slog's API nicer. (See #56345.)
Instead of

slog.Info("msg", "a", "b", "c", "d")

which many people don't like because it's hard to tell what are keys and what are values, we could instead write

slog.Info("msg", {"a", "b"}, {"c", "d"})

which is marginally more verbose but much easier to read.

@jba I think you want #12854

Hi, I am not sure about the status of this issue, I hope it's still under consideration.

I just wanted to add an example that I didn't see mentioned above, showing that this behavior in practice even causes the rule allowing elision of other types to become useless:

var dataRotate = []struct {
	a, b []int
	c    int
}{
	{
		[]int{1, 2, 3, 4, 5, 6},
		[]int{5, 6, 1, 2, 3, 4},
		2,
	},
	{
		[]int{1, 2, 3, 4, 5, 6, 7},
		[]int{5, 6, 7, 1, 2, 3, 4},
		3,
	},
	{
		[]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20},
		[]int{14, 15, 16, 17, 18, 19, 20, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13},
		7,
	},
	[...]
}

This is often how table-driven test data for real-life functions look like. This pattern is used often and is recommended by many for the appropriate situations: https://dave.cheney.net/2013/06/09/writing-table-driven-tests-in-go

(Obviously I don't mean to imply that a project member recommending this makes it "official" or whatever, just that it's a very common pattern and its ergonomics should be taken into consideration).