Learning another language is always interesting. Arun Saha walks us through Go as a C++ programmer.
Go is a statically typed, compiled programming language with memory safety, garbage collection, and CSP-style concurrency [Go][Wikipedia]. It was designed at Google in 2007, publicly announced in 2009, and version 1.0 was released in 2012. It is open-sourced under BSD-3-Clause license and developed at github [Github].
You might wonder why we are talking about the Go programming language. While most of the other top programming languages are much older, Go has achieved significant usage and popularity within just ten years of its existence [TIOBE] [Stackoverflow]. I believe that this is not accidental but a result of different language design decisions. On one hand, it has almost C- and C++-like efficiency, while on the other hand, it has Python-like brevity and a batteries-included approach.
I have been a long-time C++ and C programmer. I started learning and using Go last year. During this (ongoing) journey, I have noticed a lot of elements in Go that are similar to C++ and many elements that are different. In this article, I would like to share that learning with you. (The concurrency aspects are part of a future article.)
Variable declaration
A variable declaration in C++ has the type specified to the left of the identifier. For example,
int result = 42;
In a variable declaration in Go, the order is reversed – the type is specified to the right of the identifier. The equivalent in Go is the following.
var result int = 42
This is perhaps the biggest habit change necessary for reading and writing Go. The designers have chosen this deliberately [Pike10]. It took me a while to get used to this.
Semicolons
Unlike C++, semicolons are optional to terminate statements in Go. The lexer insert semicolons automatically, so the source code is mostly free of them. If only multiple statements are written on a line, then semicolons are necessary to separate them.
Declaration versus assignment
Go chose :=
(colon equals) as a shorthand notation to define and initialize a variable within the scope of a function or a loop.
attempt := 1 // Shorthand declaration and // assignment
A variable declaration needs the var
keyword outside of a function. It can be used inside a function as well. The following notation first defines a variable and later assigns to it.
var attempt int // Declaration … attempt = 1 // Assignment
Obviously, the above two approaches can be combined to have an explicit type declaration and assignment, as shown in the following.
var attempt int = 1 // Long declaration and // assignment
While using the new shorthand notation, a common beginner confusion is the following.
sum := 0 … sum := newsum // Error: Multiple declaration // of 'sum' sum = newsum // OK. Assignment
Zero initialization
In Go, any declared but not explicitly initialized variable would be automatically zero-initialized. There are well-defined zero values for each type, for example, 0
for numeric types, false
for boolean, ""
(empty string) for strings. Thus, the following statement not only declares but also initializes the variable.
var result int
I love this feature!
In C++ (and some other languages), a lot of bugs boil down to uninitialized variables as they do not have any automatic or implicit initialization. This required introduction of compiler flags like -Wunintialized
, -Wmaybe-uninitialized
[GCC] to detect uninitialized variables that the programmers must remember to enable and enforce. Go eliminates all those hassles and errors through this simple language specification.
Type declaration
A type declaration defines a new named type that has the same underlying type as an existing type. The following example declares Miles as a new type with float64 as the underlying type.
type Miles float64
Two named types with the same underlying type cannot be assigned or compared as shown in the example below.
type Kilometers float64 var m Miles = 26.2 var k Kilometers = 42 k = m // compilation error equal := k == m // compilation error
Functions
A function is defined with the func
keyword as shown below.
func add(a int, b int) int { return a + b }
Go allows multiple return values from a function. The following function returns both the sum and the difference of two values.
func sumdiff(a int, b int) (int, int) { sum := a + b diff := a - b return sum, diff }
It can be called and used in the following way.
func multipleReturn() { sum, diff := sumdiff(2, 3) }
The return values could be named. It helps disambiguate between multiple return values of the same type.
func sumdiff2(a int, b int) (sum int, diff int) { sum = a + b diff = a - b return }
The returned variables (sum
, diff
) are defined in the return
statement and assigned in the body of the function. The final return
statement is required.
Go does not support function overloading.
Constructor and destructor
In C++, the name of the constructor is the same as the class name. A class may have one or more constructors.
Go does not have constructors. Instead, the following convention is followed. A package provides public functions with names starting with New to (1) allocate an object, (2) initialize it per the package’s needs, and (3) return the allocated object. The following is an example of creating a new list from the “container/list” package in the Go standard library [Go].
// Create a new list and put some numbers in it. l := list.New() e4 := l.PushBack(4)
In absence of such New
functions, instantiating a struct performs zero initialization to all its members.
Go does not have destructors.
Error handling
Go does not have exceptions. However, there is a strong and widely used convention for generating and propagating errors. Any function where something can go wrong usually returns an error along with its usual return value(s). The returned error is part of the function signature, it is usually the last of the returned values. If a function can return an error, then the caller is expected to check that; it can handle it or pass it up to its caller.
The following example is from the Go standard library [Go]; Open()
opens the named file for reading. On successful opening, it returns a File object and nil error
. If it fails to open, it returns a nil File
object and an error object to capture the cause.
func Open(name string) (*File, error)
It can be used as follows.
f, err := os.Open("notes.txt") if err != nil { log.Fatal(err) }
In Go, nil
is the zero value for pointers, interfaces, maps, slices, functions, etc. It is equivalent to nullptr
in C++.
Go represents a potential error state with the built-in interface type, error
. A nil error
represents no error.
Go has a built-in function panic()
that stops the ordinary flow of control and begins panicking. It can be initiated by invoking panic()
directly. They can also be caused by runtime errors, such as division by zero.
Defer
Go provides a defer mechanism to specify a function that will be called at the exit of the current scope. It is similar to ScopeGuard or std::experimental::scope_exit
in C++. Defer is used as a regular pattern for unlocking mutexes, closing files, etc. The example below uses defer for closing a file when the function returns.
// Contents returns the file’s contents as a // string. func Contents(filename string) (string, error) { f, err := os.Open(filename) if err != nil { return "", err } defer f.Close() // f.Close will run when we’re // finished. <truncated>
defer
is not a substitute for a destructor since there is no way to use it when a heap-allocated object is deconstructed.
The built-in function recover()
regains control of a panicking situation. It is only useful inside deferred functions. If the current flow of control is panicking, a call to recover will capture the value given to panic and resume normal execution.
Visibility
Unlike C++, Go does not have class member visibility qualifiers like public
, protected
, or private
. In Go, any variable, constant, function or struct data member starting with an upper-case character is public; others (starting with a lower-case character) are private. For example,
type Person struct { Name string // public data member Phone string // public data member creditCardNumber string // private data member }
Methods
Go allows defining methods on types. A method is a function with a special receiver argument. The receiver appears in its own argument list between the func keyword and the method name.
In this example, the Distance
method has a receiver of type Point named point.
type Point struct { X, Y float64 } func (point Point) Distance() float64 { return math.Sqrt(point.X*point.X + point.Y*point.Y) }
Like C++, Go has pointers. A pointer holds the memory address of a variable. (Go does not allow pointer arithmetic though.)
point := Point{X:3, Y:4} ptr := &point
If the method needs to change any of the data members, then the method needs a pointer receiver as the following.
func (point *Point) Move(dx, dy float64) { point.X += dx point.Y += dy }
Like C++, methods can be invoked either on the variable type or the pointer type.
dist1 := ptr.Distance() dist2 := point.Distance()
A significant difference from C++ is that the object of the member function can be named anything, as opposed to the reserved keyword this
.
Const
A Go program can define compile-time constants as const.
const separator = ","
But a variable cannot be qualified as const at its declaration and initialization. I.e., there is no equivalent of the following C++ expression.
int const result = ComputeResult(…);
There is no mechanism for const pointers or pointers to const data. Methods with non-pointer receivers behave as const member functions.
The following member function uses a non-pointer receiver (i.e., Person
instead of *Person
) and is equivalent to a const-member function in C++.
func (p Person) GetName() string { return p.Name }
On the contrary, the following member function uses a pointer-receiver (*Person
) and is equivalent to a non-const-member function in C++.
func (p *Person) SetPhoneNumber(ph string) { p.Phone = ph }
Loop
There is only one kind of loop available in Go, the for
loop.
The following is a traditional init-condition-post style for
loop. There are no parentheses to enclose the init-condition-post portion. Note that, the only kind of increment that Go offers is post-increment (i.e., i++
).
func Sum(n int) int { sum := 0 for i := 1; i <= n; i++ { sum += i } return sum }
The following is a range-based for
loop iterating over a sequence of int
s.
func SumIntSequence(nums []int) int { var sum int for _, elem := range nums { sum += elem } return sum }
The range
returns two values for each iteration, the index and the element. The _
is a placeholder for a return value that is not used subsequently in the code. In the above code, _
is used to ignore the returned index value.
Common data structures
The two most widely used data structures in Go are slices and maps. Both are built into the language.
Array
Like almost all other languages, an array is a sequence of contiguous mutable elements of fixed length. The following is an array of four strings.
suits := [4]string{"clubs", "diamonds", "hearts", "spades"}
Slices, described below, are based on arrays. Most of the time, instead of using arrays directly, Go programs use slices.
Slice
Slice is a non-owning view of a subsequence of contiguously stored mutable elements in an underlying array. It is written as []T
where the elements are of type T
. A slice has three components: a pointer, a length, and a capacity.
A slice can be defined using a new underlying array or specifying a half-open range of the subsequence in an existing array or another slice.
myCards := []string{"CA", "D9"} // slice based on // a new underlying array redSuits := suits[1:3] // slice based on // an existing array trump := redSuits[:1] // slice based on // another existing slice
Multiple slices may refer to the same underlying storage, and those slices’ views may overlap.
majorSuits := suits[2:] // overlaps with redSuits
For slices with overlapping contents, mutating an element through one slice is visible to the other slices.
redSuits[1] = "xxx" fmt.Printf("%q\n", majorSuits) // prints: ["xxx" "spades"]
Unlike arrays, slices are growable using the built-in function append()
. If the underlying array has reached its capacity, then append()
allocates a new underlying array, copies the previous contents, and appends the new ones.
myCards = append(myCards, "CQ") // myCards: ["CA" "D9" "CQ"]
If other slices were sharing the original array, those slices and the original array stay untouched.
Erasing elements from a slice is achieved by concatenating the slice before and the slice after. For erasing the ith element, we concatenate the (i-1) elements on the left, i.e., [:i]
, to all the elements on the right, i.e., [i+1:]
. The following example erases the element at index 1.
myCards = append(myCards[:1], myCards[2:]...) // myCards: ["CA" "CQ"]
From a C++ viewpoint, the slice has some similarities to std::vector
from the storage management aspect and it has some other similarities to std::span
, and std::string_view
from the view sharing aspect.
Map
The map is a reference to a hash table, an unordered collection of key-value pairs, in which all the keys are distinct, and the value associated with a key can be retrieved, updated, or removed in constant time. It is written as map[K]V
, where K
and V
are the types of its keys and values. The following map associates string
s to int
s.
var rgbMap map[string]int
A map needs to be initialized with the built-in make function.
rgbMap = make(map[string]int)
The following shows insertion and retrieval.
rgbMap["red"] = 1 // insert or update redCode := rgbMap["red"] // retrieve
A Go map is equivalent to std::unordered_map
in C++.
Generics
Ten years after the initial release, Go started supporting Generics in 2022. It is equivalent to templates in C++.
Go allows expressing type constraints. The following example composes (union) the standard library provided Integer
and Float
constraints to define the Numeric
constraint.
type Numeric interface { constraints.Integer | constraints.Float }
The generic function SumSequence()
accepts a slice of type T
where T
satisfies the Numeric
constraint. The generic type T
and its constraint Numeric
are enclosed in a pair of square brackets after the function name. The return type is also the generic type T
.
func SumSequence[T Numeric](nums []T) T { var sum T for _, elem := range nums { sum += elem } return sum }
The statement var sum T
performs default zero initialization for the actual type. Like C++, you can build generic data structures. The following example shows building a generic set data structure.
type Set[K comparable] struct { elems map[K]bool } func NewSet[K comparable]() *Set[K] { var set Set[K] set.elems = make(map[K]bool) return &set } func (set *Set[K]) Add(elem K) { set.elems[elem] = true }
A sample user code is the following.
seti := NewSet[int]() seti.Add(42)
Stack versus heap allocation and garbage collection
In C++, the local or automatic variables in a function are allocated in the stack. They are deallocated when the function returns. Thus, returning the address of such a variable is a recipe for disaster.
In Go, however, the placement of a variable in stack versus heap is up to the compiler. If the lifetime of a variable exists beyond the scope of a function – based on escape analysis – then the compiler places it on the heap. Based on this principle, in the NewSet()
function above it is okay to return the address of its local variable.
Go has automatic memory management or garbage collection. If there is no path to reach a heap variable from any other package level variable or any currently active functions, then the variable is unreachable and can be deallocated.
Interface
An interface in Go is an abstract type; it is a collection of one or more behaviors (methods) that are offered as part of this interface. This way it is like Pure Abstract Virtual Classes (PABC) in C++. One or more structs can satisfy an interface by implementing all the methods of the interface. Such structs are known as instances of that interface.
The methods are named as verbs (e.g., Read
, Write
, Close
) and the interfaces are named as nouns that perform those verbs (e.g., Reader
, Writer
, Closer
).
The Reader
interface offers a Read
method to read from some source, outside the scope of this function, into the byte buffer buf
, returning the number of bytes read n
(where 0 <= n
<= len(buf)
) and any error encountered err
.
type Reader interface { Read(buf []byte) (n int, err error) }
The Writer
interface offers a Write
method to write len(buf)
bytes from the buffer buf
to the underlying data stream, returning the number of bytes written n
(where 0 <= n
<= len(buf)
) and any error encountered err
that caused the write to stop early.
type Writer interface { Write(buf []byte) (n int, err error) }
A user-defined type can implement such standard interfaces and avail the standard library methods. The following example shows how a user-defined type Gadget
implements the Writer
interface.
type Gadget struct { serial []byte } func (gadget *Gadget) Write(data []byte) (n int, err error) { gadget.serial = make([]byte, len(data)) copy(gadget.serial, data) return len(data), nil }
A client of Gadget can use it like the following.
serial := []byte("123456789") gadget := Gadget{} fmt.Fprintf(&gadget, "%s", serial)
Interfaces can be composed to make bigger interfaces.
The interface ReadWriter
is an interface that combines the Reader
and Writer
interfaces.
type ReadWriter interface { Reader Writer }
An expression may be assigned to an interface if and only if its type satisfies the interface.
// Declaration: w is a variable of interface // type io.Writer var w io.Writer w = os.Stdout // OK: os.Stdout is of type *os.File which // has Write method w = time.Second // compile error: time.Second is of type // time.Duration lacking Write method
The empty interface, interface{}
, also known as any
, is satisfied by any value.
A struct can satisfy more than one interface. When a struct implements an interface, then it may or may not explicitly specify the interface. When it is not explicitly specified, the compiler uses structural typing to determine if a struct is implicitly satisfying an interface and allows substitution.
An interface value can be converted to its concrete value or a different kind of interface value by an operation known as type assertion. The following example shows how an interface value w
may be converted to a variable f
of its concrete type.
var w io.Writer w = os.Stdout f := w.(*os.File) // success: f == os.Stdout c := w.(*bytes.Buffer) // runtime panic: // interface holds *os.File, not *bytes.Buffer
The following example shows how the interface value w
of interface type io.Writer
(from above) is converted to interface value rw
of interface type io.ReadWriter
.
rw := w.(io.ReadWriter) // success: *os.File has both Read and Write
Inheritance
Go does not offer inheritance. A struct cannot inherit another struct. However, inheritance-like behavior can be achieved by designing interface(s), and struct(s) satisfying those interface(s) [ Saha21].
Packages and modules
Go source files are bundled into packages, and packages are bundled into modules.
A package groups files of similar functionalities together. The source code for a package resides in one or more .go files, usually in a directory whose name is the same as the package name. All such files list the name of the package at the beginning of the file, e.g., package fmt. Files outside the package can refer to or use a package by importing it, e.g., import "fmt"
. Each package serves as a separate namespace. From a C++ point of view, it is similar to a library from the file organization and build aspect, and namespace from the naming scope aspect.
A module is a collection of Go packages stored in a file tree with a go.mod file at its root. The go.mod file defines the module’s dependency requirements.
Eco system
Go is not just a language; it comes with a rich toolchain [Edwards19] ecosystem around it. Following are some frequently used tools in the ecosystem:
go build
to build,go run
to build and run an executable,go test
to build and run the tests and benchmarks, andgo doc
to build the documentation from comments and examples.
Beyond the basics, there are
go get
to download a package from the Internetgo fmt
to format the source code uniformly, and so on.
Go offers a flag -race
that can be passed to go build
or go test
to instrument the code for race detection.
Conclusion and further reading
This article is a quick introduction to Go from a C++ perspective. It is by no means a tutorial on Go. For that, please refer to the resources below.
Effective Go [Go-1] and The Go Programming Language [Donovan15] are excellent sources for starting to learn Go. The Go Playground [Go-2] is an excellent tool to write and execute Go programs from the comfort of a browser.
Acknowledgments
Many thanks to Prakash Jalan, Frances Buontempo, and the Overload reviewers for their feedback on the earlier versions of this article.
Note: The opinions expressed in this article are solely the author’s.
References
[Donovan15] Alan A. A. Donovan and Brian W. Kernighan (2015) The Go Programming Language, Addison-Wesley Professional Computing Series, ISBN: 978-0134190440
[Edwards19] Alex Edwards ‘An Overview of Go’s Tooling’, published 15 April 2019 at https://www.alexedwards.net/blog/an-overview-of-go-tooling
[GCC] ‘Options to request or suppress warnings’ at https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html
[Github] Go: The Go Programming Language – https://github.com/golang
[Go] The Go website: https://go.dev/
[Go-1] Effective Go, https://go.dev/doc/effective_go
[Go-2] The Go Playground: https://go.dev/play/
[Pike10] Rob Pike ‘Go’s declaration syntax’ published 7 Jul 2020 at https://go.dev/blog/declaration-syntax
[Saha21] Arun Saha ‘Inheritance in golang’ published 27 Oct 2021 at https://medium.com/@arunksaha/inheritance-in-golang-44680461cbcf
[Stackoverflow] ‘Most poular technologies’ at https://survey.stackoverflow.co/2022/#technology-most-popular-technologies
[TIOBE] ‘TIOBE Index for November 2022’: https://www.tiobe.com/tiobe-index/
[Wikipedia] ‘Go (programming language)’: https://en.wikipedia.org/wiki/Go_(programming_language)
Arun is a software engineer and works in different areas of software-defined data centers including networking and storage systems. Arun is passionate about building robust software infrastructure, engineering high quality software, and improving productivity. Arun holds a B.S. and Ph.D. in Computer Science.