Having written a lot of articles about Python and Rust, I thought it was about time I took a look at another popular modern language, Go. In this article I’m kicking off by looking at the basic syntax and semantics as a foundation for looking at the more advanced features in future articles.
This is the 1st of the 6 articles that currently make up the “All Go” series.
I’ve been taking a fair look at Rust and Python over the last couple of years, but since I’m seeing increased use of Go in teams at work I thought it was past time that I broadened my horizons a little and took a look at that as well. Since I’m starting from a position of essentially zero knowledge this is going to be similar to my series on Rust in that I’m not going to worry about the history of the language and just consider the latest stable version, which is 1.20 at time of writing.
I’ll briefly outline my starting knowledge and expectations of Go before I become familiar with it, because I think it’ll be interesting to compare these to my views at the end of the series of articles.
Go is a statically typed language which compiles to native machine code. It was designed at Google by Ken Thompson and Rob Pike, of Bell Labs fame, and Robert Griesemer. Ken’s involvement in particular is interesting as he was the creator of the B programming language and worked with Dennis Ritchie on C, probably the most successful programming language of all time.
It is apparently a fairly simple language and intended to be comparatively easy to learn. It’s been designed with memory safety in mind, relying on a garbage collector. It doesn’t offer conventional class-based object orientation, but does seem to offer some support for object oriented styles of programming, relying on structural typing to check for consistency with defined interfaces, in contrast to its otherwise nominal type system. Go’s interfaces are similar to Rust traits, although it appears as if they are less flexible — they only offer dynamic dispatch, as opposed to Rust’s choice of static or dynamic, and they seem to lack the type parameters which Rust traits offer. Also similar with Rust it doesn’t offer exceptions but uses returned values to indicate errors, and it also offers generics, although these were only added to the language in 2022.
A key differentiator of Go1 seems to be the core support it offers for coroutines, or goroutines as it calls them, and channels, which are language builtins with Unix pipe-like semantics for sending messages between such goroutines. These aren’t quite the same as the async
/await
support present in, say, Python and Rust in that goroutines are green threads which may be scheduled across different OS threads — therefore I’m assuming that mutexes and other first class concurrency controls may still be required.
Based on the little I’ve seen of it so far I’d expect Go to be somewhere between Python and Rust in its learning curve, and also between them in terms of performance. Garbage collection makes life easier for the programmer, but at the expense of additional CPU and memory overhead, and potentially occasional performance glitches in long-running processes, depending how smart the garbage collector is at dealing with long-lived objects which are nonetheless eventually freed. Some of the other features of the language also point to potential performance hits, such as dynamic dispatch and the automatic selection of stack vs. heap for storing variables. However, although I’d expect Go to be slower than Rust in general, I’d still expect it to be an order of magnitude more performant than an interpreted language like Python.
My overall impression is of a language with a fairly gentle learning curve, and which is likely performant enough for most tasks, but it achieves that simplicity at the cost of flexibility. Languages with less flexibility tend to push developers more strongly in the direction of a single idiomatic style and this can be a polarising decision depending on whether someone in particular gets on well with that style.
But all that said, these are all based on little knowledge and the point of this series of articles is to gather than knowledge. So let’s jump in and find out!
Let’s kick off with a quick review of Go’s builtin types to ease us in. I’m going to assume you’re starting at a point of some familiarity with some C-like language and focus on what I feel are the more unique aspects, so apologies if I gloss over anything too quickly. The Go website has links to some more comprehensive tutorials if you’re after something with a slower pace and some interactivity.
It has all the usual suspects for basic scalar values:
bool
for boolean values true
and false
.uint32
and int64
for unsigned and signed integers of 8, 16, 32 and 64 bits.float32
and float64
for IEEE 754 floating point values.string
is a sequence of bytes and is immutable once created, as in Python.And a few others that are a bit more esoteric:
complex64
and complex128
are pairs of float32
and float64
respectively to represent complex numbers.uint
and int
are aliases for the native word size of the architecture.uintptr
is an integer large enough to store the bit representation of a pointer value.rune
is an alias of int32
which represents a Unicode code point.Most of this is pretty self-explanatory, although it’s worth noting in particular that string
is more like bytes
in Python — there’s no requirement that it holds any particular encoding, and it certainly doesn’t represent Unicode code points. Go source files are always interpreted as UTF-8, so string literals will always be UTF-8 unless they use escape sequences to include specific bytes. How Go handles Unicode is really a topic for another day2.
Go arrays are homogenous, containing only a single type, and have a fixed size which is set at declaration time. Go also offers slices which is a view attached to an underlying array. The size of a slice can change, unlike an array, and the portion of the underlying array that extends beyond a slice is part of its “capacity”. This notion of capacity will be familiar to users of the C++ STL, but it’s important to remember that it only applies to slices — the arrays themselves provide the storage.
Here are some example array declarations.
var intArray [100]uint32
var floats = [...]float32{1.2, 3.4}
numbers := ["one", "two", "three"]
As you can see the size can be explicitly stated, or inferred from an initialiser with [...]
. You can also see an example of the :=
“walrus” operator, which is another shortcut for inferring the type of a variable from its initialiser and allows you to omit the var
keyword. This operator works for other types, not just arrays.
True to its C-like roots Go also offers structs, which are declared with a fairly similar syntax. One difference, however, is that they’re anonymous — i.e. they don’t have a name. However, you can use the type
keyword to create a new type with the structure specified. Here’s an example.
type Person struct {
firstName string
lastName string
age uint
}
There’s nothing to stop you using the anonymous version if you like, but it can get pretty verbose. You can see that in the example below, which also shows how structs can be initialised.
ken := struct {
firstName string
lastName string
age uint
}{
firstName: "Ken",
lastName: "Thompson",
age: 80,
}
So far we’ve looked at only explicitly named fields, but there’s also an option to use embedded fields where the field isn’t named, it only supplies a type. In these cases the type effectively becomes the field name.
Just to illustrate the principle, here’s an example of mixing an explicit and an embedded field:
type MyStruct struct {
name string
float32
}
func someFunc() {
var instance = MyStruct {
name: "Andy",
float32: 2.34,
}
...
}
That said, I’m not sure that the intention is to use this feature with basic types like float32
3 — I get the impression this feature is really just intended to be used to embed one struct
within another. There’s a feature called promotion which allows the fields of the embedded struct
to be accessed in the enclosing one, and it all seems a little like that type of “inheritance” you can fake in C by making one structure the prefix of another and casting the types.
However, I’m going to defer proper discussion of this topic to a later article, as there seem to be some subtleties, particularly when we start adding methods, so it deserves a full discussion.
Go also offers pointers with a syntax that will be very familiar to those used to C or C++.
func increment(value *int) {
*value += 1
}
func someOtherFunc() {
value := 100
increment(&value)
// value == 101
}
Although these are syntactically similar to C pointers, they are more limited to keep them safe — in particular, pointer arithmetic is not permitted. Null pointers are still allowed, however, and are specified with the nil
keyword — if you try to dereference one of these at runtime then your application will immediately terminate, much as it would in C/C++.
As far as I can tell Go offers neither references nor pass-by-reference (both in the C++ use of the terms), so pointers are going to be quite important. I’m pleased to see that Go doesn’t seem to offer the sort of implicit pass-by-reference which can make C++ code harder to read by forcing someone browsing the code to check out the function prototype just to tell if it’s able to modify the argument or not.
Something that might also be surprising to C/C++/Rust programmers is that Go has no way to pass a constant pointer — there’s no way for function to indicate in its formal signature that it won’t modify a value passed by a pointer. If you don’t want it to be modified, pass it by value4.
Go offers our old friend the map (aka associative array) as a builtin. It’s unsorted and just associates a key type with a value type.
myMap := map[string]int{"one": 1}
myMap["two"] = 2
delete(myMap, "one")
The usual square bracket notation is used to retrieve and set elements, and the builtin function delete()
is used to remove elements. You can also use a builtin make()
function to construct a map (and other types), and the advantage of this is that you can specify a size hint to avoid the overhead of unnecessary resizing operations.
myMap := make(map[string]int, 100)
The specific data structure used by a map isn’t specified by the language, but currently it’s a simple hash table with chaining of entries that hash to the same bucket. When the table hits a threshold load factor it’s resized, doubling the number of buckets. There are some additional subtleties, but that’s probably enough of an idea to decide on how to use these maps appropriately. In particular it looks like there’s quite a lot of fiddliness around resizing the table whilst other code is in the middle of iterating through it, but I’ll defer that level of detail to a potential future article.
Finally, I thought I should mention channels, since they are a builtin type, but we’ll be looking at them in more detail later when we look at goroutines and how they’re used.
A channel is a homogeneously typed bi- or unidirectional queue for sending and receiving values between two pieces of code. The make()
function can be used to construct a queue — by default this will be unbuffered, so the sending call will block until the value is received. However, a capacity can be passed as the second argument to make()
, and this allows that many items to be queued up in the channel without blocking the sender.
chan float32 // Bidirectional
chan<- int // Send only
<-chan string // Receive only
Channels seem widely used in Go and have a special <-
operator for sending and receiving values. I’m not going to drill into their semantics any further here, however, since they’re not really useful until we’re talking about concurrency.
We’ve already seen some examples of declaring variables in the previous section, but it’s important to be familiar with these sorts of basics so I’ll go over here anyway.
There are two ways to declare a variable — one uses the var
keyword, the other the :=
operator.
// Can declare and initialise multiple at a time
var one, two, three int = 1, 2, 3
// If the initialiser is omitted, it gets default value
var defaultInitialised string
// Type may be inferred from initialiser
var noType = 23.45
// The := operator allows `var` to be omitted
noVar := "hello"
Variables have a type, and Go’s strict typing mean that you can only assign that type to it — although that has a bit of a caveat which we’ll see in the Untyped Constants section below.
If you use const
instead of var
then you declare a constant. As I mentioned earlier in the section on pointers, this is the only use of the const
keyword — it’s not used to indicate the immutability of function parameters, for example.
Constants come in two flavours, typed and untyped.
The typed flavour are more or less as you’d expect.
const name string = "Andy"
This works like a variable of type string
in that you can only assign it to variables of the same type, due to Go’s strict typing rules.
type AndyString string
const name string = "Andy"
// This is illegal, types are not the same
var x string = name
The sort of thing in the previous exampled would be quite useful, however, and that’s where untyped constants come in. In this case the constant is associated with a bare literal expression, without any type.
const name = "Andy"
Now you can use name
anywhere where a string literal would be permitted, not just where only a specific string
type value would be permitted. It’s worth noting that these literals still do have a default type, which is used for type inference.
const name = "Andy"
// Variable `x` ends up with type `string`
x := name
Some of you might have noticed there was something missing from the Types and Structures section earlier: enumerations. This is quite intentional, because Go doesn’t offer any form of enumeration.
However, it does offer a useful, if quirky, feature for simulating integer enumerations using constants. This feature uses the keyword iota
5. This evaluates to an auto-incrementing integer value within the context of a single const
statement — it resets itself back to zero at the next const
.
type Color int
const (
black Color = iota
red
green
yellow
blue
magenta
cyan
white
)
type Style int
const (
normal Style = iota
bold
_
_
underscore
blink
_
reverse
concealed
)
In the declaration above, black
will have value 0
, red
will be 1
, green
will be 2
and all the way up until white
is 7
.
Because we then start a new const
block, iota
resets and normal
will be 0
and bold
will be 1
. The use of the _
blank identifier consumes the next two values, and underscore
then gets value 4
.
This probably works fairly well for most cases, although I’m a little uncomfortable with the lack of namespacing that you typically get with enumerations, but at least Go’s strict typing means that we can use a different type for each set of values and the compiler will prevent us cross-assigning enumeration values.
You can also use iota
in more flexible expressions, as shown below.
type SizeUnit uint
const (
KB SizeUnit = 1 << (10 * (iota + 1))
KB
MB
GB
TB
)
Now we get into areas which tend to vary a little more between languages: flow control. There’s a lot here that will no doubt be familiar, but there are a few interesting details that are more specific to Go.
if
/else
¶Let’s start off looking at conditionals with our old friend the humble if
/else
statement.
func eat(apples *uint) {
if *apples == 0 {
fmt.Println("Sorry, no apples")
return
} else if *apples == 1 {
fmt.Println("This is your last apple!")
} else {
fmt.Println("Plenty of apples left.")
}
*apples -= 1
}
I don’t know what I can really add that isn’t blindingly obvious about that example. Er. Look, no brackets around conditions. Um. OK, so moving on…
switch
¶Rather more interesting is the switch
statement, which has several forms.
It can be used with constants, as with many C-like languages. You can specify multiple values for a single case
statement, and there’s no need for break
— control does not fall through to the next case
section by default. If you do want it to fall through, you can include an explicit fallthrough
keyword to make that happen.
func ordinal(n int) string {
suffix := "th"
switch n % 100 {
case 11, 12, 13:
default:
switch n % 10 {
case 1: suffix = "st"
case 2: suffix = "nd"
case 3: suffix = "rd"
}
}
return fmt.Sprintf("%d%s", n, suffix)
}
if
/else
¶You can also omit the expression and just use switch
as an alternative form of a series of if
/else
statements.
switch {
case rate > hardLimit:
fmt.Println("Rate has exceeded hard limit")
fallthrough
case rate > softLimit:
fmt.Println("Rate has exceeded soft limit")
}
It’s worth noting that the fallthrough
here automatically chains into the next case statement, it does not check the condition on that next statement. If you’re guaranteed that softLimit <= hardLimit
in this example then that’s fine, but it’s something you need to be aware of.
The other use for the switch
statement is quite different, and is a little like a more limited form of Rust’s match
statement. This is used for matching the type of an expression rather than its value.
switch value.(type) {
case bool:
fmt.Println("Boolean")
case int:
fmt.Println("Integer")
case float32, float64:
fmt.Println("Float")
default:
fmt.Println("Some other type")
}
One small point: fallthrough
is not permitted in this type of switch
statement.
In general I’m not a fan of writing code which switches on a type, except for diagnostics purposes, but it may be that it becomes more important given Go’s lack of certain other forms of polymorphism. In any case, I’ll reserve judgement, but it’s interesting to note it down as an option.
for
¶Now we come to the flow control construct which, in my opinion, varies the most between languages. In Go’s case for
is actually the only looping construct, and it has three distinct forms.
In any of these forms the break
and continue
statements can be used, and have the usual C-like semantics.
while
Form¶A for
loop with a single condition is equivalent to a while
loop in many other languages, and has straightforward semantics.
i := 10
for i > 0 {
fmt.Printf("%d / ", i)
i -= 1
}
Closely related you can omit the condition entirely and you effectively have a while true {
…}
. This will continue until a break
statement exits the loops.
for {
fmt.Println("Forever is a long time")
}
Go’s for
loops can also take the same form as C’s loops, with three clauses:
The same countdown as above can instead be generated with this code.
for j := 10; j > 0; j-- {
fmt.Printf("%d / ", j)
}
I feel a bit nostalgic seeing this form of for
still in use, although I’m not entirely convinced it’s the most accessible to people coming across it for the first time. Still, there it is.
Finally, there’s a form of for
which is more familiar in modern languages which allows iteration across a collection — this uses the range
keyword.
There are a few details which are interesting about this. Firstly, if you use it on arrays or slices, it yields a 2-tuple of the index and the value.
items := []int{111, 222, 333}
for i, value := range items {
fmt.Printf("Item %d is %d\n", i, value)
}
This will produce the following output.
Item 0 is 111
Item 1 is 222
Item 2 is 333
Getting the indices is a nice touch, even if you don’t need them all the time. It’s quite often that in Python I find myself using enumerate()
to generate these, so I can do things like quote line numbers in diagnostic errors.
One aspect that’s not immediately obvious here is that if you declare new variables with :=
, as in the example above, the scope of these is only within the for
block itself.
When iterating over a map
you can receive both the keys and values…
mapping := map[string]int{"one": 1, "two": 2, "three":3}
for key, value := range mapping {
fmt.Printf("%s -> %d\n", key, value)
}
… or just the keys, depending if you assign the result to two or one variables.
for key := range mapping {
fmt.Printf("%s\n", key)
}
As you’d expect from a hash table, the order of iteration is arbitrary and isn’t even guaranteed to be consistent from one loop to the next6.
A slightly surprising special case is iterating across a string
value — instead of getting the bytes of the string, you get rune
values (equivalent to int32
) where each one represents a single Unicode code point.
for i, value := range "Runes: ᚠᛇᚻ" {
fmt.Printf("Item %d is «%v» (%T)\n", i, value, value)
}
With apologies for the self-referential pun, take a look at the output below and see if you can spot anything unexpected.
Item 0 is «82» (int32)
Item 1 is «117» (int32)
Item 2 is «110» (int32)
Item 3 is «101» (int32)
Item 4 is «115» (int32)
Item 5 is «58» (int32)
Item 6 is «32» (int32)
Item 7 is «5792» (int32)
Item 10 is «5831» (int32)
Item 13 is «5819» (int32)
The first 7 values are decimal ASCII values, as expected, and the other three are the Unicode values U+16A0, U+16C7 and U+16BB, which are the hex versions of the decimals in the output.
The unexpected thing is that you can see the indices become discontinuous — this is because it’s still presenting the byte offsets of the values within the string. This is potentially quite useful, but it’s important to remember that this will only work for UTF-8.
To me this contrasts with Python’s approach, which is to make their str
object independent of any particular encoding, and choose you to select an encoding explicitly in most cases. Don’t get me wrong, UTF-8 is very common, but there is a risk that people just shift their attitude from “everything is ASCII” into “everything is UTF-8”, which could still well be problematic in some cases. Ultimately, however, if developers are aware of the issues and use their chosen language correctly, either approach can work.
There are potentially some other special cases for this form of the for
loop, such as the behaviour with channels, but it makes more sense to cover those later when we drill into other language features.
The last of what I regard as the four key areas of basic semantics of an imperative language are how functions are written and used.
We’ve already seen the syntax a few times and there’s nothing particularly outlandish about it.
func hypotenuse(a, b float64) float64 {
return math.Sqrt(a * a + b * b)
}
Unlike in C/C++ the type follows the parameter names, as we’ve seen in earlier examples. When multiple parameters share the same type, it can be elided on all but the last, as in the example above. The return type, if any, follows the closing parenthesis of the parameter list.
Functions returning values in Go require an explicit return
statement, they don’t automatically return the value of the final expression as in some other languages.
Functions are also able to return multiple values.
func someFunc(x int) (int, int) {
return x % 2, x % 10
}
It would be tempting to think of this as a single return value which is a tuple, but that’s not accurate — Go doesn’t offer tuples7. The multiple value return is a special case, although it’s a very heavily used one as it’s often used to return both a value and a potential error code. This is typically done using the builtin error
type, but we’ll look at that in a future article once we’ve looked at interfaces.
Similar to C, functions can also be variadic, which is to say that they can take an arbitrary number of parameters. This is always specified as zero or more normal parameters first, and then the variadic parameters are always at the end.
import (
"fmt"
"strings"
)
func joinInts(separator string, nums ...int) string {
var ret strings.Builder
for i, num := range nums {
if i > 0 {
ret.WriteString(separator)
}
fmt.Fprintf(&ret, "%d", num)
}
return ret.String()
}
The nums
parameter appears as a []int
slice in the example above, so we can use len()
on it or use it in a for
…range
loop as above. Of course, it’s also valid to call this with no variadic parameters, in which case nums
will be nil
, so you need to make sure your code copes gracefully in that case too.
If you already have a slice of the correct type, you can pass it straight in as an argument list by appending an ellipsis to it, like this.
myArray := []int{11, 22, 33, 44, 55}
myList := joinInts(", ", myArray...)
So that’s where I’m going to leave my first glance at Go. I made a conscious decision to start with the basics this time, unlike my Rust articles where I started in the middle and plunged headlong into the beginning. So I’m sorry if there was a dearth of exciting features.
Hopefully there were enough interesting details sprinkled through, however, such as the use of iota
to simulate enumerations, the multitudinous forms of both the for
and switch
statements, and the quirky range-based iteration through strings.
Now we’ve got the basics sorted out there’s plenty of more advanced topics left to drill into — closures, interfaces, generics, structure embedding, goroutines, deferred calls, and more. I haven’t yet decided which ones I’ll drill into first, but I’m aiming to get through it all at some point.
I hope it’s been interesting, and I look forward to covering more of what Go offers. Have a great day!
Aside from its lack of semicolons, which is of course the most important difference. ↩
If this didn’t make sense to you, and you’re wondering why there’s all this fuss about characters and encodings, you really should go read Joel Spolsky’s excellent blog post on the topic. There wasn’t any excuse for any programmer not to know it when he wrote that article, so there certainly isn’t now, two decades later. ↩
But I checked and it seems like this does work. ↩
How clever the compiler is at doing pass by value efficiently with large structures is something on which I’m going to avoid speculating until I’m much more familiar with the language and its implementation. ↩
Thankfully it’s a keyword, they don’t expect you to type U+03B9 all over the place. ↩
In particular, any insertion might cause the hash table to be resized and this will perturb all the elements as they’re rehashed into new buckets. ↩
About the closest things it has appear to be arrays, or the anonymous structs we looked at earlier, depending whether you want named fields or not. ↩