More compact syntax for slice operations

Hi, I have a design question about go.
I have a feeling that handling slices is unnecessarily clunky in go. Consider the following use case:
You are given a slice mySlice of integers and want to compute a slice containing the pointwise squares of the first slice. Currently this has to be done like this:

mySlice:=[]int{3,1,42}
result:=make([]int,len(mySlice))
for i,val:=range mySlice{
    result[i]=val*val
}

However other languages allow for something like the following syntax:

mySlice:=[]int{3,1,42}
result:=[val*val for _,val:=range mySlice]

which I consider pretty neat. Of course it is not a huge difference, but I have to write something like the first piece of code pretty often and this really blows up code size.

What do you think about it?

Hi @peteole,

Welcome to the forum,

I see your point; however, Go focuses on readability rather than quick writing. When there are half a dozen different ways to write a loop, reading code becomes more involved.

You can find many other places where Go sacrifices shortcuts for readability. For example, there is no ternary if-then expression. (You may know this from other languages: a := b ? c : d.) And those design decisions were done on purpose. There should be only one way of doing someting.

The language is intentionally kept boring. That’s what makes Go suitable for large teams.

1 Like

Thanks for your answer!

I kind of already realized the benefits of this, even doing “failure-attracting” work is surprisingly reliable in go since there is no magic going on.

However this boilerplate code issue is really common for me and I actually don’t consider my solution very readable. Do you think that including a map function in a standard library would be more feasible? (It would need to use generics so we would need to wait for the next version for it, right?)
It is actually pretty simple, the following works in the online compiler:

package main

import (
	"fmt"
)

// The playground now uses square brackets for type parameters. Otherwise,
// the syntax of type parameter lists matches the one of regular parameter
// lists except that all type parameters must have a name, and the type
// parameter list cannot be empty. The predeclared identifier "any" may be
// used in the position of a type parameter constraint (and only there);
// it indicates that there are no constraints.

func Print[T any](s []T) {
	for _, v := range s {
		fmt.Print(v)
	}
}

func Map[T any, U any](arr []T,f func(T) U) []U{
	res:=make([]U,len(arr))
	for i,val:=range arr{
		res[i]=f(val)
	}
	return res
}

func main() {
	numbers:=[]int64{2,1,4}
	result:=Map(numbers,func(i int64)int64{return i*i})
	Print(result)
}

However when looking at this code I am wondering how difficult it would be to infer parameter- and returntypes for anonymous functions passed as a parameter to another function. Actually all types should already be defined without any further specification, right?

3 Likes

I absolutely agree, generic functions seem the way to go for that kind of problem.

Yes, at the point where you define the anonymous function, you finally have to specify concrete types (in your code, that’s int64 for parameter and return type, and the code obviously works well).

At the moment, I cannot come up with a scenario where type inference would become difficult - do you have an example in mind?

Actually I was wondering if it would be possible to omit the parameter types of the anonymous function since they are already totally defined by the “outer function” (here the Map function) parameter type. This would make the code even simpler:

func main() {
	numbers:=[]int64{2,1,4}
	result:=Map(numbers,func(i){return i*i})
	Print(result)
}

Or like this if we still specify the return type:

func main() {
	numbers:=[]int64{2,1,4}
	result:=Map(numbers,func(i)int64{return i*i})
	Print(result)
}

The above examples don’t compile, but I actually don’t see why the compiler shouldn’t allow it: It does not add any further complexity, the compiler does some implicit type inference anyways and the code still is 100% typesafe

It may be that a driving force in Go language design is to keep the compiler as fast as possible, probably to avoid this situation xkcd: Compiling. :wink:

1 Like

A very valuable feature of Go is that it is non-magic. With very few exceptions, you can look at a part of some Go code and can tell what it does, without ambiguity.

The first code snippet, however, is already quite ambiguous. You’ll need to look up the definition of Map to tell if func(i) is meant to have a return value. Otherwise the return statement inside would be an error.

