The Awkward Compromise: Go Generics vs. C++ Templates

For years, one of the defining characteristics of Go was what it deliberately left out. The language built its reputation on radical simplicity, fast compilation, and a strict minimal-feature mindset. When Go 1.18 introduced type parameters, many hailed it as the modern evolution the language desperately needed.

However, for developers coming from a C++ background, using Go’s generics often feels less like a robust evolution and more like an awkward compromise. While C++ templates have grown into a deeply integrated, expressive system for compile-time metaprogramming, Go’s implementation feels tacked on—introducing syntactic clutter and unexpected boilerplate without delivering the power required for true compile-time abstractions.


1. The Awkwardness in Practice: A Comparison

The fundamental friction becomes clear when trying to constrain type parameters using both primitive operations and method signatures.

In C++20, Concepts allow you to express semantic and structural requirements directly and concisely:

#include <concepts>
#include <iostream>

// Constrain T to numeric types that also support custom stringification
template<typename T>
concept PrintableNumber = (std::integral<T> || std::floating_point<T>) && requires(T a) {
    { a.to_string() } -> std::same_as<std::string>;
};

template<PrintableNumber T>
T add_and_log(T a, T b) {
    T result = a + b;
    std::cout << "Result: " << result.to_string() << '
';
    return result;
}

In Go, expressing the equivalent constraint requires navigating overloaded interface definitions, manual type-set unions, and self-referential type parameter signatures:

package main

import "fmt"

// Go has no built-in 'Number' constraint, requiring manual enumeration of underlying types
type Number interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
		~float32 | ~float64
}

// Combining a method constraint with a generic scalar type forces a multi-parameter signature
type PrintableNumber[T Number] interface {
	Number
	ToString() string
}

// Notice the awkward self-referential parameter: T must be bounded by PrintableNumber[T]
func AddAndLog[T Number, P PrintableNumber[T]](a, b P) T {
	// Go does not allow operator + directly on P, requiring explicit conversion to T
	result := T(a) + T(b)
	fmt.Printf("Result: %s
", a.ToString())
	return result
}

This snippet highlights the syntactic friction in Go:

  • Manual Type Unions: Operators like + or < cannot be constrained by method signatures alone. You must enumerate every primitive type (~int | ~float64 ...).
  • Verbose Instantiation Signatures: To combine method constraints with underlying primitive types, function definitions often require multiple linked type parameters ([T Number, P PrintableNumber[T]]).
  • Conversion Overhead: Even when P is constrained by Number, Go’s type checker forces explicit type conversions (T(a) + T(b)) to perform arithmetic.

2. Missing Foundations: Specialization, Variadics, and Non-Type Parameters

The true strength of C++ templates goes beyond avoiding duplicate code for int and float containers. It enables zero-cost abstractions through features Go intentionally omits:

  • Template Specialization: Tailoring logic for specific types (e.g., bit-packing optimizations for std::vector<bool>).
  • Variadic Templates: Accepting arbitrary numbers of type parameters (template<typename... Args>).
  • Non-Type Parameters: Parametrizing types by values, such as fixed-size stack arrays (template<typename T, size_t N>).

Because Go lacks non-type parameters and variadic generics, common patterns like stack-allocated fixed-size buffers or type-safe varargs functions still require falling back on []any slices or runtime reflection. Developers bear the syntactic noise of type parameters like [T any, U comparable] without gaining access to compile-time type transformations.


3. Clash with Go’s Original Philosophy

Go’s primary virtue was readability: code was explicit, uniform, and contained no hidden method overloads or macro expansions.

Generics disrupt this balance without providing full metaprogramming capabilities:

  1. Visual Noise: Function signatures and interface declarations are significantly more cluttered.
  2. Incomplete Abstraction: Because the type system cannot express complex relationships without verbose workarounds, generic code often feels clunky rather than elegant.

Conclusion

Go generics are adequate for basic utility functions, generic data structures (like queues or trees), and slice helpers. However, for developers accustomed to the expressive, zero-overhead metaprogramming capabilities of C++, Go’s implementation feels like an incomplete step.

By attempting to avoid the complexity of C++ templates, Go produced a constrained system that adds visual overhead while withholding the deep compile-time capabilities systems developers rely on.