Solod: Go can be a better C
I'm working on a new programming language named Solod (So). It's a strict subset of Go that translates to C, without hidden memory allocations and with source-level interop.
Highlights:
- Go in, C out. You write regular Go code and get readable C11 as output.
- Zero runtime. No garbage collection, no reference counting, no hidden allocations.
- Everything is stack-allocated by default. Heap is opt-in through the standard library.
- Native C interop. Call C from So and So from C — no CGO, no overhead.
- Go tooling works out of the box — syntax highlighting, LSP, linting and "go test".
So supports structs, methods, interfaces, slices, multiple returns, and defer. To keep things simple, there are no channels, goroutines, closures, or generics.
So is for systems programming in C, but with Go's syntax, type safety, and tooling.
Hello world • Language tour • Compatibility • Design decisions • FAQ • Final thoughts
'Hello world' example
This Go code in a file main.go:
package main
type Person struct {
Name string
Age int
Nums [3]int
}
func (p *Person) Sleep() int {
p.Age += 1
return p.Age
}
func main() {
p := Person{Name: "Alice", Age: 30}
p.Sleep()
println(p.Name, "is now", p.Age, "years old.")
p.Nums[0] = 42
println("1st lucky number is", p.Nums[0])
}
Translates to a header file main.h:
#pragma once
#include "so/builtin/builtin.h"
typedef struct main_Person {
so_String Name;
so_int Age;
so_int Nums[3];
} main_Person;
so_int main_Person_Sleep(void* self);
Plus an implementation file main.c:
#include "main.h"
so_int main_Person_Sleep(void* self) {
main_Person* p = (main_Person*)self;
p->Age += 1;
return p->Age;
}
int main(void) {
main_Person p = (main_Person){.Name = so_str("Alice"), .Age = 30};
main_Person_Sleep(&p);
so_println("%.*s %s %" PRId64 " %s", p.Name.len, p.Name.ptr, "is now", p.Age, "years old.");
p.Nums[0] = 42;
so_println("%s %" PRId64, "1st lucky number is", p.Nums[0]);
}
Language tour
In terms of features, So is an intersection between Go and C, making it one of the simplest C-like languages out there — on par with Hare.
And since So is a strict subset of Go, you already know it if you know Go. It's pretty handy if you don't want to learn another syntax.
Let's briefly go over the language features and see how they translate to C.
Variables • Strings • Arrays • Slices • Maps • If/else and for • Functions • Multiple returns • Structs • Methods • Interfaces • Enums • Errors • Defer • C interop • Packages
Values and variables
So supports basic Go types and variable declarations:
// so
const n = 100_000
f := 3.14
var r = '本'
var v any = 42
// c
const so_int n = 100000;
double f = 3.14;
so_rune r = U'本';
void* v = &(so_int){42};
byte is translated to so_byte (uint8_t), rune to so_rune (int32_t), and int to so_int (int64_t).
any is not treated as an interface. Instead, it's translated to void*. This makes handling pointers much easier and removes the need for unsafe.Pointer.
nil is translated to NULL (for pointer types).
Strings
Strings are represented as so_String type in C:
// c
typedef struct {
const char* ptr;
size_t len;
} so_String;
All standard string operations are supported, including indexing, slicing, and iterating with a for-range loop.
// so
str := "Hi 世界!"
println("str[1] =", str[1])
for i, r := range str {
println("i =", i, "r =", r)
}
// c
so_String str = so_str("Hi 世界!");
so_println("%s %u", "str[1] =", so_at(so_byte, str, 1));
for (so_int i = 0, _iw = 0; i < so_len(str); i += _iw) {
_iw = 0;
so_rune r = so_utf8_decode(str, i, &_iw);
so_println("%s %" PRId64 " %s %d", "i =", i, "r =", r);
}
Converting a string to a byte slice and back is a zero-copy operation:
// so
s := "1世3"
bs := []byte(s)
s1 := string(bs)
// c
so_String s = so_str("1世3");
so_Slice bs = so_string_bytes(s); // wraps s.ptr
so_String s1 = so_bytes_string(bs); // wraps bs.ptr
Converting a string to a rune slice and back allocates on the stack with alloca:
// so
s := "1世3"
rs := []rune(s)
s1 := string(rs)
// c
so_String s = so_str("1世3");
so_Slice rs = so_string_runes(s); // allocates
so_String s1 = so_runes_string(rs); // allocates
There's a so/strings stdlib package for heap-allocated strings and various string operations.
Arrays
Arrays are represented as plain C arrays (T name[N]):
// so
var a [5]int // zero-initialized
b := [5]int{1, 2, 3, 4, 5} // explicit values
c := [...]int{1, 2, 3, 4, 5} // inferred size
d := [...]int{100, 3: 400, 500} // designated initializers
// c
so_int a[5] = {0};
so_int b[5] = {1, 2, 3, 4, 5};
so_int c[5] = {1, 2, 3, 4, 5};
so_int d[5] = {100, [3] = 400, 500};
len() on arrays is emitted as compile-time constant.
Slicing an array produces a so_Slice.
Slices
Slices are represented as so_Slice type in C:
// c
typedef struct {
void* ptr;
size_t len;
size_t cap;
} so_Slice;
All standard slice operations are supported, including indexing, slicing, and iterating with a for-range loop.
// so
s1 := []string{"a", "b", "c", "d", "e"}
s2 := s1[1 : len(s1)-1]
for i, v := range s2 {
println(i, v)
}
// c
so_Slice s1 = (so_Slice){(so_String[5]){
so_str("a"), so_str("b"), so_str("c"),
so_str("d"), so_str("e")}, 5, 5};
so_Slice s2 = so_slice(so_String, s1, 1, so_len(s1) - 1);
for (so_int i = 0; i < so_len(s2); i++) {
so_String v = so_at(so_String, s2, i);
so_println("%" PRId64 " %.*s", i, v.len, v.ptr);
}
As in Go, a slice is a value type. Unlike in Go, a nil slice and an empty slice are the same thing:
// so
var nils []int = nil
var empty []int = []int{}
// c
so_Slice nils = (so_Slice){0};
so_Slice empty = (so_Slice){0};
make() allocates a fixed amount of memory on the stack (sizeof(T)*cap). append() only works up to the initial capacity and panics if it's exceeded. There's no automatic reallocation; use the so/slices stdlib package for heap allocation and dynamic arrays.
Maps
Maps are fixed-size and stack-allocated, backed by parallel key/value arrays with linear search. They are pointer-based reference types, represented as so_Map* in C. No delete, no resize.
// c
typedef struct {
void* keys;
void* vals;
size_t len;
size_t cap;
} so_Map;
Only use maps when you have a small, fixed number of key-value pairs. For anything else, use heap-allocated maps from the so/maps package (planned).
Most of the standard map operations are supported, including getting/setting values and iterating with a for-range loop:
// so
m := map[string]int{"a": 11, "b": 22}
for k, v := range m {
println(k, v)
}
// c
so_Map* m = &(so_Map){(so_String[2]){
so_str("a"), so_str("b")},
(so_int[2]){11, 22}, 2, 2};
for (so_int _i = 0; _i < (so_int)m->len; _i++) {
so_String k = ((so_String*)m->keys)[_i];
so_int v = ((so_int*)m->vals)[_i];
so_println("%.*s %" PRId64, k.len, k.ptr, v);
}
As in Go, a map is a pointer type. A nil map emits as NULL in C.
If/else and for
If-else and for come in all shapes and sizes, just like in Go.
Standard if-else with chaining:
// so
if x > 0 {
println("positive")
} else if x < 0 {
println("negative")
} else {
println("zero")
}
// c
if (x > 0) {
so_println("%s", "positive");
} else if (x < 0) {
so_println("%s", "negative");
} else {
so_println("%s", "zero");
}
Init statement (scoped to the if block):
// so
if num := 9; num < 10 {
println(num, "has 1 digit")
}
// c
{
so_int num = 9;
if (num < 10) {
so_println("%" PRId64 " %s", num, "has 1 digit");
}
}
Traditional for loop:
// so
for j := 0; j < 3; j++ {
println(j)
}
// c
for (so_int j = 0; j < 3; j++) {
so_println("%" PRId64, j);
}
While-style loop:
// so
i := 1
for i <= 3 {
println(i)
i = i + 1
}
// c
so_int i = 1;
for (; i <= 3;) {
so_println("%" PRId64, i);
i = i + 1;
}
Range over an integer:
// so
for k := range 3 {
println(k)
}
// c
for (so_int k = 0; k < 3; k++) {
so_println("%" PRId64, k);
}
Functions
Regular functions translate to C naturally:
// so
func sumABC(a, b, c int) int {
return a + b + c
}
// c
static so_int sumABC(so_int a, so_int b, so_int c) {
return a + b + c;
}
Named function types become typedefs:
// so
type SumFn func(int, int, int) int
fn1 := sumABC // infer type
var fn2 SumFn = sumABC // explicit type
s := fn2(7, 8, 9)
// main.h
typedef so_int (*main_SumFn)(so_int, so_int, so_int);
// main.c
main_SumFn fn1 = sumABC;
main_SumFn fn2 = sumABC;
so_int s = fn2(7, 8, 9);
Exported functions (capitalized) become public C symbols prefixed with the package name (package_Func). Unexported functions are static.
Variadic functions use the standard ... syntax and translate to passing a slice:
// so
func sum(nums ...int) int {
total := 0
for _, num := range nums {
total += num
}
return total
}
func main() {
sum(1, 2, 3, 4, 5)
}
// c
static so_int sum(so_Slice nums) {
so_int total = 0;
for (so_int _ = 0; _ < so_len(nums); _++) {
so_int num = so_at(so_int, nums, _);
total += num;
}
return total;
}
int main(void) {
sum((so_Slice){(so_int[5]){1, 2, 3, 4, 5}, 5, 5});
}
Function literals (anonymous functions and closures) are not supported.
Multiple returns
So supports two-value multiple returns in two patterns: (T, error) and (T1, T2). Both cases translate to so_Result C type:
// so
func divide(a, b int) (int, error) {
return a / b, nil
}
func divmod(a, b int) (int, int) {
return a / b, a % b
}
// c
typedef struct {
so_Value val;
so_Value val2;
so_Error err;
} so_Result;
// c
static so_Result divide(so_int a, so_int b) {
return (so_Result){.val.as_int = a / b, .err = NULL};
}
static so_Result divmod(so_int a, so_int b) {
return (so_Result){.val.as_int = a / b, .val2.as_int = a % b};
}
Named return values are not supported.
Structs
Structs translate to C naturally:
// so
type person struct {
name string
age int
}
bob := person{"Bob", 20}
alice := person{name: "Alice", age: 30}
fred := person{name: "Fred"}
// c
typedef struct person {
so_String name;
so_int age;
} person;
person bob = (person){so_str("Bob"), 20};
person alice = (person){.name = so_str("Alice"), .age = 30};
person fred = (person){.name = so_str("Fred")};
new() works with types and values:
// so
n := new(int) // *int, zero-initialized
p := new(person) // *person, zero-initialized
n2 := new(42) // *int with value 42
p2 := new(person{name: "Alice"}) // *person with values
// c
so_int* n = &(so_int){0};
person* p = &(person){0};
so_int* n2 = &(so_int){42};
person* p2 = &(person){.name = so_str("Alice")};
Methods
Methods are defined on struct types with pointer or value receivers:
// so
type Rect struct {
width, height int
}
func (r *Rect) Area() int {
return r.width * r.height
}
func (r Rect) resize(x int) Rect {
r.height *= x
r.width *= x
return r
}
Pointer receivers pass void* self in C and cast to the struct pointer. Value receivers pass the struct by value, so modifications operate on a copy:
// c
typedef struct main_Rect {
so_int width;
so_int height;
} main_Rect;
so_int main_Rect_Area(void* self) {
main_Rect* r = (main_Rect*)self;
return r->width * r->height;
}
static main_Rect main_Rect_resize(main_Rect r, so_int x) {
r.height *= x;
r.width *= x;
return r;
}
Calling methods on values and pointers emits pointers or values as necessary:
// so
r := Rect{width: 10, height: 5}
r.Area() // called on value (address taken automatically)
r.resize(2) // called on value (passed by value)
rp := &r
rp.Area() // called on pointer
rp.resize(2) // called on pointer (dereferenced automatically)
// c
main_Rect r = (main_Rect){.width = 10, .height = 5};
main_Rect_Area(&r);
main_Rect_resize(r, 2);
main_Rect* rp = &r;
main_Rect_Area(rp);
main_Rect_resize(*rp, 2);
Methods on named primitive types are also supported.
Interfaces
Interfaces in So are like Go interfaces, but they don't include runtime type information.
Interface declarations list the required methods:
// so
type Shape interface {
Area() int
Perim(n int) int
}
In C, an interface is a struct with a void* self pointer and function pointers for each method (less efficient than using a static method table, but simpler; this might change in the future):
// c
typedef struct main_Shape {
void* self;
so_int (*Area)(void* self);
so_int (*Perim)(void* self, so_int n);
} main_Shape;
Just as in Go, a concrete type implements an interface by providing the necessary methods:
// so
func (r *Rect) Area() int {
// ...
}
func (r *Rect) Perim(n int) int {
// ...
}
// c
so_int main_Rect_Area(void* self) {
// ...
}
so_int main_Rect_Perim(void* self, so_int n) {
// ...
}
Passing a concrete type to functions that accept interfaces:
// so
func calcShape(s Shape) int {
return s.Perim(2) + s.Area()
}
r := Rect{width: 10, height: 5}
calcShape(&r) // implicit conversion
calcShape(Shape(&r)) // explicit conversion
// c
static so_int calcShape(main_Shape s) {
return s.Perim(s.self, 2) + s.Area(s.self);
}
main_Rect r = (main_Rect){.width = 10, .height = 5};
calcShape((main_Shape){.self = &r,
.Area = main_Rect_Area,
.Perim = main_Rect_Perim});
calcShape((main_Shape){.self = &r,
.Area = main_Rect_Area,
.Perim = main_Rect_Perim});
Type assertion works for concrete types (v := iface.(*Type)), but not for interfaces (iface.(Interface)). Type switch is not supported.
Empty interfaces (interface{} and any) are translated to void*.
Enums
So supports typed constant groups as enums:
// so
type ServerState string
const (
StateIdle ServerState = "idle"
StateConnected ServerState = "connected"
StateError ServerState = "error"
)
Each constant is emitted as a C const:
// main.h
typedef so_String main_ServerState;
extern const main_ServerState main_StateIdle;
extern const main_ServerState main_StateConnected;
extern const main_ServerState main_StateError;
// main.c
const main_ServerState main_StateIdle = so_str("idle");
const main_ServerState main_StateConnected = so_str("connected");
const main_ServerState main_StateError = so_str("error");
iota is supported for integer-typed constants:
// so
type Day int
const (
Sunday Day = iota
Monday
Tuesday
)
Iota values are evaluated at compile time and translated to integer literals:
// c
typedef so_int main_Day;
const main_Day main_Sunday = 0;
const main_Day main_Monday = 1;
const main_Day main_Tuesday = 2;
Errors
Errors use the so_Error type (a pointer):
// c
struct so_Error_ {
const char* msg;
};
typedef struct so_Error_* so_Error;
So only supports sentinel errors, which are defined at the package level using errors.New (implemented as compiler built-in):
// so
import "solod.dev/so/errors"
var ErrOutOfTea = errors.New("no more tea available")
// c
#include "so/errors/errors.h"
so_Error main_ErrOutOfTea = errors_New("no more tea available");
Errors are compared using ==. This is an O(1) operation (compares pointers, not strings):
// so
func makeTea(arg int) error {
if arg == 42 {
return ErrOutOfTea
}
return nil
}
err := makeTea(42)
if err == ErrOutOfTea {
println("out of tea")
}
// c
static so_Error makeTea(so_int arg) {
if (arg == 42) {
return main_ErrOutOfTea;
}
return NULL;
}
so_Error err = makeTea(42);
if (err == main_ErrOutOfTea) {
so_println("%s", "out of tea");
}
Dynamic errors (fmt.Errorf), local error variables (errors.New inside functions), and error wrapping are not supported.
Defer
defer schedules a function or method call to run at the end of the enclosing scope.
The scope can be either a function (as in Go):
// so
func funcScope() {
xopen(&state)
defer xclose(&state)
if state != 1 {
panic("unexpected state")
}
}
Or a bare block (unlike Go):
// so
func blockScope() {
{
xopen(&state)
defer xclose(&state)
if state != 1 {
panic("unexpected state")
}
// xclose(&state) runs here, at block end
}
// state is already closed here
}
Deferred calls are emitted inline (before returns, panics, and scope end) in LIFO order:
// c
static void funcScope(void) {
xopen(&state);
if (state != 1) {
xclose(&state);
so_panic("unexpected state");
}
xclose(&state);
}
Defer is not supported inside other scopes like for or if.
C interop
Include a C header file with so:include:
//so:include <stdio.h>
Declare an external C type (excluded from emission) with so:extern:
//so:extern FILE
type os_file struct{}
Declare an external C function (no body or so:extern):
func fopen(path string, mode string) *os_file
//so:extern
func fclose(stream *os_file) int {
_ = stream
return 0
}
When calling extern functions, string and []T arguments are automatically decayed to their C equivalents: string literals become raw C strings ("hello"), string values become char*, and slices become raw pointers. This makes interop cleaner:
// so
f := fopen("/tmp/test.txt", "w")
// c
os_file* f = fopen("/tmp/test.txt", "w");
// not like this:
// fopen(so_str("/tmp/test.txt"), so_str("w"))
The decay behavior can be turned off with the nodecay flag:
//so:extern nodecay
func set_name(acc *Account, name string)
The so/c package includes helpers for converting C pointers back to So string and slice types. The unsafe package is also available and is implemented as compiler built-ins.
Packages
Each Go package is translated into a single .h + .c pair, regardless of how many .go files it contains. Multiple .go files in the same package are merged into one .c file, separated by // -- filename.go -- comments.
Exported symbols (capitalized names) are prefixed with the package name:
// geom/geom.go
package geom
const Pi = 3.14159
func RectArea(width, height float64) float64 {
return width * height
}
Becomes:
// geom.h
extern const double geom_Pi;
double geom_RectArea(double width, double height);
// geom.c
const double geom_Pi = 3.14159;
double geom_RectArea(double width, double height) { ... }
Unexported symbols (lowercase names) keep their original names and are marked static:
// c
static double rectArea(double width, double height);
Exported symbols are declared in the .h file (with extern for variables). Unexported symbols only appear in the .c file.
Importing a So package translates to a C #include:
// so
import "example/geom"
// c
#include "geom/geom.h"
Calling imported symbols uses the package prefix:
// so
a := geom.RectArea(5, 10)
_ = geom.Pi
// c
double a = geom_RectArea(5, 10);
(void)geom_Pi;
That's it for the language tour!
Compatibility
So generates C11 code that relies on several GCC/Clang extensions:
- Binary literals (
0b1010) in generated code. - Statement expressions (
({...})) in macros. __attribute__((constructor))for package-level initialization.__auto_typefor local type inference in generated code.__typeof__for type inference in generic macros.allocaformake()and other dynamic stack allocations.
You can use GCC, Clang, or zig cc to compile the transpiled C code. MSVC is not supported.
Supported operating systems: Linux, macOS, and Windows (partial support).
Design decisions
So is highly opinionated.
Simplicity is key. Fewer features are always better. Every new feature is strongly discouraged by default and should be added only if there are very convincing real-world use cases to support it. This applies to the standard library too — So tries to export as little of Go's stdlib API as possible while still remaining highly useful for real-world use cases.
No heap allocations are allowed in language built-ins (like maps, slices, new, or append). Heap allocations are allowed in the standard library, but they must clearly state when an allocation happens and who owns the allocated data.
Fast and easy C interop. Even though So uses Go syntax, it's basically C with its own standard library. Calling C from So, and So from C, should always be simple to write and run efficiently. The So standard library (translated to C) should be easy to add to any C project.
Readability. There are several languages that claim they can transpile to readable C code. Unfortunately, the C code they generate is usually unreadable or barely readable at best. So isn't perfect in this area either (though it's arguably better than others), but it aims to produce C code that's as readable as possible.
Go compatibility. So code is valid Go code. No exceptions.
Non-goals:
Raw performance. You can definitely write C code by hand that runs faster than code produced by So. Also, some features in So, like interfaces, are currently implemented in a way that's not very efficient, mainly to keep things simple.
Hiding C entirely. So is a cleaner way to write C, not a replacement for it. You should know C to use So effectively.
Go feature parity. Less is more. Iterators aren't coming, and neither are generic methods.
Frequently asked questions
I have heard these several times, so it's worth answering.
Why not Rust/Zig/Odin/other language?
Because I like C and Go.
Why not TinyGo?
TinyGo is lightweight, but it still has a garbage collector, a runtime, and aims to support all Go features. What I'm after is something even simpler, with no runtime at all, source-level C interop, and eventually, Go's standard library ported to plain C so it can be used in regular C projects.
How does So handle memory?
Everything is stack-allocated by default. There's no garbage collector or reference counting. The standard library provides explicit heap allocation in the so/mem package when you need it.
Is it safe?
So itself has few safeguards other than the default Go type checking. It will panic on out-of-bounds array access, but it won't stop you from returning a dangling pointer or forgetting to free allocated memory.
Most memory-related problems can be caught with AddressSanitizer in modern compilers, so I recommend enabling it during development by adding -fsanitize=address to your CFLAGS.
Can I use So code from C (and vice versa)?
Yes. So compiles to plain C, therefore calling So from C is just calling C from C. Calling C from So is equally straightforward.
Can I compile existing Go packages with So?
Not really. Go uses automatic memory management, while So uses manual memory management. So also supports far fewer features than Go. Neither Go's standard library nor third-party packages will work with So without changes.
How stable is this?
Not for production at the moment.
Where's the standard library?
There is a growing set of high-level packages (so/bytes, so/mem, so/slices, ...). There are also low-level packages that wrap the libc API (so/c/stdlib, so/c/stdio, so/c/cstring, ...). Check the links below for more details.
Final thoughts
Even though So isn't ready for production yet, I encourage you to try it out on a hobby project or just keep an eye on it if you like the concept.
Further reading:
★ Subscribe to keep up with new posts.