Deep Dive Into the Go Type System

Deep Dive Into the Go Type System

Go has a very interesting type system. It eschews classes and inheritance in favor of interfaces and composition, but on the other hand it doesn't have templates or generics. The way it handles collections is also unique. 

In this tutorial you'll learn about the ins and outs of the Go type system and how to effectively utilize it for writing clear and idiomatic Go code.

The Big Picture of the Go Type System

The Go type system supports the procedural, object-oriented and functional paradigms. It has very limited support for generic programming. While Go is a decidedly static language, it does provide enough flexibility for dynamic techniques via interfaces, first class functions, and reflection. Go's type system is missing capabilities that are common in most modern languages:

  • There is no exception type since Go's error handling is based on return codes and the error interface. 
  • There is no operator overloading.
  • There is no function overloading (same function name with different parameters).
  • There are no optional or default function parameters.

Those omissions are all by design in order to make Go as simple as possible.

Type Aliases

You can alias types in Go and create distinct types. You can't assign a value of the underlying type to an aliased type without conversion. For example, the assignment var b int = a in the following program causes a compilation error because the type Age is an alias of int, but it is not an int:

You can group type declarations or use one declaration per line:

Basic Types

All the usual suspects are here: bool, string, integers and unsigned integers with explicit bit sizes, floating point numbers (32-bit and 64-bit), and complex numbers (64-bit and 128-bit).

Strings

Strings in Go are UTF8 encoded and thus can represent any Unicode character. The strings package provides a slew of string operations. Here is an example of taking an array of words, converting them to proper case, and joining them to a sentence.

Pointers

Go has pointers. The null pointer (see zero values later) is nil. You can get a pointer to a value using the & operator and get back using the * operator. You can have pointers to pointers too.

Object-Oriented Programming 

Go supports object-oriented programming via interfaces and structs. There are no classes and no class hierarchy, although you can embed anonymous structs within structs, which provide a sort of single inheritance. 

For a detailed exploration of object-oriented programming in Go, check out Let's Go: Object-Oriented Programming in Golang.

Interfaces

Interfaces are the cornerstone of the Go type system. An interface is just a collection of method signatures. Every type that implements all the methods is compatible with the interface. Here is a quick example. The Shape interface defines two methods: GetPerimeter() and GetArea(). The Square object implements the interface.

The empty interface interface{} is compatible with any type because there are no methods that are required. The empty interface can then point to any object (similar to Java's Object or C/C++ void pointer) and is often used for dynamic typing. Interfaces are always pointers and always point to a concrete object.

For a whole article on Go interfaces, check out: How To Define and Implement a Go Interface.

Structs

Structs are Go's user-defined types. A struct contains named fields, which may be basic types, pointer types, or other struct types. You can also embed structs anonymously in other structs as a form of implementation inheritance. 

In the following example, the S1 and S2 structs are embedded in the S3 struct, which also has its own int field and a pointer to its own type:

Type Assertions

Type assertions let you convert an interface to its concrete type. If you already know the underlying type, you can just assert it. If you're not sure, you can try several type assertions until you discover the right type. 

In the following example, there is a list of things that contains strings and non-string values represented as a slice of empty interfaces. The code iterates over all the things, trying to convert each item to a string and store all the strings in a separate slice that it eventually prints.

Reflection

The Go reflect package lets you directly check the type of an interface without type assertions. You can also extract the value of an interface and convert it to an interface if you wish (not as useful). 

Here is a similar example to the previous example, but instead of printing the strings it just counts them, so there is no need to convert from interface{} to string. The key is calling reflect.Type() to get a type object, which has a Kind() method that allows us to detect if we're dealing with a string or not.

Functions

Functions are first class citizens in Go. That means that you can assign functions to variables, pass functions as arguments to other functions, or return them as results. That enables you to use the functional programming style with Go. 

The following example demonstrates a couple of functions, GetUnaryOp() and GetBinaryOp(), that return anonymous functions selected randomly. The main program decides if it needs a unary operation or a binary operation based on the number of arguments. It stores the selected function in a local variable called "op" and then invokes it with the correct number of arguments. 

Channels

Channels are an unusual data type. You can think of them as message queues used for passing messages between goroutines. Channels are strongly typed. They are synchronized and have dedicated syntax support for sending and receiving messages. Each channel can be receive-only, send-only, or bi-directional. 

Channels can also be optionally buffered. You can iterate over the messages in a channel using range, and go routines can block on multiple channels at the same time using the versatile select operation. 

Here is a typical example where the sum of squares of a list of integers is computed in parallel by two go routines, each one responsible for half of the list. The main function waits for results from both go routines and then adds up the partial sums for the total. Note how the channel c is created using the make() built-in function and how the code reads from and writes to the channel via the special <- operator.

This is just scraping the surface. For a detailed review of channels, check out:

Collections

Go has several built-in generic collections that can store any type. These collections are special, and you can't define your own generic collections. The collections are arrays, slices, and maps. Channels are also generic and can be considered collections too, but they have some pretty unique properties, so I prefer to discuss them separately.

Arrays

Arrays are fixed-sized collections of elements of the same type. Here are some arrays:

The array's size is part of its type. You can copy arrays of the same type and size. The copy is by value. If you want to store items of different type, you can use the escape hatch of an array of empty interfaces.

Slices

Arrays are pretty limited due to their fixed size. Slices are much more interesting. You can think of slices as dynamic arrays. Under the covers, slices use an array for storing their elements. You can check the length of a slice, append elements and other slices, and most fun of all you can extract sub-slices similar to Python slicing:

When you copy slices, you just copy the reference to the same underlying array. When you slice, the sub-slice still points to the same array. But when you append, you get a slice that points to a new array.

You can iterate over arrays or slices using a regular loop with indexes or using ranges. You can also create slices in a given capacity that will be initialized with the zero value of their data type using the make() function:

Maps

Maps are collections of key-value pairs. You can assign them map literals or other maps. You can also create empty maps using the make built-in function. You access elements using square brackets. Maps support iteration using range, and you can test if a key exists by trying to access it and checking the second optional boolean return value.

Note that iteration is not in creation or insertion order.

Zero Values

There are no uninitialized types in Go. Each type has a predefined zero value. If a variable of a type is declared without assigning it a value, then it contains its zero value. This is an important type safety feature.

For any type T, *new(T) will return one zero value of T.

For boolean types, the zero value is "false". For numerical types, the zero value is... zero. For slices, maps and pointers, it's nil. For structs, it's a struct where all the fields are initialized to their zero value.

What About Templates or Generics?

Go has none. This is probably the most common complaint about Go's type system. The Go designers are open to the idea, but don't know yet how to implement it without violating the other design principles underlying the language. What can you do if you badly need some generic data types? Here are a few suggestions:

  • If you only have a few instantiations, consider just creating concrete objects.
  • Use an empty interface (you'll need to type assert back to your concrete type at some point).
  • Use code generation.

Conclusion

Go has an interesting type system. The Go designers made explicit decisions to stay on the simple side of the spectrum. If you're serious about Go programming, you should invest the time and learn about its type system and its idiosyncrasies. It will be well worth your time.

Source: Tuts Plus

About the Author