☑ All Go: Basic Semantics

3 Jun 2023 at 10:45PM in Software
 |  | 
go

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.

two gophers

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!

Types and Structures

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.

Scalars

It has all the usual suspects for basic scalar values:

  • bool for boolean values true and false.
  • Types of the form 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.

Arrays

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.

Structs

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 float323 — 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.

Pointers

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.

Maps

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.

Channels

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.

Variables and Constants

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.

Variables

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.

Constants

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.

Typed Constants

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

Untyped Constants

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

Iota

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 iota5. 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
)

Conditionals and Loops

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.

With Constants

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)
}

Alternative 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.

Type Switches

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
}

Infinite Loop

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")
}

C-like Form

Go’s for loops can also take the same form as C’s loops, with three clauses:

  • Initialiser
  • Condition
  • Increment

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.

Range Form

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.

Functions

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.

Function Parameters

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.

Multiple Return Values

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.

Variadic Functions

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 forrange 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...)

Conclusions

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!


  1. Aside from its lack of semicolons, which is of course the most important difference. 

  2. 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. 

  3. But I checked and it seems like this does work. 

  4. 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. 

  5. Thankfully it’s a keyword, they don’t expect you to type U+03B9 all over the place. 

  6. 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. 

  7. 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. 

The next article in the “All Go” series is All Go: Methods and Interfaces
Sat 10 Jun, 2023
3 Jun 2023 at 10:45PM in Software
 |  | 
go