Solod (So) is a strict subset of Go that transpiles to regular C. This document lists the features it supports. If a feature isn't listed, it's not supported.
Values • Variables • Strings • Arrays • Slices • Maps • If/else • Switch • For • Goto • Functions • Multiple returns • Variadic functions • Structs • Methods • Interfaces • Enums • Errors • Panic • Defer • C interop • Generics • Embeds • Packages
So supports basic Go types:
// Integers.
const d1 = 123
const d2 = 100_000
const d3 = 0b1010
const d4 = 0o600
const d5 = 0xBadFace
const d6 = 0x_67_7a_2f_cc_40_c6
// Floating-point numbers.
const f1 = 3.14
const f2 = 0.25
const f3 = 1e-9
const f4 = 6.022e23
const f5 = 1e6
// Runes.
const r1 = 'a'
const r2 = 'ä'
const r3 = '本'
const r4 = '\xff'
const r5 = '\u12e4'In C, the default type for integers is so_int (int64_t), for floats it's double, and for runes it's int32_t.
Complex numbers are not supported.
Constants are translated to C const qualifiers.
So supports all the main ways to declare and initialize a variable in Go.
var with an explicit or inferred type:
var vInt int = 42
var vFloat float64 = 3.14
var vBool bool = true
var vByte byte = 'x'
var vRune rune = '本'
var vString = "hello"
var vSlice = []int{1, 2, 3}
var vStruct = person{age: 42}
var vPtr = &vStruct
var vAnyVal any = 42
var vAnyPtr any = vPtr
var vNil any = nilShort variable declaration:
vInt := 42
vFloat := 3.14
vBool := true
vByte := 'x'
vRune := '本'
vString := "hello"
vSlice := []int{1, 2, 3}
vStruct := person{age: 42}
vPtr := &vStruct
vAnyVal := any(42)
vAnyPtr := any(vPtr)
vNil := any(nil)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.
As in Go, all variables are implicitly initialized to zero values:
var vInt int // 0
var vFloat float64 // 0
var vBool bool // false
var vByte byte // 0
var vRune rune // 0
var vString string // "", len=0
var vSlice []int // len=0, cap=0
var vStruct person // all fields are set to zero values
var vPtr *person // NULL
var vNil any // NULLStrings are represented as so_String type in C (a struct with a ptr and len). String literals are wrapped in so_str().
Indexing a string returns a byte (uint8_t):
str := "Hi 世界!"
chr := str[0] // byte valueIterating over a string with range decodes UTF-8 runes:
for i, r := range str {
println("i =", i, "r =", r)
}Slicing a string returns a new string (zero-copy):
s := "hello"
s1 := s[:] // "hello"
s2 := s[2:] // "llo"
s3 := s[:3] // "hel"
s4 := s[1:4] // "ell"String comparison uses dedicated functions (so_string_eq, etc.) instead of C operators:
s1 := "hello"
s2 := "world"
if s1 == s2 || s1 < s2 {
println("ok")
}Converting a string to a byte or rune slice:
s := "1世3"
bs := []byte(s) // zero-copy view of s
rs := []rune(s) // allocatesConverting a byte or a rune slice to a string:
s1 := string(bs) // zero-copy view of bs
s2 := string(rs) // allocatesstring([]byte) and []byte(string) are zero-copy views that alias the original data. Modifying the byte slice will affect the string and vice versa. Clone the data if you need an independent copy.
Converting a byte or rune to a string:
var b byte = 'A'
s1 := string(b) // "A"
var r rune = '世'
s2 := string(r) // "世" (UTF-8 encoded)String concatenation with + and += is supported for both literals and variables. Adding string variables allocates memory on the stack, so avoid using them for large strings or strings that should be on the heap. Instead, use the so/strings package.
Arrays are represented as plain C arrays (T name[N]). They are value types - copied on struct assignment and support direct indexing.
Array literals:
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 initializersNamed array types:
type IntArray [3]int
var arr IntArrayArrays can be struct fields:
type Box struct {
nums [3]int
}len() and cap() on arrays are emitted as compile-time constants.
Slicing an array produces a so_Slice:
nums := [...]int{1, 2, 3, 4, 5}
s := nums[1:4] // s is a so_SliceLimitations:
- Arrays decay to pointers when passed to functions (no value semantics on calls).
- Cannot return arrays from functions.
- Array assignment uses
memcpy.
Slices are represented as so_Slice type in C (a struct with a data pointer, len, and cap).
Slice literals:
strs := []string{"a", "b", "c"}
twoD := [][]int{{1, 2, 3}, {4, 5, 6}}Unlike in Go, a nil slice and an empty slice are the same thing:
// Both emit `(so_Slice){0}`.
var nils []int = nil
var empty []int = []int{}Slicing:
s1 := nums[:] // full slice
s2 := nums[2:] // from index 2
s3 := nums[:3] // up to index 3
s4 := nums[1:4] // from 1 to 4Full slice expressions (s[low:high:max]) are supported to limit the capacity of the resulting slice:
s := nums[1:3:4] // len=2, cap=3Built-in operations:
s := make([]int, 4) // allocate with len=4, cap=4
s = make([]int, 0, 8) // allocate with len=0, cap=8
s = append(s, 1) // append a single value
s = append(s, 2, 3) // append multiple values
s = append(s, other...) // append another slice
n := copy(dst, src) // copy elements
l := len(s) // length
c := cap(s) // capacity
x := s[2] // index accessmake() 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 package instead of make and append for heap allocation and dynamic arrays.
Iterating over a slice with range:
for i, v := range nums {
println(i, v)
}Arithmetic and bitwise compound assignments work on slice elements:
s[1] += 10
s[1] <<= 2
s[1]++clear zeros all elements of a slice to their zero value. Length and capacity are unchanged. clear is not supported for maps.
Maps are fixed-size and stack-allocated, backed by "mask-step-index" hashtables. They are pointer-based reference types, represented as so_Map* in C. No delete, no resize.
Only use maps when you have a small, fixed number of items (<1024). For anything else, use heap-allocated maps from the so/maps package.
Map literals:
m1 := map[string]int{"a": 11, "b": 22}
m2 := map[int]string{11: "a", 22: "b"}Creating a map with make:
m := make(map[string]int, 2)The capacity argument is required and determines the fixed size of the map. make() allocates key and value arrays on the stack.
Setting and getting values:
m["a"] = 11
v := m["a"]Comma-ok pattern to check if a key exists:
v, ok := m["a"]
if !ok {
println("not found")
}If the key is not found, the value is the zero value for the element type and ok is false.
Iterating over a map with range:
for k, v := range m {
println(k, v)
}Supported key types: all integer types, bool, float32, float64, string, and pointers.
Limitations:
- Maps have a fixed capacity set at creation time. Setting a key when the map is full panics.
- Compound assignment on map index (
m["a"] += 1) is not supported. - Arrays as map value types are not supported.
- Returning maps from functions is not supported (stack-allocated keys/vals become dangling).
deleteis not supported.
Standard if, else if, and else:
if 7%2 == 0 {
println("even")
} else {
println("odd")
}Chained conditions:
if x > 0 {
println("positive")
} else if x < 0 {
println("negative")
} else {
println("zero")
}Init statement (scoped to the if block):
if num := 9; num < 10 {
println(num, "has 1 digit")
}Switch statements are translated to if/else-if/else chains.
Tagged switch:
switch x {
case 1:
println("one")
case 2, 3:
println("two or three")
default:
println("other")
}Tagless switch (bool conditions):
switch {
case x > 100:
println("big")
case x > 0:
println("positive")
}Init statement (scoped to the switch block):
switch n := compute(); n {
case 42:
println("answer")
}String switch uses so_string_eq for comparisons.
fallthrough and type switches are not supported.
Traditional for loop:
for j := 0; j < 3; j++ {
println(j)
}While-style loop:
for i <= 3 {
println(i)
i = i + 1
}Infinite loop:
for {
println("loop")
break
}Range over an integer:
for k := range 3 {
println(k)
}Range over a slice and range over a string are also supported.
break and continue work as expected.
Labels and goto map directly to C:
for i := range 10 {
if i%2 == 0 {
goto next
}
next:
fails++
if fails > 2 {
goto fallback
}
}
fallback:
println("done")Regular function declarations:
func sumABC(a, b, c int) int {
return a + b + c
}Named function types and function variables:
type SumFn func(int, int, int) int
fn1 := sumABC // infer type
var fn2 SumFn = sumABC // explicit type
s := fn2(7, 8, 9)Function literals (anonymous functions / closures) are not supported. Use named types instead, like SumFn in the example above.
Exported functions (capitalized) become public C symbols prefixed with the package name (package_Func). Unexported functions are static.
Exported functions must only use exported types in their signatures (parameters and return types).
So supports two-value multiple returns in two patterns: (T, error) and (T1, T2).
The (T, error) pattern - the second value is error:
func divide(a, b int) (int, error) {
return a / b, nil
}The (T1, T2) pattern - two values of any supported type:
func divmod(a, b int) (int, int) {
return a / b, a % b
}Destructuring:
q, err := divide(10, 3) // new variables
q, err = divide(20, 7) // reassign existing
_, err2 := divide(10, 3) // blank identifier
r, _ := divide(10, 3) // ignore second value
d, m := divmod(10, 3) // two values
_, m2 := divmod(10, 3) // blank identifierIf-init with multi-return:
if n, err := f.Read(64); err != nil {
println("error")
}Forwarding a multi-return call:
func forwardCall() (int, error) {
return divide(10, 3)
}
func forwardDivmod() (int, int) {
return divmod(10, 3)
}Supported return types:
bool byte float64
int int64 rune
string []T *TSo also supports the (T, error) pattern, where T is a struct type. To use it, you need to define a struct type called {T}Result with two fields: val T and err error:
type FileResult struct {
val File
err error
}
func create(size int) (File, error) {
return File{size: size}, nil
}Not supported: returning interface values, named return values.
Variadic functions use the standard ... syntax:
func sum(nums ...int) int {
total := 0
for _, num := range nums {
total += num
}
return total
}Calling with individual arguments or spreading a slice:
sum(1, 2)
sum(1, 2, 3)
nums := []int{1, 2, 3, 4}
sum(nums...)Variadic methods work the same way:
func (l *Logger) Info(msg string, attrs ...Attr) {
// attrs is a []Attr slice
}
l.Info("hello", attr1, attr2)Struct type declarations:
type Person struct {
name string
age int
}Struct literals (positional, named fields, or partial):
bob := Person{"Bob", 20}
alice := Person{name: "Alice", age: 30}
fred := Person{name: "Fred"}Pointer to struct:
ann := &Person{name: "Ann", age: 40}Field access (automatically uses -> for pointers in C):
ann.age = 41
sp := &sean
sp.age = 51Anonymous structs:
dog := struct {
name string
isGood bool
}{"Rex", true}Inner structs (anonymous struct fields):
type Benchmark struct {
name string
loop struct {
n int
i int
}
}
b := Benchmark{name: "Test", loop: struct{ n, i int }{n: 200, i: 10}}
b.loop.n = 100new() works with types and values:
n := new(int) // *int, zero-initialized
p := new(point) // *point, zero-initialized
n2 := new(42) // *int with value 42
p2 := new(point{1, 2}) // *point with valuesMethods are defined on struct types with pointer or value receivers:
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.
Calling methods on values and pointers:
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)Methods on named primitive types are also supported:
type HttpStatus int
func (s HttpStatus) String() string {
// ...
}Interfaces in So are like Go interfaces, but they don't include runtime type information.
Interface declarations list the required methods:
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).
Interface methods must use pointer receivers, since the vtable uses void* self function pointers. Converting a concrete type to an interface requires passing a pointer:
s := Shape(&r)
var s2 Shape = &rPassing a concrete type to functions that accept interfaces:
func calcShape(s Shape) int {
return s.Perim(2) + s.Area()
}
calcShape(&r) // implicit conversion
calcShape(Shape(&r)) // explicit conversionType assertions:
_, ok := s.(*Rect) // comma-ok pattern (checks without panic)
r := s.(*Rect) // direct assertion
// But not both; this is not supported.
// r, ok := s.(*Rect)Empty interfaces (interface{} and any) are translated to void*.
Converting between interfaces is not supported: no type assertions like iface.(AnotherIface) and no type switches.
So supports typed constant groups as enums:
type HttpStatus int
const (
StatusOK HttpStatus = 200
StatusNotFound HttpStatus = 404
StatusError HttpStatus = 500
)String-based enums:
type ServerState string
const (
StateIdle ServerState = "idle"
StateConnected ServerState = "connected"
StateError ServerState = "error"
)Each constant is emitted as a C const. Exported constants are public, unexported ones are static.
iota is supported for integer-typed constants:
type Day int
const (
Sunday Day = iota
Monday
Tuesday
)Iota values are evaluated at compile time and translated to integer literals:
typedef so_int main_Day;
const main_Day main_Sunday = 0;
const main_Day main_Monday = 1;
const main_Day main_Tuesday = 2;Errors use the so_Error type (a pointer). So only supports sentinel errors, which are defined at the package level using errors.New:
import "solod.dev/so/errors"
var ErrOutOfTea = errors.New("no more tea available")Returning and checking errors:
func makeTea(arg int) error {
if arg == 42 {
return ErrOutOfTea
}
return nil
}
err := makeTea(42)
if err != nil {
println("got error")
}
if err == ErrOutOfTea {
println("out of tea")
}Errors are compared using ==. This is an O(1) operation (compares pointers, not strings).
Dynamic errors (fmt.Errorf), local error variables (errors.New inside functions), and error wrapping are not supported.
The zero value of error is nil (NULL in C).
panic() accepts a string literal, string variable, or error value and immediately terminates the program:
panic("something went wrong")
msg := "runtime error"
panic(msg)
var err = errors.New("not found")
panic(err)In C, this is emitted as a macro call so_panic(...).
recover is not supported.
defer schedules a function or method call to run at the end of the enclosing scope.
This scope can be either a function:
func main() {
xopen(&state)
defer xclose(&state)
println("working...")
// xclose(&state) runs here
}Or a bare block:
func example() {
{
xopen(&state)
defer xclose(&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.
Defer is not supported inside other scopes like for or if.
So provides several tools for easy C interop. They are explained in a separate document.
So supports two forms of generic functions: extern declarations and inline macros. Both are very limited and usually not needed. They are explained in a separate document.
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:
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 as forward declarations.
Importing a package translates to a C #include:
import "example/geom"#include "geom/geom.h"Calling imported symbols uses the package prefix:
a := geom.RectArea(5, 10)
_ = geom.Pidouble a = geom_RectArea(5, 10);
(void)geom_Pi;There's no automatic order for declarations within a package. You need to declare constants, variables, and types in the order that C expects:
- If a function F uses a constant C or a variable V, you must declare V and C before F.
- If type B refers to type A, you must declare A before B.
Each package can have an init() function (with no arguments or return values) that runs automatically before main(). Unlike Go, only one init function is allowed per package.
Init functions can be used to initialize package-level variables with non-static values.
var state int
func init() {
state = 42
}If the program has multiple packages, each with its own init function, the order in which the init functions are called is not guaranteed.