PONYλM2Modula-2

C++.CodeCompared.To/Go

An interactive executable cheatsheet comparing C++ and Go

C++23 (GCC) Go 1.26.5
Hello World & the Toolchain
Hello, World
Superficially similar, and the two details that differ are both deliberate: the package declaration, and the fact that an unused import would stop this compiling.
#include <iostream> int main() { std::cout << "Hello, World!" << std::endl; return 0; }
package main import "fmt" func main() { fmt.Println("Hello, World!") }
Every Go file names its package on the first line, and main is the one that produces an executable. The import is not a textual include but a real dependency on a package — and if you imported fmt without using it, the build would fail, not warn. That is a running theme: Go turns things other languages warn about into hard errors, on the reasoning that a warning nobody fixes is a warning nobody reads. Tabs are the standard indentation because gofmt emits them, which is the subject of the next row.
The build, which is the whole argument
If you are evaluating Go, this is the row that matters — the language design is largely downstream of it. Go was designed at a company where a C++ build took the better part of an hour.
// The C++ compilation model, and the reason a clean build takes minutes: // // - Every #include is textually pasted into every translation unit // - <iostream> alone expands to tens of thousands of lines // - Templates are instantiated separately in each unit, then deduplicated // by the linker // - Change one header and everything depending on it rebuilds // // The workarounds are an industry: precompiled headers, unity builds, // ccache, distcc, forward declarations, the pimpl idiom, and now C++20 // modules — which are a decade into a still-incomplete rollout. #include <iostream> int main() { std::cout << "a clean build of a mid-sized project: minutes" << std::endl; return 0; }
// Go's compilation model, and the reason a clean build takes seconds: // // - A package is compiled ONCE, to an object file with a summary of // its exported API attached // - An importer reads that summary — it never reads the dependency's // source, and never re-compiles it // - So the cost of importing a package is O(its API), not O(its code), // and it does not compound transitively // - Import cycles are forbidden outright, which keeps the graph a DAG // // There are no header files to keep in sync, no precompiled headers, no // unity builds and no ccache, because none of them would have anything // to do. package main import "fmt" func main() { fmt.Println("a clean build of a mid-sized project: seconds") }
The mechanism is worth understanding because it explains several later omissions. Since an importing package reads only a compact summary of its dependency's API, compilation cost does not compound the way it does when every #include pastes a header into every translation unit. Forbidden import cycles keep the graph acyclic and therefore parallelizable. And it is not free: this model is part of why Go went a decade without generics, since a summary must describe everything an importer might instantiate. Fast builds bought a smaller language, and whether that trade is worth it is the argument you are actually evaluating.
gofmt ends the style argument
This looks like a triviality and is one of the most-cited reasons teams report liking Go. Removing the option removed the argument.
// Formatting is a project decision, so every project makes it again: // brace placement, indent width, tabs or spaces, pointer alignment, // column limits, include ordering. clang-format has ~100 options and // a .clang-format file is a normal thing to argue about in review. #include <iostream> int main() { // Allman braces, because this project chose them. if ( true ) { std::cout << "whatever this project settled on" << std::endl; } return 0; }
// gofmt has NO options. There is one layout, it is not configurable, // and every editor applies it on save. Formatting never appears in a // code review because there is nothing to have an opinion about. package main import "fmt" func main() { if true { fmt.Println("what gofmt produces, everywhere, always") } }
The absence of configuration is the entire feature: clang-format is more capable and therefore something a team must agree about, while gofmt gives everyone the same answer whether they like it or not. The knock-on effect is that all Go code you will ever read looks the same, so unfamiliar code is easier to skim. Related tools follow the same philosophy — go vet for likely mistakes, go test for tests, go doc for documentation — all in the toolchain, none to choose.
Variables & Types
Every variable has a zero value
C++ splits this: class types default-construct, built-in types do not. Go has no split — every type has a zero value and every declaration produces it.
#include <iostream> #include <string> #include <vector> int main() { int total; // indeterminate — reading it is UB total = 0; // so you must initialize it std::string name; // class types DO default-construct std::vector<int> readings; std::cout << total << " [" << name << "] " << readings.size() << std::endl; return 0; }
package main import "fmt" func main() { var total int // 0, guaranteed var name string // "", guaranteed var readings []int // nil, and safe to len() and append() to fmt.Println(total, "["+name+"]", len(readings)) }
The rule is uniform: numbers are 0, strings are "", pointers, slices, maps, channels and interfaces are nil, and a struct is all of its fields zeroed recursively. That eliminates the uninitialized-read bug entirely, and it shapes API design in a way worth noticing — a type is usually built so its zero value is useful, which is why var buffer bytes.Buffer and var lock sync.Mutex are ready to use with no constructor call. A nil slice is not a null pointer crash: len is 0 and append works.
Declarations read left to right
Go reversed C's declaration syntax deliberately, and the payoff shows up exactly where C++ hurts most.
#include <iostream> int main() { // The spiral rule: read outward from the identifier, alternating. // "pointer to array of 3 pointers to function taking int returning int" int (*(*complicated)[3])(int); (void)complicated; auto inferred = 42; // auto, since C++11 const double ratio = 1.5; std::cout << inferred << " " << ratio << std::endl; return 0; }
package main import "fmt" func main() { // Types read left to right, outermost first — no spiral. // "pointer to array of 3 of func(int) int" var straightforward *[3]func(int) int _ = straightforward inferred := 42 // := declares and infers const ratio = 1.5 fmt.Println(inferred, ratio) }
C declaration syntax was designed so a declaration mirrors the expression that uses the variable, which is elegant and unreadable past two levels — hence the "spiral rule" and cdecl.org. Go puts the name first and the type after, read outermost-in, so no tool is needed. Two other things here: := declares and infers in one step and works only inside a function, and _ is the blank identifier, which is how you discard a value — needed because an unused variable is a compile error, not a warning.
No implicit numeric conversion at all
The C++ program prints -1 is NOT less than 1, which is the usual arithmetic conversions working exactly as specified. Go declines to have that feature.
#include <iostream> int main() { int signed_count = -1; unsigned int unsigned_count = 1; // Both convert to unsigned. This prints the WRONG answer. if (signed_count < unsigned_count) { std::cout << "-1 < 1" << std::endl; } else { std::cout << "-1 is NOT less than 1" << std::endl; } double ratio = 3; // int → double, silent std::cout << ratio << std::endl; return 0; }
package main import "fmt" func main() { var signedCount int32 = -1 var unsignedCount uint32 = 1 // signedCount < unsignedCount does not COMPILE: mismatched types. if int64(signedCount) < int64(unsignedCount) { fmt.Println("-1 < 1") } else { fmt.Println("-1 is NOT less than 1") } var ratio float64 = 3 // an untyped CONSTANT, so this is fine // var other float64 = someInt // but a typed int is not fmt.Println(ratio) }
Go performs no implicit conversion between numeric types — not signed to unsigned, not int32 to int64, not even int to float64. Every one is written out, which is noisier and makes the sign-comparison trap impossible. The exception that keeps it bearable is untyped constants: a literal like 3 has no type until it is used, so var ratio float64 = 3 works while assigning a typed int variable would not. Note also that int and int32 are distinct types even when they are the same width.
Memory & Lifetime
The compiler decides stack or heap
This is the single most disorienting row for a C++ programmer, because the code is the exact mistake you have been trained to never make.
#include <iostream> #include <memory> struct Point { int x; int y; }; // Returning a pointer to a local is a dangling pointer. YOU decide // where it lives, so you must allocate it on the heap explicitly. std::unique_ptr<Point> make_point() { return std::make_unique<Point>(3, 4); } int main() { auto point = make_point(); std::cout << point->x << " " << point->y << std::endl; return 0; }
package main import "fmt" type Point struct { X int Y int } // Taking the address of a local is completely safe. The compiler's // escape analysis sees the pointer outlives the frame and heap-allocates // it; nothing is dangling, and you did not have to say which. func makePoint() *Point { point := Point{X: 3, Y: 4} return &point } func main() { point := makePoint() fmt.Println(point.X, point.Y) }
Go has no stack/heap distinction in the source: you write &point and the compiler performs escape analysis to decide where the object actually lives. If the pointer never leaves the function, it stays on the stack and costs nothing; if it escapes, it is heap-allocated and the garbage collector owns it. So new versus stack allocation is not a decision you make, and returning a pointer to a local — undefined behavior in C++ — is idiomatic Go. You can see what the compiler decided with go build -gcflags=-m, which is the tool to reach for when allocation shows up in a profile.
defer instead of destructors
Go has no destructors and no finalizers you should rely on. defer is the replacement, and the difference in where the obligation lives is the thing to internalize.
#include <iostream> #include <string> class Transaction { public: explicit Transaction(std::string name) : name_(std::move(name)) { std::cout << "begin " << name_ << std::endl; } ~Transaction() { std::cout << "end " << name_ << std::endl; } private: std::string name_; }; void work() { Transaction transaction("payment"); // cleanup is attached to the TYPE std::cout << "working" << std::endl; } // runs here, automatically int main() { work(); return 0; }
package main import "fmt" func work() { fmt.Println("begin payment") defer fmt.Println("end payment") // cleanup is attached to the CALL SITE fmt.Println("working") } // deferred calls run here, last-registered first func main() { work() }
A C++ destructor belongs to the type, so the author of Transaction guarantees cleanup and every user gets it for free. A defer belongs to the call site, so every caller must remember to write it — the compiler will not. In exchange it is simpler and more flexible: deferred calls run in LIFO order at function exit including on panic, and their arguments are evaluated immediately while the call itself waits. The idiom is to defer on the line after acquiring, so file, err := os.Open(…) is followed immediately by defer file.Close().
A garbage collector tuned for latency
Go's collector is concurrent and tuned hard for pause time rather than throughput, which makes the trade different from the one you may be assuming.
#include <iostream> #include <memory> #include <vector> int main() { // Deterministic: freed exactly when the last owner goes away, and // the cost is paid by whoever drops the last reference. std::vector<std::shared_ptr<int>> holders; for (int index = 0; index < 3; index += 1) { holders.push_back(std::make_shared<int>(index)); } std::cout << holders.size() << " allocations, freed at scope exit" << std::endl; return 0; }
package main import ( "fmt" "runtime" ) func main() { // Non-deterministic: the collector reclaims these at some later // point, concurrently with the program, in sub-millisecond pauses. holders := make([]*int, 0, 3) for index := range 3 { value := index holders = append(holders, &value) } var stats runtime.MemStats runtime.ReadMemStats(&stats) fmt.Println(len(holders), "allocations, freed whenever the GC gets to them") fmt.Println("GC cycles so far:", stats.NumGC) }
Pauses are typically well under a millisecond and largely independent of heap size, because marking runs concurrently with the program. What you pay is throughput — the collector uses real CPU, and write barriers cost something on every pointer store — plus the fact that memory is released later than a C++ program would release it. For a C++ programmer the practical adjustments are: allocation rate is what to optimize (not allocation count at a single site), GOGC trades memory for CPU, and the honest answer for hard real-time or a fixed memory budget is still C++. Note for index := range 3, the range-over-integer form added in Go 1.22.
Strings
Strings are immutable byte slices
A Go string is an immutable view over bytes — a length and a pointer, closer to std::string_view than to std::string, but owning and never dangling.
#include <iostream> #include <string> int main() { std::string greeting = "hello"; greeting[0] = 'H'; // mutable in place greeting += ", world"; std::cout << greeting << std::endl; std::cout << greeting.size() << std::endl; return 0; }
package main import "fmt" func main() { greeting := "hello" // greeting[0] = 'H' // cannot assign: strings are immutable greeting = "H" + greeting[1:] // a NEW string greeting += ", world" fmt.Println(greeting) fmt.Println(len(greeting)) // BYTES, like std::string }
Immutability means passing a string is always cheap (two words) and always safe, with no copy and no aliasing question. len counts bytes exactly as size() does, and indexing yields a byte. Building a string with += in a loop is O(n²) since each step allocates, so the idiom is strings.Builder — the direct analogue of reserving capacity on a std::string. Converting to []byte copies, precisely because the string cannot be allowed to change underneath.
Ranging over a string yields runes
Go source is defined to be UTF-8 and string literals are UTF-8, so the language can offer decoding that std::string cannot.
#include <iostream> #include <string> int main() { std::string greeting = "naïve"; std::cout << "bytes: " << greeting.size() << std::endl; // Counting characters means decoding UTF-8 by hand. std::size_t characters = 0; for (unsigned char byte : greeting) { if ((byte & 0xC0) != 0x80) { characters += 1; } } std::cout << "characters: " << characters << std::endl; return 0; }
package main import ( "fmt" "unicode/utf8" ) func main() { greeting := "naïve" fmt.Println("bytes:", len(greeting)) fmt.Println("characters:", utf8.RuneCountInString(greeting)) // range over a string decodes UTF-8: index is a BYTE offset, // value is a rune (an int32 code point). for index, character := range greeting { fmt.Printf("%d:%c ", index, character) } fmt.Println() }
Two indexing modes coexist and confusing them is the classic Go string bug: greeting[2] gives a single byte, while range decodes and yields runes with their byte offsets — notice the indexes jump from 2 to 4 across the two-byte ï. A rune is just an alias for int32 holding a code point. Unlike Rust, Go does not guarantee a string is valid UTF-8; invalid bytes decode to the replacement character rather than being rejected, which is more forgiving and less safe.
Slices & Maps
Slices are a view plus an owner
A slice is the single most important Go type and it does the jobs of std::vector and std::span at once, which is why it behaves surprisingly.
#include <iostream> #include <span> #include <vector> int main() { std::vector<int> readings{1, 2, 3, 4, 5}; // A span is a non-owning view. Outliving the vector dangles. std::span<int> window(readings.data() + 1, 3); window[0] = 99; std::cout << readings[1] << " " << window.size() << std::endl; std::cout << readings.size() << " " << readings.capacity() << std::endl; return 0; }
package main import "fmt" func main() { readings := []int{1, 2, 3, 4, 5} // A slice is pointer + len + cap. It SHARES the backing array, // and it keeps that array alive — nothing can dangle. window := readings[1:4] window[0] = 99 fmt.Println(readings[1], len(window)) fmt.Println(len(readings), cap(readings)) }
The three-word header — pointer, length, capacity — means a slice both views a backing array and keeps it alive, so unlike a std::span it can never dangle. The cost is that sharing is the default: window[0] = 99 changes readings too, which is the behavior to watch for when passing slices around. append writes into spare capacity when there is some and allocates a fresh array when there is not, so whether it aliases the original depends on capacity — which is why append's result must always be assigned back.
append may or may not alias
This is the Go gotcha most likely to cost a C++ programmer an afternoon, and it has no counterpart in std::vector.
#include <iostream> #include <vector> int main() { std::vector<int> readings{1, 2, 3}; // push_back never aliases: the vector owns its buffer outright, // and reallocation invalidates iterators but not other vectors. std::vector<int> copy = readings; // a genuine copy copy.push_back(4); std::cout << readings.size() << " " << copy.size() << std::endl; return 0; }
package main import "fmt" func main() { readings := make([]int, 3, 8) // len 3, cap 8 — room to spare copy(readings, []int{1, 2, 3}) shared := readings[:2] shared = append(shared, 99) // writes INTO readings' array fmt.Println(readings[2], "was overwritten by append") full := []int{1, 2, 3} // len 3, cap 3 — no room other := append(full, 4) other[0] = 99 // reallocated, so full is untouched fmt.Println(full[0], other[0]) }
Whether append shares the backing array with the slice you appended to depends on whether there was spare capacity — so the same line aliases or does not depending on runtime state. The first case above silently overwrites readings[2]; the second reallocates and leaves the original alone. Two habits defuse it: always assign the result (slice = append(slice, …)), and when you need a guaranteed-independent copy, say so with slices.Clone or a three-index slice (readings[0:2:2]) that caps capacity at the length.
Maps, and the comma-ok idiom
Go's map indexing avoids the operator[] trap in a way worth adopting mentally: a missing key reads as zero and inserts nothing.
#include <iostream> #include <string> #include <unordered_map> int main() { std::unordered_map<std::string, int> stock; stock["widget"] = 7; // operator[] INSERTS a default when the key is missing. if (auto found = stock.find("sprocket"); found != stock.end()) { std::cout << found->second << std::endl; } else { std::cout << "sprocket: absent" << std::endl; } std::cout << stock.size() << std::endl; return 0; }
package main import "fmt" func main() { stock := map[string]int{"widget": 7} // Indexing a missing key returns the ZERO VALUE and inserts nothing. // The second return value distinguishes "absent" from "zero". if count, present := stock["sprocket"]; present { fmt.Println(count) } else { fmt.Println("sprocket: absent") } fmt.Println(len(stock)) // still 1 — the lookup did not insert }
The two-value form — the "comma ok" idiom — is how you tell a stored zero from an absent key, and it appears in three other places in Go (type assertions, channel receives, and the same on any map read). Note also that iteration order is deliberately randomized: Go shuffles it on every run specifically to stop code depending on an order the implementation never promised, which is the opposite of Python's guarantee and stricter than std::unordered_map, whose order is merely unspecified rather than actively varied. Sort the keys when you need determinism.
Control Flow
One loop keyword
Go has for and nothing else — no while, no do, no separate range-for keyword.
#include <iostream> #include <vector> int main() { std::vector<int> readings{10, 20, 30}; for (int index = 0; index < 3; index += 1) { std::cout << index << " "; } std::cout << std::endl; for (int value : readings) { std::cout << value << " "; } std::cout << std::endl; int countdown = 3; while (countdown > 0) { std::cout << countdown << " "; countdown -= 1; } std::cout << std::endl; return 0; }
package main import "fmt" func main() { readings := []int{10, 20, 30} for index := 0; index < 3; index++ { // the three-clause form fmt.Print(index, " ") } fmt.Println() for _, value := range readings { // range form fmt.Print(value, " ") } fmt.Println() countdown := 3 for countdown > 0 { // the while form — same keyword fmt.Print(countdown, " ") countdown-- } fmt.Println() }
All four shapes are spellings of the same keyword: three clauses, a condition alone (the while), nothing at all (an infinite loop), and range. The range form always yields two values — index and element for a slice, key and value for a map — so the _ discarding the index above is extremely common. One genuine difference from C++: ++ is a statement, not an expression, so x = y++ does not compile and there is no prefix form. Braces are mandatory even for one-line bodies.
switch does not fall through
Go inverted the default: cases break automatically, and falling through requires the explicit fallthrough keyword.
#include <iostream> #include <string> int main() { int code = 2; switch (code) { case 1: std::cout << "one" << std::endl; break; // forget this and control falls through case 2: case 3: std::cout << "two or three" << std::endl; break; default: std::cout << "other" << std::endl; } return 0; }
package main import "fmt" func main() { code := 2 switch code { case 1: fmt.Println("one") case 2, 3: // a list, rather than stacked empty cases fmt.Println("two or three") default: fmt.Println("other") } // A switch with no subject replaces an if/else ladder. switch { case code > 10: fmt.Println("large") case code > 1: fmt.Println("small") } }
The missing break bug is gone, and grouping cases is a comma-separated list rather than stacked empty labels. Two capabilities C++ lacks: a switch can test any comparable type including strings, and a subject-less switch takes boolean cases, which is the idiomatic replacement for a long if/else if ladder. What Go does not have is exhaustiveness checking — a missing case is silently nothing, exactly as in C++, so the linter exhaustive exists to fill the gap.
Functions
Multiple return values
This looks like a small convenience and is load-bearing: Go's entire error-handling design depends on it.
#include <iostream> #include <tuple> // A tuple, a struct, or out-parameters. All three are ceremony. std::tuple<int, int> divide(int numerator, int denominator) { return {numerator / denominator, numerator % denominator}; } int main() { auto [quotient, remainder] = divide(17, 5); std::cout << quotient << " " << remainder << std::endl; return 0; }
package main import "fmt" // Multiple returns are built into the language, not built out of tuples. func divide(numerator, denominator int) (int, int) { return numerator / denominator, numerator % denominator } func main() { quotient, remainder := divide(17, 5) fmt.Println(quotient, remainder) }
Because a function can return a result and a status without wrapping them, Go never needed exceptions, std::optional or an out-parameter convention — value, err := doSomething() is the whole mechanism, and the errors section below is just this row applied. Note the parameter list (numerator, denominator int), where consecutive parameters sharing a type name it once. Return values can also be named, which documents them and lets a bare return send the current values.
No overloading, no default arguments
Both absences are deliberate, and the replacement for default arguments is the reason the zero-value rule earlier matters so much.
#include <iostream> #include <string> void report(int value) { std::cout << "int " << value << std::endl; } void report(double value) { std::cout << "double " << value << std::endl; } void connect(const std::string& host, int port = 80, bool secure = false) { std::cout << host << ":" << port << " secure=" << std::boolalpha << secure << std::endl; } int main() { report(1); report(1.5); connect("example.com"); return 0; }
package main import "fmt" // The type goes in the name, exactly as in C. func reportInt(value int) { fmt.Println("int", value) } func reportFloat(value float64) { fmt.Println("double", value) } // No default arguments. An options struct is the idiom, and its ZERO // VALUE supplies the defaults — which is why zero values are designed // to be useful. type ConnectOptions struct { Port int Secure bool } func connect(host string, options ConnectOptions) { if options.Port == 0 { options.Port = 80 } fmt.Printf("%s:%d secure=%v\n", host, options.Port, options.Secure) } func main() { reportInt(1) reportFloat(1.5) connect("example.com", ConnectOptions{}) }
Go's stated reason for rejecting overloading is that it complicates the reader's job more than it helps the writer's — resolving which function a call means should not require type deduction. Default arguments went for the same reason. The options-struct pattern above compensates, and it works precisely because every field has a well-defined zero value, so ConnectOptions{} is a meaningful "all defaults". The other common spelling is functional options (connect(host, WithPort(443))), which reads better in libraries and costs a closure per option.
Closures capture by reference, always
Go closures always capture by reference, and there is no syntax to ask for a copy — which used to be a famous source of bugs and, since Go 1.22, mostly is not.
#include <iostream> #include <functional> int main() { int running = 0; // The capture list decides: by value or by reference, per variable. auto add = [&running](int value) { running += value; }; add(3); add(4); std::cout << running << std::endl; return 0; }
package main import "fmt" func main() { running := 0 // No capture list. A closure captures VARIABLES, not values, and // the captured variable is kept alive by escape analysis. add := func(value int) { running += value } add(3) add(4) fmt.Println(running) }
The variable itself is captured, so a closure sees later changes and can outlive the enclosing function safely, since escape analysis moves the variable to the heap. The classic trap was a loop variable shared by every closure created in the loop; Go 1.22 changed loop variables to be per-iteration, which fixed it, so older advice about copying the variable into the loop body is now obsolete. When you genuinely want a snapshot rather than a reference, assign to a new variable inside the closure or pass it as a parameter.
Structs & Methods
Methods, and the value/pointer receiver choice
The receiver is written out as a parameter before the name, and choosing value or pointer is the closest thing Go has to const-correctness.
#include <iostream> class Counter { public: void increment(int by) { total_ += by; } // non-const: may mutate int total() const { return total_; } // const: may not private: int total_ = 0; }; int main() { Counter counter; counter.increment(5); std::cout << counter.total() << std::endl; return 0; }
package main import "fmt" type Counter struct { total int } // Pointer receiver: can mutate, and the caller's value is affected. func (counter *Counter) Increment(by int) { counter.total += by } // Value receiver: gets a COPY, so it cannot mutate the original. func (counter Counter) Total() int { return counter.total } func main() { var counter Counter counter.Increment(5) // Go takes the address automatically fmt.Println(counter.Total()) }
A value receiver gets a copy, so it cannot mutate the original — but this is a convention, not a guarantee like const, since the copy may still contain pointers to shared data. Go takes the address automatically when you call a pointer method on an addressable value, so counter.Increment(5) works without &. The style rule is to be consistent per type: if any method needs a pointer receiver, give them all pointer receivers, because a mixed set makes the method set confusing when the type is used through an interface.
Embedding instead of inheritance
Go has no inheritance. Embedding looks like it and is composition with a syntactic convenience layered on.
#include <iostream> #include <string> class Base { public: void describe() const { std::cout << "I am " << name_ << std::endl; } protected: std::string name_ = "a base"; }; class Derived : public Base { public: Derived() { name_ = "a derived"; } // inherits STATE and behavior }; int main() { Derived derived; derived.describe(); return 0; }
package main import "fmt" type Base struct { Name string } func (base Base) Describe() { fmt.Println("I am", base.Name) } type Derived struct { Base // EMBEDDED: a field with no name, promoting its methods Extra int } func main() { derived := Derived{Base: Base{Name: "a derived"}, Extra: 1} derived.Describe() // promoted from Base fmt.Println(derived.Name) // field promoted too fmt.Println(derived.Base.Name) // and still reachable explicitly }
An embedded field has no name, and its fields and methods are promoted to the outer type, so derived.Describe() works. What does not happen is subtyping: a Derived is not a Base, cannot be assigned to one, and there is no virtual dispatch — Base.Describe calling another method gets Base's, never an override, so the template-method pattern does not work. Polymorphism in Go comes from interfaces (next section), not from embedding. The embedded value is still reachable by type name, which is how you disambiguate when two embedded types promote the same name.
Interfaces
Interfaces are satisfied implicitly
This is Go's best idea, and it inverts a dependency you have probably always paid without noticing.
#include <iostream> #include <memory> #include <string> #include <vector> // The relationship is DECLARED: Square must name Shape to be one, and // that means Shape must exist before Square is written. class Shape { public: virtual ~Shape() = default; virtual double area() const = 0; }; class Square : public Shape { public: explicit Square(double side) : side_(side) {} double area() const override { return side_ * side_; } private: double side_; }; int main() { std::vector<std::unique_ptr<Shape>> shapes; shapes.push_back(std::make_unique<Square>(3.0)); for (const auto& shape : shapes) { std::cout << shape->area() << std::endl; } return 0; }
package main import "fmt" // Square never mentions Shape. It satisfies the interface by having // the method — so the interface can be defined AFTER the type, by a // package that does not own it, for a type it has never seen. type Shape interface { Area() float64 } type Square struct { Side float64 } func (square Square) Area() float64 { return square.Side * square.Side } func main() { shapes := []Shape{Square{Side: 3}} for _, shape := range shapes { fmt.Println(shape.Area()) } }
Because satisfaction is structural rather than declared, the consumer defines the interface it needs, not the producer. That means you can write a one-method interface describing exactly what your function uses and pass it a type from a third-party library that has never heard of you — no adapter, no wrapper, no modification. It also inverts the usual advice: Go interfaces should be small (io.Reader has one method) and defined next to the code that consumes them. The cost is that satisfaction is accidental, so a renamed method silently stops satisfying an interface somewhere else.
Type assertions and type switches
Recovering the concrete type from an interface is built into the language rather than being a cast, and it cannot be compiled out.
#include <iostream> #include <memory> #include <string> class Shape { public: virtual ~Shape() = default; }; class Square : public Shape { public: double side = 3.0; }; class Circle : public Shape { public: double radius = 1.0; }; int main() { std::unique_ptr<Shape> shape = std::make_unique<Square>(); // dynamic_cast, and it needs RTTI enabled. if (auto* square = dynamic_cast<Square*>(shape.get())) { std::cout << "square of side " << square->side << std::endl; } else if (auto* circle = dynamic_cast<Circle*>(shape.get())) { std::cout << "circle of radius " << circle->radius << std::endl; } return 0; }
package main import "fmt" type Shape interface{ Area() float64 } type Square struct{ Side float64 } type Circle struct{ Radius float64 } func (square Square) Area() float64 { return square.Side * square.Side } func (circle Circle) Area() float64 { return 3.14159 * circle.Radius * circle.Radius } func main() { var shape Shape = Square{Side: 3} // A type switch — built in, always available, no RTTI flag. switch concrete := shape.(type) { case Square: fmt.Println("square of side", concrete.Side) case Circle: fmt.Println("circle of radius", concrete.Radius) } // The comma-ok form for a single type. if square, ok := shape.(Square); ok { fmt.Println("still a square:", square.Side) } }
A type switch is dynamic_cast's job with dedicated syntax, and there is no equivalent of -fno-rtti — an interface value always carries its dynamic type, so this always works. The single-type form uses the comma-ok idiom again; without the second variable, a failed assertion panics rather than yielding null, which is the trap. Go's culture treats heavy type-switching as a smell, the same way C++ treats a chain of dynamic_casts: if you are branching on concrete types, the interface probably wants another method.
A non-nil interface holding a nil pointer
This is the single most notorious Go bug, it survives code review routinely, and it has no C++ analogue because a C++ pointer has only one null.
#include <iostream> struct Problem {}; // A null pointer is null. There is one representation and one check. Problem* find_problem(bool fail) { if (fail) { return new Problem(); } return nullptr; } int main() { Problem* problem = find_problem(false); std::cout << std::boolalpha << (problem == nullptr) << std::endl; delete problem; return 0; }
package main import "fmt" type ProblemError struct{} func (problem *ProblemError) Error() string { return "a problem" } // BUG: the return type is the interface, but a typed nil is returned. func findProblem(fail bool) error { var problem *ProblemError // nil pointer if fail { problem = &ProblemError{} } return problem // wrapped in a non-nil interface! } func main() { err := findProblem(false) fmt.Println("err == nil?", err == nil) // false, despite no problem // Why: an interface is (type, value). Here the type is // *ProblemError and only the VALUE is nil. fmt.Printf("type=%T value=%v\n", err, err) }
An interface value is a pair: a dynamic type and a value. It is nil only when both halves are nil. Returning a nil *ProblemError as an error fills in the type half, so the result compares unequal to nil while carrying nothing — and every if err != nil in the caller fires on a success path. The fix is a rule rather than a check: never declare a variable of concrete error type and return it as error. Return a literal nil on the success path, and construct the concrete error only where you actually return one.
Templates vs Generics
Templates vs type parameters
Generics arrived in Go 1.18, twelve years after the language shipped, and they are deliberately much less capable than templates.
#include <iostream> #include <vector> template <typename Element> Element largest(const std::vector<Element>& values) { Element best = values[0]; for (const Element& value : values) { if (value > best) { best = value; } } return best; } int main() { std::cout << largest(std::vector<int>{3, 9, 2}) << std::endl; std::cout << largest(std::vector<double>{1.5, 0.5}) << std::endl; return 0; }
package main import ( "cmp" "fmt" ) // The constraint is part of the signature, so the body is checked once // rather than at each instantiation. func largest[Element cmp.Ordered](values []Element) Element { best := values[0] for _, value := range values { if value > best { best = value } } return best } func main() { fmt.Println(largest([]int{3, 9, 2})) fmt.Println(largest([]float64{1.5, 0.5})) }
A constraint is an interface used as a bound, so cmp.Ordered names the types supporting <, and the body is checked once against it — the error points at your definition rather than unrolling into a library. What Go does not have: no specialization, no non-type parameters (so no std::array<int, 3>), no variadic type parameters, no metaprogramming, and no generic methods — a method cannot introduce a type parameter of its own. Implementation is a hybrid of monomorphization and dictionary passing, so performance sits between a template and a virtual call. Templates are a compile-time programming language; Go's generics are strictly a way to avoid writing the same function twice.
Error Handling
Errors are values, and checking them is the code
Go has no exceptions. This is the most-complained-about thing in the language, and the complaint and the defense are both about the same three lines.
#include <iostream> #include <stdexcept> #include <string> int parsePort(const std::string& text) { int value = std::stoi(text); // throws if (value < 0) { throw std::out_of_range("port must not be negative"); } return value; } // Callers in between say NOTHING about errors — propagation is invisible. int doubledPort(const std::string& text) { return parsePort(text) * 2; } int main() { try { std::cout << doubledPort("8080") << std::endl; std::cout << doubledPort("-1") << std::endl; } catch (const std::exception& problem) { std::cout << "failed: " << problem.what() << std::endl; } return 0; }
package main import ( "errors" "fmt" "strconv" ) func parsePort(text string) (int, error) { value, err := strconv.Atoi(text) if err != nil { return 0, fmt.Errorf("parsing port: %w", err) // %w WRAPS } if value < 0 { return 0, errors.New("port must not be negative") } return value, nil } // Every propagating function says so. This is the famous verbosity. func doubledPort(text string) (int, error) { value, err := parsePort(text) if err != nil { return 0, err } return value * 2, nil } func main() { if value, err := doubledPort("8080"); err == nil { fmt.Println(value) } if _, err := doubledPort("-1"); err != nil { fmt.Println("failed:", err) } }
error is an ordinary interface with one method, so errors are values you return, inspect and wrap like anything else. The case for it is that every place a function can fail is visible in the source and in the signature, so there are no invisible early exits and no exception-safety analysis. The case against is that if err != nil { return nil, err } is perhaps a fifth of the lines in a real Go program, and several proposals to shorten it have all been rejected. %w wraps an error so errors.Is and errors.As can inspect the chain — the equivalent of catching a specific exception type.
panic and recover are not exceptions
Go has a mechanism that unwinds the stack, and using it the way you would use exceptions is considered wrong.
#include <iostream> #include <stdexcept> #include <vector> int main() { std::vector<int> readings{1, 2, 3}; // Exceptions are a general control-flow mechanism, used for both // bugs and recoverable conditions depending on house style. try { std::cout << readings.at(10) << std::endl; } catch (const std::out_of_range&) { std::cout << "caught: index out of range" << std::endl; } std::cout << "still running" << std::endl; return 0; }
package main import "fmt" func mightPanic() (result string) { // recover is only meaningful inside a deferred function. defer func() { if problem := recover(); problem != nil { result = fmt.Sprintf("caught: %v", problem) } }() readings := []int{1, 2, 3} _ = readings[10] // panics: index out of range return "unreachable" } func main() { fmt.Println(mightPanic()) fmt.Println("still running") }
A panic signals a bug — an index out of range, a nil dereference, an impossible state — and the intended response is to fix it, not to catch it. recover exists mainly so a server can stop one request's panic from killing the process, and so a package can convert an internal panic into an ordinary error at its public boundary. It only works inside a deferred function, which is why the closure above looks the way it does. If you find yourself panicking to report an expected condition, you are fighting the language; return an error instead.
Goroutines & Channels
Goroutines cost about two kilobytes
The word "lightweight" undersells this. The difference in cost is about three orders of magnitude, and it changes which designs are available.
#include <iostream> #include <thread> #include <vector> int main() { std::vector<std::thread> workers; std::vector<int> squares(4); // An OS thread: ~8MB of reserved stack, a syscall to create, and // a kernel context switch to schedule. Thousands is not an option. for (int number = 0; number < 4; number += 1) { workers.emplace_back([number, &squares]() { squares[number] = number * number; }); } for (std::thread& worker : workers) { worker.join(); } for (int square : squares) { std::cout << square << " "; } std::cout << std::endl; return 0; }
package main import ( "fmt" "sync" ) func main() { var waiting sync.WaitGroup squares := make([]int, 4) // A goroutine: ~2KB of growable stack, scheduled in USER SPACE by // the runtime onto a small pool of OS threads. Millions is normal. for number := range 4 { waiting.Add(1) go func() { defer waiting.Done() squares[number] = number * number }() } waiting.Wait() for _, square := range squares { fmt.Print(square, " ") } fmt.Println() }
A goroutine starts with a small growable stack and is multiplexed onto OS threads by Go's own scheduler, so creating one costs roughly a function call and blocking one costs nothing — the scheduler simply runs another. That is why the idiomatic Go server is one goroutine per connection, a design that is untenable with std::thread and which C++ reaches for coroutines and executors to approximate. sync.WaitGroup is the join: Add before launching, Done when finished, Wait to block. Note that goroutines are not awaited by default — main returning kills them all.
Channels, and communicating instead of sharing
The Go proverb is "do not communicate by sharing memory; share memory by communicating," and this row is what it means in practice.
#include <condition_variable> #include <iostream> #include <mutex> #include <queue> #include <thread> int main() { std::queue<int> queue; std::mutex guard; std::condition_variable ready; // A queue between threads is assembled by hand from three pieces. std::thread producer([&]() { for (int value = 1; value <= 3; value += 1) { { std::lock_guard<std::mutex> held(guard); queue.push(value); } ready.notify_one(); } }); int received = 0; while (received < 3) { std::unique_lock<std::mutex> held(guard); ready.wait(held, [&]() { return !queue.empty(); }); std::cout << queue.front() << " "; queue.pop(); received += 1; } producer.join(); std::cout << std::endl; return 0; }
package main import "fmt" func main() { // A channel is the queue, the mutex and the condition variable, // as one built-in type. values := make(chan int) go func() { for value := 1; value <= 3; value++ { values <- value // blocks until a receiver is ready } close(values) // says "no more", which ends the range below }() for value := range values { // receives until closed fmt.Print(value, " ") } fmt.Println() }
A channel is a typed, synchronized queue built into the language, replacing the mutex-plus-condition-variable-plus-queue assembly on the left. An unbuffered channel is a rendezvous: the send blocks until a receiver takes it, which synchronizes the two goroutines as a side effect. close signals completion and makes range terminate — and the rule is that only the sender closes, since sending on a closed channel panics. Go still has sync.Mutex and it is the right tool for guarding a small piece of shared state; channels are for handing ownership of data from one goroutine to another.
select, and cancellation
select is the piece with no C++ equivalent at all: it waits on several channel operations simultaneously and proceeds with whichever becomes ready.
#include <atomic> #include <chrono> #include <iostream> #include <thread> int main() { // Cancellation is a convention you build: a flag, checked often, // and threaded through every layer by hand. std::atomic<bool> cancelled{false}; std::thread worker([&cancelled]() { for (int step = 0; step < 100; step += 1) { if (cancelled.load()) { std::cout << "worker: cancelled" << std::endl; return; } } std::cout << "worker: finished" << std::endl; }); cancelled.store(true); worker.join(); return 0; }
package main import ( "context" "fmt" ) func worker(ctx context.Context, done chan<- string) { for step := 0; step < 100; step++ { // select waits on several channel operations at once and takes // whichever is ready first. select { case <-ctx.Done(): done <- "worker: cancelled" return default: // makes the select non-blocking } } done <- "worker: finished" } func main() { ctx, cancel := context.WithCancel(context.Background()) done := make(chan string) go worker(ctx, done) cancel() fmt.Println(<-done) }
That primitive is what makes timeouts, cancellation and fan-in expressible without a state machine — a select with a time.After case is a timeout, and a default case makes the whole thing non-blocking. context.Context is the convention built on top: it carries a cancellation signal (and a deadline, and request-scoped values) down a call tree, and by convention it is the first parameter of any function that may block. Every standard-library call that does I/O accepts one, so cancellation propagates through code you did not write — which is the part C++ has no answer for.
The race detector
Go does not prevent data races the way Rust does — it makes them easy to find, which is a different and more pragmatic bargain.
#include <iostream> #include <mutex> #include <thread> #include <vector> int main() { int total = 0; std::mutex guard; std::vector<std::thread> workers; // A data race is undefined behavior, and finding one needs a tool // outside the standard: ThreadSanitizer, a compiler extension you // opt into with a flag most people never learn about. // g++ -fsanitize=thread ... for (int worker_number = 0; worker_number < 4; worker_number += 1) { workers.emplace_back([&total, &guard]() { for (int step = 0; step < 1000; step += 1) { std::lock_guard<std::mutex> held(guard); total += 1; } }); } for (std::thread& worker : workers) { worker.join(); } std::cout << total << std::endl; return 0; }
package main import ( "fmt" "sync" ) func main() { total := 0 var lock sync.Mutex var waiting sync.WaitGroup // Go ships a race detector in the toolchain: go test -race, // go run -race. It reports the two conflicting accesses and both // stacks, and it finds races that did not corrupt anything yet. for range 4 { waiting.Add(1) go func() { defer waiting.Done() for range 1000 { lock.Lock() total++ lock.Unlock() } }() } waiting.Wait() fmt.Println(total) }
The race detector is part of the toolchain rather than a separate build mode you have to discover: add -race to go test or go run and any conflicting unsynchronized access is reported with both stack traces, even when the run produced the right answer. It is dynamic, so it only sees races on code paths actually executed — running your test suite with -race in continuous integration is the standard practice. ThreadSanitizer does the same job for C++ and is the same technology; the difference is that in Go it is one flag everyone already knows about.
Packages, Modules & Deploy
One dependency story
The dependency story is the second half of the toolchain argument, and like the first it is about there being exactly one answer.
// C++ has no standard package manager, so a project picks from: // the system package manager, vcpkg, Conan, a git submodule, FetchContent, // or vendoring the source. Most large projects use more than one. // // vcpkg install fmt // find_package(fmt CONFIG REQUIRED) // target_link_libraries(app PRIVATE fmt::fmt) // // Versions are resolved by whatever that tool decides, and two // dependencies wanting different versions of a third is your problem. #include <iostream> int main() { std::cout << "linked against whatever the build found" << std::endl; return 0; }
// One tool, in the toolchain, with no separate install: // // go mod init example.com/report — creates go.mod // go get github.com/some/library — adds it, pins it in go.sum // go build — resolves and builds // // Imports are URLs, so there is no central registry to be down or to // squat names on. Minimal version selection picks the LOWEST version // satisfying every requirement, so builds are reproducible by default // rather than by remembering to commit a lockfile. package main import "fmt" func main() { fmt.Println("resolved by go.mod, pinned by go.sum") }
Two design choices are worth knowing. Import paths are URLs, so publishing a package means pushing to a repository and there is no registry to be compromised, rate-limited, or name-squatted. And minimal version selection picks the lowest version satisfying all requirements rather than the newest allowed — the opposite of npm and Cargo — so adding a dependency cannot silently upgrade an unrelated one, and a build is reproducible without a lockfile ritual. go.sum records hashes, and the checksum database makes tampering detectable.
Deployment is one file
Static linking by default is the operational payoff, and it is the reason Go took over the infrastructure tier so completely.
// A dynamically linked C++ binary needs its libraries present and // compatible on the target: libstdc++, libgcc, glibc, plus every // third-party .so. Version skew between build and deploy hosts is a // whole genre of production incident, which is much of why the // industry reached for containers. // // ldd ./report // libstdc++.so.6 => ... // libc.so.6 => ... // // Static linking is possible and fights glibc every step of the way. #include <iostream> int main() { std::cout << "ship the binary AND make the host match" << std::endl; return 0; }
// go build produces ONE statically linked binary with the runtime and // garbage collector inside it. No shared libraries, nothing to install // on the target, and cross-compiling is two environment variables: // // GOOS=linux GOARCH=arm64 go build // // A container image for a Go service is often FROM scratch plus the // binary — a few megabytes, with no base image to patch for CVEs. package main import "fmt" func main() { fmt.Println("ship the binary. That is the whole deployment.") }
Docker, Kubernetes, Terraform, Prometheus and most of that generation are Go programs, and single-binary deployment is a large part of why. Cross-compilation needs no toolchain to install because the compiler is not a wrapper around a system linker for the common cases. The costs are honest ones: binaries start around 2MB because the runtime is included, and linking against C libraries via cgo gives up static linking, cheap cross-compilation and some performance all at once — which is why idiomatic Go libraries go to real effort to be pure Go.
What Go Left Out on Purpose
There is no const
Go's const declares compile-time constants — numbers, strings, booleans. It is not const-correctness, and there is no substitute.
#include <iostream> #include <vector> // const propagates through the type system: a const reference to a // vector cannot be modified, and the compiler enforces it everywhere. void observe(const std::vector<int>& readings) { // readings.push_back(4); // error: read-only std::cout << readings.size() << std::endl; } int main() { std::vector<int> readings{1, 2, 3}; observe(readings); std::cout << readings.size() << std::endl; return 0; }
package main import "fmt" // Go's const is only for compile-time SCALARS. There is no way to say // "this slice must not be modified" — the callee is trusted not to. func observe(readings []int) { readings[0] = 99 // nothing prevents this, and the caller sees it fmt.Println(len(readings)) } func main() { readings := []int{1, 2, 3} observe(readings) fmt.Println(readings[0]) // 99 }
You cannot express "this parameter is read-only" for a slice, map, pointer or any type with reference semantics. The compensations are partial: passing a struct by value copies it, so a value receiver is a genuine read-only view of the top level, and an interface exposing only reader methods hides the mutating ones. Neither is enforcement — a caller can always type-assert back. For a C++ programmer used to const as a design tool this is a real loss, and Go's position is that const-correctness costs more in complexity than it returns in guarantees. Judge that for yourself; it is one of the sharper disagreements between the two languages.
No operator overloading
Arithmetic operators cannot be overloaded, which makes numeric code more verbose and makes reading unfamiliar code more predictable.
#include <iostream> class Money { public: explicit Money(int cents) : cents_(cents) {} Money operator+(const Money& other) const { return Money(cents_ + other.cents_); } bool operator==(const Money& other) const = default; int cents() const { return cents_; } private: int cents_; }; int main() { Money total = Money(150) + Money(275); std::cout << total.cents() << " " << std::boolalpha << (total == Money(425)) << std::endl; return 0; }
package main import "fmt" type Money struct { Cents int } // A method, because + cannot be given a meaning. func (money Money) Add(other Money) Money { return Money{Cents: money.Cents + other.Cents} } func main() { total := Money{Cents: 150}.Add(Money{Cents: 275}) // == DOES work: it is defined structurally for comparable structs. fmt.Println(total.Cents, total == Money{Cents: 425}) }
The reasoning is the same as for overloading generally: a + b should not require knowing the types to know what happens, and should never allocate or block behind your back. The practical cost lands on anything matrix- or big-number-shaped, where a.Add(b).Mul(c) is genuinely worse than a + b * cmath/big is the standard library living with it. One thing you do get free: == is defined structurally for any struct whose fields are all comparable, so no operator== is needed. Structs containing slices, maps or functions are not comparable and using == on them is a compile error.
Unused variables and imports are errors
Go turns two of C++'s most-ignored warnings into hard errors, and the effect on a codebase is larger than it sounds.
#include <iostream> #include <vector> // unused — at most a warning, usually silent int main() { int computed = 42; // unused — a warning with -Wall, not an error (void)computed; std::cout << "builds fine" << std::endl; return 0; }
package main import ( "fmt" // "os" — an unused import is a COMPILE ERROR ) func main() { computed := 42 _ = computed // the blank identifier is how you say "deliberately unused" fmt.Println("builds, but only because of that _ =") }
The reasoning is that a warning nobody is forced to fix accumulates until the build is noisy enough that nobody reads any of it — so Go has essentially no warnings, only errors. In practice it means dead imports and leftover variables never build up, and the diff for deleting code is always complete. It is genuinely annoying while debugging, when commenting out one line orphans a variable; the escape hatches are _ = value and, for imports, _ "package" which imports purely for side effects. Note that unused function parameters and package-level variables are fine — the rule is local variables and imports only.
What you give up, in one list
Worth ending on, because the honest summary is a trade rather than an upgrade, and knowing which side you are on decides whether the move is right for you.
// The C++ features with no Go equivalent, gathered: // // RAII and destructors → defer, at the call site // const-correctness → nothing // Operator overloading → methods // Inheritance and virtual → embedding + interfaces // Templates as metaprogramming→ generics that only avoid duplication // Exceptions → (value, error) pairs // Move semantics, rvalue refs → garbage collection // Deterministic destruction → a concurrent collector // Unions → interfaces or a tagged struct // Pointer arithmetic → slices (or the unsafe package) // Compile-time evaluation → constants, and code generation #include <iostream> int main() { std::cout << "a large language, and you keep all of it" << std::endl; return 0; }
// What you get for it: // // Builds in seconds, not minutes // One formatter, one test runner, one dependency tool, one linter // One statically linked binary, cross-compiled with two env vars // Goroutines and channels, with a race detector in the toolchain // A specification small enough to hold in your head // Code that all looks the same, so unfamiliar code reads easily // // The bet is that most software is limited by how fast a team can // safely change it, not by how fast it runs. Where that bet is wrong — // hard real-time, fixed memory budgets, the last 2x of throughput — // C++ is still the answer, and reaching for Go there is a mistake. package main import "fmt" func main() { fmt.Println("a small language, and you keep all of it in your head") }
Go is a smaller language than C++ by a very wide margin, and nearly every omission above was argued for rather than merely unimplemented. The question is not which list is longer but which one describes your bottleneck. If your pain is build times, onboarding, dependency management, deployment, and getting concurrency right, Go addresses all five directly. If your pain is the last factor of two in throughput, a fixed memory ceiling, deterministic latency, or expressing invariants in the type system, Go removes tools you rely on and gives back things you already have. Most teams that move are in the first group; most teams that regret it were in the second.