When Go's zero value meets C's reality
You found a C library that performs arbitrary-precision arithmetic in microseconds. You wrap it in Go to use it in your application. You create a variable, call a method, and your program segfaults. The stack trace points deep into the C library, ending at an initialization function you never called.
Go promises that the zero value of any type is safe to use. If you declare var x MyStruct, you can call methods on x immediately. C does not make that promise. A C struct for a math library is often a complex layout of pointers and internal state that requires setup before the first operation. If you pass a zero-value Go struct to C, you are passing uninitialized memory or null pointers. The C library assumes the struct is ready and crashes trying to dereference garbage.
The wrapper must bridge this gap. You need a pattern that preserves Go's zero-value ergonomics for the user while ensuring the C struct is initialized before any C code touches it. The solution is a lazy initializer hidden behind a flag.
The lazy initialization pattern
The pattern adds a boolean field to your Go struct. This field tracks whether the underlying C struct has been initialized. Every method that interacts with C checks this flag at the start. If the flag is false, the method calls the C initialization function and sets the flag to true. If the flag is true, the method proceeds directly to the C operation.
This approach makes the Go zero value safe. The user writes var x Int and calls x.Set(42). The method handles the initialization transparently. The user never sees the C setup code. The wrapper lies to the user about the zero value being ready, but the lie is consistent and safe.
The initialization function must be idempotent. Calling it multiple times must be safe. The flag check guarantees this. If the struct is already initialized, the C function is skipped. This matters because methods often call other methods. If Add calls Mul internally, both might try to initialize. The flag prevents double initialization, which could corrupt the C struct or leak memory.
Here is the struct and the initialization helper. The code wraps a hypothetical C integer type. The flag is named init to match the convention in the source library, though initialized is more explicit. Receiver names are short; z is standard for math types.
package main
/*
#include <stdlib.h>
typedef struct {
int value;
} CInt;
void cinit(CInt* c) {
c->value = 0;
}
*/
import "C"
// Int wraps a C integer with lazy initialization.
// The zero value is safe; methods initialize the C struct on first use.
type Int struct {
// c holds the underlying C struct.
c C.CInt
// init tracks whether cinit has been called.
// This flag enables zero-value safety for Go users.
init bool
}
// doinit ensures the C struct is initialized before use.
// It is idempotent: calling it multiple times is safe.
func (z *Int) doinit() {
if z.init {
return
}
z.init = true
C.cinit(&z.c)
}
The doinit method is small but critical. It checks the flag, returns early if work is done, sets the flag, and calls the C function. The C function receives a pointer to the struct. In Go, &z.c takes the address of the embedded C struct. The C function modifies the memory in place.
Every public method that touches the C struct must call doinit as its first action. This ensures that no matter which method the user calls first, the struct is ready.
// Set assigns a value to the wrapped integer.
// It initializes the C struct if needed.
func (z *Int) Set(v int) {
z.doinit()
z.c.value = C.int(v)
}
// Add increments the value by v.
func (z *Int) Add(v int) {
z.doinit()
z.c.value += C.int(v)
}
func main() {
var x Int
// Zero value is safe. doinit runs inside Set.
x.Set(10)
x.Add(5)
}
The user code looks like standard Go. var x Int creates a zero value. x.Set(10) works immediately. The initialization happens inside Set. The user does not need to call a constructor or check for readiness. The wrapper handles the complexity.
Goroutines are cheap. Channels are not magic.
Cleanup and resource leaks
Initialization is only half the contract. C libraries often allocate memory during initialization. If you call mpz_init in GMP, the library prepares internal buffers. If you never clean up, those buffers leak. Go's garbage collector manages Go memory, but it does not know about C allocations. The GC will eventually collect the Go struct, but the C memory remains allocated until the process exits.
You must provide a cleanup method. The cleanup method releases C resources and resets the flag. Resetting the flag is important. If the user reuses the struct after cleanup, the flag must be false so that doinit runs again. Otherwise, the struct stays in a broken state where the C memory is freed but the flag claims it is valid.
// Clear releases the underlying C resources.
// Call this when the Int is no longer needed.
// After Clear, the struct can be reused; the flag resets.
func (z *Int) Clear() {
if z.init {
C.cfree(&z.c)
z.init = false
}
}
The cleanup method checks the flag. If the struct was never initialized, there is nothing to free. Calling the C free function on an uninitialized struct is undefined behavior and likely crashes. The flag prevents this. The method also sets init to false. This allows the struct to be reused. The user can call Clear, then Set again, and the wrapper will reinitialize the C struct.
Convention aside: Go developers prefer explicit cleanup over finalizers. runtime.SetFinalizer can call a function when the GC collects an object, but finalizers are non-deterministic and can hold memory longer than necessary. Explicit Clear or Close methods give the user control. Document that the user must call Clear when done. If the library supports io.Closer, implement that interface.
C memory is a loan. Return it when done.
The thread safety trap
The lazy initialization pattern has a hidden flaw. The flag check and update are not atomic. If two goroutines call methods on the same struct simultaneously, a data race occurs. Both goroutines might read z.init as false at the same time. Both proceed to call the C initialization function. The C struct gets initialized twice, which can corrupt internal state or double-allocate memory.
The Go race detector will catch this. If you run your program with -race, the detector reports concurrent reads and writes to the init field. This is a bug.
Math types are often copied rather than shared. If each goroutine has its own Int value, there is no race. The struct is passed by value or each goroutine declares its own variable. In that case, the simple flag is safe.
If the struct is shared across goroutines, you need synchronization. A mutex protects the flag and the C struct. The doinit method locks the mutex, checks the flag, initializes if needed, and unlocks. This adds overhead to every method call. The cost depends on the workload. For heavy computation, the lock overhead is negligible. For tight loops with small operations, the lock might dominate.
You can also document that the struct is not thread-safe. This is common for C wrappers. The user must ensure exclusive access. This shifts the burden to the caller but keeps the wrapper simple.
Shared state needs locks. Check the race detector before shipping.
Pitfalls and compiler errors
Wrapping C structs introduces pitfalls that the Go compiler cannot catch. The compiler checks Go types, but it does not understand C invariants. You must ensure the C struct is initialized before use. If you forget to call doinit in a new method, the compiler will not warn you. The program compiles cleanly and crashes at runtime.
Code reviews and tests are essential. Write tests that exercise every public method. Use the race detector in tests. Verify that cleanup works by checking memory usage or using tools like Valgrind.
Another pitfall is passing the wrong pointer to C. C functions often expect pointers to structs. If you pass the struct by value, C receives a copy. Modifications in C do not affect the Go struct. The compiler might allow this if the types match loosely, or it might reject it.
If you pass a struct where a pointer is expected, the compiler complains with cannot use z.c (variable of struct type C.CInt) as *C.CInt value in argument. You must take the address with &. If you pass a pointer where a value is expected, C might read garbage. Always check the C function signature.
When wrapping GMP, the type mpz_t is an array. Go represents this as a slice or array. Accessing the first element requires indexing. If you pass the slice header to C instead of the pointer to the data, C sees the wrong memory.
The compiler rejects this with cannot use z.i (variable of type []C.mpz_t) as *C.mpz_t value in argument. You must use &z.i[0] to get the pointer to the first element. This is a common mistake. The expression &z.i[0] takes the address of the first element of the slice or array. C receives a pointer to the data, which is what it expects.
The compiler checks types, not C invariants. You check the invariants.
Decision: when to use this pattern
Wrapping C structs requires careful design. Choose the right pattern based on the library and your needs.
Use a lazy initialization wrapper when the C library requires setup and you want Go-like zero-value ergonomics for the user. This pattern hides complexity and makes the wrapper feel idiomatic. It works well for libraries where initialization is cheap and cannot fail.
Use an explicit constructor function when the C initialization can fail and you need to return an error immediately. If the C function returns an error code or allocates memory that might fail, a constructor like NewInt() (*Int, error) lets the user handle errors upfront. The zero value remains invalid, and the user must check the error.
Use a pure Go implementation when the C dependency adds build complexity or portability constraints. Cgo requires a C compiler and increases binary size. If a Go library provides similar performance and functionality, prefer Go. It simplifies deployment and avoids C interop bugs.
Use a sync.Once wrapper when the initialization is global and happens exactly once per process. If the C library has a global setup function that runs once, sync.Once ensures thread-safe one-time execution. This does not apply to per-instance initialization.
Use a mutex-protected wrapper when the struct is shared across goroutines. If multiple goroutines access the same instance, synchronization is required to prevent data races. Document the thread safety guarantees clearly.