After checking Map, we know that the func must return some U, but what concrete type will it be? For this, you’d have to analyze the function body, where the expression i*i indicates that the return type must be i's type.

So far so good, but what if the function body is something like,

if i < 0 {
    return -1
}
return 1

This code returns numeric literals that, by definition have no particular type unless they get assigned to a variable. So you’d have to look where the function result is going to be used - this means, we need to go back to Map. Here, the func result is assigned to res[i], and res is of type []U but what is U?
In main, the result of Map is assigned to variable result, which gets its type from the result of Map. We are in a catch 22.

Also, what is the correct type of function parameter i? You need to look up Map again, see that the type parameter T is the same as the one for numbers, then go to the definition of numbers, and then you finally know the type of i.

This is not very readable if you ask me. Losing readability is not a small price for saving a few keystrokes.

And there is another problem. Consider that you can also pass a regular function to Map. With the type inference you suggest, the following declaration would be valid:

func square(i) {
    return i*i
}

At this point, it is impossible to tell what parameter and return types square has. It totally depends on the context where square is used.

In other words, the above function may or may not be correct code. We simply cannot tell without looking at all of the places where square is used.

And the places where square is finally used can be quite far away, maybe even in a different file.

func main() {
	numbers:=[]int64{2,1,4}
	result:=Map(numbers, square)
	Print(result)
}

How can the compiler, or a human reviewer, determine whether func square is type safe? Both, compiler and human, would have to -

  • dig through all code to ensure that square is not used anywhere else except in the Map context that provides the actual types,
  • find all Map calls to ensure that square is not accidentally used with, for example, strings or structs, for which i*i is not exactly a well-defined expression.

So things are getting super complex. The code is no more easy to review, nor would the compiler be able to compile the code fast.

I know that Go’s verbosity is often frowned upon, but looking at the alternatives, I’d rather have a verbose and boring language where I can see at the first glance what the code in front of me is actually doing.

2 Likes

Building on @christophberger comments about readability, note that even for other languages which do have richer type inference, type annotations are still strongly encouraged for readability. Also, those languages typically support function overloading, so the ambiguity that can occur is not a (direct) problem. Go design errs on the side of prefers clarity.

1 Like

Thanks for your detailed answer!
I totally see that such a feature would add a lot of complexity to the compiler which simply may not be desired.

Anyways, I (with my limited knowledge of go) don’t consider this to be that hopeless:
I think about anonymous functions a bit like number literals: One could define them so that they do not have a type until you assign them to a variable. At the point of the assignment, the type would have to be clear. For instance this would be valid:

sqrt func(int64) int64:=func(i){return i*i}

since at the point of assignment the types would be clear.

However this

sqrt:=func(i){return i*i}

or this

func sqrt(i){return i*i}

would be invalid for the reasons you described above.

So if at the point of assignment to the parameter of the Map function all types would be clear, it should work.
However the question if this is the case seems not that easy to answer to me with generic functions:
The “source of truth” for generic types does not at all seem obvious to me. If a generic function which uses the generic type multiple times is called, which occurrence actually decides the value of the generic type?
The way I would implement this is the following:

For each occurrence of the generic type decide if it is “defining” or “driven”. For instance a parameter which already has a fixed type would be defining and a return value would be driven.
If the defining occurrences have a different type or if there are no defining occurrences, throw an error, else replace the generic type with the type from the defining occurrences.

So in the case of the map function, an error would be thrown if the return type of the anonymous function is not specified since here the “no defining occurrences”-case would be present.

Also deciding if an anonymous function returns nothing or just does not specify its return type should be simple: Either the function definition or the “receiving variable” the anonymous function is written to has to specify the return type, so this is totally clear.

Anyways I see that while as far as I understand it such a type inference would be possible with limited effort, it may just not be desired for the sake of clarity.

Yes, there have to be tradeoffs sometimes.

Note that while I agree with “it may not be desired for the sake of clarity”, this is just my own opinion. I cannot speak for the rest of the community, let alone the Go maintainers.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.