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:
package main type Age int func main() { var a Age = 5 var b int = a } Output: tmp/sandbox547268737/main.go:8: cannot use a (type Age) as type int in assignment
You can group type declarations or use one declaration per line:
type IntIntMap map[int]int StringSlice []string type ( Size uint64 Text string CoolFunc func(a int, b bool)(int, error) )
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).
bool string int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 uintptr byte // alias for uint8 rune // alias for int32, represents a Unicode code point float32 float64 complex64 complex128
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.
package main import ( "fmt" "strings" ) func main() { words := []string{"i", "LikE", "the ColORS:", "RED,", "bLuE,", "AnD", "GrEEn"} properCase := []string{} for i, w := range words { if i == 0 { properCase = append(properCase, strings.Title(w)) } else { properCase = append(properCase, strings.ToLower(w)) } } sentence := strings.Join(properCase, " ") + "." fmt.Println(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.
package main import ( "fmt" ) type S struct { a float64 b string } func main() { x := 5 px := &x *px = 6 fmt.Println(x) ppx := &px **ppx = 7 fmt.Println(x) }
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.
type Shape interface { GetPerimeter() uint GetArea() uint } type Square struct { side uint } func (s *Square) GetPerimeter() uint { return s.side * 4 } func (s *Square) GetArea() uint { return s.side * s.side }
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:
package main import ( "fmt" ) type S1 struct { f1 int } type S2 struct { f2 int } type S3 struct { S1 S2 f3 int f4 *S3 } func main() { s := &S3{S1{5}, S2{6}, 7, nil} fmt.Println(s) } Output: &{{5} {6} 7}
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.
package main import "fmt" func main() { things := []interface{}{"hi", 5, 3.8, "there", nil, "!"} strings := []string{} for _, t := range things { s, ok := t.(string) if ok { strings = append(strings, s) } } fmt.Println(strings) } Output: [hi there !]
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.
package main import ( "fmt" "reflect" ) func main() { things := []interface{}{"hi", 5, 3.8, "there", nil, "!"} stringCount := 0 for _, t := range things { tt := reflect.TypeOf(t) if tt != nil && tt.Kind() == reflect.String { stringCount++ } } fmt.Println("String count:", stringCount) }
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.
package main import ( "fmt" "math/rand" ) type UnaryOp func(a int) int type BinaryOp func(a, b int) int func GetBinaryOp() BinaryOp { if rand.Intn(2) == 0 { return func(a, b int) int { return a + b } } else { return func(a, b int) int { return a - b } } } func GetUnaryOp() UnaryOp { if rand.Intn(2) == 0 { return func(a int) int { return -a } } else { return func(a int) int { return a * a } } } func main() { arguments := [][]int{{4,5},{6},{9},{7,18},{33}} var result int for _, a := range arguments { if len(a) == 1 { op := GetUnaryOp() result = op(a[0]) } else { op := GetBinaryOp() result = op(a[0], a[1]) } fmt.Println(result) } }
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.
package main import "fmt" func sum_of_squares(s []int, c chan int) { sum := 0 for _, v := range s { sum += v * v } c <- sum // send sum to c } func main() { s := []int{11, 32, 81, -9, -14} c := make(chan int) go sum_of_squares(s[:len(s)/2], c) go sum_of_squares(s[len(s)/2:], c) sum1, sum2 := <-c, <-c // receive from c total := sum1 + sum2 fmt.Println(sum1, sum2, total) }
This is just scraping the surface. For a detailed review of channels, check out:
-
GoLet's Go: Golang Concurrency, Part 2
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:
package main import "fmt" func main() { a1 := [3]int{1, 2, 3} var a2 [3]int a2 = a1 fmt.Println(a1) fmt.Println(a2) a1[1] = 7 fmt.Println(a1) fmt.Println(a2) a3 := [2]interface{}{3, "hello"} fmt.Println(a3) }
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:
package main import "fmt" func main() { s1 := []int{1, 2, 3} var s2 []int s2 = s1 fmt.Println(s1) fmt.Println(s2) // Modify s1 s1[1] = 7 // Both s1 and s2 point to the same underlying array fmt.Println(s1) fmt.Println(s2) fmt.Println(len(s1)) // Slice s1 s3 := s1[1:len(s1)] fmt.Println(s3) }
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:
package main import "fmt" func main() { // Create a slice of 5 booleans initialized to false s1 := make([]bool, 5) fmt.Println(s1) s1[3] = true s1[4] = true fmt.Println("Iterate using standard for loop with index") for i := 0; i < len(s1); i++ { fmt.Println(i, s1[i]) } fmt.Println("Iterate using range") for i, x := range(s1) { fmt.Println(i, x) } } Output: [false false false false false] Iterate using standard for loop with index 0 false 1 false 2 false 3 true 4 true Iterate using range 0 false 1 false 2 false 3 true 4 true
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.
package main import ( "fmt" ) func main() { // Create map using a map literal m := map[int]string{1: "one", 2: "two", 3:"three"} // Assign to item by key m[5] = "five" // Access item by key fmt.Println(m[2]) v, ok := m[4] if ok { fmt.Println(v) } else { fmt.Println("Missing key: 4") } for k, v := range m { fmt.Println(k, ":", v) } } Output: two Missing key: 4 5 : five 1 : one 2 : two 3 : three
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.
package main import ( "fmt" ) type S struct { a float64 b string } func main() { fmt.Println(*new(bool)) fmt.Println(*new(int)) fmt.Println(*new([]string)) fmt.Println(*new(map[int]string)) x := *new([]string) if x == nil { fmt.Println("Uninitialized slices are nil") } y := *new(map[int]string) if y == nil { fmt.Println("Uninitialized maps are nil too") } fmt.Println(*new(S)) }
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.
Powered by WPeMatico