Conversion Strategies

How go2cs turns each Go construct into C#, one section per topic, with the Go and the C# it becomes side by side. This page is for a Go developer reading converted code, or anyone evaluating go2cs. Each section ends with a link into the reference, which holds every emitted form, edge case and guard test, for maintainers. Each section stands on its own, so you can start with whichever construct you care about.

The converted C# aims to be both behaviorally and visually similar to the Go it came from, so a Go developer can read it and follow it. Two things make that possible: a hand-written runtime library, golib, and a set of Roslyn source generators that add the members C# cannot spell directly.

The C# snippets below are drawn from the actual converted standard library (src/core/) wherever possible, paired with their original Go source; the rest come from the behavioral tests. Each code block carries an HTML comment naming the file and line it was copied from. The glyphs you will see, such as ж, Ꮡ and Δ, are listed in Reading Converted Code.


Contents


At a glance

go2cs turns Go source into C# that a Go developer can read line by line. A Go package becomes one C# partial class whose members are all static. It is partial because each of the package’s files adds its own part to the same class. Go’s types become C# types from the golib runtime library, and a few Unicode glyphs mark the names the converter adds.

This section shows two short, complete examples, then a table that pairs every Go construct with the C# form it takes. Each part links to the section that explains it in full.

A whole program. This Go program reads a map with the comma-ok form. Its C# conversion follows it, complete:

package main

import "fmt"

func main() {
	m := map[string]int{"a": 1, "b": 2}

	// value + ok, present.
	v, ok := m["a"]
	fmt.Println(v, ok) // 1 true

	// value + ok, absent (value is the zero value).
	v2, ok2 := m["z"]
	fmt.Println(v2, ok2) // 0 false

	// blank value, present.
	_, ok3 := m["b"]
	fmt.Println(ok3) // true

	// if-init comma-ok with a blank value.
	if _, ok4 := m["z"]; !ok4 {
		fmt.Println("z absent") // printed
	}

	// reassignment (not a new declaration) into existing variables.
	var w int
	var present bool
	w, present = m["b"]
	fmt.Println(w, present) // 2 true
}
namespace go;

using fmt = fmt_package;

partial class main_package {

internal static void Main() {
    var m = new map<@string, nint>{["a"] = 1, ["b"] = 2};

    var (v, ok) = m["a", ꟷ];
    fmt.Println(v, ok);

    var (v2, ok2) = m["z", ꟷ];
    fmt.Println(v2, ok2);

    var (_, ok3) = m["b", ꟷ];
    fmt.Println(ok3);

    var (_, ok4) = m["z", ꟷ]; if (!ok4) {
        fmt.Println("z absent");
    }

    nint w = default;
    bool present = default;
    (w, present) = m["b", ꟷ];
    fmt.Println(w, present);
}

} // end main_package

What to notice:

A type and a method. A struct, a function that returns a pointer to it, and a method with a pointer receiver:

type reg struct {
	entries []string
	count   int
}

func newReg() *reg {
	return &reg{}
}

func (r *reg) add(name string) string {
	r.entries = append(r.entries, name)
	r.count++
	return name + "-added"
}
partial struct reg {
    internal slice<@string> entries;
    internal nint count;
}

internal static ж<reg> newReg() {
    return Ꮡ(new reg());
}

internal static @string add(this ref reg r, @string name) {
    r.entries = append(r.entries, name);
    r.count++;
    return name + "-added"u8;
}

What to notice:

The glyphs, in brief. The glyphs are Unicode letters, not operators, so C# accepts them in names; goǃ, for example, ends in the Latin letter U+01C3, which most fonts draw like an exclamation mark. ж<T> is a pointer type, Ꮡ takes an address, and ~p reads through a pointer with golib’s ~ operator on ж<T>. A Δ marks a renamed name, such as xΔ1 for a shadowing variable, and @ escapes a C# keyword. ꟷ picks the comma-ok form of a map index or channel receive, and ᐧ does the same for a type assertion.

ᒐ is the frame that runs a function’s deferred calls, and goǃ starts a goroutine. ᐸꟷ, drawn to look like Go’s <-, sends to or receives from a channel. ˢ marks a string literal stored once. Reading Converted Code lists every glyph with an example.

Every construct, in one table. Each row pairs a Go construct with the C# form it takes, and its first column links to the section that explains it. The last column, Provided by, names where the behavior lives; the converter writes all of the text. “converter” means the C# form alone does the job. “golib” means a golib type or function implements it at run time. A generator name (TypeGenerator, RecvGenerator, ImplementGenerator, LazyHolderGenerator or PartialStubGenerator) means that source generator adds members at compile time.

Section Go C# Provided by
Packages package bufio partial class bufio_package in namespace go; functions are static methods converter
Packages import "unicode/utf8" using utf8 = unicode.utf8_package; and a reference to that package’s project converter
Initialization var names = []string{…} · func init() a static field, or for a larger pure-data value a [GoLazy] holder class built on its first read · [GoInit] internal static void init(), run once when the package loads converter · LazyHolderGenerator
Converted tests func TestBool(t *testing.T) a static method in a separate <pkg>.tests program, which runs each test the way go test does converter
Library or source an imported package a project reference, or a NuGet reference to the pre-converted standard library converter
Constants const UTFMax = 4 · Deflate uint16 = 8 public static UntypedInt UTFMax => 4;, a golib type that keeps an untyped constant’s exact value · public const uint16 Deflate = 8; converter
Integers int · uint nint · nuint, the C# native-sized integers converter
Integers uintptr golib’s uintptr struct golib
Integers int32 · rune · float64 · … same-named C# aliases declared in each project file, such as rune for System.Int32 converter
Named numbers type Float float64 public partial struct Float /*num:float64*/; TypeGenerator
Floating point float64 · complex128 · complex64 double · .NET’s Complex, each under its Go name as an alias · golib’s complex64 struct converter · golib
Nil and zero nil default!, or golib’s nil in a pointer comparison or pointer argument golib
Built-ins len(s) · append(s, x) · make([]uint32, 6) len(s) · append(s, x) · new slice<uint32>(6), the first two from golib’s builtin class golib
any any · interface{} any, an alias for object declared in each project file converter
Multi-assignment a, b = b, a (a, b) = (b, a); converter
Shadowing an inner x := 5 nint xΔ1 = 5; converter
Multiple results func Atoi(s string) (int, error) public static (nint, error) Atoi(@string s) converter
Comma-ok v, ok := m["a"] var (v, ok) = m["a", ꟷ]; golib
Slices and arrays []int{2, 3, 4} · [N]T slice<nint>([2, 3, 4]), or [2, 3, 4] where C# knows the type · array<T> golib
Strings string · "Atoi" @string · "Atoi", a plain C# literal golib converts once; "Atoi"u8 where the bytes are read in place; in the standard library, a static readonly field atoiˢ, read through a same-named accessor, when the literal’s words name it golib
Maps map[string]int{"a": 1, "b": 2} new map<@string, nint>{["a"] = 1, ["b"] = 2} golib
Generics func Swap[T any](a, b T) (T, T) · [S Shape] public static (T, T) Swap<T>(T a, T b) · where S : Shape converter
Type aliasing type P = *bool global using P = go.ж<bool>; converter
Methods func (r *reg) add(name string) string internal static @string add(this ref reg r, @string name), plus a ж<reg> overload RecvGenerator
Closures func() string { … } as a value a lambda typed Func<@string>, or a C# local function when a name := func… variable is only ever called converter
Loops for _, n := range nums · break scan foreach (var (_, n) in range(nums)) · goto break_scan; converter
Switch case 4, 5, 6: case 4 or 5 or 6: converter
Type switch i.(string) · s, ok := i.(string) · switch i.(type) i._<@string>() · var (s, ok) = i._<@string>(ᐧ) · switch (i.type()) golib
Defer and panic defer f() · panic(v) · recover() defer(…, ref ᒐ) inside try/catch/finally · throw panic(v) · recover() golib
Goroutines go generate(ch) goǃ(generate, …) golib
Channels make(chan int) · ch <- 12 · <-ch · select new channel<nint>(0) · ch.ᐸꟷ(12) · ᐸꟷ(ch) · switch (select(…)) golib
Structs type reg struct { entries []string; … } partial struct reg { internal slice<@string> entries; … } TypeGenerator
Embedding type Record struct { Person; Employee } /*embed*/ public Person Person; in Go’s field order, plus the promoted fields and methods TypeGenerator
Interfaces type Reader interface { … } partial interface Reader, plus the glue that lets each type used as a Reader implement it ImplementGenerator
Reflection reflect.TypeOf(want) reflect.TypeOf(want), unchanged: the converted reflect package, backed by golib converter
Pointers *T · &x · *p ж<T> · Ꮡx · ~p to read, or p.Value to read or write golib
Implicit dereferencing s.val, where s is a *span s.val, through a field property on the box converter, FieldAccessGenerator
unsafe unsafe.Pointer · unsafe.Sizeof(x) @unsafe.Pointer · the constant /* unsafe.Sizeof(x) */ 32 converter
No Go body func archMax(x, y float64) float64 internal static partial float64 archMax(float64 x, float64 y);, with a hand-written body or a throwing stub PartialStubGenerator
Comments // maximum number of bytes … the same comment, near the same place (placement is best effort); kept only with -comments, except the license header, which is always kept converter

The machinery behind these forms has its own sections: Source Generators, Manually-Converted Declarations, the purego build, Packages That Do Not Type-Check and Deterministic Output.

Full detail: Reference → Contents — one page per topic, with every emitted form, edge case and guard test.


Reading Converted Code: Names and Glyphs

Converted C# keeps Go’s names wherever it can: bindAdd stays bindAdd, and the entry point main becomes Main. The naming rules in this section and the glyphs in the tables are the exceptions. The glyphs mark the names and helpers the converter adds. Most are Unicode letters that C# accepts in identifiers and ordinary Go code does not use, so they never clash with a Go name.

Go types such as slice<T>, @string (Go’s string) and ж<T> (Go’s pointer) come from golib, the runtime library every converted project uses: see The golib Runtime Library.

A first example. This Go program declares a struct type, a method with a pointer receiver, and a function that takes a pointer and binds the method to a variable:

type accum struct{ total int }

func (a *accum) add(n int) int {
	a.total += n
	return a.total
}
…
func bindAdd(a *accum) {
	add := a.add
	fmt.Println("bound add:", add(5), add(7)) // 5 12
}
partial struct accum {
    internal nint total;
}

internal static nint add(this ref accum a, nint n) {
    a.total += n;
    return a.total;
}

internal static void bindAdd(ж<accum> a) {
    var add = (nint p1) => a.add(p1);
    fmt.Println("bound add:", add(5), add(7));
}

Read it piece by piece:

The caller takes the address of a new accum and reads a field through the pointer:

acc := &accum{}
bindAdd(acc)
fmt.Println("accum total:", acc.total) // 12
…
    var acc = Ꮡ(new accum());
    bindAdd(acc);
    fmt.Println("accum total:", acc.total);

new accum() is the empty literal accum{}. It calls the generated parameterless constructor, which builds the zero value. Go’s nil usually becomes C#’s default! (Nil and Zero Values).

Ꮡ(…) is Go’s &: it puts the value in a ж<accum> box. acc.total reads the field through the pointer as Go does, through a property go2cs generates on the box, and panics if acc is nil (Implicit Pointer Dereferencing).

Some glyphs look like punctuation but are letters. ꓸ (U+A4F8) is not a dot, ᐧ (U+1427) is not a middle dot, ꟷ (U+A7F7) is not a dash, ǃ (U+01C3) is not an exclamation mark, and ᴛ (U+1D1B) is a small capital T. C# reads each one as part of a name, so reflectꓸValue is one identifier, not a member access.

The common glyphs. Each row names a glyph or naming pattern, what it means, real examples from converted code, and the section that explains it in full.

Glyph or pattern Meaning Example Explained in
ж<T>, StandardBox<T> Go pointer *T: a golib heap box (read “zhe”). StandardBox<T> is the class a new box is created as when it holds its own value; other box kinds point into existing storage, such as an array element, a struct field or native memory ж<accum> Ꮡa, new StandardBox<Outer>(default(Outer)) Pointers
Ꮡ Address-of &x. As a prefix: a variable that holds a box, or a field’s generated address property, Go’s &s.f Ꮡ(new accum()), ᏑgOuter, Ꮡb.Ꮡoff for &b.off Pointers
~p Reads the whole value through a pointer, a copy, and panics on nil like Go’s *p var recvʗ1 = ~p;, the copy a method value p.Hello binds Implicit Pointer Dereferencing
Δ prefix A name renamed to avoid a clash with a golib name, a method, Main or a namespace. go2cs and golib also use it for members they add themselves ΔGoFrame, ΔMonth, ΔMain, using Δio = io_package; (go.io is also a namespace), golib’s array<T>.ΔClone() The naming rules in this section
Δ1 suffix A second declaration of the same name: a variable that shadows an outer one, or a repeated init function hourΔ1, initΔ1 Shadowing; repeated init: Functions and Methods
ʗ1 suffix A copy of a variable, taken for the lambda that uses it var f1ʗ1 = f1; Function Values and Closures
ʗp suffix The raw incoming parameter, when the body copies it into a local of the Go name: a variadic pack becomes a slice, or a value whose address is taken moves into a heap box with heap(…) params ꓸꓸꓸerror errsʗp; readOnlyParam(Rect rʗp) then ref var r = ref heap(rʗp, out var Ꮡr); Slices and Arrays (variadic); Pointers (address taken, the same heap(…) pattern as a local)
ᴛ A name the converter makes up: a temporary, a lambda parameter, an init helper or a lazy var’s holder class ᴛ1, selᴛ2, initᴛsingle, lazyᴛ_Lu Multi-Assignment; init helpers: Initialization Order
ˢ, ᶜ suffix A string literal (ˢ) or a local constant (ᶜ), stored once in a static field whose declaration sits in the same file namedStringConversionˢ, fnAtoiᶜ Strings
ꓸ A dot inside one name, such as a package-qualified alias reflectꓸValue Type Aliasing
ꓸꓸꓸ in a type or name Go’s ...: ꓸꓸꓸT aliases Span<T> for a variadic parameter; s.ꓸꓸꓸ and appendꓸꓸꓸ spread a slice; Funcꓸꓸꓸ<…> is a variadic func type using ꓸꓸꓸerror = Span<error>;, fmt.Sprintf(format, a.ꓸꓸꓸ), appendꓸꓸꓸ(all, batch) Slices and Arrays
ꓸꓸꓸ as an argument A channel send or receive that registers with a select instead of running at once ᐸꟷ(selᴛ2, ꓸꓸꓸ) Channels and select
ᐸꟷ, ꟷᐳ Channel send, receive, and receive inside select ch.ᐸꟷ(textˢ), ᐸꟷ(done), selᴛ2.ꟷᐳ(out var v) Channels and select
/*<-*/ A channel’s direction, kept as a comment: /*<-*/channel<T> is <-chan T, channel/*<-*/<T> is chan<- T /*<-*/channel<nint> src Channels and select
goǃ The go statement; the ǃ keeps the method’s name apart from go, the namespace every converted file lives in goǃ(() => fmt.Println("First")) Goroutines
ꟷ golib’s const bool ꟷ = false. As an extra argument it picks the overload that returns (value, ok), for a comma-ok receive or map lookup; its value is not used var (v, ok) = ᐸꟷ(d, ꟷ);, var (_, before) = seen[memo, ꟷ]; Comma-Ok Forms
ᐧ golib’s const bool ᐧ = true. It is the true of a tagless switch, and the extra argument of a comma-ok type assertion switch (ᐧ), err._<ж<PathError>>(ᐧ) Expression Switch; Comma-Ok Forms
._<T>() Type assertion x.(T); with ᐧ, its comma-ok form i._<@string>(), err._<ж<PathError>>(ᐧ) Empty Interface (any)
ᒐ The GoFrame local that runs a function’s deferred calls; names that start with ᒐ, such as the label ᒐdone, belong to the same frame GoFrame ᒐ = default;, ᒐdone: return (@out, label); Defer / Panic / Recover
break_L, continue_L goto targets for Go’s labeled break L and continue L goto break_scan;, continue_scan:; Loops, Range and Labels
XжI, XᴠI An adapter class: pointer *X (ж) or value X (ᴠ) as interface I new joinErrorжerror(e), new HandlerᴠIface(…) Interfaces
default! Go nil (golib nil in pointer contexts), and the zero value of a variable declared without a value return (n, default!); Nil and Zero Values
partial struct, this ref A converted Go type, and a pointer-receiver method; a source generator completes each partial struct accum, internal static nint len(this ref box b) Source Generators
nint, nuint Go int, uint internal nint total; Integer Types and Arithmetic
@name A C# keyword used as a name @in, @unsafe, @string The naming rules in this section
<pkg>_package The static partial class that holds a package partial class strings_package Package Conversion

Less common names. These appear in specific kinds of code. A first read can skip them.

Glyph or pattern Meaning Example Explained in
ᶠ suffix A shared delegate, used when a function that has an @string twin is taken as a value. The function takes golib’s sstring, a string view that allocates nothing, and the twin is a second overload that takes @string fmt.Sprintfᶠ Strings
ᐧᐧ A true that C# does not treat as a constant (a static readonly field). It marks a leading case true: in a tagless switch, so C# still accepts the cases after it case {} when ᐧᐧ: Reference → Expression Switch
ᴋ A temporary that keeps a pointer passed to a system call alive until the call returns var ᴋ0 = @unsafe.SliceData(p); unsafe.Pointer and uintptr
ᴷ suffix Go’s 32-byte stack buffer for a slice variable whose append does not escape, or whose one escape Go moves to the heap (x = move2heap(xᴷ, x); ahead of that statement). The function declares one per variable at its entry, and the variable’s first qualifying append passes it var xᴷ = new stackAppend<int64>();, then x = append(xᴷ, x, (int64)i); Reference → Slices and Arrays
ᴅ, ᴺ suffix Keep a Go type name. ᴅ is an empty C# marker interface, never implemented, whose [GoLocalName] attribute holds the Go name of a type converted to a using alias. ᴺ is an extra type parameter for a type argument’s name [GoLocalName("Token")] public partial interface Tokenᴅ {}, nameOf<T, Tᴺ> Reflection
_Δp0 A made-up name for an unnamed or _ parameter where a plain _ would not work: C# forbids two parameters with the same name, and a parameter named _ would capture the body’s own discards (a _ = …, or a select case that receives and drops the value, case <-ch:). Otherwise a lone blank parameter stays _, as in discard(nint _). A function type or interface method has no body: two or more unnamed parameters there get these names, and a lone one is _ (Handler(nint _)), or _Δp0 when it is declared inside or after a function whose body uses a discard (Read(slice<byte> _Δp0)). Nothing reads either name bothUnnamed(nint _Δp0, params ꓸꓸꓸstring ʗp), Seq2Like<K, V>(K _Δp0, V _Δp1) The naming rules in this section
Set‿ The map write that a nested assignment m[k1][k2] = v calls on the element m[k1], used only when the element’s Go type declares a method named Set or is itself named Set. A Go method becomes an extension method, and C# runs a same-named member of the generated wrapper in its place, so the wrapper gives up that name: Set is dropped, and Add, Remove, Clear, ContainsKey, TryGetValue and a channel’s Send and Sent become explicit interface implementations. ‿ (U+203F) is connector punctuation, legal in a C# name and impossible in a Go one envs["x"].Set‿("Mixed", "m"); Source Generators
<func>_<type> A type declared inside a function, lifted to package scope main_point Struct Types
<pkg>_internal_test_package, <pkg>_test_package The classes for in-package test files and for the external _test package strings_internal_test_package Converted Tests

A C# keyword is escaped with @. Go names such as in, base and unsafe are C# keywords. The @ prefix lets C# use them as names, so import "unsafe" becomes using @unsafe = unsafe_package;. Here a struct field named in becomes @in:

type Outer struct {
	head byte
	in   Inner
}
partial struct Outer {
    internal byte head;
    internal Inner @in;
}

A name that would clash gets a Δ prefix. The converted code relies on golib names such as GoFrame, builtin and slice. A Go name that matches one is renamed, so golib’s own name still means golib’s type. For example, GoFrame is the golib type of the local that runs a function’s deferred calls (Defer / Panic / Recover), so a Go struct named GoFrame becomes ΔGoFrame:

type GoFrame struct{ k int }
partial struct ΔGoFrame {
    internal nint k;
}

A Go type that shares its name with a method is renamed the same way. A method becomes an extension method of the package class, which also holds the package’s types, so the two names would clash there. For example, the time package’s type Month becomes ΔMonth, because Time also has a method named Month. A Go function named Main becomes ΔMain, since C# reserves Main for the entry point.

A capitalized Go name becomes public; any other name becomes internal. This is Go’s export rule in C# terms. Lowercase accum, its field total and its method add are all internal, while the exported function GetPrintLn is public. Its result type, Go’s func(string), becomes the C# delegate Action<@string> (Function Values and Closures):

func GetPrintLn() func(string) {
	return func(src string) {
		fmt.Println(src)
	}
}
public static Action<@string> GetPrintLn() {
    return (@string src) => {
        fmt.Println(src);
    };
}

A method is public only when its own name is exported and its receiver type is emitted public: an exported type, or an unexported type that an exported signature exposes (Functions and Methods). A package-level struct such as accum shows no modifier, because the source generator adds it on the generated half of the partial type.

Full detail: Reference → Shadowing the names go2cs itself spells — which Go names are @-escaped or Δ-renamed to avoid a clash with C# keywords or emitted names, and why.


The golib Runtime Library

Go’s built-in types and behaviors become C# types and functions in golib. golib is the hand-written runtime library behind every converted project. go2cs never generates it. Every converted project references it, and it is the same library for every program.

The project file go2cs writes adds golib for you. By default it adds golib as a project reference to the local source tree. A conversion run with -recurse=nuget references the published NuGet packages instead, and golib arrives as the package go.lib (Compiled Library versus Source Code).

Converted code uses golib’s names everywhere, such as slice<T>, @string, len and nil. Some of them carry a glyph, a special character such as ж or Δ. Reading Converted Code: Names and Glyphs lists every glyph.

Go built-ins that .NET has no match for have golib counterparts. This table names each one and links the section that explains it.

Go golib Section
[]T, [N]T slice<T>, array<T> Slices and Arrays
string @string, an immutable byte string; sstring, its stack-only form Strings
map[K]V map<K, V> Maps
chan T channel<T> Channels and select
*T ж<T>, golib’s pointer type: a box that holds a value or points into existing storage Pointers
nil nil, a NilType value Nil and Zero Values
error the error interface Interfaces
uintptr uintptr, a golib struct that wraps nuint unsafe.Pointer and uintptr
len, cap, append, copy, delete, close, clear, min, max methods of the builtin class Built-in Functions
defer, panic, recover defer, panic, recover in builtin Defer / Panic / Recover
go f(x) goǃ(f, x) in builtin Goroutines

The last character of goǃ is not an exclamation mark. It is the Latin letter U+01C3, which most fonts draw the same way, and C# accepts a letter in a name. It keeps the method’s name apart from go, the C# namespace every converted file lives in.

A small program shows golib at work. This Go program makes a buffered channel and prints its length and capacity:

package main

import "fmt"

func main() {
	ch := make(chan int, 3)
	fmt.Println(len(ch), cap(ch))
	…
}
namespace go;

using fmt = fmt_package;

partial class main_package {

internal static void Main() {
    var ch = new channel<nint>(3);
    fmt.Println(len(ch), cap(ch));
    …
}

} // end main_package

Both versions print 0 3: the new channel holds nothing yet and has room for 3 values.

make(chan int, 3) becomes a golib channel<nint> with a buffer of 3. nint is C#’s native-size integer, the match for Go’s int (Integer Types and Arithmetic). The Go package becomes the C# class main_package, and fmt is an alias for the converted fmt package’s class (Package Conversion). The file has no using line for golib. The next two rules say why.

Built-in functions keep their Go names. The project file go2cs writes imports golib’s builtin class with using static. So a call such as len(ch) reads as it does in Go. The same project file also maps Go’s sized numeric names, and any, to .NET types:

    <Using Include="go.builtin" Static="True" />
…
    <Using Include="System.Object" Alias="any" />
    <Using Include="System.Byte" Alias="uint8" />
…
    <Using Include="System.Int32" Alias="int32" />
…
    <Using Include="System.Double" Alias="float64" />
…
    <Using Include="System.Int32" Alias="rune" />

golib’s Go-facing types sit directly in the go namespace. Every converted package lives in go or in a namespace nested inside it, such as go.crypto. So converted code names slice<T>, channel<T> or @string with no using line.

golib’s internal helpers live one level down, in go.golib. The library is named golib, and so is this one sub-namespace. It holds runtime helpers that have no Go name, such as SparseArray<T>. Code in namespace go reaches a helper as golib.<name>, again with no using line.

golib.SparseArray<T> builds a slice from a literal keyed by index. The keys may skip positions:

type kindT uint

var kindNames = []string{
	kindT(1): "one",
	kindT(3): "three",
}
…
	fmt.Println(kindNames[kindT(3)], len(kindNames)) // three 4
partial struct kindT /*num:nuint*/;

internal static slice<@string> kindNames = new golib.SparseArray<@string>{
    [1] = "one",
    [3] = "three"
}.slice();
…
    fmt.Println(kindNames[((kindT)3)], len(kindNames));

.slice() turns the result into a golib slice<@string>. Positions 0 and 2 hold the empty string, so the length is 4, as in Go. Each "…" is a plain C# literal; @string converts it implicitly, once (Strings).

The other names in this example come from other sections. Go’s type kindT uint becomes a struct whose name is followed by the comment /*num:nuint*/. That tells a source generator to fill in a numeric type backed by nuint, with its operators and conversions (Named Numeric Types and Constant Contexts). A key written as a constant expression, rather than a constant’s name, is folded to its integer value, so kindT(1) becomes the plain index 1; a cast over the named type would not resolve to the int the literal’s SparseArray takes. The index expression keeps Go’s kindT(3) as a cast. kindT converts implicitly to its underlying nuint, so it indexes the slice as Go’s uint does.

An import alias sometimes takes the Δ rename mark. Δ marks a name that go2cs changed to avoid a clash in C#. A package’s sub-packages live in a C# namespace named after it: math/bits lives in the namespace go.math. Go’s math package itself imports math/bits, so a program that imports math always has go.math in view. Inside namespace go, the bare name math already means that namespace, so an alias named math would clash. The alias becomes Δmath:

import (
	"fmt"
	"math"
)
…
	fmt.Println(math.Float64bits(z))
namespace go;

using fmt = fmt_package;
using Δmath = math_package;
…
    fmt.Println(Δmath.Float64bits(z));

A file in a nested namespace, such as go.crypto, keeps the plain using math = math_package;. There C# finds the file’s own aliases before it looks in the outer go namespace, so no clash arises. The helper namespace go.golib never causes this clash, because no Go package is named golib.

Full detail: Reference → The go.golib support namespace — why golib’s helpers avoid Go package names, exactly when an import alias takes the Δ rename mark, and how renamed types and aliases from other packages are spelled.


Package Conversion

A Go package becomes one C# project. All of the package’s code lives in one static class named <name>_package, so package registry becomes registry_package. Each Go file becomes one .cs file that adds its part to that class, which is why the class is partial.

The examples in this section come from one small test program. Package main imports a package named registry and calls it. It also imports two packages, pnglike and jpeglike, with Go’s blank import _, only so that their init functions run.

Package-level declarations become static members of the package class. A function becomes a static method, and a package variable becomes a static field. An exported (capitalized) name is public; any other name is internal. Go methods become C# extension methods, shown in Functions and Methods.

package registry

var decoders = map[string]string{}

// Register records a decoder. Called only from another package's init.
func Register(name, describe string) {
	decoders[name] = describe
}

// Lookup reports the decoder registered under name, if any.
func Lookup(name string) (string, bool) {
	describe, ok := decoders[name]
	return describe, ok
}

// Count reports how many decoders have registered so far.
func Count() int {
	return len(decoders)
}
namespace go.BlankImportSideEffects;

partial class registry_package {

internal static map<@string, @string> decoders = new map<@string, @string>{};

public static void Register(@string name, @string describe) {
    decoders[name] = describe;
}

public static (@string, bool) Lookup(@string name) {
    var (describe, ok) = decoders[name, ꟷ];
    return (describe, ok);
}

public static nint Count() {
    return len(decoders);
}

} // end registry_package

Each file’s part leaves off public static. The full class declaration, with those modifiers, is in the project’s generated package_info.cs, described later in this section.

The types come from golib, go2cs’s hand-written C# runtime library (The golib Runtime Library). @string is Go’s string (Strings), map<K, V> is Go’s map (Maps), and nint is Go’s int (Integer Types and Arithmetic). len is golib’s version of Go’s built-in (Built-in Functions).

decoders[name, ꟷ] is Go’s comma-ok lookup. ꟷ is a golib marker constant that only asks for the (value, ok) pair, and a Go multi-result becomes a C# tuple (Multi-Result Values and Comma-Ok Forms). The Go comments are missing because this test converts without -comments (Comments).

The import path picks the namespace. The path’s leading segments become a namespace under the root namespace go. The class takes the Go package name, which is almost always the path’s last segment. So BlankImportSideEffects/registry is registry_package in namespace go.BlankImportSideEffects. A one-segment path sits directly in namespace go. That covers fmt, and also this test’s main package, whose import path is just the module name BlankImportSideEffects.

The standard library follows the same rule. unicode/utf8 is utf8_package in namespace go.unicode. math/rand/v2 declares package rand, so it is rand_package in namespace go.math.rand.

An import becomes a using alias named for the package. Converted code keeps calling registry.Count(), exactly as in Go. The importer also opens the package’s namespace (using BlankImportSideEffects;), because C# finds extension methods, and so Go methods, only through an open namespace. A blank import emits only a comment, since a using _ alias would take over C#’s discard _:

package main

import (
	"fmt"

	_ "BlankImportSideEffects/jpeglike"
	_ "BlankImportSideEffects/pnglike"
	"BlankImportSideEffects/registry"
)
namespace go;

using fmt = fmt_package;
// blank import: BlankImportSideEffects.jpeglike_package (side effects only; no using emitted — a `using _` alias hijacks C# discards)
// blank import: BlankImportSideEffects.pnglike_package (side effects only; no using emitted — a `using _` alias hijacks C# discards)
using registry = BlankImportSideEffects.registry_package;
using BlankImportSideEffects;

partial class main_package {

main sits in namespace go, so it can leave the go. prefix off BlankImportSideEffects. Other files may spell it out as go.BlankImportSideEffects; both name the same namespace.

init becomes a [GoInit] method, and main becomes Main. [GoInit] is the project’s alias for C#’s [ModuleInitializer], which .NET runs once, before the first use of anything in its assembly. The rest of main.cs continues the main_package class opened in the previous example:

// countAtInit records what the registry held when THIS package's own init ran. Go orders an
// imported package's initialization before the importer's, blank imports included, so it is 2.
var countAtInit int

func init() {
	countAtInit = registry.Count()
}

func main() {
	fmt.Println("count at init:", countAtInit)

	// "giflike" is the negative control: never registered, so a lookup that "succeeds" for
	// everything would not prove anything.
	for _, name := range []string{"jpeglike", "pnglike", "giflike"} {
		if describe, ok := registry.Lookup(name); ok {
			fmt.Println("found:", name, "=>", describe)
		} else {
			fmt.Println("missing:", name)
		}
	}

	fmt.Println("count:", registry.Count())
}
internal static nint countAtInit;

[GoInit] internal static void init() {
    countAtInit = registry.Count();
}

internal static void Main() {
    fmt.Println("count at init:", countAtInit);

    foreach (var (_, name) in range(slice<@string>(["jpeglike", "pnglike", "giflike"]))) {
        var (describe, ok) = registry.Lookup(name); if (ok) {
            fmt.Println("found:", name, "=>", describe);
        } else {
            fmt.Println("missing:", name);
        }
    }

    fmt.Println("count:", registry.Count());
}

} // end main_package

Every Go string literal here is a plain C# literal. In a string slot, such as "jpeglike", golib converts it to @string once. The fmt.Println arguments are any slots, where a C# string would arrive as System.String, so go2cs-gen intercepts each of those calls at compile time and passes the literal as a boxed @string (Strings, Empty Interface).

The range loop becomes a foreach (Loops, Range and Labels). .slice() turns the array into a golib slice, and enumerating it yields (index, value) pairs, as Go’s range does (Slices and Arrays). The if’s init statement sits just before it on the same line, as Go writes it: no other name in the loop body is describe or ok, so the two need no block of their own (Shadowing).

Imported packages initialize first, blank imports included. Go runs every imported package’s init before the importer’s own. A [GoInit] method runs only when something in its assembly is first used. That can come too late, and for a blank import it never comes, because nothing names the package. pnglike does all of its work in init:

package pnglike

import "BlankImportSideEffects/registry"

func init() {
	registry.Register("pnglike", "pnglike decoder")
}
namespace go.BlankImportSideEffects;

using registry = go.BlankImportSideEffects.registry_package;
using go.BlankImportSideEffects;

partial class pnglike_package {

[GoInit] internal static void init() {
    registry.Register("pnglike", "pnglike decoder");
}

} // end pnglike_package

Each project also gets a generated package_info.cs. It declares the package class itself, as public static partial class, with [GoPackage] recording the Go package name. It also holds one [GoInit] hook for each import that has anything to initialize, directly or through its own imports. Each hook calls builtin.initPackage, which runs the imported package’s [GoInit] methods if they have not run yet:

namespace go;

[GoPackage("main")]
[GoTestMatchingConsoleOutput]
public static partial class main_package
{
…
    // <ImportInitializers>
    [GoInit] internal static void initᴛᴛimportꓸBlankImportSideEffectsꓸjpeglike() => builtin.initPackage(typeof(BlankImportSideEffects.jpeglike_package));
    [GoInit] internal static void initᴛᴛimportꓸBlankImportSideEffectsꓸpnglike() => builtin.initPackage(typeof(BlankImportSideEffects.pnglike_package));
    [GoInit] internal static void initᴛᴛimportꓸBlankImportSideEffectsꓸregistry() => builtin.initPackage(typeof(BlankImportSideEffects.registry_package));
    [GoInit] internal static void initᴛᴛimportꓸfmt() => builtin.initPackage(typeof(fmt_package));
    // </ImportInitializers>
}

The hooks must run before the package’s own init. C# runs an assembly’s module initializers in the order its files are compiled, and the project compiles package_info.cs first. So every import is initialized before main’s init runs, and the program prints count at init: 2, just as Go does. builtin.initPackage does not run the imported package’s variable initializers; those still run the first time the package class is used.

The source generators add module initializers of their own, for the lookup tables golib keeps, such as each package’s pointer adapters and zero-value factories. Each one only queues the package’s registrations; golib runs a table’s queue the first time a lookup in it misses, so none of that work runs at startup.

Each hook name reads as “init, import, path”. ᴛ marks a name the converter makes up, doubled here to keep these hooks apart from its other init helpers, and ꓸ stands in for the / of the import path (Names and Glyphs). [GoTestMatchingConsoleOutput] appears only in tests; it marks a program whose output is compared with Go’s. package_info.cs also holds package-wide global using aliases for Type Aliasing, and the attributes that drive the Source Generators.

A main package becomes an executable; every other package becomes a library. The main project has <OutputType>Exe</OutputType>, and registry’s has <OutputType>Library</OutputType>. Each imported package is its own project, and the importer references it. The project file also defines the GoInit alias and compiles package_info.cs ahead of the other files:

    <OutputType>Exe</OutputType>
…
    <Using Include="System.Runtime.CompilerServices.ModuleInitializerAttribute" Alias="GoInitAttribute" />
…
    <Compile Include="package_info.cs" Condition="Exists('package_info.cs')" />
    <!-- Include only .cs files from current folder -->
    <Compile Include="*.cs" Exclude="package_info.cs" />
…
    <ProjectReference Include="$(go2csPath)core/golib/golib.csproj" />

    <ProjectReference Include="$(go2csPath)core/fmt/fmt.csproj" />
    <ProjectReference Include="jpeglike/BlankImportSideEffects.jpeglike.csproj" />
    <ProjectReference Include="pnglike/BlankImportSideEffects.pnglike.csproj" />
    <ProjectReference Include="registry/BlankImportSideEffects.registry.csproj" />

$(go2csPath) is where go2cs’s runtime library (golib) and converted standard library live. The standard library can also come from NuGet (Compiled Library versus Source Code).

A main package’s project also carries the program’s build info. Go links a program’s module lines into the binary for runtime/debug.ReadBuildInfo. The converter writes the same lines into the main project as a GoModInfo assembly attribute: path, mod <module> (devel), and under -recurse one dep line per third-party module the program links, with its go.sum hash. golib adds the build settings the program itself knows, its DefaultGODEBUG when one is set, CGO_ENABLED, GOARCH and GOOS, so ReadBuildInfo reports the program’s build info as Go’s does. A converted test program registers its own path and mod lines when it starts, as a Go test binary carries them. A test of a main package also lists that package’s dep lines, because Go’s test binary copies the package’s own build info there; a test of any other package lists none, as Go’s does. Settings with no managed value (-compiler, -buildmode, GOAMD64, vcs.*) are never reported, a program converted outside a module carries no stamp, and a replaced dependency is reported without its replacement, which can be a local path:

    <AssemblyAttribute Include="go.GoModInfoAttribute">
      <_Parameter1>path&#9;go2cs/BuildInfoSettings&#10;mod&#9;go2cs/BuildInfoSettings&#9;(devel)&#9;&#10;</_Parameter1>
    </AssemblyAttribute>

Build constraints pick each target’s Go files. The converter selects Go files by their build constraints and file-name suffixes, as go build does. The standard library is converted for Windows, Linux and macOS, each on amd64. Files whose C# differs between them go into windows/, linux/ and darwin/ subfolders. For example, os keeps dir_windows.cs in os/windows/ and dirent_linux.cs in os/linux/.

The project compiles its flat files plus the one folder named by the GoTargetOS build property. It defaults to windows; a build sets another target with, for example, -p:GoTargetOS=linux. A package with OS subfolders keeps a package_info.cs in each one, and the project compiles the chosen target’s copy first. The Exists conditions let one project file serve both layouts:

  <PropertyGroup Condition="'$(GoTargetOS)'==''">
    <GoTargetOS>windows</GoTargetOS>
  </PropertyGroup>
…
    <Compile Include="package_info.cs" Condition="Exists('package_info.cs')" />
    <Compile Include="$(GoTargetOS)/package_info.cs" Condition="Exists('$(GoTargetOS)/package_info.cs')" />
    <!-- Include only .cs files from current folder -->
    <Compile Include="*.cs" Exclude="package_info.cs" />
…
    <Compile Include="$(GoTargetOS)/*.cs" Exclude="$(GoTargetOS)/package_info.cs" />

Three C# warnings that Go’s own semantics draw are turned off per file, by an .editorconfig beside the project. Go folds unsafe.Sizeof(x) and a constant expression over a local const at compile time, and the converter writes the folded value with the Go text in a comment, so a local whose every use folded is declared and never read in C# (CS0219). A package variable with no initializer that no file of the package writes, or a blank var _ T, is a field never assigned (CS0649); C# reports that on an internal field only in a project that grants no InternalsVisibleTo, so a package with in-package tests holds none. And uint64(nsec) | x with a signed nsec sign-extends in Go exactly as the C# cast does (CS0675). Changing the emission to silence these would make it say something other than the Go, so the conversion derives each fact from the Go source and writes one section per file that holds it, anchored with a leading slash and naming the per-OS folder where the file lives in one. A section switches the warning off for that one file and nowhere else, which also hides a genuine new instance in that file; that is the cost, and why the entries are per file rather than a project-wide NoWarn. The entries follow the code: a conversion that no longer finds a fact drops its section, and deletes the file when none is left. The file is the converter’s only when its first line is the go2cs marker; a package’s own .editorconfig is never touched (the conversion prints a warning naming the entries it would have written).

# go2cs: per-file warning entries, written by every conversion of this package -- do not edit
# Each section turns off one C# warning in one converted file, for a reason the converter
# derived from the Go source; the comment above it names the GOOS flavours that hold it.

[/linux/runtime1.cs]
# CS0219: linux
dotnet_diagnostic.CS0219.severity = none

Full detail: Reference → Package Conversion — project names and paths, cross-package references and NuGet use, exported type aliases, the import-initialization rules including blank imports and test projects, build-constraint file selection, the per-OS layout including hand-written files, and the generated solution files.


Package-Level Variable Initialization Order

Go initializes package-level vars in dependency order: a var is set only after every var it reads. C# sets static fields in a different order. So go2cs moves the few vars where the two orders differ into a generated static constructor. Every other package var keeps its plain, readable field initializer, except one whose value is pure data, which is built on its first read.

The problem: C# runs static field initializers in file order. A Go package becomes one C# partial class named <name>_package, with one C# file per Go file (Package Conversion). Within one file, C# runs static field initializers top to bottom. Across the files of a partial class, the order is undefined. A field that reads another field too early sees its zero value, or null.

A var that reads a var declared later in its file moves. It becomes a bare field plus an init method named initᴛ<name>, placed right beside it. The package’s generated static constructor calls that method, in Go’s order. Here first reads base, declared after it, so only first moves. Left as a field initializer, first would read base as 0 and be 1, not 42.

var first = base + 1

var base = 41
internal static nint first;
internal static void initᴛfirst() { first = @base + 1; }

internal static nint @base = 41;

In the C#, nint is Go’s int (Integer Types and Arithmetic). The ᴛ glyph marks a name the converter generates, so it never clashes with a Go name. @base is base with C#’s @ escape, because base is a C# keyword (Names and Glyphs).

A var that reads a var from another file moves. C# gives no order between files, so go2cs never relies on one. Here entryName, in entries.go, calls a method on registry, declared in registry.go. So entryName moves, and registry stays a plain field initializer. newReg returns a new *reg, and the method add records a name in it.

var entryName = registry.add("alpha")
var registry = newReg()
internal static @string entryName;
internal static void initᴛentryName() { entryName = registry.add("alpha"); }
internal static ж<reg> registry = newReg();

Without the move, entries.cs could run first. add would then run on a still-nil registry and fail with a nil dereference while the class initializes. @string is golib’s Go string type, and "alpha" is a plain C# literal golib converts once (Strings). ж<reg> is golib’s heap box, the C# form of Go’s *reg (Pointers).

A var that reads a moved var moves too. A moved var gets its value only in the static constructor, after every field initializer has run. So entryUpper moves because it reads entryName, even though entryName is declared above it in the same file.

var entryUpper = entryName + "!"
internal static @string entryUpper;
internal static void initᴛentryUpper() { entryUpper = entryName + "!"u8; }

A read inside a called function counts too. Go traces dependencies through the functions an initializer calls, and go2cs does the same. stdinName calls describe, and describe reads names, a var declared in registry.go. So stdinName moves, while names keeps its field initializer.

func describe(n int) string {
	return names[n]
}
var names = []string{"stdin", "stdout"}
var stdinName = describe(0)
internal static @string stdinName;
internal static void initᴛstdinName() { stdinName = describe(0); }
internal static slice<@string> names = ["stdin", "stdout"];

slice<@string> is golib’s form of Go’s []string (Slices and Arrays).

A func literal called in an initializer counts the same way. computed calls a func literal whose body reads base, declared in main.go, so computed moves. The literal becomes a C# lambda that states its result type, passed to golib’s func and called at once: func(nint () => {…})() reads like Go’s func() int {…}() (Function Values and Closures).

var computed = func() int { return base * 2 }()
internal static nint computed;
internal static void initᴛcomputed() { computed = func(nint () => {
    return @base * 2;
})(); }

A string constant counts as a dependency. Most Go constants have no initialization order in C# either. A constant becomes a C# const, or, when C# cannot declare it const, a property that returns its value on each read (Constant Values). A string constant is different: a property would build its u8 string again on every read, so it stays a static readonly field with an initializer. That field has the same order problem as a var. This holds for a plain const s = "x" and for a named string type alike. Here label is a named string type, and pipeLabel reads its constant labelPipe, declared in another file, so pipeLabel moves.

type label string

const labelPipe label = "pipe"
var pipeLabel = string(labelPipe) + "!"
partial struct label /*@string*/;

internal static readonly label labelPipe = "pipe"u8;
internal static @string pipeLabel;
internal static void initᴛpipeLabel() { pipeLabel = ((@string)labelPipe) + "!"u8; }

The comment /*@string*/ after its name makes label a named type built on @string; a source generator fills in its members (Source Generators).

A hoisted string literal counts the same way. In the standard library, and under -hoist-literals, go2cs moves a string literal used inside a function into a static readonly field whose name ends in ˢ, so the string is built once, not on every call (Strings). That field is a static field initializer too, declared in the file that uses the literal. So a var whose initializer calls a function that reads such a field also moves, even when it reads no package var. A plain literal, the default everywhere else, is not a field, so it moves nothing.

Here describe, in z_impl.go, returns a literal. By default the literal is plain and derivedAtInit, in a_decls.go, stays a field initializer. Under -hoist-literals the literal becomes the field derivedThroughALaterFileˢ, declared in that later file, and derivedAtInit moves.

var derivedAtInit = describe()
func describe() string {
	return "derived through a later file"
}
internal static @string describe() {
    return "derived through a later file";
}
internal static @string derivedAtInit = describe();

A generated package_init.cs calls the init methods in Go’s order. It declares the package class’s static constructor, which calls each initᴛ method. Go’s own type checker computes the order, and go2cs copies it. Go’s rule: take vars in declaration order, with files in name order, but skip a var until every var it reads is set. A package where no var moves has no package_init.cs. This is the whole generated class for the PackageVarInitOrder example package:

partial class main_package {
    static main_package() {
        initᴛpipeLabel();
        initᴛcomputed();
        initᴛfirst();
        initᴛentryName();
        initᴛentryUpper();
        initᴛstdinName();
    }
} // end main_package

The files here are entries.go, main.go and registry.go. pipeLabel reads only a constant, so it is ready at once. computed and first wait for base. entryName, entryUpper and stdinName wait for registry and names, in the last file.

This order is safe because of how C# initializes a class. C# runs every static field initializer, in every file, before the static constructor body. So each moved var finds its unmoved dependencies already set. The constructor then sets the moved vars in Go’s order.

A var whose value is pure data is built on its first read. Go’s linker writes a table of constants and function values into the binary as static data, so it costs a Go program nothing at startup. As a C# field initializer, the same table would be built, and the code that builds it compiled, the first time any member of its package is used. So the converter writes such a var’s declaration unchanged, inside a small holder class named for the var and marked [GoLazy]. .NET runs the holder’s field initializer the first time the holder is used, which is the var’s first read. unicode’s range tables are built this way:

var _Lu = &RangeTable{
	R16: []Range16{
		{0x0041, 0x005a, 1},
…
}
…
	Upper  = _Lu // Upper is the set of Unicode upper case letters.
	Lu     = _Lu // Lu is the set of Unicode characters in category Lu (Letter, uppercase).
[GoLazy] partial class lazyᴛ_Lu { internal static ж<RangeTable> _Lu = Ꮡ(new RangeTable(
    R16: [
        new(0x0041, 0x005a, 1),
…
)); }
…
[GoLazy] partial class lazyᴛUpper { public static ж<RangeTable> Upper = _Lu; }           // Upper is the set of Unicode upper case letters.
[GoLazy(Initial = true)] partial class lazyᴛLu { public static ж<RangeTable> Lu = _Lu; } // Lu is the set of Unicode characters in category Lu (Letter, uppercase).

ж<RangeTable> is golib’s form of Go’s pointer *RangeTable, and Ꮡ(…) takes an address, as Go’s & does (Pointers). Inside each holder is the declaration the var would have without it. go2cs-gen, go2cs’s source generators, gives the package class an accessor under the var’s Go name, internal static ref ж<RangeTable> _Lu => ref lazyᴛ_Lu._Lu;, so code reads and writes _Lu as it would a field, and neither the package’s static constructor nor package_init.cs builds it.

Which vars are built this way. The value must not depend on when it is built, and building it must do nothing anyone could see. So an eligible initializer holds only constants, types, package functions and func literals, composite literals and &T{…} allocations, conversions and operators, slices with constant bounds, and reads of same-package vars that are themselves eligible and never assigned or addressed. A call, a read of an imported var or of a field, an index or a dereference, a method value, & of storage, a channel receive or a type assertion keeps the var eager. So does a var declared in a _test.go file, a var whose declaration is hand-owned and a var that copies another whose storage a slice aliases. A var whose address is taken keeps its heap box: the holder holds the box, [GoLazy] partial class lazyᴛX { internal static readonly ж<T> ᏑX = new StaticBox<T>(…); }, the ref property X => ref ((StaticBox<T>)ᏑX).ValueSlot follows it unchanged, and &X is the same box before and after the first read. A var whose address only a pointer-receiver method value takes, such as f := X.M, has no box of its own, so it stays eager. Of the eligible vars, a var is built on its first read when its initializer has at least 20 syntax nodes, or when it reads a var that is, because an eager reader would build that var at startup anyway: Upper is one short line, but it reads _Lu. runtime’s two malloc dispatch tables, mallocScanTable and mallocNoScanTable, are always built this way; if either broke a rule, the conversion would stop and name it.

An exported var keeps its initial value for the vars built from it. Go builds Categories, a map of the category tables, when the program starts, so a program that later assigns unicode.Lu does not change it. The converted Categories is built on its first read, which may come after such an assignment. So Lu’s holder is marked [GoLazy(Initial = true)]: the generator keeps Lu’s initial value in the holder’s initial field, and each initializer built on a first read reads that field, as ["Lu"] = lazyᴛLu.initial. An exported var that such an initializer reads is always built on its first read too, so it has a holder to keep the value in.

A package with nothing left to order has no package_init.cs. A var built on its first read never enters the ordered static constructor. When every var a package would move there is built on its first read instead, the conversion writes no package_init.cs for the package, and removes one that an earlier conversion wrote.

A var that reads another package’s var needs no ordering. Each Go package is its own C# class. .NET initializes a class before its static fields are first read, so the imported package is always ready. This matches Go, which initializes imported packages before the package that imports them.

An initᴛ method is not a Go func init(). Go’s init functions become [GoInit] methods, which are .NET module initializers (Functions and Methods). As in Go, an init body sees every package var already set, because .NET initializes the package class before the body reads its fields.

Full detail: Reference → Package-Level Variable Initialization Order — how dependencies are traced, the other var shapes that move, and the tests that guard each.


Converted Tests

go2cs -tests converts a package’s Go tests along with its code. Each x_test.go becomes x_test.cs beside the production .cs files. The tests build into their own program, <pkg>.tests, and a generated host runs them the way go test does. The testing package they call is go2cs’s hand-written testing, not a converted one.

The examples in this section use a few golib names. golib is go2cs’s runtime library (The golib Runtime Library):

The glyph table lists every glyph.

A test file converts like any other Go file. Its types, tables and helpers convert by the usual rules. func TestX(t *testing.T) becomes a public static void method that takes ж<testing.T> Ꮡt. This is a complete table-driven test from Go’s path package:

package path_test

import (
	"path"
	. "path"
	…
	"testing"
)
…
type ExtTest struct {
	path, ext string
}

var exttests = []ExtTest{
	{"path.go", ".go"},
	{"path.pb.go", ".go"},
	{"a.dir/b", ""},
	{"a.dir/b.go", ".go"},
	{"a.dir/", ""},
}

func TestExt(t *testing.T) {
	for _, test := range exttests {
		if x := Ext(test.path); x != test.ext {
			t.Errorf("Ext(%q) = %q, want %q", test.path, x, test.ext)
		}
	}
}
namespace go;

using Δpath = path_package;
using static path_package;
…
using testing = testing_package;

partial class path_test_package {
…
partial struct ExtTest {
    internal @string path, ext;
}

internal static slice<ExtTest> exttests = [
    new("path.go", ".go"),
    new("path.pb.go", ".go"),
    new("a.dir/b", ""),
    new("a.dir/b.go", ".go"),
    new("a.dir/", "")
];

public static void TestExt(ж<testing.T> t) {
    foreach (var (_, test) in range(exttests)) {
        @string x = Ext(test.path); if (x != test.ext) {
            t.Errorf("Ext(%q) = %q, want %q", test.path, x, test.ext);
        }
    }
}
…
} // end path_test_package

How to read the C#:

Each kind of test file has its own class. An external test package, package path_test, becomes the class path_test_package. A same-package test file, such as package strings in export_test.go, goes to strings_internal_test_package. Both classes live in the test program, not in the production assembly.

Same-package tests still reach unexported names. Go’s unexported names are C# internal. The production project grants the .tests assembly access to them with <InternalsVisibleTo Include="$(AssemblyName).tests" />. So the internal test class reaches them, as Go’s same-package tests do.

Go’s export_test.go pattern needs no hand edits. Go test authors often add a same-package file that wraps unexported code in exported names. The external tests then call those wrappers. In go2cs the wrapper lands in the internal test class:

package strings
…
func StringFind(pattern, text string) int {
	return makeStringFinder(pattern).next(text)
}
namespace go;

using static go.strings_package;

partial class strings_internal_test_package {
…
public static nint StringFind(@string pattern, @string text) {
    return makeStringFinder(pattern).next(text);
}
…
} // end strings_internal_test_package

using static go.strings_package; lets the wrapper call the unexported makeStringFinder by its bare name.

An external test file imports both the production class and the internal test class with using static. A call to a wrapper still names its class. That spelling binds the call to the wrapper exactly, and never to a same-named member of another imported class:

package strings_test
…
		got := StringFind(tc.pat, tc.text)
namespace go;

using slices = slices_package;
using static strings_package;
using testing = testing_package;
using static go.strings_internal_test_package;

partial class strings_test_package {
…
        nint got = strings_internal_test_package.StringFind(tc.pat, tc.text);

A generated host registers every runnable test. go2cs_test_host.cs is the test program’s Main. It registers each test by its Go name, its C# method, and the Go file and line that declare it. Tests are found at conversion time, not by reflection, so each result can name the Go file and line.

The next example is from container/list. Test code follows the same naming as production code: the Go import path container/list gives the namespace go.container (Package Conversion). list_test.go is a same-package test file (package list), so its tests live in list_internal_test_package:

package list
…
func TestExtending(t *testing.T) {
// Code generated by go2cs test conversion. DO NOT EDIT.
namespace go.container;

using go.testing_runtime;

internal static class Go2CsTestHost
{
    public static int Main(string[] args)
    {
        TestRegistry registry = new("container/list", new string[]
        {
            "example_test.go",
            "list.go",
            "list_test.go",
        });
        registry.Add("TestExtending", list_internal_test_package.TestExtending, "list_test.go", 159);
        …
        return TestHost.Run(registry, args);
    }
}

How to read the host:

The host takes go test’s flags. It accepts the usual ones, such as -run, -v and -count. If the package has a TestMain, the host registers it and calls it, as go test does.

Examples, benchmarks and fuzz targets convert but do not run. The host runs only tests and TestMain. The other functions stay in any converted test file that also holds tests, as BenchmarkClone does in clone_test.cs. A file that holds only examples or benchmarks gets no .cs at all.

A test project normally references the production project. strings.tests.csproj compiles only the converted test files and references strings.csproj; it does not recompile the production sources. Recompiling them would make a second, distinct copy of every production type. A value that another converted package passes in would then not match the copy the test uses. Referencing keeps one copy of each type.

Known differences from Go are listed beside the package. A hand-written go2cs_test_disclosures.json names each test whose C# result is known to differ from go test, with the reason. A test that fails without such an entry counts as a mismatch. A third-party module keeps its manifests in the committed tree src/tests/ModuleDisclosures/<module path>@<version>/<package dir>/, read when the run passes -module-disclosures src/tests/ModuleDisclosures. A manifest there applies only to that exact module version, and a manifest in the package’s own output directory still takes precedence. A manifest can also carry notes, which the package’s proof page prints above the verdicts, for a caveat that is not a divergence: google/uuid v1.6.0’s manifest lists no disclosures and notes an upstream test race that Go itself reproduces.

Full detail: Reference → Test suites reference the production project — the test-project models and when each applies, the internal bridge class and its metadata files, test-side name collisions, and exactly which test files get no .cs.


Compiled Library versus Source Code

Go builds a program from the source of every package it imports. go2cs instead turns each Go package into its own C# project, the way C# libraries are usually built and shipped. Converted code therefore treats every package as a separately compiled library. You will notice two effects: some pointers become heap boxes where Go keeps the value on the stack, and the standard library can come from NuGet.

Go decides stack or heap by reading the code a pointer flows into. When Go’s compiler can see that a called function never keeps a pointer beyond the call, even a function in another package, it keeps the pointed-to value on the stack. A C# method’s signature cannot change with what its body does, and an exported function can be called from packages converted separately. So go2cs chooses each function’s signature from its own package alone.

An exported function’s pointer parameter is a heap box. Go’s *T becomes golib’s ж<T>, a small heap object that holds one value or points into one, such as a field of another box (Pointers). Another package may call an exported (capitalized) function, so its signature must never depend on its body. The rule follows the name alone: it holds even in package main, which nothing imports. A local whose address is passed to such a function lives in a box too, even where Go keeps that local on the stack:

type Buffer struct {
	buf      []byte
	off      int
	lastRead int8
}
…
func main() {
…
	b := Buffer{}
	PrintValPtr(&b.off)
	PrintValPtr(&b.off)
…
}
…
func PrintValPtr(ptr *int) {
	fmt.Printf("Value available at *ptr = %d\n", *ptr)
	*ptr++
}
partial struct Buffer {
    internal slice<byte> buf;
    internal nint off;
    internal int8 lastRead;
}
…
internal static void Main() {
…
    ref var b = ref heap<Buffer>(new Buffer(), out var Ꮡb);
    PrintValPtr(Ꮡb.Ꮡoff);
    PrintValPtr(Ꮡb.Ꮡoff);
…
}
…
public static void PrintValPtr(ж<nint> Ꮡptr) {
    ref var ptr = ref Ꮡptr.DerefOrNull();

    fmt.Printf("Value available at *ptr = %d\n"u8, ptr);
    ptr++;
}

Each call prints b.off and then increments it through the pointer, so the first call prints 0 and the second prints 1. Reading the C#, line by line:

An unexported function can take a ref instead. Only code in the same package can call an unexported function, so the converter sees every caller. A pointer parameter that is only dereferenced usually becomes a C# ref parameter, and &x at the call becomes ref x. A local whose address reaches only such parameters stays a plain local, with no box:

func addTo(out *uint64, v uint64) {
	*out += v
}
…
func main() {
…
	var total uint64
	addTo(&total, 5)
	addTo(&total, 7)
	fmt.Println("total:", total)
…
}
internal static void addTo(ref uint64 @out, uint64 v) {
    @out += v;
}
…
internal static void Main() {
    uint64 total = default;
    addTo(ref total, 5);
    addTo(ref total, 7);
    fmt.Println("total:", total);
…
}

Reading the C#:

A deferred or go call keeps the box. Its arguments are evaluated now and stored until the call runs, and C# cannot store a ref, so the pointer stays a box. For example, defer printVal(&x) keeps x in a box, even though printVal takes a ref everywhere else (Defer / Panic / Recover, Goroutines, Pointers).

The standard library is referenced, never reconverted. go2cs -recurse converts a program’s own packages and its third-party dependencies from source. (-recurse=module converts only the program’s own packages.) Every standard-library package the program imports is a reference to an already-converted library. Plain -recurse makes these project references into the go2cs install, such as core/fmt/fmt.csproj.

go2cs -recurse=nuget references the standard library as NuGet packages. Each package ID is go. plus the Go import path, with dots for slashes: net/http becomes go.net.http. Every converted project also gets go.lib, the golib runtime, and go.gen, the source generators. The program’s own packages stay project references. A program that imports fmt gets these lines in its .csproj:

<PackageReference Include="go.fmt" Version="$(GoStdLibVersion)" />
<PackageReference Include="go.lib" Version="$(GoStdLibVersion)" />
<PackageReference Include="go.gen" Version="$(GoStdLibVersion)" PrivateAssets="all" />

$(GoStdLibVersion) follows the Go minor doing the conversion. go2cs writes a Directory.Build.props beside the converted projects that sets it to that minor with a float. For example, Go 1.N.P gives 1.N.*, and restore takes the newest published 1.N.x.y package. Set the property yourself, for example with -p:GoStdLibVersion=…, to pin one exact version.

-recurse=nuget refuses a mismatched Go release. The published packages match one Go release, by major and minor version. When the converting Go toolchain is a different release, the conversion stops with an error, because its standard library would not match the packages and the project could not restore. Only the major and minor numbers are compared, so a different patch number is accepted.

Full detail: Reference → Compiled Library versus Source Code — why source availability shapes Go’s escape analysis, and how the converter chooses between NuGet and source references.


Constant Values

Go has two kinds of constant. A typed constant has a declared type, as in const n int = 3. An untyped constant, as in const n = 3, has no fixed type and takes one from each place it is used. go2cs keeps that difference visible, and it always writes the constant’s exact value into the C#.

A package-level constant takes one of three C# forms (a constant declared inside a function follows the rules further down this section):

When the value is computed, a /* … */ comment beside it keeps the Go expression.

An untyped numeric constant is a property of a golib wrapper type. golib is go2cs’s runtime library. Its wrappers UntypedInt, UntypedFloat and UntypedComplex hold the value and convert implicitly to every Go numeric type. Hex spelling is kept, so masks stay recognizable:

const (
	maxWidth           = 12
	decoderInvalidCode = 0xffff
	flushBuffer        = 1 << maxWidth
)
internal static UntypedInt maxWidth => 12;
internal static UntypedInt decoderInvalidCode => 0xffff;
internal static UntypedInt flushBuffer => /* 1 << maxWidth */ 4096;

name => value returns the value on each read and stores nothing. A Go constant needs no initialization, and a property has none, so code anywhere in the package can read it in any order (Package-Level Variable Initialization Order).

An untyped constant takes its type where it is used. A := from one gives the new variable Go’s default type for that constant: rune for a rune constant, float64 for a float. The C# names that type instead of writing var:

const replacementChar = '�' // untyped rune (mirrors unicode.ReplacementChar)
const scale = 2.5                // untyped float
…
func main() {
	codepoint := replacementChar
	s := string(codepoint)
	fmt.Println(len(s), codepoint)

	factor := scale
	fmt.Println(factor * 2)
…
internal static UntypedInt replacementChar => /* '�' */ 65533;
internal static UntypedFloat scale => 2.5;
…
internal static void Main() {
    rune codepoint = replacementChar;
    @string s = (@string)codepoint;
    fmt.Println(len(s), codepoint);

    float64 factor = scale;
    fmt.Println(factor * 2D);
…

A rune is an integer, so a rune constant is an UntypedInt too. The wrapper does not need to remember that it was a rune: the converter reads each use’s type from Go’s type checker when it converts, and writes that type into the C#.

rune and float64 are C# aliases that keep Go’s names, for C#’s 32-bit int (System.Int32) and double (Integer Types and Arithmetic). @string is golib’s Go string (Strings). 2D is a C# double literal: Go’s untyped 2 takes the float64 type of factor.

A rune literal is a bare C# char where its slot takes one. C#’s 'x' is a char, which C# converts implicitly to rune and the wider integer and float types, so a literal compared with a variable, or written into a slot of one of those types, stays 'x'. A char does not convert to byte, int8 or int16, so in those slots the literal carries one cast to the slot’s type. Where C# would keep the char as its own type, as with var or a value passed as any, the literal stays (rune)'x':

	r := 'a'
	fmt.Printf("%T %v\n", r, r)
	var typed rune = 'b'
…
	var b byte = 'q'
…
	fmt.Println(takesRune('y'), retRune())
	if r == 'a' && r < 'z' {
    var r = (rune)'a';
    fmt.Printf("%T %v\n"u8, r, r);
    rune typed = 'b';
…
    byte b = (byte)'q';
…
    fmt.Println(takesRune('y'), retRune());
    if (r == 'a' && r < 'z') {

Passed to an any parameter, an untyped constant takes its default type. Go gives an untyped integer the type int, which is C# nint, so the C# casts to (nint). Without the cast, the wrapper struct itself would be passed, and fmt would print the struct instead of the number. "size=%d"u8 is a C# UTF-8 string literal:

const fsize = 5
…
func main() {
…
	fmt.Println(fmt.Sprintf("size=%d", fsize+1))
internal static UntypedInt fsize => 5;
…
internal static void Main() {
…
    fmt.Println(fmt.Sprintf("size=%d"u8, (nint)(fsize + 1)));

In arithmetic with a typed value, the constant is cast to that value’s type. The wrapper converts to and from every numeric type, so C# could convert a to the wrapper instead. The result would then have the wrong type: the code may not compile, or may compute at the wrong width. The cast selects the operator Go uses, here uint64 multiplication:

const two32 = 1 << 32 // untyped const -> UntypedInt; value exceeds int32
…
func main() {
	var a uint64 = 100
	var b uint64 = 3
	fmt.Println(a*two32 + b) // 429496729603
…
internal static UntypedInt two32 => /* 1 << 32 */ 4294967296;
…
internal static void Main() {
    uint64 a = 100;
    uint64 b = 3;
    fmt.Println(a * (uint64)two32 + b);
…

A typed constant of a basic numeric type is a C# const. Go int is C# nint, a native-width integer (Integer Types and Arithmetic). This file sits in the package’s windows/ folder, which holds the files that differ between target operating systems (Package Conversion):

	O_RDONLY int = syscall.O_RDONLY // open the file read-only.
public const nint O_RDONLY = /* syscall.O_RDONLY */ 0; // open the file read-only.

C# accepts a const nint or const nuint only for a value that fits 32 bits. A wider value takes the property form instead. unchecked lets C# accept a cast to nint of a value that only fits a 64-bit nint:

const maxInt = int(^uint(0) >> 1)
internal static nint maxInt => /* int(^uint(0) >> 1) */ unchecked((nint)9223372036854775807);

A uintptr constant is a property for a similar reason: golib’s uintptr is a struct, and C# has no const of a struct type (unsafe.Pointer and uintptr).

An untyped boolean constant is a plain const bool, with the Go expression in a comment:

	const c = 3 < 4 // c is the untyped boolean constant true
    const bool c = /* 3 < 4 */ true;

A constant of a named type is a property of that type. A named numeric type such as type Order int becomes a C# struct, and C# cannot declare a const of a struct type. partial struct Order /*num:nint*/; declares a struct over Go int (C# nint); a source generator fills in its body, operators and conversions (Named Numeric Types and Constant Contexts).

In a constant group, iota and implicit repetition fold to plain values, one declaration per name. The first keeps /* iota */ to show where the numbering starts:

// Order specifies the bit ordering in an LZW data stream.
type Order int

const (
	// LSB means Least Significant Bits first, as used in the GIF file format.
	LSB Order = iota
	// MSB means Most Significant Bits first, as used in the TIFF and PDF
	// file formats.
	MSB
)
partial struct Order /*num:nint*/;

// LSB means Least Significant Bits first, as used in the GIF file format.
public static Order LSB => /* iota */ 0;
// MSB means Most Significant Bits first, as used in the TIFF and PDF
// file formats.
public static Order MSB => 1;

A constant expression of a named type keeps that type. Where its value fits 32 bits, the C# keeps the Go expression: a := 2 * time.Second becomes var a = 2 * time.ΔSecond;, and the wrapper’s own * returns a Duration (Δ marks a renamed name, Names and Glyphs). A wider value is written folded, with the named type as a cast: b := 8 * time.Hour becomes var b = (time.Duration)(28800000000000L);. Either way the value is a Duration, so fmt prints 8h0m0s through Duration’s String method, not a count of nanoseconds (example, detail).

A constant declared inside a function takes its type from its uses. Every use of a local constant is in view inside its function. When every use needs the same type, the constant is declared at that type, and it is a C# const where C# allows one:

func localPrecision(x float64) float64 {
	const c = 5.42857142857142815906e-01
	const d = -7.05306122448979611050e-01
	const smallestNormal = 2.22507385850720138309e-308 // 2**-1022 = 0x0010000000000000
	if x < smallestNormal {
		return d
	}
	return c + x*d
}
internal static float64 localPrecision(float64 x) {
    const float64 c = 5.42857142857142815906e-01;
    const float64 d = -7.05306122448979611050e-01;
    const float64 smallestNormal = 2.22507385850720138309e-308;
    if (x < smallestNormal) {
        return d;
    }
    return c + x * d;
}

A package-level untyped constant always keeps its wrapper. It can be used from any function or file of the package, and those uses may need different types.

When a local constant’s uses need different types, it keeps its wrapper type as a local variable:

	const mixed = 0.25 // float64 AND float32 uses -> stays UntypedFloat
	var f64 float64 = mixed
	var f32 float32 = mixed
	fmt.Println(f64, f32)
        UntypedFloat mixed = 0.25;
        float64 f64 = mixed;
        float32 f32 = mixed;
        fmt.Println(f64, f32);

A complex constant is a real complex value. It is written as its real part plus its imaginary part. .i() is a golib extension method that makes a number imaginary, so 3D.i() is Go’s 3i. An untyped or complex128 constant uses C# double halves (D), and a complex64 constant uses float halves (F). Complex types are structs in C#, so each constant is a property:

const (
…
	cNegImag    = 2.25 - 0.75i        // a NEGATIVE imaginary part
	cPureImag   = 3i                  // a ZERO real part
…
	cFolded     = (1 + 2i) * (3 + 4i) // a folded complex EXPRESSION (-5+10i)
)
…
const c64 complex64 = 1.5 + 2.5i
internal static UntypedComplex cNegImag => /* 2.25 - 0.75i */ 2.25D + -0.75D.i();
internal static UntypedComplex cPureImag => /* 3i */ 3D.i();
…
internal static UntypedComplex cFolded => /* (1 + 2i) * (3 + 4i) */ -5D + 10D.i();

internal static complex64 c64 => /* 1.5 + 2.5i */ 1.5F + 2.5F.i();

A string constant is a static readonly field. A string value is an allocation, and a field creates it once, where a property would create it on every read. @string is golib’s Go string, and "op"u8 is a C# UTF-8 literal (Strings). A constant of a named string type is a field of that named type in the same way:

const prefix = "op"
internal static readonly @string prefix = "op"u8;

A string constant declared inside a function becomes a local initialized from a plain C# literal. golib converts the literal to @string once and shares the result, so a call allocates nothing for it (plain literals):

func main() {
…
	const plain = "plain"
	fmt.Println(plain)
internal static void Main() {
…
    @string plain = "plain";
    fmt.Println(plain);

In the standard library, and under -hoist-literals, the value moves up to a class field instead, so it is built once rather than on every call: static readonly @string plainᶜ = "plain"u8;, and the local copies it (@string plain = plainᶜ;), which shares the same bytes and allocates nothing. The field’s name ends in ᶜ, which keeps it apart from the local that keeps the Go name (Reading Converted Code).

Full detail: Reference → Constant Values — why a property rather than a field, the iota forms, exact float and complex rendering, when a local constant keeps its wrapper, and unchecked casts for native-width values.


Integer Types and Arithmetic

Go’s platform-sized int and uint become C#’s native-sized nint and nuint. Like Go’s, they are 32 or 64 bits wide depending on the target platform. The fixed-width types keep their Go names, so most arithmetic reads as it does in Go:

func addInt(a, b int) int { return a + b }
internal static nint addInt(nint a, nint b) { return a + b; }

In a few places C# arithmetic behaves differently from Go’s: narrow types, unsigned negation, shifts and a signed division by -1. There the converter adds a cast, rewrites the expression or calls the golib runtime library. Division by zero and slice bounds also need care. Each case has its own paragraph in this section.

The fixed-width names are aliases. int8 through uint64, and rune, are aliases of the C# primitives. Each converted project declares them. So uint8 is C#’s byte, int64 is long, and rune is int. Go’s byte stays C#’s own byte, the same type as uint8.

uintptr is its own type. It becomes golib’s uintptr, a small struct that holds one nuint. It is not an alias, because Go treats uint and uintptr as different types, and %T and type switches tell them apart. A uintptr declaration reads as in Go:

var i uintptr = 2
uintptr i = 2;

Its use with unsafe.Pointer is covered in unsafe.Pointer and uintptr.

Narrow arithmetic is cast back to its type. Go computes int8, uint8, int16 and uint16 arithmetic at that width, so the uint8 sum 200 + 100 wraps to 44. C# promotes these types to int first, so the sum is 300. The converter casts the result back to the narrow type, which restores Go’s wrapped value.

In this example, takeU8 is a function that takes a uint8. In the C#, fmt is Go’s fmt package, converted to C# like any other package (Package Conversion):

var a, b uint8 = 200, 100

// Argument context.
fmt.Println(takeU8(a + b)) // 300 wraps to 44
uint8 a = 200;
uint8 b = 100;

fmt.Println(takeU8((uint8)(a + b)));

The same cast appears wherever the result is assigned, declared, returned or stored in a struct field:

y := a + b // short-var declaration; y is uint8, wraps to 44
fmt.Println(y)

y = y + 1 // reassignment, 45
fmt.Println(y)
var y = (uint8)(a + b);
fmt.Println(y);

y = (uint8)(y + 1);
fmt.Println(y);

A comparison gets the cast too, even though nothing is stored. Without it, C# would compare 300, not 44:

fmt.Println(a+b == 44)   // 300 wraps to 44 (uint8) -> true
fmt.Println((uint8)(a + b) == 44);

A narrow result keeps Go’s width wherever it goes. When the result is passed as any, used as a map key or an index, or converted to a wider type, the cast sits on the arithmetic itself. An any value keeps Go’s type too, so %T reports int8, not int32. Here a is an int8 holding 100, so Go’s a + a is -56:

var x any = a + a
any x = (int8)(a + a);

No cast is added where only the low bits matter, such as more arithmetic of the same type or a conversion to a type no wider.

Wider types need no narrowing cast. int32 and wider types are not promoted, and C# arithmetic is unchecked by default. So +, - and * on them wrap on overflow exactly as in Go, with no cast.

A bitwise result is cast only where C# would change its type. C# computes &, | and ^ on the narrow types in int, so a narrow result is cast back, as narrow arithmetic is. At 32 and 64 bits C# keeps the operands’ type. So when both operands already have the result’s type, a bitwise expression has no cast, as in the examples that follow. The cast stays on a named type, on uintptr, and where an operand is a constant C# might type differently, such as a constant on the right of Go’s “and not” &^.

Unsigned negation subtracts from zero. C#’s unary minus never keeps an unsigned type: it rejects uint64 and nuint, and widens smaller unsigned values to a signed type. Go’s -x on an unsigned value of type T becomes ((T)0 - x), which wraps the same way. The common “lowest set bit” idiom shows it:

func lowestSetBit(x uint32) uint32 {
	return x & -x // wrap-around negation; isolates the lowest set bit
}
internal static uint32 lowestSetBit(uint32 x) {
    return x & ((uint32)0 - x);
}

Here ((uint32)0 - x) is the negation. Both operands of & are uint32, so the & has no cast.

Bitwise complement uses C#’s spelling. Go’s unary ^x becomes C#’s ~x, and Go’s “and not” x &^ y becomes x & ~y. This example clears the top bit of a uint64:

var hi uint64 = 0xFFFFFFFFFFFFFFFF
fmt.Println(hi &^ (1 << 63)) // clear the high bit -> 0x7FFFFFFFFFFFFFFF
uint64 hi = 0xFFFFFFFFFFFFFFFFUL;
fmt.Println((uint64)(hi & ~((uint64)1 << 63)));

The constant 1 becomes (uint64)1, so the shift happens at 64 bits, as in Go, where the untyped constant takes hi’s type uint64 (Constant Values). A large unsigned literal gets C#’s UL suffix so it compiles. The shift keeps its parentheses because C#’s ~ binds tighter than a shift, as Go’s ^ does. The & keeps its cast because its right operand is a constant.

Shifts and bitwise operators get parentheses where C# would regroup them. Go puts <<, >> and & at the level of *, and | and ^ at the level of +. C# ranks shifts below + and -, and &, ^ and | below ==. The converter keeps an operand’s parentheses only where C#’s precedence would group the bare text differently from Go’s tree, so x>>4 + x keeps them and a | b<<8 does not. C#’s shift takes an int count, while Go accepts any integer type, so a count that is not an integer literal is cast to int:

var x uint64 = 0x100
fmt.Println(x>>4 + x) // (0x100>>4)+0x100 = 16 + 256 = 272
uint64 x = 0x100;
fmt.Println((x >> 4) + x);

A shift count that can reach the width calls a golib helper. In Go, shifting by the operand’s full width or more gives 0, or -1 when a negative value is shifted right. C# masks the count instead, so a 64-bit value shifted by 64 comes back unchanged. When the count is not provably below the width, >> becomes .Rsh(…) and << becomes .Lsh(…). These are extension methods from golib’s GoShift, one set per operand type. The 64-bit ones are:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static uint64 Rsh(this uint64 x, uint64 n) => n >= 64 ? 0UL : x >> (int)n;

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static uint64 Lsh(this uint64 x, uint64 n) => n >= 64 ? 0UL : x << (int)n;

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int64 Rsh(this int64 x, uint64 n) => n >= 64 ? x >> 63 : x >> (int)n;

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int64 Lsh(this int64 x, uint64 n) => n >= 64 ? 0L : x << (int)n;

An unsigned count is taken as a uint64, so a count of any unsigned type, such as nuint, passes straight in. In the signed Rsh, x >> 63 fills the result with the sign bit: -1 for a negative value, 0 otherwise.

A signed count is taken as an int64 instead (one.Lsh((int64)(n))), and binds a second set of overloads. Go panics with the runtime error “negative shift amount” when a signed count is below zero at run time, and these raise that same panic, which recover sees; a count of zero or more goes to the unsigned helper. So a signed count is never kept native on the strength of y % M alone, since Go’s -3 % 8 is -3.

Here the counts come from a slice at run time, and several are 64 or more. In the C#, slice<nuint>([…]) builds a golib slice, and foreach (var (_, k) in range(c)) is Go’s for _, k := range c (Loops, Range and Labels):

c := []uint{0, 1, 63, 64, 65, 200}
var u uint64 = 0x8000000000000001

// Unsigned 64-bit: count >= 64 -> 0 (both directions).
for _, k := range c {
	fmt.Println(u>>k, u<<k)
}
var c = slice<nuint>([0, 1, 63, 64, 65, 200]);
uint64 u = 0x8000000000000001UL;

foreach (var (_, k) in range(c)) {
    fmt.Println(u.Rsh(k), u.Lsh(k));
}

A compound shift by a count not provably below the width calls the matching assigning helper: Go’s y <<= s becomes y.LshAssign(s);, which evaluates y once and stores the result back into it.

A map element has no reference to pass, so its compound shift is written out. When the map and the key can be read twice without effect, Go’s m[1] >>= 40 becomes m[1] = m[1].Rsh(…). Otherwise the map and the key go to golib’s RshAssign, so the key is still evaluated once:

m[key()] >>= wide
m.RshAssign(key(), wide);

A type parameter constrained to integers takes the same rule. .NET’s own shift operator masks the count to the type argument’s width, so the converter calls golib’s generic Lsh and Rsh, which read the width from the type argument. The count is measured against the narrowest type the constraint allows:

x <<= n
x = x.Lsh(n);

A count provably below the width keeps C#’s own operator. That covers a constant in range, such as x >> 4, and a count masked or reduced below the width, such as k & 63, or k % 64 when k is unsigned. These are most shifts in real code. Continuing the same example:

fmt.Println(u >> (c[3] & 63)) // 64 & 63 = 0 -> u
fmt.Println(u >> (c[4] % 64)) // 65 % 64 = 1 -> u >> 1
fmt.Println(u >> (int)(c[3] & 63));
fmt.Println(u >> (int)(c[4] % 64));

Each count is a variable expression, so it is cast to int. Go’s own parentheses around the count become the cast’s parentheses.

A named integer type, such as type Word uint, becomes a C# struct whose operators are generated at compile time (Named Numeric Types, Source Generators). A shift on it keeps the plain >> in the converted code, because that generated operator applies the same Go rule.

Division and remainder check for -1. Go defines the one signed division that overflows: the most negative value divided by -1 wraps back to itself, and any value modulo -1 is 0, with no panic. .NET throws an OverflowException there instead, so the plain C# operator would stop a program that Go runs to completion. So a signed int, int32 or int64 division or remainder where the divisor may be -1 and the dividend may be the most negative value calls golib’s quo or rem. Each checks for -1 and otherwise uses C#’s own / or %:

return Point{p.X / k, p.Y / k}
return new Point(quo(p.X, k), quo(p.Y, k));

A constant divisor keeps the plain operator (a constant -1 folds), and so does a constant dividend other than the most negative value, a len or cap dividend, and a named integer type, whose generated operators carry the same check.

Division by zero panics as in Go. C# throws a DivideByZeroException for a zero divisor, from the plain operators and from quo and rem alike. When golib’s panic handling catches that exception, it turns it into Go’s runtime error: integer divide by zero panic. So recover catches it as in Go, and an unrecovered one reports Go’s message.

A slice bound keeps its full value. C#’s range syntax, s[lo..hi], accepts only an int, and Go’s int is nint. So a bound that is not a constant goes to golib’s .slice method instead, as a nint, where Go’s bounds check sees all of it. Here s.pos is a Go int field:

n := copy(p, s.data[s.pos:])
nint n = copy(p, s.data.slice(s.pos));

Slicing itself is explained in Slices and Arrays.

Full detail: Reference → Native and Narrow Integer Types — every place a narrow result is cast back, how a shift count is proven in range, shifts on named types, and how integer literals are written.


Named Numeric Types and Constant Contexts

A Go type defined over a number type, such as type Duration int64, becomes a small C# wrapper struct. The converter emits one line: a partial struct whose name is followed by the comment /*num:<underlying>*/. The TypeGenerator source generator writes the rest of the struct when the project compiles. So code that uses the type reads almost line for line as in Go.

What the generator adds. The generated struct has one field that holds the value. It has implicit conversions to and from the underlying type, so Duration d = 7; needs no cast. It also has Go’s operators, each returning the named type. Go’s number type names, such as int64, uint8 and float64, are C# aliases for long, byte and double (Integer Types and Arithmetic).

A named type, its constants and its methods. Here is Go’s time.Duration, a count of nanoseconds, with its unit constants and one method:

type Duration int64
…
const (
	Nanosecond  Duration = 1
	Microsecond          = 1000 * Nanosecond
	Millisecond          = 1000 * Microsecond
	Second               = 1000 * Millisecond
	Minute               = 60 * Second
	Hour                 = 60 * Minute
)
…
func (d Duration) Seconds() float64 {
	sec := d / Second
	nsec := d % Second
	return float64(sec) + float64(nsec)/1e9
}
partial struct Duration /*num:int64*/;
…
public static Duration ΔNanosecond => 1;
public static Duration Microsecond => /* 1000 * Nanosecond */ 1000;
public static Duration Millisecond => /* 1000 * Microsecond */ 1000000;
public static Duration ΔSecond => /* 1000 * Millisecond */ 1000000000;
public static Duration ΔMinute => /* 60 * Second */ 60000000000;
public static Duration ΔHour => /* 60 * Minute */ 3600000000000;
…
public static float64 Seconds(this Duration d) {
    var sec = d / ΔSecond;
    var nsec = d % ΔSecond;
    return (float64)sec + (float64)nsec / 1e9D;
}

Reading the C#, piece by piece:

Operators keep the named type. The wrapper has Go’s arithmetic and comparison operators, ++ and --. Over an integer it also has the bitwise and shift operators. Each operator that computes a value returns the named type, as in Go, so d / ΔSecond is still a Duration. Shifts follow Go’s rules: a shift by the full bit width or more gives 0, or -1 when a negative value is shifted right, where C# would mask the count (Integer Types and Arithmetic), and a negative signed count panics with Go’s “negative shift amount”. The wrapper’s shift operators take an int, uint64 or int64 count. A count provably below the width is cast to int; any other unsigned count passes as it is, and a signed one is cast to int64, as for an unnamed integer:

type word uint64
…
func maskFor(s uint) word {
	m := word(1)<<s - 1
	return m
}
partial struct word /*num:uint64*/;
…
internal static word maskFor(nuint s) {
    var m = (((word)1) << s) - 1;
    return m;
}

Here m is a word, because both << and - return a word. Go’s uint is C#’s nuint.

A conversion out of the wrapper is one cast; a conversion into it goes through the underlying type. The wrapper converts implicitly to and from its exact underlying type. It also accepts golib’s untyped-constant wrappers, described in the next rules. Going out, it also declares an explicit operator to every basic number type, each the underlying’s own C# cast, so uint64(s) becomes (uint64)s and gives the same value as Go’s conversion. Going in from another number type, the value goes to the underlying first, so traceArg(procs) becomes (traceArg)(uint64)procs:

type traceArg uint64 // underlying uint64
…
type nameOff int32   // underlying int32
…
	var procs int32 = 5
	a := traceArg(procs) // int32 -> traceArg: ((traceArg)(uint64)procs)
…
	var s nameOff = 7
	e := uint64(s) // nameOff(int32) -> uint64: ((uint64)s)
partial struct traceArg /*num:uint64*/;
…
partial struct nameOff /*num:int32*/;
…
    int32 procs = 5;
    var a = (traceArg)(uint64)procs;
…
    nameOff s = 7;
    var e = (uint64)s;

When the other type already is the underlying type, one cast is enough: int32(s) becomes (int32)s.

An iota group becomes one property per name. This is Go’s usual way to write an enumeration. Inside a const group, iota counts 0, 1, 2 and so on, and a line with no expression repeats the one before it. In C# each value is computed ahead of time, and the first keeps its Go expression as a comment. The type is renamed ΔMonth because Time also has a Month() method, and both live in time_package.

type Month int

const (
	January Month = 1 + iota
	February
	March
	…
	December
)
partial struct ΔMonth /*num:nint*/;

public static ΔMonth January => /* 1 + iota */ 1;
public static ΔMonth February => 2;
public static ΔMonth March => 3;
…
public static ΔMonth December => 12;

A constant takes the type Go gives it in context. An untyped Go constant, one declared with no type, becomes a property of UntypedInt. That is a struct from golib, the go2cs runtime library (The golib Runtime Library), and it converts to any Go integer type (Constant Values). Go gives such a constant the type of the operand beside it. Where the operand beside it fixes that type, the converter adds a cast to it, so C# picks the same operator Go does:

type Tag uint8

const classConstructed = 0x20 // untyped const
const classContext = 0x80

func (t Tag) Constructed() Tag { return t | classConstructed }
func (t Tag) Context() Tag     { return t | classContext }
partial struct Tag /*num:uint8*/;

internal static UntypedInt classConstructed => 0x20;
internal static UntypedInt classContext => 0x80;

public static Tag Constructed(this Tag t) { return (Tag)(t | (uint8)classConstructed); }
public static Tag Context(this Tag t) { return (Tag)(t | (uint8)classContext); }

The cast names the underlying uint8, not Tag. uint8 converts to Tag by itself, so t | (uint8)classConstructed uses Tag’s own | operator and the result is a Tag, as in Go. The outer (Tag) cast restates Go’s result type.

The same rule covers function arguments. Go’s built-in min takes arguments of one type, so an untyped constant argument is cast to the type of the others. In C#, min comes from golib’s builtin class, which every converted file can call without a prefix (Built-in Functions). Here uintptr is golib’s struct for Go’s uintptr (unsafe.Pointer and uintptr):

const limit = 128 << 10 // untyped
…
func clampU(n uintptr) uintptr { return min(n, limit) }
internal static UntypedInt limit => /* 128 << 10 */ 131072;
…
internal static partial uintptr clampU(uintptr n) { return min(n, (uintptr)(limit)); }

Values print as Go prints them. A constant expression of a named type keeps that type after it is computed. So 8 * time.Hour stays a Duration, and fmt.Println prints it through Duration’s own String method as 8h0m0s, not as a count of nanoseconds (detail). In this program the C# alias for the time package is Δtime. The Δ marks a rename: the program’s blank import of time/tzdata references a package whose class lives in the C# namespace go.time, so inside namespace go the bare name time already names that namespace. "4:" stays a plain C# literal, which go2cs-gen passes to fmt.Println as golib’s Go string @string (Strings).

func foldedConstant() (time.Duration, float64, int) {
	d := 8 * time.Hour
	return d, d.Seconds(), int((8 * time.Hour).Seconds())
}
…
	d, secs, east := foldedConstant()
	fmt.Println("4:", d, secs, east)
using Δtime = time_package;
…
internal static (Δtime.Duration, float64, nint) foldedConstant() {
    var d = (Δtime.Duration)(28800000000000L);
    return (d, d.Seconds(), (nint)((Δtime.Duration)(28800000000000L)).Seconds());
}
…
    var (d, secs, east) = foldedConstant();
    fmt.Println("4:", d, secs, east);

Both programs print 4: 8h0m0s 28800 28800.

Full detail: Reference → Named Numeric Types and Constant Contexts — the less common operators and casts, conversions between assemblies, constants too large for 64 bits, and named boolean and complex types.


Floating-Point Values

Go’s float32 and float64 are C#’s float and double. Each converted project declares the Go names as aliases, as it does for the fixed-width integers (Integer Types and Arithmetic). complex128 is an alias of .NET’s System.Numerics.Complex, and complex64 is a struct of two float32 halves from golib, go2cs’s runtime library. Both C# floating-point types follow IEEE 754, as Go’s do, so infinities, NaN and negative zero behave as in Go: a float divided by zero gives +Inf or -Inf, with no panic.

A float literal keeps its Go spelling. It gains C#’s type suffix, D for float64 and F for float32, except in a typed const declaration, which writes a float64 value bare and a float32 one with C#’s f. A Go-only form that C# cannot read, such as the hex float 0x1p-2 or the trailing-dot 2., is written as its decimal value instead (0.25D, 2D). A float constant follows the rules in Constant Values: a float32 or float64 one is a C# const, and an untyped one is a static property of type UntypedFloat, a golib type that keeps the constant’s exact value.

A float prints as Go prints it. go2cs converts fmt and strconv from Go’s own source, so fmt.Println formats a float with Go’s own strconv code, never with .NET’s ToString. Go’s default form is the shortest decimal that reads back as the same value. It switches to exponent form when the decimal exponent is below -4 or 6 or more, and writes the exponent signed and with at least two digits. .NET’s ToString would print 1e6 as 1000000:

var e5 float64 = 1e5
var e6 float64 = 1e6
var million float64 = 1000000.0
var justUnder float64 = 999999.0
…
fmt.Println(e5, e6, million, justUnder)
float64 e5 = 1e5D;
float64 e6 = 1e6D;
float64 million = 1000000.0D;
float64 justUnder = 999999.0D;
…
fmt.Println(e5, e6, million, justUnder);

Both programs print 100000 1e+06 1e+06 999999. A float32 value formats at single precision, so float32(1.0/3.0) prints 0.33333334, and a complex value prints in Go’s (1+2i) form.

Full detail: Reference → Floating-Point Formatting — how Go’s default float form differs from .NET’s, and the behavioral tests that guard it.


Nil and Zero Values

In Go, every variable starts at its type’s zero value. nil is the zero value of pointers, slices, maps, channels, functions and interfaces. Converted C# writes most zero values, and most nils, as default!.

default! is C#’s default value for the target type. The ! tells the compiler that a null here is intended. The golib runtime library types give that default value Go’s nil behavior, so reading a nil map, or taking len of a nil slice, works as it does in Go.

A few Go forms need something other than default!. Each one has its own rule in this section. In the table, ж<T> is golib’s heap box for a Go pointer *T, and the Ꮡ prefix marks the box of a pointer parameter whose body also reads through it (glyph table):

Go C# Why
var s []byte, a returned nil default! golib types treat C#’s default as Go’s nil.
p == nil, or nil passed to a pointer parameter p == nil (Ꮡp == nil where the body also reads through p), f(nil) golib’s nil matches both forms of a nil pointer.
var x any = (*int)(nil) any x = ((ж<nint>)nil); The interface keeps the type *int.
var dpi any = dp, for a pointer dp any dpi = dp.OrTypedNil(); The same, for a pointer variable.
var a [5]int array<nint> a = new(5); The array needs its length.
var z holder, a struct holding an array holder z = new(); C#’s default skips field initializers.
var e Embedded, a struct that embeds a type Embedded e = new(nil); The constructor builds each embedded field.
var ar <-chan int /*<-*/channel<nint> ar = /*<-*/channel<nint>.RecvOnly; The nil channel keeps its direction.

A variable declared without a value is default, and == nil stays == nil. A Go slice is a golib slice<T>, which is nil when it has no backing array. So []byte{} is empty but not nil, as in Go. Here probe prints its arguments:

func probe(name string, isNil bool, length, capacity int) {
	fmt.Println(name, isNil, length, capacity)
}

func main() {
	var zero []byte
	probe("zeroValue", zero == nil, len(zero), cap(zero))

	emptyLit := []byte{}
	probe("emptyLiteral", emptyLit == nil, len(emptyLit), cap(emptyLit))
internal static void probe(@string name, bool isNil, nint length, nint capacity) {
    fmt.Println(name, isNil, length, capacity);
}

internal static void Main() {
    slice<byte> zero = default;
    probe("zeroValue", zero == nil, len(zero), cap(zero));

    var emptyLit = slice<byte>([]);
    probe("emptyLiteral", emptyLit == nil, len(emptyLit), cap(emptyLit));

Go and C# both print zeroValue true 0 0, then emptyLiteral false 0 0. @string is Go’s string, and nint is Go’s int (Integer Types). A literal passed to a string parameter, such as "zeroValue", is a plain C# literal that golib converts once and shares (Strings). .slice() wraps a C# array as a non-nil golib slice.

Each nil value behaves as it does in Go. The golib types, pointers included, give their nil value these behaviors:

Nil value What happens
slice len and cap are 0, and append works (Slices and Arrays).
map golib’s map<K, V> is a struct with no dictionary behind it, so nothing throws a C# null error. Reads return the zero value, len is 0, range is empty and delete does nothing. A write panics (Maps).
channel A send or receive blocks forever, and in a select its case is never chosen (Channels).
pointer Comparing it is safe. A pointer-receiver method can be called on it, because go2cs reads through the pointer only where the method’s body does (Functions and Methods). Reading or writing through it panics with Go’s invalid memory address or nil pointer dereference, which recover catches (Defer / Panic / Recover).
interface == nil is true only when it holds nothing at all, not even a nil pointer (Interfaces).

A pointer compared with nil, or passed nil as an argument, uses golib’s nil. A Go pointer *T becomes golib’s ж<T> heap box (read “zhe”). A pointer parameter p that arrives as a box keeps its Go name. When a body also reads through the pointer, C# reaches the value by ref, either as a ref var p local or as a ref T p parameter, and the box is then named Ꮡp to leave p free for that value (Pointers, glyph table). A local pointer variable keeps its Go name, as dp does below. The functions below only compare or forward the pointer, so each keeps the box under its Go name:

type node struct{ val int }
…
func onlyNilCheck(p *node) string {
	if p == nil {
		return "nil"
	}
	return "set"
}
…
func inner(p *node) bool      { return p == nil }
func passThrough(p *node) bool { return inner(p) }
partial struct node {
    internal nint val;
}

internal static @string onlyNilCheck(ж<node> p) {
    if (p == nil) {
        return "nil";
    }
    return "set";
}

internal static bool inner(ж<node> p) { return p == nil; }
internal static bool passThrough(ж<node> p) { return inner(p); }
…
internal static void Main() {
    fmt.Println(onlyNilCheck(nil));
    fmt.Println(onlyNilCheck(Ꮡ(new node())));
    fmt.Println(passThrough(nil));
    fmt.Println(passThrough(Ꮡ(new node())));

passThrough forwards its pointer to inner, which only compares it with nil. Go’s main calls onlyNilCheck and passThrough each once with nil and once with &node{}. Go and C# both print nil, set, true, false. &node{} becomes Ꮡ(new node()): new node() builds a zero-value node, and Ꮡ(…) boxes it and takes its address. A source generator completes the partial struct. "nil" and "set" are plain C# literals, returned as Go strings (Strings).

golib has two forms of a nil pointer. default! gives a C# null. Converting golib’s nil to a pointer type gives one shared nil box for that type. Go sees no difference, and comparing with golib’s nil treats both as nil, so pointer comparisons use it.

So a nil pointer that is declared, assigned or returned stays default!: var nilNode *node becomes ж<node> nilNode = default!;. A nil passed to a pointer parameter stays golib’s nil, which hands the function the shared nil box. A returned nil error is an interface, so it is also default!.

A nil pointer stored in an interface keeps its type. In Go, any((*int)(nil)) is not nil, and %T prints *int. A C# null carries no type, so the converter stores the shared nil box instead. Converting nil to a pointer type, as in ((ж<nint>)nil), gives that box directly:

	// Bare any boxing of a typed nil keeps the type.
	var x any = (*int)(nil)
	fmt.Printf("%T\n", x)
	fmt.Println("x==nil", x == nil)
…
    any x = ((ж<nint>)nil);
    fmt.Printf("%T\n"u8, x);
    fmt.Println("x==nil", x == nil);

Go and C# both print *int, then x==nil false. "x==nil" is a plain C# literal: it is an any argument to fmt.Println, so go2cs-gen, go2cs’s source generators, intercepts the call and passes it as a boxed Go string (Empty Interface). The "%T\n"u8 format string is a C# UTF-8 literal (Strings).

A pointer variable holds a plain default!, which carries no type. So a pointer variable entering an interface passes through golib’s OrTypedNil(), which swaps a C# null for the shared nil box:

	var dp *int
…
	// var spec, then plain assignment
	var dpi any = dp
	fmt.Println("dpi==nil", dpi == nil, "dpi==x", dpi == x)
…
    ж<nint> dp = default;
…
    any dpi = dp.OrTypedNil();
    fmt.Println("dpi==nil", dpi == nil, "dpi==x", AreEqual(dpi, x));

Go and C# both print dpi==nil false dpi==x true. Here x is the interface from the previous example. An interface is a C# object, so dpi == default! asks whether it holds nothing at all. C#’s == between two objects compares only references, so two interface values are compared with golib’s AreEqual, which compares type and value as Go does (Interfaces).

A fixed-size array is constructed, and so is a struct that holds one. Go’s [N]T becomes golib’s array<T>, which keeps its length in the instance. So var a [5]int becomes array<nint> a = new(5);, and a struct field tbl [8]int gets the field initializer = new(8).

C#’s default runs no constructor and skips field initializers. So a struct holding an array becomes new(), which runs them. A struct of plain fields stays default!:

type holder struct {
	name string
	tbl  [8]int
	tail []int
}

type wrapper struct {
	id int
	h  holder
}

type point struct {
	x, y int
}

func main() {
…
	var z holder
	fmt.Println(len(z.name), len(z.tbl), len(z.tail)) // 0 8 0
…
	var w wrapper
…
	var p point
partial struct holder {
    internal @string name;
    internal array<nint> tbl = new(8);
    internal slice<nint> tail;
}

partial struct wrapper {
    internal nint id;
    internal holder h;
}

partial struct point {
    internal nint x, y;
}

internal static void Main() {
    holder z = new();
    fmt.Println(len(z.name), len(z.tbl), len(z.tail));
…
    wrapper w = new();
…
    point p = default;

wrapper is constructed too, because its holder field needs construction. A source generator completes each partial struct, including its constructors (Struct Types).

A struct that embeds another type is constructed with new(nil). The embed is an ordinary field named for its type, marked /*embed*/ (Struct Type Embedding). The generator gives every struct a constructor that takes golib’s nil and builds the zero value. That constructor builds each embedded field: a value embed by its own new T(nil), and a pointer embed as a nil ж<T> box rather than a C# null, so a promoted access through it panics as Go does. new(nil) calls it. Here Padded is a struct of plain fields, so it stays default!, and Embedded embeds it:

type Padded struct {
	flag  bool
	count int64
	tag   byte
	name  string
	data  []byte
	code  int32
}
…
type Embedded struct {
	lead byte
	Padded
	trail int16
}
…
	var p Padded
	var e Embedded
partial struct Padded {
    internal bool flag;
    internal int64 count;
    internal byte tag;
    internal @string name;
    internal slice<byte> data;
    internal int32 code;
}

partial struct Embedded {
    internal byte lead;
    /*embed*/ public Padded Padded;
    internal int16 trail;
}
…
    Padded p = default;
    Embedded e = new(nil);

A directional channel’s zero value keeps its direction. Go’s chan T, <-chan T and chan<- T all become golib’s channel<T>, which has no direction in its C# type. The /*<-*/ comment shows where Go’s arrow was, but a value carries no comment. So the nil value records the direction itself: channel<T>.RecvOnly is a nil receive-only channel, and channel<T>.SendOnly is a nil send-only one (Channels and select):

	var ar <-chan int
	var as chan<- int
    /*<-*/channel<nint> ar = /*<-*/channel<nint>.RecvOnly;
    channel/*<-*/<nint> @as = channel/*<-*/<nint>.SendOnly;

@as is as with C#’s @ prefix, because as is a C# keyword. A struct field of directional channel type gets the same value as its field initializer. A plain chan T stays default!.

Full detail: Reference → Nil and Zero Values — how each golib type treats its nil value, how a typed nil is kept at every point where a pointer enters an interface, and how reflection reads it.


Built-in Functions

Go’s built-in functions keep their Go names in C#. len, append, make and the rest are static methods of the builtin class in golib, the go2cs runtime library. Every converted project imports that class with a global using static go.builtin, so a call like len(x) reads the same in C# as it does in Go.

A few built-ins change shape, because C# needs them written differently. This table lists every built-in and where it is explained in more depth:

| Go | C# | More in | |—|—|—| | len(x), cap(x) | len(x), cap(x). Both return nint, which is how go2cs writes Go’s int. The len of a string counts bytes, as in Go | Slices and Arrays, Strings | | append(s, a, b) | append(s, a, b). Past capacity it grows the backing array by Go’s own growth rule. An append that Go keeps on the stack passes a 32-byte stack buffer first, append(xᴷ, x, v) | Slices and Arrays, ᴷ buffer | | append(s, t...), with t a slice | appendꓸꓸꓸ(s, t). The ꓸꓸꓸ glyph stands for Go’s .... golib takes a slice<T> operand as the slice it is, never boxed to an interface | Reading Converted Code, Slices and Arrays | | copy(dst, src) | copy(dst, src) | Slices and Arrays | | clear(x) | clear(x). It zeroes a slice’s elements and empties a map | Slices and Arrays, Maps | | make(T, …) | a constructor: new slice<T>(…), new map<K, V>() or new channel<T>(…) | Slices and Arrays, Maps, Channels and select | | new(T) | @new<T>(), which returns a ж<T> (read “zhe”): golib’s heap box for a Go pointer. The @ lets C# use new as a name | Pointers, Reading Converted Code | | delete(m, k) | delete(m, k) | Maps | | close(ch) | close(ch) | Channels and select | | min(…), max(…) | min(…), max(…) | — | | complex(r, i), real(c), imag(c) | the same calls, over complex64 or complex128 | Constant Values | | print(…), println(…) | the same calls. They write to standard error, as Go’s do | — | | panic(v) | throw panic(v). panic builds the exception, and throw tells C# that the code path ends here | Defer / Panic / Recover | | recover() | recover(). A named function that no other package can call writes recover(calledByDefer), and a function literal that is not itself the deferred call writes recoverChecked(), so each returns nil unless called directly by the deferred function, as in Go | Defer / Panic / Recover |

Most calls do not change. The arguments and their order stay as Go wrote them. Here a Go []byte literal becomes a C# array wrapped as a golib slice<byte> by .slice() (Slices and Arrays), and Go’s int becomes nint, C#’s native-size integer (Integer Types and Arithmetic). The converter sometimes writes the type Go infers, as in nint n; that declares the same variable var would:

x := []byte{1, 2, 3}
y := []byte{1, 2, 3, 4, 5}
n := min(len(x), len(y))
fmt.Println(n) // 3
var x = slice<byte>([1, 2, 3]);
var y = slice<byte>([1, 2, 3, 4, 5]);
nint n = min(len(x), len(y));
fmt.Println(n);

make becomes a constructor. A Go slice, map or channel is a golib type in C#: slice<T>, map<K, V> or channel<T>. So make becomes new on that type, and its size arguments pass through unchanged. An unbuffered channel gets an explicit capacity of 0:

Go C#
s := make([]int, 2, 4) var s = new slice<nint>(2, 4);
tbl = make(map[any]string) tbl = new map<any, @string>();
ch := make(chan int, 3) var ch = new channel<nint>(3);
u := make(chan string) var u = new channel<@string>(0);

@string is go2cs’s Go string (Strings). any is a global C# alias for object that stands for Go’s empty interface (Empty Interface).

Inside a generic function, make becomes golib’s make<S>(…). When the type being made is a type parameter, C# cannot call new with arguments on it. So the converter calls golib’s make, passing the type parameter. Here S is the slice type parameter, written S ~[]E in Go (Generics). The where lines tell C# what S and E can do; the long constraint for E is omitted:

func Scale[S ~[]E, E Integer](s S, c E) S {
	r := make(S, len(s))
	for i, v := range s {
		r[i] = v * c
	}
	return r
}
public static S Scale<S, E>(S s, E c)
    where S : /* ~[]E */ ISlice<E>, ISupportMake<S>, ISliceWrap<S, E>, new()
…
{
    var r = make<S>(len(s));
    foreach (var (i, v) in range(s)) {
        r[i] = v * c;
    }
    return r;
}

The for … range loop becomes a C# foreach over index and value (Loops, Range and Labels).

Spreading a slice into append uses appendꓸꓸꓸ. In Go, append(s, m...) adds every element of m. In C#, the call is named appendꓸꓸꓸ, and the whole slice m passes as one argument. The ꓸꓸꓸ glyph in a name stands for Go’s ... (Reading Converted Code). golib has an appendꓸꓸꓸ overload for each operand kind, and the one for a slice<T> takes m as the slice it is, never boxed to an interface. This complete function shows make and the spread together:

func named(s []int, n int) []int {
	m := make([]int, n)
	return append(s, m...)
}
internal static slice<nint> named(slice<nint> s, nint n) {
    var m = new slice<nint>(n);
    return appendꓸꓸꓸ(s, m);
}

Appending make(...)... directly uses makeꓸꓸꓸ. Go treats append(s, make([]T, n)...) as “grow s by n zero elements” and never builds the temporary slice. In C#, makeꓸꓸꓸ<T>(n) returns a small golib value that holds only the count. An appendꓸꓸꓸ overload takes that value and grows s by n zero elements, so nothing extra is allocated. Converted code writes this form; you never need to write it yourself:

func extend(s []int, n int) []int {
	return append(s, make([]int, n)...)
}
internal static slice<nint> extend(slice<nint> s, nint n) {
    return appendꓸꓸꓸ(s, makeꓸꓸꓸ<nint>(n));
}

new(T) returns a heap box. Go’s new(T) gives a pointer to a zeroed T. In C#, @new<T>() returns a ж<T> (read “zhe”): golib’s heap box, which stands in for a Go pointer (Pointers). Here Holder is a struct with a Name string field. Go reaches the field through the pointer automatically. C# reaches it through the box’s .Value (Implicit Pointer Dereferencing):

b := new(Holder)
b.Name = "a"
var b = @new<Holder>();
b.Name = "a";

The "a" is a plain C# literal, the form go2cs uses for a Go string literal in a string slot (Strings).

A method named like a built-in makes calls write builtin.<name>. All functions and methods of a Go package are members of one static C# class (Functions and Methods). C# looks in that class before it looks at using static go.builtin. So a method named len would hide the built-in len for the whole package, including in functions like freeLen that have nothing to do with the method’s type. The converter therefore writes builtin.len(…) for each real built-in call (detail):

type box struct{ items []int }
…
func (b *box) len() int { return len(b.items) + 1 }

func freeLen() int {
	s := []int{1, 2, 3}
	return len(s)
}
partial struct box {
    internal slice<nint> items;
}

internal static nint len(this ref box b) { return builtin.len(b.items) + 1; }

internal static nint freeLen() {
    var s = slice<nint>([1, 2, 3]);
    return builtin.len(s);
}

partial struct box is a converted Go type (Struct Types). In the method, this ref box b is its receiver: the method gets the box by reference, which is how go2cs writes a pointer receiver. The method call itself stays b.len().

A local that shadows a built-in needs no prefix. When Go code declares a local variable or function named like a built-in, such as make := func(…), Go calls the local too. So C# emits a plain call to the local, and no builtin. prefix is needed.

Full detail: Reference → Slices and Arrays — how make, append, copy and clear build, grow and zero elements, the spread forms and the append-of-make form, and their edge cases.


Empty Interface (any)

Go’s empty interface, interface{} or any, becomes C# any. In C#, any is an alias for object that every converted project declares, so the converted code keeps Go’s spelling. A value is boxed with its Go type wherever it enters an any. Type assertions, type switches and == then see the same dynamic type that Go sees.

A type assertion becomes ._<T>(). Go’s x.(T) becomes x._<T>(), a golib extension method on object. It returns the value when the dynamic type is T. Otherwise it panics with Go’s own interface conversion message. Go’s int is C# nint, the native-sized integer, so .(int) becomes ._<nint>() (Integer Types and Arithmetic):

var a any = 7
fmt.Println(a.(int))
any a = (nint)7;
fmt.Println(a._<nint>());

An untyped constant boxes at its Go default type. In Go, the 7 stored in an any is an int, so the converter writes (nint)7. A bare C# 7 would box as a 32-bit Int32, and the later .(int) would panic. The same rule lets a type switch pick case int: rather than case int32:.

A string literal boxes as a Go string. Go’s string is golib’s @string, a byte string; the @ only escapes the C# keyword. A literal stored in an any holds an @string, never a C# string, so x.(string) and case string: match it. Where the converter writes that value out inline, it is (@string)"hi": the cast turns the C# literal into a Go string, which golib converts once and shares. The next two paragraphs say where that form appears.

A literal stored in an any is also boxed only once when its words name it. The converter hoists it into one static readonly field. The field is declared just above the first function in the package that uses it, and its generated name ends in ˢ. So "hello" becomes the field helloˢ, initialized once from (@string)"hello"u8, a C# UTF-8 literal cast to a Go string, and an element of a composite literal reads its field too: []any{"keep"} reads keepˢ. A literal whose name would say less than its value, such as "hi", stays inline as (@string)"hi" and is boxed at each use. The generated comment gives the reason for hoisting. Go keeps string literals in read-only memory (RODATA) at no run-time cost, so the C# allocates each hoisted literal only once.

A hoisted literal whose every use in the package is an any is stored already boxed. Its field has type object. If any use needs a plain string, the field is a @string instead (Strings). Here "hello" feeds only var i interface{}, so helloˢ is an object. Outside the standard library, the literals passed to fmt.Println, whose parameter is ...any, are not hoisted. They stay plain C# literals, and go2cs-gen, go2cs’s source generators, intercepts each of those calls at compile time and passes the literal as a boxed @string:

func safeAssertions() {
	var i interface{} = "hello"

	// Type assertion with ok check
	if s, ok := i.(string); ok {
		fmt.Println("Value is a string:", s)
	} else {
		fmt.Println("Value is not a string")
	}

	// This will not panic, just set ok to false
	if n, ok := i.(int); ok {
		fmt.Println("Value is an int:", n)
	} else {
		fmt.Println("Value is not an int")
	}
}
// Hoisted @string literals (single allocation; Go keeps these in RODATA)
private static readonly object helloˢ = (@string)"hello"u8;

internal static void safeAssertions() {
    any i = helloˢ;

    if (i._<@string>(out var s)) {
        fmt.Println("Value is a string:", s);
    } else {
        fmt.Println("Value is not a string");
    }

    if (i._<nint>(out var n)) {
        fmt.Println("Value is an int:", n);
    } else {
        fmt.Println("Value is not an int");
    }
}

An if over a comma-ok assertion folds it into the condition. Both ifs here would otherwise declare an ok in one method, so each init becomes the condition itself: i._<@string>(out var s) is the overload of _<T> that writes the value to s and returns whether the assertion held, instead of panicking. When it fails, s holds the zero value, as Go’s comma-ok form gives (Shadowing). Elsewhere, Go’s v, ok := x.(T) passes ᐧ, golib’s constant true, whose value is ignored: the extra argument only selects the overload that returns a (value, ok) tuple (Multi-Result Values and Comma-Ok Forms).

== on interfaces compares dynamic type, then value. It becomes a call to golib’s AreEqual. Two values are equal only when both their Go types and their values match, as in Go (Interfaces). AreEqual appears unqualified because every converted project imports golib’s builtins with using static (Built-in Functions):

var x any = 42
fmt.Println(x == 42) // true
fmt.Println(x != 42) // false
any x = (nint)42;
fmt.Println(AreEqual(x, (nint)42));
fmt.Println(!AreEqual(x, (nint)42));

The right-hand 42 is also an untyped constant compared with an any. Go gives it its default type int, so it becomes (nint)42. As a bare Int32, it would never equal the nint inside x.

Like Go, comparing two interfaces that hold the same uncomparable type, such as two slices or two maps, panics at run time. A test against nil stays a plain x == default!. default! is C#’s null for object; the ! tells the compiler the null is intended (Nil and Zero Values).

A pointer keeps its box. Go stores the pointer itself in an interface, not a copy of the value it points to. A Go pointer *pp is golib’s ж<pp> (read “zhe”), a heap box (Pointers). This small program keeps a free list of any values, puts a *pp into it, and asserts it back out:

type pp struct {
	id  int
	buf []byte
}

var freeList []any

func poolPut(x any) { freeList = append(freeList, x) } // x is the EMPTY interface

func poolGet() any {
	if n := len(freeList); n > 0 {
		x := freeList[n-1]
		freeList = freeList[:n-1]
		return x
	}
	return new(pp)
}
…
func keep(q *pp) { poolPut(q) }
…
	c := &pp{id: 100}
	keep(c)                // poolPut(c): stores the *pp box
	got := poolGet().(*pp) // must be c (same pointer), not a copy
	got.id = 200
	fmt.Println(c.id) // 200 — proves got IS c
partial struct pp {
    internal nint id;
    internal slice<byte> buf;
}

internal static slice<any> freeList;

internal static void poolPut(any x) { freeList = append(freeList, x); }

internal static any poolGet() {
    nint n = len(freeList); if (n > 0) {
        var x = freeList[n - 1];
        freeList = freeList.slice(0, n - 1);
        return x;
    }
    return @new<pp>();
}
…
internal static void keep(ж<pp> q) { poolPut(q.OrTypedNil()); }
…
    var c = Ꮡ(new pp(id: 100));
    keep(c);
    var got = poolGet()._<ж<pp>>();
    got.id = 200;
    fmt.Println(c.id);

The Go struct pp becomes a C# partial struct (Struct Types). slice<T> is golib’s Go slice, and freeList.slice(0, n - 1) is Go’s freeList[:n-1] (Slices and Arrays). golib’s @new<pp>() is Go’s new(pp): it returns a box holding a zero pp.

Ꮡ has two uses here. As a call, Ꮡ(…) is Go’s &, so Ꮡ(new pp(id: 100)) is &pp{id: 100}. As a name prefix, it marks a pointer parameter, so q becomes Ꮡq. A pointer parameter that arrives as a box takes the prefix. That leaves the plain Go name free for the pointed-to value when a body needs it (Implicit Pointer Dereferencing). (A small private helper that only dereferences its pointer can take ref T instead; see Pointers.) A local such as c keeps its plain name.

keep passes the box itself into the any. So asserting it back with ._<ж<pp>>() returns the same pointer, and writing got.id changes c.id. OrTypedNil() keeps a nil *pp typed inside the interface, as Go does (detail).

~ reads through a pointer, .Value writes through it. ~c returns a copy of the value c points to, like Go’s *c. got.Value is a ref to the real storage, so got.Value.id = 200 writes into the shared pp (Implicit Pointer Dereferencing).

The glyph table lists every mark used here.

Full detail: Reference → Empty Interface (any) — where string literals and untyped constants are boxed, and when comparing two interfaces panics.


Multi-Assignment and Evaluation Order

Go’s parallel assignment, a, b = x, y, becomes a C# tuple deconstruction, (a, b) = (x, y). Both languages read every right-hand value before they store any, so the converted line behaves like the Go line and reads like it.

In the examples, Go’s int appears as C# nint, a native-sized integer (Integer Types and Arithmetic).

Reassigning several variables is one tuple. Every value on the right is read first, and only then are the targets written. That is why a swap needs no temporary in either language. Here left and right are package-level variables.

	left, right = right, left
	fmt.Println(left, right) // 20 10
…
var left = 10
var right = 20
    (left, right) = (right, left);
    fmt.Println(left, right);
…
internal static nint left = 10;
internal static nint right = 20;

Fields, elements and pointer targets join the tuple too. Written as separate stores, a swap would lose a value: the second store would read what the first one just wrote. So a struct field, a slice element or a target reached through a pointer is assigned in the same tuple as a plain variable. Here e is a pointer to an edge, while f and g are plain edge values.

	e := &edge{out: 7, arg: 9}
	e.out, e.arg = e.arg, e.out
	fmt.Println(e.out, e.arg) // 9 7   (not 9 9)

	// Field swap on a VALUE struct, plus a cross-struct rotate reading pre-assignment values.
	f := edge{out: 1, arg: 2}
	g := edge{out: 3, arg: 4}
	f.out, g.out, f.arg = g.out, f.arg, f.out
	fmt.Println(f.out, f.arg, g.out, g.arg) // 3 1 2 4
…
type edge struct {
	out int
	arg int
}
    var e = Ꮡ(new edge(@out: 7, arg: 9));
    (e.@out, e.arg) = (e.arg, e.@out);
    fmt.Println(e.@out, e.arg);

    var f = new edge(@out: 1, arg: 2);
    var g = new edge(@out: 3, arg: 4);
    (f.@out, g.@out, f.arg) = (g.@out, f.arg, f.@out);
    fmt.Println(f.@out, f.arg, g.@out, g.arg);
…
partial struct edge {
    internal nint @out;
    internal nint arg;
}

A few things in the C# come from other sections:

A := that reuses a name declares only the new ones. Go’s c, d := two(c) assigns the existing c and declares a new d. The converter puts a declaration (var, or an explicit type such as nint) only in front of each new element. So one tuple both assigns c and declares d.

func two(x int) (int, int) {
	return x, x * 10
}
…
func redeclareLocal() int {
	c := 5
	c, d := two(c) // c reused (a local), d new
	return c + d
}
internal static (nint, nint) two(nint x) {
    return (x, x * 10);
}
…
internal static nint redeclareLocal() {
    nint c = 5;
    (c, var d) = two(c);
    return c + d;
}

A Go function with several results returns a C# tuple, so its call deconstructs directly (Multi-Result Values and Comma-Ok Forms). A := in an inner block that reuses an outer name makes a new variable instead; that case is covered in Short Variable Redeclaration (Shadowing).

A := becomes a tuple when a later value re-reads an earlier target. In x, frac := x>>3, x&7, Go computes frac from the old x. Two separate C# statements would compute it from the new x. So the converter keeps the statement as one tuple. When no later value re-reads an earlier target, as with m, n here, the converter writes plain separate statements, which is what most := lines look like.

	x := uint64(0b1011010) // 90
	x, frac := x>>3, x&7
	fmt.Println(x, frac) // 11 2  (2 = 90&7, NOT 3 = 11&7)
…
	m := 5
	m, n := m+1, 100
	fmt.Println(m, n) // 6 100
    var x = (uint64)0b1011010;
    (x, var frac) = (x >> 3, x & 7);
    fmt.Println(x, frac);
…
    nint m = 5;
    m = m + 1;
    nint n = 100;
    fmt.Println(m, n);

uint64 is the go2cs alias for C#’s ulong, so (uint64) is an ordinary C# cast. The constant shift count 3 stays a bare literal, as in Go (Integer Types and Arithmetic).

In a tuple of separate values, a new Go int is declared as nint, not var. In a mixed tuple, var would infer a 32-bit C# int from a plain literal such as 100, not Go’s native-sized nint. So the converter always writes the explicit type for a new int (or uint) element there. When the right side is one call, as in (c, var d) = two(c) above, the call’s result type already fixes each element, so var is safe there. This example also re-reads a and b on the right, so it stays one tuple for the same reason as x, frac.

	a, b := 10, 20
	a, b, c := b, a, a+b
	fmt.Println(a, b, c) // 20 10 30
    nint a = 10;
    nint b = 20;
    (a, b, nint c) = (b, a, a + b);
    fmt.Println(a, b, c);

A multi-value return runs its calls before it reads plain operands. Go’s spec runs the calls in a return left to right, but leaves open when a plain operand such as c.n is read. The standard Go compiler, gc, always runs the calls first and reads the plain operands after. go2cs matches gc, because that is what Go programs observe.

A C# tuple reads strictly left to right. So when a call can change an earlier operand, the converter first moves the call into a temporary. The temporary is a local whose name starts with ᴛ, the glyph go2cs uses for names it makes up (Reading Converted Code). Its number is only a counter that keeps names unique in the file; it has no other meaning.

Here bump has a pointer receiver, so it changes the caller’s c.

type counter struct {
	n int
}

func (c *counter) bump() int {
	c.n++
	return c.n
}

// readThenBump reads a FIELD of the value the later call mutates.
func readThenBump() (int, int) {
	var c counter
	return c.n, c.bump()
}
partial struct counter {
    internal nint n;
}

internal static nint bump(this ref counter c) {
    c.n++;
    return c.n;
}

internal static (nint, nint) readThenBump() {
    counter c = default;
    var ᴛ2 = c.bump();
    return (c.n, ᴛ2);
}

Go’s readThenBump returns 1 1. Without the temporary, return (c.n, c.bump()) would copy c.n while it is still 0 and return 0 1. The same shape in a real parser would return an empty result beside a nil error.

In the C#, a pointer-receiver method becomes an extension method on ref counter; the ref is what lets bump change the caller’s c (Functions and Methods). default! is C#’s default value, here Go’s zero counter. The ! matters only for reference types, where it tells the compiler a null is intended; the converter writes it everywhere (Nil and Zero Values).

Most returns stay one tuple, as written in Go. The converter moves a call only when the return statement itself shows that the call can reach an earlier operand: through a pointer-receiver method on it, an &x argument, or a pointer the operand is read through. A call that changes the operand some other way, such as through a package-level variable or a variable captured by a closure, is not detected. When the call works on something else, as here, nothing moves.

// unrelatedOperand's call touches a different variable entirely.
func unrelatedOperand() (int, int) {
	var a counter
	var b counter
	return a.n, b.bump()
}
internal static (nint, nint) unrelatedOperand() {
    counter a = default;
    counter b = default;
    return (a.n, b.bump());
}

A method with a value receiver never moves either: it works on a copy, so the caller cannot see any change.

Full detail: Reference → Multi-Assignment and Evaluation Order — the full rules for which targets make a tuple, the edge cases of a mixed :=, and exactly when a return moves its calls into temporaries.


Short Variable Redeclaration (Shadowing)

Go’s short variable declaration := can reuse a name in two ways. In the same scope, it reuses the existing variable and declares only the new names. In a nested block, it declares a new variable that “shadows” the outer one. Only the second case needs a new name in C#.

Converted Go functions are static methods of the package’s C# class, such as main_package (see Package Conversion). In the C# examples, nint is Go’s int (see Integer Types and Arithmetic).

Reusing a variable in the same scope needs no rename. In Go, c, d := two(c) assigns the existing c and declares only d. The C# tuple assignment writes var on the new element alone, so c stays the same variable.

func two(x int) (int, int) {
	return x, x * 10
}
…
func redeclareLocal() int {
	c := 5
	c, d := two(c) // c reused (a local), d new
	return c + d
}
internal static (nint, nint) two(nint x) {
    return (x, x * 10);
}
…
internal static nint redeclareLocal() {
    nint c = 5;
    (c, var d) = two(c);
    return c + d;
}

Go’s multiple results become a C# tuple (see Multi-Result Values and Comma-Ok Forms).

A variable that shadows one in an enclosing block is renamed. In Go, the inner variable hides the outer one until its block ends, and the outer variable keeps its own value. C# does not allow this. A C# local’s scope covers its whole block, so a nested local may not reuse the name of any local in an enclosing block.

So the converter renames the inner (shadowing) variable. It appends Δ and a number: z becomes zΔ1. The outer variable keeps its Go name. Every use inside the inner scope is rewritten to the new name, so the two variables behave exactly as they do in Go. The Δ glyph is listed in Reading Converted Code: Names and Glyphs.

A nested variable is renamed even when the outer one comes later. In this function the nested var z appears first in the source, and the function-level z := x * x comes after it. Go accepts this. C# checks the whole block regardless of order, so the nested z becomes zΔ1.

func f(x int) int {
	if x >= 2 {
		var z int // nested `var` decl; collides with the later function-level z
		if x > 5 {
			z = 100
		} else {
			z = 200
		}
		return z
	}
	z := x * x // function-level, declared textually AFTER the nested `var z`
	return z
}
internal static nint f(nint x) {
    if (x >= 2) {
        nint zΔ1 = default;
        if (x > 5) {
            zΔ1 = 100;
        } else {
            zΔ1 = 200;
        }
        return zΔ1;
    }
    nint z = x * x;
    return z;
}

The Go var z int starts at its zero value. C# writes that as default!: the type’s default value, with ! telling the compiler a null is intended. The converter writes it for every type, including nint (see Nil and Zero Values).

Each further shadow of the same name takes the next number. The numbers count the renames of one name within one function, in source order. Here v and ok are declared three times: at function level, in an if statement’s init, and in the for loop body. The function-level pair keeps its names. The if pair comes first in the source and becomes vΔ1, okΔ1. The loop-body pair becomes vΔ2, okΔ2.

func forwardOk() int {
	m := map[string]int{"a": 1, "b": 2, "c": 3}
	total := 0
	v, ok := m["a"]
	if ok {
		total += v
	}
	for n := 0; n < 2; n++ {
		if v, ok := m["b"]; ok {
			total += v
			continue
		}
		v, ok := m["c"]
		if !ok {
			break
		}
		total += v
	}
	return total
}
internal static nint forwardOk() {
    var m = new map<@string, nint>{["a"] = 1, ["b"] = 2, ["c"] = 3};
    nint total = 0;
    var (v, ok) = m["a", ꟷ];
    if (ok) {
        total += v;
    }
    for (nint n = 0; n < 2; n++) {
        var (vΔ1, okΔ1) = m["b", ꟷ]; if (okΔ1) {
            total += vΔ1;
            continue;
        }
        var (vΔ2, okΔ2) = m["c", ꟷ];
        if (!okΔ2) {
            break;
        }
        total += vΔ2;
    }
    return total;
}

A few other things in this example have their own sections:

A local named like a built-in the function calls is renamed, and the call keeps its name. Go’s built-ins, such as len and cap, are static methods of the golib builtin class. Converted code imports that class with using static, so the calls read as in Go (see Built-in Functions). A C# local named cap would hide the cap method, so the local becomes capΔ1.

func capPlusOne(s []int) int {
	cap := cap(s)
	return cap + 1
}
internal static nint capPlusOne(slice<nint> s) {
    nint capΔ1 = cap(s);
    return capΔ1 + 1;
}

Here slice<nint> is golib’s Go slice, []int (see Slices and Arrays).

A local that shadows a package-level variable is renamed, and earlier reads of the global name the package class. Package-level variables become static fields of the package’s C# class, here main_package (see Package Conversion). In Go, a local’s scope starts after its declaration, so the first line of this function still reads the global plainCounter. In C#, a bare plainCounter would bind to the local for the whole method.

var plainCounter = 100 // a plain global (no collision rename)
…
func plainGlobalShadow() int {
	x := plainCounter * 2 // reads GLOBAL plainCounter (200) before the local decl
	plainCounter := 5     // local shadows the global
	return x + plainCounter // 200 + 5 = 205
}
partial class main_package {
…
internal static nint plainCounter = 100;

internal static partial nint plainGlobalShadow() {
    nint x = main_package.plainCounter * 2;
    nint plainCounterΔ1 = 5;
    return x + plainCounterΔ1;
}
…
} // end main_package

The read of the global is written main_package.plainCounter, which no local can hide. The converter also renames the local to plainCounterΔ1, so the global and the local never share a C# name inside the function. The function returns 205, as in Go. The method is partial because the Go source, elided here, marks it //go:noinline; that mark has nothing to do with shadowing (Source Generators). The Go comment’s “collision rename” is a different rule, for a package-level name that clashes with a method name; it does not apply here.

An if or switch init statement is written as Go writes it, wherever that cannot change a name. Go scopes if n := next(); … to the if, and C# has no init clause. When the names the init declares occur nowhere else in the enclosing block, and the statement holds no closure, the init is simply a statement before the if, on the same line. A comma-ok init that collides only through its ok folds into the condition instead, which declares no ok: an assertion becomes x._<T>(out var v) and a map lookup becomes a pattern, m[k, ꟷ] is (var v, true). An else-if’s own init folds the same way. Any other init keeps a block of its own ({ … }) around the declaration and its statement, so its names stay scoped as in Go.

func classify(x any) string {
	if s, ok := x.(Shape); ok {
		return fmt.Sprint("area ", s.Area())
	}
	if l, ok := x.(Named); ok {
		return "name " + l.Name()
	}
internal static @string classify(any x) {
    if (x._<Shape>(out var s)) {
        return fmt.Sprint("area ", s.Area());
    }
    if (x._<Named>(out var l)) {
        return "name "u8 + l.Name();
    }

Full detail: Reference → Short Variable Redeclaration (Shadowing) — more shadowing cases, including package names, constants, closures and names that clash with methods.


Multi-Result Values and Comma-Ok Forms

A Go function can return several values. A C# method returns one, so the converted function returns a C# value tuple, and the caller takes it apart with a deconstruction: var (a, b) = f();. Go’s comma-ok forms, such as v, ok := m[k], call a second golib overload that returns a (value, ok) tuple.

In the examples, nint is Go’s int (Integer Types) and @string is Go’s string (Strings). default! is C#’s default value, which golib uses for Go’s zero value and for nil (Nil and Zero Values).

A multi-result function returns a value tuple. return a, b becomes return (a, b);, and a, b := f() becomes var (a, b) = f();. The results here are named _, Go’s blank identifier, so the C# tuple elements stay unnamed:

func match(x, y int) (_, _ int) {
	if x > y {
		return y, x
	}
	return x, y
}
…
	a, b := match(5, 2)
	fmt.Println(a, b) // 2 5
internal static (nint, nint) match(nint x, nint y) {
    if (x > y) {
        return (y, x);
    }
    return (x, y);
}
…
    var (a, b) = match(5, 2);
    fmt.Println(a, b);

Named results keep their names. The C# tuple names its elements as Go does. When the body uses a named result, or has a bare return, that result is also a local, declared at the top of the function with its zero value, default!. A result that is named only for documentation keeps its name on the tuple type and gets no local. A bare return returns the locals’ current values. Here is the whole of io.ReadAtLeast:

func ReadAtLeast(r Reader, buf []byte, min int) (n int, err error) {
	if len(buf) < min {
		return 0, ErrShortBuffer
	}
	for n < min && err == nil {
		var nn int
		nn, err = r.Read(buf[n:])
		n += nn
	}
	if n >= min {
		err = nil
	} else if n > 0 && err == EOF {
		err = ErrUnexpectedEOF
	}
	return
}
public static (nint n, error err) ReadAtLeast(Reader r, slice<byte> buf, nint min) {
    nint n = default;
    error err = default;

    if (len(buf) < min) {
        return (0, ErrShortBuffer);
    }
    while (n < min && err == nil) {
        nint nn = default;
        (nn, err) = r.Read(buf.slice(n));
        n += nn;
    }
    if (n >= min) {
        err = default;
    } else if (n > 0 && AreEqual(err, EOF)) {
        err = ErrUnexpectedEOF;
    }
    return (n, err);
}

nn, err = r.Read(…) assigns to variables that already exist, so its deconstruction (nn, err) = … has no var. The converter writes else if across two lines; it is an ordinary else if.

The other names come from other sections. slice<byte> is Go’s []byte, and buf.slice(n) is Go’s buf[n:]: a bound that is not a constant goes to golib’s .slice method (Slices and Arrays). err == default! is Go’s err == nil. Comparing two interface values, as in err == EOF, goes through golib’s AreEqual, which follows Go’s == rules (Interfaces).

When := declares only some of its names, only the new names get var. Go’s := may reuse a variable that already exists, as long as at least one name is new (Short Variable Redeclaration). C# allows a deconstruction to mix new and existing variables, so rbr2, err := … becomes (var rbr2, err) = …;:

func makeCursor(pos int, tag string) (cursor, error) {
	return cursor{pos: pos, tag: tag}, nil
}
…
func streams() (int, int) {
	rbr1, err := makeCursor(10, "one")
	if err != nil {
		return 0, 0
	}
	rbr2, err := makeCursor(20, "two")
	if err != nil {
		return 0, 0
	}
…
internal static (cursor, error) makeCursor(nint pos, @string tag) {
    return (new cursor(pos: pos, tag: tag), default);
}
…
internal static (nint, nint) streams() {
    var (rbr1, err) = makeCursor(10, "one");
    if (err != nil) {
        return (0, 0);
    }
    (var rbr2, err) = makeCursor(20, "two");
    if (err != nil) {
        return (0, 0);
    }
…

"one" and "two" are passed to string parameters, so they are plain C# literals that golib converts once (Strings).

A multi-value return keeps Go’s order. gc, the standard Go compiler, runs the calls in a return first and reads the plain values beside them afterwards, an order Go’s spec leaves open. A C# tuple evaluates left to right instead. So when a call could change a value beside it, the converter runs the call first, into a temporary. A name starting with ᴛ is a temporary the converter makes up (Names and Glyphs):

func addressArgument() (int, int) {
	n := 0
	return n, raise(&n)
}

func raise(n *int) int {
	*n += 10
	return *n
}
internal static (nint, nint) addressArgument() {
    nint n = 0;
    var ᴛ5 = raise(ref n);
    return (n, ᴛ5);
}

internal static nint raise(ref nint n) {
    n += 10;
    return n;
}

Both results are 10, as in Go. Without the temporary, C# would read n before raise changes it, and return 0 and 10. Here the pointer parameter *int becomes ref nint, and &n becomes ref n (Pointers). The numbers after ᴛ only keep the names unique and carry no meaning.

A multi-result call passed straight to another call goes through temporaries. Go lets f(g()) pass all of g’s results as f’s arguments. C# cannot pass one tuple as several arguments, so the converter deconstructs g() into ᴛ temporaries first:

func parts() (int, int) {
	return 3, 4
}
…
func combine(a, b int) int {
	return a*10 + b
}
…
	s := combine(parts())
internal static (nint, nint) parts() {
    return (3, 4);
}
…
internal static nint combine(nint a, nint b) {
    return a * 10 + b;
}
…
    var (ᴛ3, ᴛ4) = parts();
    nint s = combine(ᴛ3, ᴛ4);

A comma-ok form calls a second overload. In Go, a map read, a channel receive and a type assertion give one value, or a value and a bool when assigned to two variables. C# cannot overload on return type alone, so golib adds an overload with one extra bool argument that returns (value, ok).

The extra argument’s value is never read; it only makes C# pick the comma-ok overload. Map reads and channel receives pass ꟷ, golib’s constant false. Type assertions pass ᐧ, golib’s constant true. The two spellings are a convention and behave the same.

Go operation Single value Comma-ok
Map read m[k] m[k] m[k, ꟷ]
Channel receive <-ch ᐸꟷ(ch) ᐸꟷ(ch, ꟷ)
Type assertion x.(T) x._<T>() x._<T>(ᐧ)

A failed single-value assertion panics as in Go, and recover can catch it (Defer / Panic / Recover). A failed comma-ok form returns the zero value and false instead.

Map read: ok reports whether the key is present, and an absent key gives the zero value. This whole program shows each way to use the result:

func main() {
	m := map[string]int{"a": 1, "b": 2}

	// value + ok, present.
	v, ok := m["a"]
	fmt.Println(v, ok) // 1 true

	// value + ok, absent (value is the zero value).
	v2, ok2 := m["z"]
	fmt.Println(v2, ok2) // 0 false

	// blank value, present.
	_, ok3 := m["b"]
	fmt.Println(ok3) // true

	// if-init comma-ok with a blank value.
	if _, ok4 := m["z"]; !ok4 {
		fmt.Println("z absent") // printed
	}

	// reassignment (not a new declaration) into existing variables.
	var w int
	var present bool
	w, present = m["b"]
	fmt.Println(w, present) // 2 true
}
internal static void Main() {
    var m = new map<@string, nint>{["a"] = 1, ["b"] = 2};

    var (v, ok) = m["a", ꟷ];
    fmt.Println(v, ok);

    var (v2, ok2) = m["z", ꟷ];
    fmt.Println(v2, ok2);

    var (_, ok3) = m["b", ꟷ];
    fmt.Println(ok3);

    var (_, ok4) = m["z", ꟷ]; if (!ok4) {
        fmt.Println("z absent");
    }

    nint w = default;
    bool present = default;
    (w, present) = m["b", ꟷ];
    fmt.Println(w, present);
}

map<K, V> is golib’s map (Maps). "a" is a plain C# literal, which golib converts to an @string once. "z absent" is a plain literal too: it is passed as an any value, so go2cs-gen intercepts the call and passes it as a boxed Go string (Strings). A blank value stays _, and an assignment to existing variables has no var.

Channel receive: ok is false once the channel is closed and empty. channel<T> is golib’s channel. Sending is a method on the channel, d.ᐸꟷ(7). Receiving is a function, ᐸꟷ(d). ᐸꟷ is drawn to look like Go’s <-; the ꟷ inside the name is part of the name, not the comma-ok argument (Channels and select). This prints 7 true, 8 true, then 0 false:

	d := make(chan int, 2)
	d <- 7
	d <- 8
	close(d)
	v, ok := <-d
	fmt.Println(v, ok)
	v, ok = <-d
	fmt.Println(v, ok)
	v, ok = <-d
	fmt.Println(v, ok)
    var d = new channel<nint>(2);
    d.ᐸꟷ(7);
    d.ᐸꟷ(8);
    close(d);
    var (v, ok) = ᐸꟷ(d, ꟷ);
    fmt.Println(v, ok);
    (v, ok) = ᐸꟷ(d, ꟷ);
    fmt.Println(v, ok);
    (v, ok) = ᐸꟷ(d, ꟷ);
    fmt.Println(v, ok);

Type assertion: ok reports whether the interface holds the asserted type. x.(T) becomes golib’s x._<T>(), and the extra ᐧ picks the comma-ok form. any is Go’s empty interface, an alias for C#’s object (Empty Interface). helloˢ is the literal "hello", stored once in a static readonly field typed object, because it is used only as an any value, so the field holds it already boxed (Strings):

func safeAssertions() {
	var i interface{} = "hello"

	// Type assertion with ok check
	if s, ok := i.(string); ok {
		fmt.Println("Value is a string:", s)
	} else {
		fmt.Println("Value is not a string")
	}

	// This will not panic, just set ok to false
	if n, ok := i.(int); ok {
		fmt.Println("Value is an int:", n)
	} else {
		fmt.Println("Value is not an int")
	}
}
private static readonly object helloˢ = (@string)"hello"u8;

internal static void safeAssertions() {
    any i = helloˢ;

    if (i._<@string>(out var s)) {
        fmt.Println("Value is a string:", s);
    } else {
        fmt.Println("Value is not a string");
    }

    if (i._<nint>(out var n)) {
        fmt.Println("Value is an int:", n);
    } else {
        fmt.Println("Value is not an int");
    }
}

An if with a comma-ok initializer keeps Go’s scope. Go scopes the variables of if s, ok := …; ok to that if and its else. The converter wraps the declaration and the if in a C# { … } block, so each ok in safeAssertions is a separate variable, as in Go. The map example’s ok4 gets the same block.

Full detail: Reference → Multi-Result Values and Comma-Ok Forms — how a type assertion finds its target at run time, var a, b = f() at package level and in grouped declarations, exactly when a named result is declared, and how arguments reach variadic parameters.


Slices and Arrays

Go slices become golib slice<T>, and Go arrays become array<T>. Golib is go2cs’s hand-written C# runtime library. Both types are structs over a shared C# T[] backing array. A slice adds a start, a length and a capacity. An array has a fixed length.

Indexing, len, cap, append and copy keep their Go names and read as they do in Go (Built-in Functions). A range loop becomes a foreach over (index, value) pairs; an index-only for i := range s becomes foreach (var i in range(s)), which counts the indexes and reads no element (Loops, Range and Labels).

Go’s type names stay readable too. Go’s int appears as C# nint, a native-sized integer. Sized names such as uint32 and int64 keep their Go spelling: each converted project declares them as aliases of the C# types. uintptr is golib’s own uintptr struct (Integer Types and Arithmetic).

Literals and make. A slice literal is golib’s slice<T> builtin over a C# collection expression, an array literal a plain C# array turned into a Go value with .array(), and make calls a constructor. So []uint32{7, 8, 9} becomes slice<uint32>([7, 8, 9]), [6]int{0, 10, 20, 30, 40, 50} becomes new nint[]{0, 10, 20, 30, 40, 50}.array(), and make([]int, 3, 10) becomes new slice<nint>(3, 10). Where C# already knows the slice type, such as a package var, an assignment, a return or a field, the literal is the bare collection expression: var names = []string{"stdin", "stdout"} becomes internal static slice<@string> names = ["stdin", "stdout"]; (A slice literal is a collection expression where C# knows its target).

.slice(…) is one golib method with optional bounds. With no arguments it turns a C# array into a slice<T>. With bounds it is a Go slice expression, shown in the bound rules below.

A sub-slice shares its backing array. s[2:5] becomes the C# range s[2..5], and s[2:] becomes s[2..]. A bound that is not a constant calls golib’s .slice method instead (see A bound that is not a constant calls .slice below). A write through either slice shows through the other, as in Go. In this example base is a C# keyword, so the variable appears as @base (Names and Glyphs).

base := make([]uint32, 6)
d := base[2:5]
copy(d, []uint32{7, 8, 9})
fmt.Println(base, d, len(d), cap(d))

// element writes flow both directions between base and sub-slice
d[0] = 42
base[3] = 43
fmt.Println(base[2], d[1])
var @base = new slice<uint32>(6);
var d = @base[2..5];
copy(d, slice<uint32>([7, 8, 9]));
fmt.Println(@base, d, len(d), cap(d));

d[0] = 42;
@base[3] = 43;
fmt.Println(@base[2], d[1]);

d starts at base[2], so d[0] and base[2] are the same element. The second Println prints 42 43 in both languages.

append shares while capacity allows. Within capacity, append writes into the shared backing array. Past capacity, it allocates a new array and the result detaches, as in Go.

// append within capacity writes the shared backing in place
w := base[2:4]
x := append(w, 500)
x[0] = 501
fmt.Println(base, len(x), cap(x))

// append beyond capacity reallocates and detaches
y := append(x, 1, 2, 3)
y[0] = 999
fmt.Println(base[2], y[0], len(y), cap(y))
internal static partial void run() {
    var wᴷ = new stackAppend<uint32>();
    var xᴷ = new stackAppend<uint32>();
…
    var w = @base[2..4];
    var x = append(wᴷ, w, (uint32)500);
    x[0] = 501;
    fmt.Println(@base, len(x), cap(x));

    var y = append(xᴷ, x, (uint32)1, (uint32)2, (uint32)3);
    y[0] = 999;
    fmt.Println(@base[2], y[0], len(y), cap(y));

w has length 2 and room for two more, so x still shares base, and x[0] = 501 changes base[2]. y needs more room than x has, so y[0] = 999 leaves base[2] at 501. wᴷ and xᴷ are stack buffers (see An append Go keeps on the stack gets a stack buffer below). Neither is taken here, because w and x are not empty, so both calls are ordinary appends. run is partial because the Go function is marked //go:noinline (A partial method with a body keeps its own frame).

The untyped constants passed to append are cast to the element type, here uint32 (Constant Values). append is a generic C# method, and a bare 500 would read as a C# int there. A plain assignment such as x[0] = 501 needs no cast, because C# converts the constant.

A grown slice gets Go’s capacity. cap() after an append is observable, so when an append must grow the slice, golib computes the new capacity as Go 1.26’s runtime.growslice does: the same growth step and the same rounding up to the allocator’s size classes, for every element type (append grows a slice exactly as Go’s growslice does).

An append Go keeps on the stack gets a stack buffer. Go gives an append whose result does not escape a 32-byte backing store in the frame of the function that appends. At the variable’s first such append, an empty slice that must grow, and whose new length fits in 32 bytes, takes that buffer as its backing array. Which appends qualify is the Go compiler’s decision, so the converter reads it from the compiler’s own escape-analysis output. The function then declares one golib stackAppend<T> per such variable at its entry, named after the variable with the suffix ᴷ, and passes it as append’s first argument, as wᴷ and xᴷ above. golib’s append for this form is a small inlined head that only checks whether the append may take the buffer; the take itself, appendBuffered, is never inlined, so the first buffer does not stay reachable after the slice outgrows it. Any other append goes on as an ordinary append.

A buffered slice that escapes in one statement moves to the heap there. Go 1.26 also gives the buffer to a variable whose only escape is one statement outside its loops: a return of it, an assignment of it, or a range over it. The compiler inserts s = move2heap(s) ahead of that statement, and the converter mirrors it with s = move2heap(sᴷ, s);, the buffer declared new stackAppend<T>(moves: true). golib’s move2heap copies a slice still in the buffer to the heap, and returns a slice that already grew out of it as it is. Every variable that escapes in the statement is moved, one line each:

func movedPair(n int) ([]byte, []int16) {
	var a []byte
	var w []int16
	for i := range n {
		a = append(a, byte(i))
		w = append(w, int16(i))
	}
	return a, w
}
internal static partial (slice<byte>, slice<int16>) movedPair(nint n) {
    var aᴷ = new stackAppend<byte>(moves: true);
    var wᴷ = new stackAppend<int16>(moves: true);

    slice<byte> a = default;
    slice<int16> w = default;
    foreach (var i in range(n)) {
        a = append(aᴷ, a, (byte)i);
        w = append(wᴷ, w, (int16)i);
    }
    a = move2heap(aᴷ, a);
    w = move2heap(wᴷ, w);
    return (a, w);
}

for i := range n over an int becomes foreach (var i in range(n)). Each slice moves to the size class of its own length, so Go and C# both print the capacities 8 4 for movedPair(3). The method is partial because the Go function is marked //go:noinline, and a generator adds the never-inline attribute (Reference → Slices and Arrays covers which appends qualify, the observed-capacity form and the conversion-time cost).

A bound that is not a constant calls .slice. A C# range takes an int, which would narrow a Go int (nint) bound before Go’s bounds check could see it. So a range is kept only when every bound is a constant that fits in an int, as in @base[2..5]. Any other bound goes to golib’s .slice method as a nint: trim = trim[:len(trim)-1] becomes trim = trim.slice(0, len(trim) - 1);. An omitted low bound is written as the 0 it means.

A three-index slice calls .slice(low, high, max). A C# range has no capacity bound, so h := base[1:3:4] becomes var h = @base.slice(1, 3, 4);. Go requires the high and max bounds, and an omitted low bound is written as the 0 it means, so arr[:n:n] starts at 0. .slice takes Go’s int (nint), so an int bound passes as is. Here n is a uintptr, so it is cast to nint: arr.slice(0, (nint)(n), (nint)(n)).

A nil slice is the default value. var zero []byte becomes slice<byte> zero = default;, and zero == nil stays zero == nil, which binds slice<T>’s operator ==(slice<T>, NilType). default is C#’s default value for the type. For slice<T> that is a slice with no backing array, which is Go’s nil.

An empty literal such as []byte{} becomes slice<byte>([]). It has a backing array, a shared empty one, so it is not nil, as in Go, and allocates nothing. See Nil and Zero Values.

Arrays are values. array<T> is a struct over a shared T[], so a plain C# copy would share the elements. The converter adds .Clone() wherever Go copies an array: assignment, parameters, results and the other places Go copies a value. So d := garr becomes var d = garr.Clone();.

An array parameter is cloned on entry, so the callee’s writes stay local, as in Go:

// Arrays are passed by value (a full copy)
func test(a [2]string) {
	// Update to array will be local
	fmt.Println(a[0], a[1])
	a[0] = "Goodbye"
	fmt.Println(a[0], a[1])
}
internal static void test(/*[2]*/ array<@string> a) {
    a = a.Clone();

    fmt.Println(a[0], a[1]);
    a[0] = "Goodbye";
    fmt.Println(a[0], a[1]);
}

The comment /*[2]*/ records the Go array length. An array<T> value carries its length, but the C# type does not, so go2cs-gen reads the comment and records the length for Reflection. @string is Go’s string (Strings).

Slicing an array does not copy it. test3(a[:]) becomes test3(a[..]), and a write through that slice reaches a, as in Go.

A range over an array with a value variable iterates a copy, as in Go: for i, v := range a becomes foreach (var (i, v) in range(a.ΔRangeSnapshot())), so a write to a inside the loop does not change later values of v (Loops, Range and Labels).

A struct with array fields copies them too. In Go, copying a struct copies its array fields. Such a struct gets a ΔClone() method, and a struct copy calls it: c := d becomes var c = d.ΔClone();. A source generator writes it. The Δ prefix avoids a clash with a Clone method the Go type may declare (Struct Types). One case differs from Go: an array reached only through an embedded struct is not cloned, so a copy of the outer struct shares it (Limitations).

Converting a slice to an array copies; converting it to an array pointer shares. Go has both forms, and go2cs keeps the difference:

func writeThrough(dst []byte) {
	d := (*[4]byte)(dst)
	d[0] = 0x11
	d[1] = 0x22
	d[2] = 0x33
	d[3] = 0x44
}

// valueCopy takes the VALUE conversion and mutates it — the original must be untouched.
func valueCopy(src []byte) [4]byte {
	a := [4]byte(src)
	a[0] = 0xff
	return a
}
internal static void writeThrough(slice<byte> dst) {
    var d = Ꮡ(array<byte>.Alias(dst, 4));
    d.Value[0] = 0x11;
    d.Value[1] = 0x22;
    d.Value[2] = 0x33;
    d.Value[3] = 0x44;
}

internal static array<byte> valueCopy(slice<byte> src) {
    var a = new array<byte>(src, 4);
    a[0] = 0xff;
    return a.Clone();
}

[4]byte(src) becomes new array<byte>(src, 4), a copy, so valueCopy never changes src. Its return a clones too, because a result is a copy site.

(*[4]byte)(dst) becomes Ꮡ(array<byte>.Alias(dst, 4)). Alias is an array window over the slice’s own storage. Ꮡ(…) is go2cs’s address-of, Go’s &, and gives a golib pointer box ж<array<byte>>; d.Value reads through the box (Pointers). The box holds the window, and the window still points at dst’s backing array, so the writes in writeThrough land in the caller’s slice, as in Go.

Variadic parameters are spans. A Go ...T parameter becomes params ꓸꓸꓸT. The glyph ꓸꓸꓸ stands for Go’s ..., and ꓸꓸꓸT is a file-level alias for C# Span<T>. The parameter is renamed with a ʗp suffix, which marks an incoming parameter the body redeclares, so the body can declare the Go name xs as a slice (Names and Glyphs).

func bump(xs ...int) {
	for i := range xs {
		xs[i] += 10
	}
}

// forward only passes its pack on: the view reaches bump, and bump's writes reach the caller.
func forward(xs ...int) {
	bump(xs...)
}
using ꓸꓸꓸnint = Span<nint>;

partial class main_package {

internal static void bump(params ꓸꓸꓸnint xsʗp) {
    var xs = xsʗp.sslice();

    foreach (var i in range(len(xs))) {
        xs[i] += 10;
    }
}

internal static void forward(params ꓸꓸꓸnint xsʗp) {
    var xs = xsʗp.sslice();

    bump(xs.ꓸꓸꓸ);
}

xsʗp.sslice() gives golib’s sslice<T>: a stack-only view of the span that allocates nothing. The converter uses it when the function only reads the pack, copies from it, or passes it on to another call, as forward does. Passing it on keeps it inside the call chain. bump’s index-only for i := range xs becomes foreach (var i in range(xs)), which reads no element.

A spread passes the slice itself. bump(xs...) becomes bump(xs.ꓸꓸꓸ). .ꓸꓸꓸ is a property on both slice<T> and sslice<T> that returns the slice’s own storage as a Span<T>. So the writes in bump reach the caller’s a, as in Go:

func main() {
	a := []int{1, 2, 3}
	forward(a...)
	fmt.Println("forward wrote through:", a)
internal static void Main() {
    var a = slice<nint>([1, 2, 3]);
    forward(a.ꓸꓸꓸ);
    fmt.Println("forward wrote through:", a);

Both languages print forward wrote through: [11 12 13]. Spreading into append has its own name: append(dst, xs...) becomes appendꓸꓸꓸ(dst, xs). golib’s appendꓸꓸꓸ has an overload for each kind of operand: a slice<T> operand is taken by reference (in) as the slice it is, never boxed to an interface, and an sslice<T> pack is read as the span it already is, so the spread itself copies nothing but the appended elements.

Any other use of the pack copies it to the heap. When the function keeps, returns or captures its pack, or grows it with append or writes into it with copy, the body binds xsʗp.slice() instead, a heap slice<T> copy:

func keep(xs ...int) []int { return xs }
internal static slice<nint> keep(params ꓸꓸꓸnint xsʗp) {
    var xs = xsʗp.slice();

    return xs;
}

More on variadic parameters: Reference → Multi-Result Values and Comma-Ok Forms.

Full detail: Reference → Slices and Arrays — named slice and array wrappers, keyed and sparse literals, declared-length array literals, zero-value element construction, every array clone site and deep copy, and nil-versus-empty identity.


Strings (@string and sstring)

Go’s string becomes golib @string, never C#’s System.String. (golib is the go2cs runtime library, and the @ is C#’s prefix for a name that is also a keyword.) An @string is an immutable sequence of bytes, as in Go. len, indexing, comparison and concatenation work on those bytes, not on UTF-16 characters.

Slicing is cheap: s[i:j] is a window over the same bytes, not a copy. Sharing the bytes is safe because nothing can change them.

A range loop over a string yields runes at byte offsets. It becomes a foreach over (index, rune) pairs. The index is the byte offset where each rune starts, exactly as in Go:

s := "a¢b☺\U0001d11e!"
for i, r := range s {
	fmt.Println(i, int32(r), int32(s[i]))
}
@string s = "a¢b☺\U0001d11e!";
foreach (var (i, r) in s) {
    fmt.Println(i, (int32)r, (int32)s[i]);
}

¢ takes two bytes, ☺ three and 𝄞 four, so both programs print the indexes 0, 1, 3, 4, 7 and 11. Loops, Range and Labels covers every range form.

A literal in a string slot is a plain C# literal. Where Go’s type for the literal is exactly string (an assignment, a return, a string argument, a struct field or a package var), a Go "text" becomes the C# literal "text", as in the assignment to s in the range example. golib’s implicit conversion from a C# string to @string converts each literal once and shares the result, so a literal costs one conversion per program run, however often it is evaluated (plain literals).

Elsewhere a literal is a C# UTF-8 literal, "text"u8. A u8 literal is a ReadOnlySpan<byte> over bytes compiled into the program, so by itself it costs nothing. A comparison, a concatenation and a []byte conversion read those bytes in place. A named string type takes it too, because no conversion leads from a C# string to the named type. A generic type parameter takes it because C# would infer it as System.String from a C# string; the analyzer rule GO2CS0004 makes that binding a build error.

Conversions to and from []byte copy, as they do in Go. []byte(s) becomes a golib slice<byte> with its own copy of the bytes. string(b) becomes the cast (@string)b, which copies them back. Go’s rune keeps its name in C#, as an alias for int32:

bs := []byte("hello")
rs := []rune("héllo")

fmt.Println(len(bs), string(bs))
fmt.Println(len(rs), string(rs))
var bs = slice<byte>("hello"u8);
var rs = slice<rune>((@string)"héllo");

fmt.Println(len(bs), (@string)bs);
fmt.Println(len(rs), (@string)rs);

A []byte conversion takes the u8 bytes directly. A []rune conversion first makes the literal an @string, because @string is what decodes UTF-8 into runes.

The standard library stores a literal used as a value in a field when its words name it. Go keeps literals in read-only memory, so they cost nothing at run time. A standard-library package matches that by storing such a literal in a static readonly field of the package’s class (Package Conversion), declared just before the first function that uses it, named from the literal’s words, with the suffix ˢ marking it as generated, and read through an accessor under the same name: return "zero value return" becomes return zeroValueReturnˢ;. Reading the field skips the cache lookup a plain literal makes, so -hoist-literals gives any other package this form, and it is recommended for performance-critical code (Command Line). Every other value site, a composite element or an init body among them, takes the cached plain form in both modes, and a format string at an sstring twin keeps its u8 span, so no literal is copied on each evaluation. By default the same function reads as Go does:

func kind(n int) string {
	if n == 0 {
		return "zero value return"
	}

	return "other value return"
}
partial class main_package {

internal static @string kind(nint n) {
    if (n == 0) {
        return "zero value return";
    }

    return "other value return";
}
…
} // end main_package

Go’s int becomes C#’s nint (Integer Types and Arithmetic). In the hoisted form each distinct literal gets one field for the whole package, however many functions use it, and every use of the literal names that field.

A literal passed as any is a plain C# literal too, and go2cs-gen boxes it. fmt.Println takes ...any, which is C#’s object. A C# string there would arrive as System.String, a type no Go type switch or assertion matches. So go2cs-gen intercepts each such call at compile time: the generated method puts a cached, boxed @string in the literal’s place and calls the original, so the call allocates nothing (Source Generators, Empty Interface (any)):

fmt.Println("pre boxed any target")
internal static void Main() {
…
    fmt.Println("pre boxed any target");
…
}

A call the generator cannot reach keeps the literal in a field stored already boxed, the form the standard library gives every literal passed only as any: a go or defer argument, a call through a func value, a generic function, or a method the generator cannot resolve. Any other call it cannot intercept is the build error GO2CS0005. The reference lists each case.

A comparison or a concatenation keeps the u8 literal. It reads the bytes directly, so the literal needs no @string of its own there:

func isSentinel(word string) bool {
	return word == "comparison operand literal"
}
…
func withSuffix(suffix string) string {
	return "concat left operand " + suffix
}
internal static bool isSentinel(@string word) {
    return word == "comparison operand literal"u8;
}

internal static @string withSuffix(@string suffix) {
    return "concat left operand "u8 + suffix;
}

In the hoisted form a few other literals stay inline too. A format string such as "%d items" makes a poor field name. A composite literal element (an entry in a slice, array, map or struct literal) stays inline because hoisting every table entry would add thousands of fields and move the work of building tables into class initialization. A literal whose ASCII words are too short to name a field stays inline as well. The reference lists every case that stays inline.

A string constant declared inside a function is a local initialized from a plain literal. In the hoisted form it is stored in a field too: the field takes the constant’s own name plus the suffix ᶜ, the local keeps the Go name hello, and copying the field into it copies no bytes, because an @string is a small struct that refers to its bytes. By default:

func makeGreeting(who string) string {
	const hello = "hello, "
	return hello + who
}
internal static @string makeGreeting(@string who) {
    @string hello = "hello, ";
    return hello + who;
}

Named string types (type version string) become wrapper structs. The comment /*@string*/ after the name asks the source generators to fill in the wrapper. It keeps the whole string surface: indexing, slicing, len, comparison, + and conversions. A concatenation keeps the named type, as in Go. The type’s methods become static extension methods, so this version v is the receiver (Functions and Methods):

type version string

func (v version) tag() string { return string(v) + "!" }

func bump(v version) version { return v + "-next" }
partial struct version /*@string*/;

internal static @string tag(this version v) { return ((@string)v) + "!"u8; }

internal static version bump(version v) { return v + "-next"u8; }

sstring is a string view that allocates nothing. The name reads as stack string. Golib’s sstring is a ref struct: it lives only on the stack and points at bytes that already exist. C# rejects at compile time any code that stores a ref struct in a field, array or map, boxes it, or captures it in a lambda. So an sstring can never outlive its bytes.

It appears in two places: a string([]byte) conversion that is only read, and the string parameter of some library functions. Each is shown in its own rule in this section.

A string([]byte) that is only read becomes an sstring. Go skips the copy when the string is only read while its bytes cannot change. The converter does the same where it can prove it. Here each converted string is compared once and then discarded:

func prefix(buf []byte) bool {
	if len(buf) < 6 {
		return false
	}
	if string(buf[:3]) != "GET" {
		return false
	}
	return string(buf[3:6]) == " /x"
}
internal static bool prefix(slice<byte> buf) {
    if (len(buf) < 6) {
        return false;
    }
    if (((sstring)(buf[..3])) != "GET"u8) {
        return false;
    }
    return ((sstring)(buf[3..6])) == " /x"u8;
}

buf[3..6] is C#’s range syntax for Go’s buf[3:6] (Slices and Arrays).

Where the converter cannot prove it, the conversion stays the copying (@string) cast. Here the string is returned, so it must own its bytes:

func returnedString() string {
	b := []byte("returned")
	r := string(b)
	return r
}
internal static @string returnedString() {
    var b = slice<byte>("returned"u8);
    @string r = (@string)b;
    return r;
}

Some library functions take their string as an sstring, plus an @string twin. The converted function declares its string parameter as sstring. The source generators add a second overload, the twin, that takes an @string and forwards to it. C# picks the right overload, so the call reads the same as in Go. fmt.Sprintf is one such function, so a u8 literal argument binds with no copy:

fmt.Println(fmt.Sprintf("xxx"))
fmt.Println(fmt.Sprintf("xxx"u8));

Go’s func Sprintf(format string, a ...any) string is declared this way in the converted fmt package:

public static @string Sprintf(sstring format, params ꓸꓸꓸany aʗp) {
    var a = aʗp.slice();
…
}

The sstring parameter is what asks the generators for the twin. params ꓸꓸꓸany aʗp is Go’s variadic a ...any: ꓸꓸꓸany is the file’s alias for Span<any>, the ʗp suffix marks the raw incoming pack, and the first line of the body turns it into the slice a that the Go code uses. Functions and Methods covers variadic parameters.

A string(b) argument to a unicode/utf8 twin passes a view of the bytes. utf8.RuneCountInString and utf8.DecodeRuneInString are twinned too. When the argument is string(b) of a plain []byte, the converter writes (sstring)b, so the call binds the sstring overload over b’s own bytes instead of copying them:

raw := []byte("héllo, 世界")
fmt.Println(utf8.RuneCountInString(string(raw)), utf8.RuneCount(raw[1:]))
r2, size2 := utf8.DecodeRuneInString(string(raw[1:]))
fmt.Println(r2, size2)
// a deferred call's argument is evaluated now: the later write to raw must not reach it
defer fmt.Println("deferred count", utf8.RuneCountInString(string(raw)))
raw[1] = 'e'
fmt.Println(utf8.RuneCountInString(string(raw)))
var raw = slice<byte>("héllo, 世界"u8);
fmt.Println(utf8.RuneCountInString((sstring)raw), utf8.RuneCount(raw[1..]));
var (r2, size2) = utf8.DecodeRuneInString((sstring)(raw[1..]));
fmt.Println(r2, size2);
defer(ᴛ1 => fmt.Println("deferred count", ᴛ1), utf8.RuneCountInString((@string)raw), ref ᒐ);
raw[1] = (byte)'e';
fmt.Println(utf8.RuneCountInString((sstring)raw));

The view cannot be retained. It is passed only to the utf8 twins, which only read the parameter, and an sstring is a ref struct, which C# will not let a callee store, so the view ends with the call. Nothing may change b before the twin reads it, so every other argument of the call must be free of calls. A call inside a defer or go statement keeps the copying (@string) cast, as the deferred line shows, and so does a fmt twin, whose body runs caller code (String, Format) that could change b. In the deferred line, defer(…, ref ᒐ) is golib’s form of Go’s defer (Defer / Panic / Recover): the constant "deferred count" sits inside the thunk’s call, and the count, which is not a constant, is computed at the defer statement and passed as ᴛ1.

A twinned function used as a value is named with ᶠ. Converted to a delegate, the function would bind its sstring overload, which C# prefers, and that overload does not match a delegate typed on @string. So the generators also declare one shared delegate field, Sprintfᶠ, and the converter uses it wherever Go uses the function as a value:

f := fmt.Sprintf
fmt.Println(f("via local %s", "ok"))
Funcꓸꓸꓸ<@string, any, @string> f = fmt.Sprintfᶠ;
fmt.Println(f("via local %s", (@string)"ok"));

Funcꓸꓸꓸ<@string, any, @string> is the C# type for Go’s func(string, ...any) string (Function Values and Closures). "ok" goes to an any slot, and a u8 span cannot be boxed, so (@string) first makes it a value. It is not stored in a field because ok is too short to make a useful name. Every glyph in this section is listed in Reading Converted Code: Names and Glyphs.

Full detail: Reference → Strings (@string and sstring) — windows and conversions, literal rendering and byte-array literals, the exact hoisting rules, named-string wrappers, sstring eligibility, and how functions are chosen to take sstring.


Maps

Go’s map[K]V becomes golib map<K, V>. Golib is go2cs’s runtime library (The golib Runtime Library). map<K, V> is a small struct that wraps a .NET Dictionary. Copying it copies only a reference, so every copy sees the same entries, as in Go.

Reads (m[k]) and writes (m[k] = v) keep Go’s index syntax. len(m), delete(m, k) and clear(m) keep their Go names (Built-in Functions). Channels have their own section, Channels and select.

The examples use a few golib names. @string is Go’s string (Strings), and nint is Go’s int (Integer Types and Arithmetic). "a" is a plain C# literal, which golib converts implicitly to @string, once. Other glyphs are listed in Names and Glyphs.

A map literal becomes a C# index initializer, and comma-ok adds the ꟷ argument. A plain read, m[k], stays m[k]. The comma-ok read, v, ok := m[k], passes ꟷ as a second index:

m := map[string]int{"a": 1, "b": 2}

// value + ok, present.
v, ok := m["a"]
fmt.Println(v, ok) // 1 true

// value + ok, absent (value is the zero value).
v2, ok2 := m["z"]
fmt.Println(v2, ok2) // 0 false

// blank value, present.
_, ok3 := m["b"]
fmt.Println(ok3) // true
var m = new map<@string, nint>{["a"] = 1, ["b"] = 2};

var (v, ok) = m["a", ꟷ];
fmt.Println(v, ok);

var (v2, ok2) = m["z", ꟷ];
fmt.Println(v2, ok2);

var (_, ok3) = m["b", ꟷ];
fmt.Println(ok3);

ꟷ is a plain golib bool constant. Its value, false, is ignored: passing a second index is what selects golib’s two-value indexer. That indexer returns a tuple, (value, present), which var (v, ok) unpacks. A missing key gives the zero value and false, as in Go. Multi-Result Values and Comma-Ok Forms covers the other comma-ok forms.

range over a map becomes a foreach over key-value pairs. Each pair unpacks into (k, v), and a _ drops the part Go leaves out. The body may add and delete entries of the map it walks, as Go allows:

func main() {
…
	insert := map[string]int{"a": 1, "b": 2, "c": 3}
	visited := 0
	for k, v := range insert {
		if len(k) == 1 {
			visited++
			insert[k+"!"] = v * 10
		}
	}
	fmt.Println("insert visited:", visited)
…
}
internal static void Main() {
    var insert = new map<@string, nint>{["a"] = 1, ["b"] = 2, ["c"] = 3};
    nint visited = 0;
    foreach (var (k, v) in insert) {
        if (len(k) == 1) {
            visited++;
            insert[k + "!"u8] = v * 10;
        }
    }
    fmt.Println("insert visited:", visited);
…
}

The visit order is unspecified in both languages. Go also leaves open whether an entry added during the walk is visited, so code must not rely on it. golib’s map range iterates a snapshot of the entries, taken when the loop starts, and re-reads each value as it arrives: an entry deleted before it is reached is skipped, an overwritten value is seen at its current value, and an entry added during the walk is not visited. Each range starts its walk at a random entry, as Go’s does. Loops, Range and Labels covers range over other types.

Go’s package functions become static methods of a class for the package, and main becomes Main, the C# entry point (Package Conversion). "insert visited:" stays a plain C# literal: it is passed to fmt.Println as an any value, so go2cs-gen intercepts the call and boxes it once as a Go string, not on every call (Strings, Empty Interface).

A nil map reads as empty and panics on write. var m map[string]int declares a nil map. The converter writes Go’s nil as default! (Nil and Zero Values explains the !). For a map, default! is the map<K, V> struct’s default value, which holds no Dictionary:

func main() {
	var m map[string]int // nil map

	// read absent key -> zero value
	fmt.Println(m["a"]) // 0

	// comma-ok read -> (zero, false)
	v, ok := m["a"]
	fmt.Println(v, ok) // 0 false

	// len(nil) -> 0
	fmt.Println(len(m)) // 0

	// range over nil -> no iterations
	count := 0
	for range m {
		count++
	}
	fmt.Println(count) // 0

	// delete(nil, k) -> no-op, no panic
	delete(m, "a")
	fmt.Println("delete ok") // delete ok

	// m == nil -> true for a nil map
	fmt.Println(m == nil) // true

	// an empty but non-nil map is NOT nil
	e := map[string]int{}
	fmt.Println(e == nil) // false
…
}
internal static void Main() {
    map<@string, nint> m = default;

    fmt.Println(m["a"]);

    var (v, ok) = m["a", ꟷ];
    fmt.Println(v, ok);

    fmt.Println(len(m));

    nint count = 0;
    foreach ((_, _) in m) {
        count++;
    }
    fmt.Println(count);

    delete(m, "a"u8);
    fmt.Println("delete ok");

    fmt.Println(m == nil);

    var e = new map<@string, nint>{};
    fmt.Println(e == nil);
…
}

Every read of a nil map sees no entries: an absent key gives the zero value, len is 0, and a range runs no iterations. m == nil stays m == nil, which is true only for a map with no Dictionary. So an empty map built with {} is not nil, as in Go.

A range with no variables unpacks each pair into two C# discards, (_, _). This is ordinary C# deconstruction in a foreach; the loop body uses neither part.

A write such as m["x"] = 1 on a nil map panics with Go’s message, “assignment to entry in nil map”. A deferred recover catches that panic as it would in Go (Defer / Panic / Recover).

A named map type is a wrapper struct. type registry map[uint32]entry becomes a partial struct declared with no body. Its comma-ok read looks like a plain map’s:

type entry struct {
	tag  string
	size int
}

type registry map[uint32]entry

func lookup(reg registry, id uint32) (string, bool) {
	e, ok := reg[id]
	if !ok {
		return "missing", false
	}
	return e.tag, true
}
partial struct entry {
    internal @string tag;
    internal nint size;
}

partial struct registry /*map[uint32, entry]*/;

internal static (@string, bool) lookup(registry reg, uint32 id) {
    var (e, ok) = reg[id, ꟷ];
    if (!ok) {
        return ("missing", false);
    }
    return (e.tag, true);
}

The comment after the struct’s name names the underlying map. A source generator writes the body of registry at compile time. It wraps a map<uint32, entry> and adds the indexers, len, range and a size constructor.

Go’s two results become a C# tuple (Multi-Result Values), and entry is an ordinary converted struct (Struct Types). "missing" is returned as a Go string, so it is a plain C# literal (Strings).

A named type’s literal wraps a plain map literal, and make(registry, 4) passes its size hint to the constructor:

reg := registry{2: {tag: "leaf", size: 8}}
t1, ok1 := lookup(reg, 2)
t2, ok2 := lookup(reg, 9)
fmt.Println(t1, ok1, t2, ok2) // leaf true missing false
…
reg2 := make(registry, 4)
reg2[7] = entry{tag: "cap", size: 1}
e7, ok7 := lookup(reg2, 7)
fmt.Println(len(reg2), e7, ok7) // 1 cap true
var reg = new registry(new map<uint32, entry>{[2] = new(tag: "leaf", size: 8)});
var (t1, ok1) = lookup(reg, 2);
var (t2, ok2) = lookup(reg, 9);
fmt.Println(t1, ok1, t2, ok2);

var reg2 = new registry(4);
reg2[7] = new entry(tag: "cap", size: 1);
var (e7, ok7) = lookup(reg2, 7);
fmt.Println(len(reg2), e7, ok7);

new(tag: "leaf", size: 8) builds an entry. It calls the constructor the source generator gives every converted struct, with one named argument per field (Struct Types).

A lookup keyed by string(b) does not copy the bytes; a store does. Here k is a []byte, which becomes golib slice<byte> (Slices and Arrays). A store keeps its key, so w[string(k)] = 42 copies the bytes with a plain (@string) conversion. A read never keeps its key, so Go skips the copy there, and go2cs does the same with golib’s tmpstring(k):

func main() {
…
	w := map[string]int{}
	k := []byte("alpha")
	w[string(k)] = 42
	k[0] = 'Z'
	fmt.Println(w["alpha"], len(w))
	_, hit := w[string(k)] // "Zlpha" — must miss
	fmt.Println(hit)
…
}
internal static void Main() {
…
    var w = new map<@string, nint>{};
    var k = slice<byte>("alpha"u8);
    w[((@string)k)] = 42;
    k[0] = (byte)'Z';
    fmt.Println(w["alpha"], len(w));
    var (_, hit) = w[tmpstring(k), ꟷ];
    fmt.Println(hit);
…
}

tmpstring(k) returns a temporary @string that views the current bytes of k. That is safe because the view is used only for the lookup. Changing k after the store does not touch the stored key, so the lookup of "Zlpha" misses. Plain and comma-ok reads both take this path.

slice<byte>("alpha"u8) is golib’s form of Go’s []byte("alpha"): it copies the literal’s bytes into a new slice. w["alpha"] passes a plain C# literal, which golib converts to @string once and shares (Strings). (byte)'Z' is Go’s constant 'Z' written into a byte: a C# char does not convert to byte on its own, so the literal carries one cast to the slot’s type (Constant Values).

Full detail: Reference → Maps and Channels — how a range survives changes to its own map, nil and interface keys, exactly which string(b) lookups skip the copy, named map types, and map access through type parameters.


Generics

Go type parameters become C# generic type parameters. Go’s square brackets become angle brackets, so Stack[T] reads Stack<T>. Each Go constraint becomes a C# where clause. any and comparable add no clause.

A C# where clause can name interfaces, a base class and a few keywords, but not Go’s type sets. So the converter picks the .NET or golib interfaces that let the body’s operations compile. golib is the go2cs runtime library that provides Go types such as slice<T>. Converted code imports golib’s built-in functions with using static, so calls such as len, append and AreEqual need no class name (Built-in Functions).

A method-set interface constraint becomes a plain where clause that names the converted interface. Calling the constraint’s methods on the type parameter works as in Go. A generic function keeps the clause only where its body uses the parameter as that interface, as totalArea does; otherwise the clause is left out, because it would refuse a pointer type argument (see A pointer type argument goes in through adapters below):

type Shape interface {
	Area() float64
	Name() string
}
…
func totalArea[S Shape](shapes []S) float64 {
	var sum float64
	for _, s := range shapes {
		sum += s.Area()
	}
	return sum
}
partial interface Shape {
    float64 Area();
    @string Name();
}
…
internal static float64 totalArea<S>(slice<S> shapes)
    where S : Shape
{
    float64 sum = default;
    foreach (var (_, s) in range(shapes)) {
        sum += s.Area();
    }
    return sum;
}

A source generator completes each converted Go type. Go’s []S is golib’s slice<S>, @string is Go’s string (Strings), and float64 is C# double under its Go name. default! is the zero value (Nil and Zero Values). A range loop becomes a foreach over (index, value) pairs (Loops, Range and Labels).

An inline constraint with methods has no name for the clause to use, so the converter lifts it to a named interface, <declaration>_<parameter>: func Show[T interface{ Name() string }] declares interface Show_T and takes where T : Show_T. A constraint that names its own parameter, type Ord[T interface{ Less(T) bool }], lifts to a generic interface, where T : Ord_T<T> (Reference → Generic Constraints).

A pointer type argument goes in through adapters. In Go, totalArea(circles) with a []*Circle simply infers S = *Circle. In C#, a Go pointer is golib’s heap box ж<Circle> (Pointers), and the box does not implement Shape. A generated adapter class, CircleжShape, does (Interfaces). So the call passes the slice through golib’s widen, which builds a slice<Shape> of adapters, and C# instantiates S as Shape:

type Circle struct {
	R float64
}

func (c *Circle) Area() float64 {
	return 3.0 * c.R * c.R
}
…
	circles := []*Circle{&Circle{R: 1}, &Circle{R: 2}} // pointer instantiation — adapter-projected
…
	shapes := []Shape{&Circle{R: 1}, &Square{S: 2}}    // interface instantiation — direct
…
	fmt.Printf("circles: %.2f\n", totalArea(circles))
…
	fmt.Printf("shapes: %.2f\n", totalArea(shapes))
partial struct Circle {
    public float64 R;
}

public static float64 Area(this ref Circle c) {
    return 3.0D * c.R * c.R;
}
…
    var circles = slice<ж<Circle>>([Ꮡ(new Circle(R: 1D)), Ꮡ(new Circle(R: 2D))]);
…
    var shapes = slice<Shape>([new CircleжShape(Ꮡ(new Circle(R: 1D))), new SquareжShape(Ꮡ(new Square(S: 2D)))]);
…
    fmt.Printf("circles: %.2f\n"u8, totalArea(widen<ж<Circle>, Shape>(circles, elemᴛ0 => new CircleжShape(elemᴛ0))));
…
    fmt.Printf("shapes: %.2f\n"u8, totalArea(shapes));

this ref Circle is the receiver of a pointer-receiver method (Functions and Methods). Ꮡ(…) takes an address, as Go’s & does, and .slice() turns a C# array into a golib slice (Slices and Arrays). The ᴛ in elemᴛ0 marks a name the converter makes up, here the adapter lambda’s parameter (Reading Converted Code). Each adapter holds the original box, so a method called through it acts on the same Circle. A []Shape argument already satisfies the constraint, so totalArea(shapes) stays as Go writes it.

An operator type set becomes System.Numerics operator interfaces, so +, == and < compile on the type parameter. A Go type set lists its allowed types with |. ~int means any type whose underlying type is int, such as type MyInt int. cmp.Ordered is Go’s standard type set for types that support <: the integer and floating-point types, and string.

func Less[T Ordered](x, y T) bool {
	return (isNaN(x) && !isNaN(y)) || x < y
}
…
func isNaN[T Ordered](x T) bool {
	return x != x
}
public static bool Less<T>(T x, T y)
    where T : /* Ordered */ IAdditionOperators<T, T, T>, IEqualityOperators<T, T, bool>, IComparisonOperators<T, T, bool>, new()
{
    return (isNaN(x) && !isNaN(y)) || x < y;
}
…
internal static bool isNaN<T>(T x)
    where T : /* Ordered */ IAdditionOperators<T, T, T>, IEqualityOperators<T, T, bool>, IComparisonOperators<T, T, bool>, new()
{
    return !AreEqual(x, x);
}

The comment /* Ordered */ names the Go constraint. Then comes one interface for each operator that every type in the set supports. Ordered includes string, which has only + and comparisons, so it gets these three. An integer-only type set also gets -, *, /, %, bitwise and shift interfaces.

The trailing new() is the C# constraint that allows new T(). go2cs adds it to every operator-set clause. golib’s conversion helpers for a type parameter, the ConvertTo* family, declare where T : new().

x < y compiles through the comparison interface. But == and != on a type-parameter value always call golib’s AreEqual, so isNaN’s x != x becomes !AreEqual(x, x). AreEqual compares at run time the way Go’s == does, including a NaN never equaling itself.

comparable adds no clause. No C# constraint admits every type Go can compare with ==, such as structs, pointers and interfaces. Go’s type checker has already checked every use, so the parameter stays unconstrained, and == becomes AreEqual as under every other constraint:

func eq[T comparable](a, b T) bool { return a == b }
internal static bool eq<T>(T a, T b) { return AreEqual(a, b); }

A generic type keeps its type parameters, and each method repeats them. A Go method becomes a C# extension method, and a pointer receiver becomes this ref (Functions and Methods). An extension method cannot borrow its receiver type’s parameters. So each method declares <T> and restates the where clause:

type Stack[T ~int | ~string] struct {
	elements []T
}

func (s *Stack[T]) Push(element T) {
	s.elements = append(s.elements, element)
}

func (s *Stack[T]) Pop() (T, bool) {
	var zero T
	if len(s.elements) == 0 {
		return zero, false
	}

	index := len(s.elements) - 1
	element := s.elements[index]
	s.elements = s.elements[:index]
	return element, true
}
partial struct Stack<T>
    where T : /* ~int | ~string */ IAdditionOperators<T, T, T>, IEqualityOperators<T, T, bool>, IComparisonOperators<T, T, bool>, new()
{
    internal slice<T> elements;
}

public static void Push<T>(this ref Stack<T> s, T element)
    where T : /* ~int | ~string */ IAdditionOperators<T, T, T>, IEqualityOperators<T, T, bool>, IComparisonOperators<T, T, bool>, new()
{
    s.elements = append(s.elements, element);
}

public static (T, bool) Pop<T>(this ref Stack<T> s)
    where T : /* ~int | ~string */ IAdditionOperators<T, T, T>, IEqualityOperators<T, T, bool>, IComparisonOperators<T, T, bool>, new()
{
    T zero = GoZero<T>();
    if (len(s.elements) == 0) {
        return (zero, false);
    }

    nint index = len(s.elements) - 1;
    var element = s.elements[index];
    s.elements = s.elements.slice(0, index);
    return (element, true);
}

The three where clauses are identical. The set ~int | ~string includes string, so, as with Ordered, only + and the comparisons are common to every member. Here too, the source generator completes Stack<T>, including its zero-value constructor used below.

Pop’s two results become a C# tuple (Multi-Result Values). var zero T becomes T zero = GoZero<T>(). golib’s GoZero<T>() is default for most type arguments and builds the zero value of a struct whose default is not Go’s zero value. The sub-slice s.elements[:index] becomes s.elements.slice(0, index). A C# range would narrow each bound to int and cannot hold a negative one, so Go’s out-of-range panic could be lost. A bound that is not a constant within int goes to golib’s slice method, which checks it as Go does. The result shares the backing array as Go’s s[:i] does, and does not copy (Slices and Arrays).

Using the type reads as in Go. Go’s int is C# nint (Integer Types and Arithmetic). Stack[int]{} calls the constructor that the source generator adds for the zero value, which takes golib’s nil as its argument (Struct Types). "…"u8 is a UTF-8 string literal (Strings).

	intStack := Stack[int]{}
	intStack.Push(10)
	intStack.Push(20)
	val, _ := intStack.Pop()
	fmt.Printf("Popped from int stack: %d\n", val)
    var intStack = new Stack<nint>();
    intStack.Push(10);
    intStack.Push(20);
    var (val, _) = intStack.Pop();
    fmt.Printf("Popped from int stack: %d\n"u8, val);

A slice type set ~[]E becomes golib’s slice interfaces. ~[]E admits any slice type whose elements are E, including a named slice type such as type numbers []int. ISlice<E> is the interface every slice type implements. ISupportMake<S> lets make(S, n) build an S. ISliceWrap<S, E> lets s[i:j] and append return S again, as they do in Go.

func reverse[E any](s []E) {
	for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
		s[i], s[j] = s[j], s[i]
	}
}
…
func reverseSeq[S ~[]E, E any](x S) {
	reverse(x)
}
internal static void reverse<E>(slice<E> s) {
    for ((nint i, nint j) = (0, len(s) - 1); i < j; (i, j) = (i + 1, j - 1)) {
        (s[i], s[j]) = (s[j], s[i]);
    }
}

internal static void reverseSeq<S, E>(S x)
    where S : /* ~[]E */ ISlice<E>, ISupportMake<S>, ISliceWrap<S, E>, new()
{
    reverse(new slice<E>(x));
}

reverse takes a plain []E, so it needs no constraint. Its swap becomes a C# tuple assignment (Multi-Assignment). Inside reverseSeq, passing x as a []E becomes new slice<E>(x). That is a slice over the same backing array, as in Go, so reverse changes the caller’s elements.

Type arguments are written out when Go writes them or C# cannot infer them. Go infers E from the constraint S ~[]E. C# infers type parameters only from argument types, and no argument has type E. So each call to reverseSeq spells both type arguments:

type numbers []int
…
	a := []int{1, 2, 3, 4, 5}
	reverseSeq(a)
	fmt.Println(a) // [5 4 3 2 1]
…
	b := numbers{10, 20, 30}
	reverseSeq(b)
	fmt.Println(b) // [30 20 10]
partial struct numbers /*[]nint*/;
…
    var a = slice<nint>([1, 2, 3, 4, 5]);
    reverseSeq<slice<nint>, nint>(a);
    fmt.Println(a);

    var b = new numbers(new nint[]{10, 20, 30}.slice());
    reverseSeq<numbers, nint>(b);
    fmt.Println(b);

A named slice type such as numbers becomes a wrapper struct, its name followed by /*[]nint*/, that behaves as a slice. A slice literal is golib’s slice<T> builtin over a C# collection expression, such as slice<nint>([1, 2]), unless C# already knows its type, where it is the bare [1, 2] (Slices and Arrays). Calls C# can infer, such as totalArea(shapes), stay bare.

A type argument Go takes from untyped constants alone is written out too, wherever C# would infer a different type. Go gives a constant its default type, so show(3) calls show[int]; C# infers from the bare literal, an int, which is Go’s int32. The call becomes show<nint>(3), and %T inside show prints int as in Go. Float, rune, string, bool and complex constants already carry their type and stay bare.

Where Go writes the type arguments itself, C# keeps them. Here T appears in no parameter, so Go must name it. label is a plain string parameter, so its argument is a plain C# literal (Strings):

func describe[T any](label string) string {
	var zero T
	_ = zero
	return label
}
…
	fmt.Println(describe[fmt.Stringer]("stringer"))
internal static @string describe<T>(@string label) {
    T zero = GoZero<T>();
    _ = zero;
    return label;
}
…
    fmt.Println(describe<fmt.Stringer>("stringer"));

Conversions to or from an integer type parameter go through golib, because C# has no numeric cast to or from a type parameter. In this example Int is the name of the type parameter, not a built-in type. ConvertToUInt64<Int> turns an Int value into a uint64, and ConvertToType<Int> turns a number into an Int:

func halveN[Int ~int32 | ~int64](n Int) Int {
	if n <= 0 {
		return n
	}
	return Int(uint64(n) / 2)
}
internal static Int halveN<Int>(Int n)
    where Int : /* ~int32 | ~int64 */ IAdditionOperators<Int, Int, Int>, ISubtractionOperators<Int, Int, Int>, IMultiplyOperators<Int, Int, Int>, IDivisionOperators<Int, Int, Int>, IIncrementOperators<Int>, IDecrementOperators<Int>, IUnaryNegationOperators<Int, Int>, IModulusOperators<Int, Int, Int>, IBitwiseOperators<Int, Int, Int>, IShiftOperators<Int, int, Int>, IEqualityOperators<Int, Int, bool>, IComparisonOperators<Int, Int, bool>, new()
{
    if (n <= ConvertToType<Int>(0)) {
        return n;
    }
    return ConvertToType<Int>(ConvertToUInt64<Int>(n) / 2);
}

Even the constant 0 in n <= 0 goes through ConvertToType, because C# cannot turn the literal 0 into an Int either. The long where clause is what an integer-only type set becomes: every arithmetic, bitwise, shift and comparison interface.

The same holds wherever Go gives a constant the parameter’s type: a returned 1, an argument, a composite literal element, and the 1 in 1 << n, which takes the shift’s type. That shift becomes ConvertToType<T>(1).Lsh(n), so a count at or past the type argument’s width yields 0, as in Go.

Full detail: Reference → Generic Constraints — array and map type sets, pointer and self-referential constraints, unions such as string | []byte, per-field equality in generic structs, and how explicit type arguments are chosen, including when an untyped constant argument decides one.


Type Aliasing

A Go alias declaration, type A = B, becomes a C# global using A = B; directive. Code declared with a non-generic alias keeps the alias name as its type, so it reads like the Go source.

A plain C# using alias reaches one file; a global using reaches every file in the project. Each Go package converts to one C# project (Package Conversion), so the alias has package scope, just as in Go.

An alias is not a type definition. type Temperature = Celsius gives an existing type a second name. type Celsius float64 creates a new, distinct type, which becomes a C# partial struct as Named Numeric Types describes. Here only the alias becomes a global using:

type Celsius float64
…
type Temperature = Celsius

// Boiling returns a Celsius value.
func Boiling() Celsius { return 100 }

// Freezing returns the alias type, so a consumer can name the imported alias.
func Freezing() Temperature { return 0 }
global using Temperature = go.CrossPkgLib_package.Celsius;
…
namespace go;

partial class CrossPkgLib_package {
…
partial struct Celsius /*num:float64*/;

public static Celsius Boiling() { return 100D; }

public static Temperature Freezing() { return 0D; }

Every converted file sits in namespace go. A package’s declarations live in one class named after it, here CrossPkgLib_package (Package Conversion). The global using directives come first, outside both.

A type definition over an interface also becomes a global using. type Token any is a definition, not an alias. But Go gives it exactly the interface’s methods and lets it declare none of its own. So go2cs converts it as an alias of that interface, and any value converts to it just as it converts to the interface:

// Defined type over the empty interface — aliased to object.
type Token any

// A named interface, and a defined type OVER it — aliased to the interface.
type Stringer interface {
	String() string
}

type Named Stringer

type point struct{ x, y int }

func (p point) String() string { return fmt.Sprintf("(%d,%d)", p.x, p.y) }
…
func main() {
…
	var n Named = point{3, 4} // concrete point -> Named (defined over the Stringer interface)
	fmt.Println(n.String())
}
global using Token = object;
global using Named = go.main_package.Stringer;

namespace go;

using fmt = fmt_package;

partial class main_package {
…
partial interface Stringer {
    @string String();
}
…
partial struct point {
    internal nint x, y;
}

internal static @string String(this point p) { return fmt.Sprintf("(%d,%d)"u8, p.x, p.y); }
…
internal static void Main() {
…
    Named n = new point(3, 4);
    fmt.Println(n.String());
}

An alias target is written out in full. Ordinary converted code relies on two things: namespace go, and project-wide names such as int64 that stand for C# types. So the Header struct writes @string and int64. C# reads a using alias’s target without either, so the same types are written go.@string and long there:

type Header struct {
	Name string
	Size int64
}
…
type (
…
	names = []string
…
	sizes = map[string]int64
	u8s   = []uint8
	ifs   = []any
	cplx2 = []complex128
…
	fn  = func(string) int
	fn2 = func(Header) (string, error)
	fn0 = func()
…
)
global using names = go.slice<go.@string>;
…
global using sizes = go.map<go.@string, long>;
global using u8s = go.slice<byte>;
global using ifs = go.slice<object>;
global using cplx2 = go.slice<System.Numerics.Complex>;
…
global using fn = System.Func<go.@string, nint>;
global using fn2 = System.Func<go.main_package.Header, (go.@string, go.error)>;
global using fn0 = System.Action;
…
partial struct Header {
    public @string Name;
    public int64 Size;
}

An importing package gets its own copy of each exported alias. A global using reaches only its own project, and an imported package is a separate project. So go2cs declares every exported alias again in each importer.

Here the library package AliasImportLib exports a struct, Box, and eight aliases. Five of them are shown:

type Box struct{ V int }
…
type B2 = Box
type IntFn = func(int) int
type BoxFn = func(Box) Box
type DurFn = func(time.Duration) int
type Act = func()

In the library’s own project each alias is an ordinary global using, such as global using IntFn = System.Func<nint, nint>;. The library also lists each exported alias in its generated package_info.cs file, as an assembly attribute:

[assembly: GoTypeAlias("IntFn", "System.Func<nint, nint>")]

When a package imports AliasImportLib, go2cs reads those attributes and writes the aliases into the importer’s own package_info.cs (the same five are shown). Each name becomes <Package>ꓸ<Alias>. The ꓸ is a Unicode letter that stands in for Go’s dot, because a C# name cannot contain . (Names and Glyphs):

global using AliasImportLibꓸAct = System.Action;
global using AliasImportLibꓸB2 = go.AliasImportLib_package.Box;
global using AliasImportLibꓸBoxFn = System.Func<go.AliasImportLib_package.Box, go.AliasImportLib_package.Box>;
global using AliasImportLibꓸDurFn = System.Func<go.time_package.Duration, nint>;
global using AliasImportLibꓸIntFn = System.Func<nint, nint>;

Code in the importer then names the alias as AliasImportLibꓸIntFn where Go writes AliasImportLib.IntFn:

	var f AliasImportLib.IntFn = func(x int) int { return x + 1 }
	var g AliasImportLib.BoxFn = func(b AliasImportLib.Box) AliasImportLib.Box { return AliasImportLib.Box{V: b.V * 2} }
using AliasImportLib = AliasImportLib_package;
…
    AliasImportLibꓸIntFn f = (nint x) => x + 1;
    AliasImportLibꓸBoxFn g = (AliasImportLib.Box b) => new AliasImportLib.Box(V: b.V * 2);

A generic alias is replaced by its target at every use. A C# using alias cannot declare type parameters. A Go alias is identical to its target, so each use simply names the target, and the declaration survives as a comment. A plain alias in the same file still gets its global using:

type Pair[K comparable, V any] struct {
	Key K
	Val V
}

// Local generic aliases: of a generic type, partially instantiated, an alias of an alias, and of a map.
type P[K comparable, V any] = Pair[K, V]
type StrPair[V any] = Pair[string, V]
type SP[V any] = StrPair[V]
type IntMap[V any] = map[int]V

// A local PLAIN alias keeps its name.
type Pairs = []Pair[string, int]

func swap[T comparable](p P[T, T]) P[T, T] { return P[T, T]{Key: p.Val, Val: p.Key} }
global using Pairs = go.slice<go.main_package.Pair<go.@string, nint>>;
…
partial struct Pair<K, V> {
    public K Key;
    public V Val;
}
// type P[K comparable, V any] = Pair[K, V]
// type StrPair[V any] = Pair[string, V]
// type SP[V any] = StrPair[V]
// type IntMap[V any] = map[int]V

internal static Pair<T, T> swap<T>(Pair<T, T> p) { return new Pair<T, T>(Key: p.Val, Val: p.Key); }

Pair<K, V> is Go’s generic struct as a C# generic struct (Generics). swap takes and returns P[T, T] in Go, and Pair<T, T> in C#.

Every form expands the same way. A partially instantiated alias fills in its fixed argument, and an alias of an alias resolves all the way to the real type:

	var p P[string, int] = P[string, int]{Key: "a", Val: 1}
	sp := StrPair[bool]{Key: "b", Val: true}
	var sp2 SP[bool] = sp
	m := IntMap[string]{1: "one"}
    Pair<@string, nint> p = new Pair<@string, nint>(Key: "a", Val: 1);
    var sp = new Pair<@string, bool>(Key: "b", Val: true);
    Pair<@string, bool> sp2 = sp;
    var m = new map<nint, @string>{[1] = "one"};

StrPair[bool] and SP[bool] both become Pair<@string, bool>, and IntMap[string] becomes golib’s map<nint, @string> (Maps). "a" is a plain C# literal that golib converts to an @string once (Strings).

Full detail: Reference → Type Aliasing — how a defined type converts to and from its underlying type, the qualification rule for every kind of alias target, how an importer reads alias records, aliases of aliases, and the generic-alias forms that are not supported.


Functions and Methods

A Go package becomes a C# partial class named <name>_package (Package Conversion). Each Go function becomes a static method of that class. Each Go method becomes a C# extension method: the receiver is the first parameter, marked this, so a call such as d.speak() reads the same in both languages.

The receiver’s C# form shows how the method uses it:

Go receiver C# receiver Used when
(d dog) this dog d A value receiver: the method works on its own copy
(c *cat) this ref cat c A pointer receiver that only reads and writes through the pointer
(r *ring) this ж<ring> Ꮡr A pointer receiver that needs the pointer itself: it stores, returns or compares it, takes a field’s address, or passes it to a method that needs the box

ж<T> (read “zhe”) is the heap box of golib, the go2cs runtime library: the C# form of Go’s pointer *T. By convention, a name with the Ꮡ prefix, such as Ꮡr, holds such a box. Pointers explains both, and Names and Glyphs lists every glyph.

A value receiver is this T, and a pointer receiver is this ref T. A value receiver gets its own copy, as in Go. A pointer receiver reads and writes the caller’s storage through a C# ref, with no box and no allocation. Each partial struct is a converted Go struct (Struct Types):

type dog struct {
	name string
}

func (d dog) speak() string {
	return "woof:" + d.name
}

type cat struct {
	name string
}

func (c *cat) speak() string {
	return "meow:" + c.name
}
partial struct dog {
    internal @string name;
}

internal static @string speak(this dog d) {
    return "woof:"u8 + d.name;
}

partial struct cat {
    internal @string name;
}

internal static @string speak(this ref cat c) {
    return "meow:"u8 + c.name;
}

@string is golib’s Go string, and "woof:"u8 is a C# UTF-8 literal (Strings). For each this ref method a source generator writes a second overload that takes the box, this ж<cat>, and forwards to this method. So speak works on a cat value and on a *cat pointer, as in Go.

A value receiver copies arrays too. Go copies a struct’s arrays whenever it copies the struct. golib’s array<T> is a C# struct over a shared T[], so a plain C# struct copy would share the elements. A value receiver whose struct holds an array therefore starts with d = d.ΔClone();, a full copy that a source generator writes. The Δ prefix keeps the name from clashing with a Go name (Slices and Arrays):

type digest struct {
	h  [4]int
	x  [2]byte
	nx int
}

func (d *digest) bump() {
	for i := range d.h {
		d.h[i]++
	}
	d.x[0]++
	d.nx++
}

func (d digest) show() string {
	return fmt.Sprintf("%v %v %d", d.h, d.x, d.nx)
}
partial struct digest {
    internal array<nint> h = new(4);
    internal array<byte> x = new(2);
    internal nint nx;
}

internal static void bump(this ref digest d) {
    foreach (var i in range(len(d.h))) {
        d.h[i]++;
    }
    d.x[0]++;
    d.nx++;
}

internal static @string show(this digest d) {
    d = d.ΔClone();

    return fmt.Sprintf("%v %v %d"u8, d.h, d.x, d.nx);
}

Go’s int is C# nint (Integer Types and Arithmetic). The index-only range d.h becomes foreach over golib’s range(d.h), which yields each index and reads no element (Loops, Range and Labels). The pointer receiver bump needs no clone, because it works on the caller’s own value.

A value receiver whose address is taken is still the method’s own copy. In Go, &b on a value receiver points at that copy. When every such address only feeds a pointer parameter that the converter turns into a C# ref, as bumpBox here, the receiver stays a plain parameter and the call passes ref b, as it does for a value parameter. When the address is kept as a pointer, as p := &b, the receiver arrives as bʗp (the suffix ʗp marks an incoming parameter the body redeclares) and the body moves it into a heap box at entry with golib’s heap, so that Ꮡb is the pointer and b a ref to the boxed value. ~p is Go’s *p (Pointers):

func bumpBox(b *Box) {
	b.Tag += 10
}
…
func (b Box) BumpedRecv() int {
	bumpBox(&b)
	return b.Tag
}
…
func (b Box) ReadOnlyRecv() (int, int) {
	p := &b
	return p.R.Min, p.Tag
}
internal static void bumpBox(ref Box b) {
    b.Tag += 10;
}

public static nint BumpedRecv(this Box b) {
    bumpBox(ref b);
    return b.Tag;
}
…
public static (nint, nint) ReadOnlyRecv(this Box bʗp) {
    ref var b = ref heap(bʗp, out var Ꮡb);

    var p = Ꮡb;
    return (p.R.Min, p.Tag);
}

Either way the caller’s value is untouched: rb.BumpedRecv() returns 16 and rb.Tag stays 6.

A method that needs the pointer itself takes the box, this ж<T>. This happens when the body stores, returns or compares the receiver, takes the address of one of its fields, or passes the receiver to a method that needs the box. initSelf stores r in a field, so it takes the box Ꮡr. linkTo only writes a field, so it keeps this ref ring r:

type ring struct {
	data int
	next *ring
}

// receiver assigned to its own pointer field (self-link)
func (r *ring) initSelf() {
	r.next = r
}

// receiver linked to another node
func (r *ring) linkTo(other *ring) {
	r.next = other
}
partial struct ring {
    internal nint data;
    internal ж<ring> next;
}

internal static void initSelf(this ж<ring> Ꮡr) {
    ref var r = ref Ꮡr.DerefOrNull();

    r.next = Ꮡr;
}

internal static void linkTo(this ref ring r, ж<ring> other) {
    r.next = other;
}

The first line of initSelf binds r as a C# ref to the boxed value, so the body reads like Go, and Ꮡr is the pointer itself. For a nil box, golib’s DerefOrNull() returns a null ref. The first read or write through r then fails, and golib reports it as Go’s nil-pointer panic (Implicit Pointer Dereferencing).

On a pointer, a call reads as it does in Go. Besides its use as a name prefix, Ꮡ is also a golib function: Ꮡ(value) puts a value in a new box, like Go’s &. So &ring{…} becomes Ꮡ(new ring(…)). A box method such as initSelf takes the box directly. A this ref method such as linkTo binds the ж<T> overload that the generator writes for it:

	a := &ring{data: 1}
	a.initSelf() // a.next = a
	…
	b := &ring{data: 2}
	c := &ring{data: 3}
	b.linkTo(c)
    var a = Ꮡ(new ring(data: 1));
    a.initSelf();
    …
    var b = Ꮡ(new ring(data: 2));
    var c = Ꮡ(new ring(data: 3));
    b.linkTo(c);

A value method called on a pointer runs on a copy, as in Go, and the call is written as Go writes it. Here pb points at PeopleByAge, a named slice type whose Len method has a value receiver, and @new<T>() is Go’s new(T). go2cs-gen gives Len a twin over the box that reads the value with ~ and calls Len on that copy, after the call’s arguments are evaluated, which is when Go takes the copy. golib overloads C#’s ~ operator on ж<T> to mean Go’s *p, so ~p is not a bitwise NOT:

	pb := new(PeopleByAge)
	…
	fmt.Println(pb.Len()) // 3
    var pb = @new<PeopleByAge>();
    …
    fmt.Println(pb.Len());

On a value, Go takes the address for you, and so does the converted code. In this complete program, c is a plain Counter value. Get only reads a field, so c.Get() passes c by ref. Set and Add take the address of the field c.n, so they need the box, and c lives in one:

type Counter struct {
	n int32
}

func bump(p *int32, delta int32) int32 {
	*p += delta
	return *p
}

func (c *Counter) Add(delta int32) int32 { return bump(&c.n, delta) }
func (c *Counter) Set(v int32)           { *(&c.n) = v }
func (c *Counter) Get() int32            { return c.n }

func main() {
	var c Counter // value receiver; pointer-receiver methods called on it
	c.Set(100)
	fmt.Println("after Set:", c.Get())   // 100
	fmt.Println("Add 10:", c.Add(10))    // 110
	fmt.Println("Add 5:", c.Add(5))      // 115
	fmt.Println("final:", c.Get())       // 115
}
partial struct Counter {
    internal int32 n;
}

internal static int32 bump(ref int32 p, int32 delta) {
    p += delta;
    return p;
}

public static int32 Add(this ж<Counter> Ꮡc, int32 delta) {
    ref var c = ref Ꮡc.DerefOrNull();

    return bump(ref nonnil(ref c).n, delta);
}

public static void Set(this ж<Counter> Ꮡc, int32 v) { (Ꮡc.Ꮡn).Value = v; }
public static int32 Get(this ref Counter c) { return c.n; }

internal static void Main() {
    ref var c = ref heap(new Counter(), out var Ꮡc);
    Ꮡc.Set(100);
    fmt.Println("after Set:", c.Get());
    fmt.Println("Add 10:", Ꮡc.Add(10));
    fmt.Println("Add 5:", Ꮡc.Add(5));
    fmt.Println("final:", c.Get());
}

Reading the C# piece by piece:

Go’s export rule becomes C# access. A capitalized name is public, and any other name is internal (Names and Glyphs). So Add is public and bump is internal. The entry point func main becomes internal static void Main().

A method is public only when its type is emitted public too. That is an exported type, or an unexported type that an exported signature exposes. C# forbids a public member from exposing an internal type, so such a type is emitted public itself.

A single named result stays on the signature as a comment. The body declares it as a local, set to its zero value, and a bare return returns it. default! is C#’s zero value (Nil and Zero Values). Several results, named or not, become a C# tuple (Multi-Result Values).

func sum(s []int) (total int) {
	for _, v := range s {
		total += v
	}
	return
}
internal static nint /*total*/ sum(slice<nint> s) {
    nint total = default;

    foreach (var (_, v) in range(s)) {
        total += v;
    }
    return total;
}

slice<nint> is golib’s Go slice of int (Slices and Arrays).

A variadic parameter is a params span. The name ꓸꓸꓸ is made of a Unicode letter that looks like a dot, so it is a legal C# name that reads as Go’s .... ꓸꓸꓸnint is a file-level alias for Span<nint>. The raw pack arrives with the ʗp suffix, as xsʗp, and the body binds the Go name xs:

func sum(xs ...int) int {
	t := 0
	for _, x := range xs {
		t += x
	}
	return t
}
using ꓸꓸꓸnint = Span<nint>;
…
internal static nint sum(params ꓸꓸꓸnint xsʗp) {
    var xs = xsʗp.sslice();

    nint t = 0;
    foreach (var (_, x) in range(xs)) {
        t += x;
    }
    return t;
}

When every use of the pack stays inside the call, as here, xs is an sslice<nint>. That is a stack-only slice view, a C# ref struct, that allocates nothing. When the pack escapes the call, the body binds xsʗp.slice(), a heap slice<nint> copy (Slices and Arrays).

A call such as sum(5) stays sum(5). Spreading a slice, sum(a...), becomes sum(a.ꓸꓸꓸ). The golib property .ꓸꓸꓸ returns a Span over the slice’s own storage, so nothing is copied.

An unnamed parameter gets a made-up name, because C# requires one. A lone one is _, unless the body uses _ as a discard (_ = …, or case <-ch: in a select), which a parameter named _ would capture. Otherwise they become _Δp0, _Δp1 and so on, and an unnamed variadic pack is just ʗp:

func variadic(string, ...int)             {}
internal static void variadic(@string _Δp0, params ꓸꓸꓸnint ʗp) { }

Each init becomes a [GoInit] method. [GoInit] is the project’s alias for .NET’s [ModuleInitializer] attribute, so .NET runs the method once, when the package’s assembly loads. Package Conversion explains how imported packages are made to initialize first. C# cannot declare two methods with the same name and signature, so the first init keeps its name and the rest take a Δ suffix and a number, counted across the whole package:

var order []string

func init() {
	order = append(order, "a_first#1")
}

func init() {
	order = append(order, "a_first#2")
}
internal static slice<@string> order;

[GoInit] internal static void init() {
    order = append(order, (@string)"a_first#1");
}

[GoInit] internal static void initΔ1() {
    order = append(order, (@string)"a_first#2");
}

A u8 literal converts to @string implicitly, so append accepts it (Strings).

Using a method as a value, such as f := c.String, is covered in Function Values and Closures. Methods on generic types are covered in Generics.

Full detail: Reference → Pointers — more on receivers and pointer parameters: box-taking methods called through fields, globals and slice elements, and nil receivers.


Function Values and Closures

Go func types become C# delegates. Func literals become C# lambdas or C# local functions. A closure shares the variables it captures, as in Go, so a write on either side is seen by the other.

The examples use a few golib names. golib is the go2cs runtime library (The golib Runtime Library). nint is Go’s int (Integer Types and Arithmetic). @string is Go’s string, and "hi"u8 is a C# UTF-8 literal that becomes an @string where a string is needed (Strings).

A func type becomes Func<…> or Action<…>. Parameters map in order. A func with no result becomes an Action, and several results become one tuple result (Multi-Result Values). A nil func is a null delegate (Nil and Zero Values).

func applyInt(f func(int) int, a int, b int) int { return f(a) + f(b) }

func applyPush(f func(int)) {
	f(1)
	f(2)
	f(3)
}
internal static nint applyInt(Func<nint, nint> f, nint a, nint b) { return f(a) + f(b); }

internal static void applyPush(Action<nint> f) {
    f(1);
    f(2);
    f(3);
}

A variadic func type uses golib’s Funcꓸꓸꓸ<…> or Actionꓸꓸꓸ<…>. The glyph ꓸꓸꓸ reads as Go’s .... The type arguments list the fixed parameters, then the variadic element type, then the result. Calls through the value pass loose arguments, as in Go.

func apply(f func(prefix string, vals ...int) string) {
	fmt.Println(f("loose", 1, 2, 3))
	fmt.Println(f("empty"))

	nums := []int{4, 5}
	fmt.Println(f("spread", nums...))
}
internal static void apply(Funcꓸꓸꓸ<@string, nint, @string> f) {
    fmt.Println(f("loose", 1, 2, 3));
    fmt.Println(f("empty"));

    var nums = slice<nint>([4, 5]);
    fmt.Println(f("spread"u8, nums.ꓸꓸꓸ));
}

slice<nint>([4, 5]) is the slice literal []int{4, 5} (Slices and Arrays). The spread nums... becomes nums.ꓸꓸꓸ. Functions and Methods covers variadic parameters.

A named func type with methods becomes a C# delegate. Its methods become extension methods on the delegate, as for any named type (Functions and Methods).

type reader func() int

func (f reader) sum(extra int) int { return f() + extra }
internal delegate nint reader();

internal static nint sum(this reader f, nint extra) { return f() + extra; }

A named func type without methods usually has no declaration of its own. Each use is written as the underlying Func<…> or Action<…>, because Go converts freely between a named func type and its underlying type. A comment marks where the declaration would be. In this example, Stringy as a return type is written Func<@string>.

A func literal used as a value becomes a lambda. In the same example, returnsAFunction returns a literal, so the literal becomes the C# lambda () => { … }.

type Stringy func() string
…
func returnsAFunction() Stringy {
	return func() string {
		fmt.Printf("Inner stringy function\n")
		return "bar" // have to return a string to be stringy
	}
}
// type Stringy is a methodless func type — rendered inline as its base delegate
…
internal static Func<@string> returnsAFunction() {
    return () => {
        fmt.Printf("Inner stringy function\n"u8);
        return "bar";
    };
}

"bar" is returned as a Go string, so it is a plain C# literal that golib converts once (Strings).

A literal that its own name := func… declares, and that is only ever called, becomes a C# local function. A local function allocates nothing, while a lambda allocates a delegate. C# closures capture variables, not values, as Go’s do. So add sees the later write to base, and this prints P2: 23 27.

func p2() {
	base := 10
	add := func(a, b int) int { return a + b + base }
	base = 20
	fmt.Println("P2:", add(1, 2), add(3, 4))
}
internal static void p2() {
    nint @base = 10;
    nint add(nint a, nint b) => a + b + @base;
    @base = 20;
    fmt.Println("P2:", add(1, 2), add(3, 4));
}

base is a C# keyword, so the local is written @base (Names and Glyphs). fmt.Println takes any arguments. The literal "P2:" stays a plain C# literal, and go2cs-gen, go2cs’s source generators, intercepts the call to pass it as a Go @string boxed once per file (Strings).

A literal whose name is also used as a value stays a lambda. A local function allocates nothing only while it is only ever called: C# makes one used as a value into a delegate, which allocates as a lambda does. Here f := next copies the function value, so next is a lambda. Both names share one c, and this prints P4: 2 3 3.

func p4() {
	c := 0
	next := func() int { c++; return c }
	next()
	f := next
	fmt.Println("P4:", f(), next(), c)
}
internal static void p4() {
    nint c = 0;
    var next = () => {
        c++;
        return c;
    };
    next();
    var f = next;
    fmt.Println("P4:", f(), next(), c);
}

How a closure reaches a captured variable depends on how that variable is stored. A struct or array local that a closure captures is usually kept in a heap box, much as Go’s escape analysis moves a captured variable to the heap (Pointers). A C# lambda or local function cannot capture the ref alias of a boxed value, so the converter picks one of three routes:

Captured variable The closure reaches it Example
A local of a basic type (number, bool, string) Directly, as a plain C# capture @base in p2
A boxed local that is written after the closure exists Through its box, Ꮡt.Value probeA1
A struct, array, slice, map or channel that nothing writes afterward Through a copy, tʗ1 probeA3

A boxed local written after capture is reached through its box. golib’s heap<Tally>(out var Ꮡt) allocates a box Ꮡt holding a zero Tally, and t is a C# ref alias of the value inside it. The Ꮡ prefix marks a box, and Ꮡt.Value is the same value reached through the box. The local function bump writes through Ꮡt.Value, so both sides change one variable, and this prints A1: 106 s, as Go does.

type Tally struct {
	total int
	log   string
}
…
func probeA1() {
	t := Tally{5, "s"}
	bump := func() { t.total += 100 }
	bump()
	t.total++
	fmt.Println("A1:", t.total, t.log)
}
partial struct Tally {
    internal nint total;
    internal @string log;
}
…
internal static void probeA1() {
    ref var t = ref heap<Tally>(new Tally(5, "s"), out var Ꮡt);
    void bump() {
        Ꮡt.Value.total += 100;
    }
    bump();
    t.total++;
    fmt.Println("A1:", t.total, t.log);
}

Each partial struct is a converted Go type (Struct Types).

A captured variable that nothing writes after the closure exists is read through a copy. The copy always matches, because the variable never changes afterward. The ʗ suffix marks a copy the converter makes for a closure or a method value, and the number keeps the names unique, as in tʗ1 (detail). This uses the same Tally struct, and prints A3: 7.

func probeA3() {
	t := Tally{5, "s"}
	t.total += 2
	get := func() int { return t.total }
	fmt.Println("A3:", get())
}
internal static void probeA3() {
    ref var t = ref heap<Tally>(new Tally(5, "s"), out var Ꮡt);
    t.total += 2;
    var tʗ1 = t;
    nint get() => tʗ1.total;
    fmt.Println("A3:", get());
}

A value-receiver method value copies its receiver. d.printName binds the value d has at that moment, so this program prints Name = James twice. The converter copies d into dʗ1 and calls the method on the copy from a lambda.

type data struct {
	name string
}

func (d data) printName() {
	fmt.Println("Name =", d.name)
}

func main() {
	d := data{name: "James"}
	f1 := d.printName
	f1()
	d.name = "Gretchen"
	f1()
}
partial struct data {
    internal @string name;
}

internal static void printName(this data d) {
    fmt.Println("Name =", d.name);
}

internal static void Main() {
    var d = new data(name: "James");
    
    var dʗ1 = d;
    var f1 = () => dʗ1.printName();
    f1();
    d.name = "Gretchen";
    f1();
}

"James" and "Gretchen" fill string slots, so they are plain C# literals. "Name =" is plain too: it is passed to fmt.Println as an any, so go2cs-gen intercepts the call and boxes it once as a Go string (Strings).

A pointer-receiver method value binds the variable itself. c.dec is Go shorthand for (&c).dec, so c moves into a heap box Ꮡc. That box has golib type ж<counter>: ж<T> is golib’s heap box, and a Go *T is a reference to one (Pointers). The delegate binds to the box, so every write dec makes lands in c. viaLocal returns c.n as 88, as in Go.

type counter struct {
	n     int
	calls int
}

// Pointer receiver: writes must reach the caller's variable.
func (c *counter) dec(step int) int {
	c.n -= step
	c.calls++
	return c.n
}
…
func viaLocal() (int, int) {
	c := counter{n: 100}
	sum := applyInt(c.dec, 5, 7)
	return c.n, sum
}
partial struct counter {
    internal nint n;
    internal nint calls;
}

internal static nint dec(this ref counter c, nint step) {
    c.n -= step;
    c.calls++;
    return c.n;
}
…
internal static (nint, nint) viaLocal() {
    ref var c = ref heap<counter>(new counter(n: 100), out var Ꮡc);
    nint sum = applyInt(Ꮡc.dec, 5, 7);
    return (c.n, sum);
}

A pointer-receiver method is written as an extension method on this ref counter. From that receiver a source generator adds the matching dec overload on ж<counter>, which Ꮡc.dec binds to (Functions and Methods). applyInt is the function from the first example in this section.

Full detail: Reference → Function values and method values — how each method-value and method-expression form binds its receiver.


Loops, Range and Labels

Go has one loop keyword, for. The converter turns each Go loop into a C# for, while or foreach, chosen by the loop’s shape, so a converted loop reads much like the original. Two things look new to a reader: a loop variable is sometimes copied so that each iteration gets its own, and a labeled break or continue becomes a goto. The small glyphs in these names (ᐧ, Δ, ᴛ, ᴷ, Ꮡ) are explained where they first appear here, and all of them are listed in Reading Converted Code.

A for keeps its shape. A three-clause loop stays a C# for. A loop with only a condition, for i < 10 {, becomes while (i < 10) {. An endless for { becomes for (;;) {, and a three-clause loop without a condition keeps the empty clause, for i := 0; ; i++ { as for (nint i = 0; ; i++) {. A loop with no condition never ends as far as C# knows, so the function needs no trailing return, just as in Go.

This example shows two things: the endless loop, and two loop variables that the converter renames.

func compute(n int) int {
	total := 0
	count := 0
	for {
		for off := 0; off < n; off++ {
			total += off
		}
		for off := n; off > 0; off-- {
			total += off
		}
		off := n * 10 // block-level, declared after both loops in the same for{} body
		total += off
		count++
		if count >= 2 {
			return total
		}
	}
}
internal static nint compute(nint n) {
    nint total = 0;
    nint count = 0;
    for (;;) {
        for (nint offΔ1 = 0; offΔ1 < n; offΔ1++) {
            total += offΔ1;
        }
        for (nint offΔ2 = n; offΔ2 > 0; offΔ2--) {
            total += offΔ2;
        }
        nint off = n * 10;
        total += off;
        count++;
        if (count >= 2) {
            return total;
        }
    }
}

Go’s int becomes nint (Integer Types). The Δ1 and Δ2 suffixes rename the two loop variables. C# does not let an inner scope reuse a name that its enclosing block also declares, as off is here (Shadowing).

Every range becomes a foreach. Two range variables become a C# tuple, var (i, v), and a blank _ in the pair stays _. A slice or an array enumerates its values in C#, so a loop that needs Go’s (index, value) pairs ranges golib’s range(s), and one that needs only the index ranges the length; a string, a map and a channel enumerate in Go’s own terms:

Go C# Notes
for i, v := range s foreach (var (i, v) in range(s)) slice: index and element (Slices and Arrays); a named slice type names its element type, range<uint>(x)
for i, v := range a foreach (var (i, v) in range(a.ΔRangeSnapshot())) array value: iterates a copy, as Go does (Slices and Arrays)
for i, r := range s foreach (var (i, r) in s) string: byte offset and rune (Strings)
for k, v := range m foreach (var (k, v) in m) map (Maps)
for v := range c foreach (var v in c) channel: each received value; ends when closed and drained (Channels)
for i := range size foreach (var i in range(size)) integer: golib’s range helper returns a RangeOverInt<T>, an allocation-free struct that counts 0 to size - 1
for i := range s foreach (var i in range(len(s))) slice or array, index only: counts 0 to len(s) - 1 and reads no element

ΔRangeSnapshot() is a golib method that gives the loop its own copy of the array. The Δ prefix marks a name chosen so it cannot clash with a Go name (Names and Glyphs).

An index-only range reads no element. When a range over a slice, an array or a pointer to an array names only the index (for i := range s, for i, _ := range s, or for range s), golib’s range(s) reads the length once and counts the indexes, as Go does. The same holds for a variadic parameter that the converter emits as golib’s stack-only sslice<T> (Slices and Arrays). A pointer to an array held in a variable passes its type’s length, range(p, N), so a nil one ranges N times without being read, as in Go. A pointer parameter or pointer receiver ranges the count itself, range(N). In C# it arrives already dereferenced, as a ref to the array, which is null for a nil pointer and faults when read. Leaving it out is safe: Go does not evaluate the range operand when at most one iteration variable is present and the length is constant, so a nil one ranges N times there too. A string keeps the pair form, foreach (var (i, _) in s), because its indexes are the byte offsets where each rune starts.

A range that assigns with = copies into your variables. A C# foreach must declare its own variables. So when Go ranges into variables that already exist, the foreach declares temporaries, and the body starts by copying them into the real variables. The ᴛ suffix marks a name the converter makes up. The temporaries take generic names such as iᴛ1 and vᴛ1, not your variables’ names:

nums := []int{2, 3, 4}
sum := 0

var i, num, total int

for i, num = range nums {
	sum += num
	total += i
}
var nums = slice<nint>([2, 3, 4]);
nint sum = 0;

nint i = default;
nint num = default;
nint total = default;

foreach (var (iᴛ1, vᴛ1) in range(nums)) {
    i = iᴛ1;
    num = vᴛ1;

    sum += num;
    total += i;
}

slice<nint>([…]) builds a golib slice, Go’s int becomes nint, and default! is Go’s zero value (Nil and Zero Values).

Loop variables are per-iteration. Go gives each iteration of for i := … its own i. A C# for shares one variable across all iterations, so closures made in the loop would all see its last value. When a closure captures i, the converted loop counts with a hidden iᴛ1 and declares a fresh i from it at the top of each iteration. This Go prints g1: 0 1 2, and so does the C#:

func g1() {
	var fs []func() int
	for i := 0; i < 3; i++ {
		fs = append(fs, func() int { return i })
	}
	fmt.Println("g1:", fs[0](), fs[1](), fs[2]())
}
internal static void g1() {
    var fsᴷ = new stackAppend<Func<nint>>();

    slice<Func<nint>> fs = default;
    for (nint iᴛ1 = 0; iᴛ1 < 3; iᴛ1++) {
        var i = iᴛ1;
        fs = append(fsᴷ, fs, () => i);
    }
    fmt.Println("g1:", fs[0](), fs[1](), fs[2]());
}

slice<Func<nint>> is a golib slice of C# delegates. The Go function literal becomes the lambda () => i (Function Values and Closures). The literal "g1:" stays a plain C# literal; go2cs-gen boxes it as a Go string for fmt.Println (Strings). fsᴷ stands for the 32-byte stack buffer Go gives a slice whose appends do not escape the function: append(fsᴷ, fs, …) lets the slice’s first append use that buffer, as Go’s compiled code does (Names and Glyphs).

A body that changes i copies it back. If the body writes i, the change must also advance the loop. So the C# adds iᴛ1 = i; at the end of the body and before each continue. This Go prints g2: 3, then 2, 4 and 6, and so does the C#:

func g2() {
	var fs []func() int
	for i := 0; i < 6; i++ {
		if i%2 == 0 {
			continue
		}
		i++
		fs = append(fs, func() int { return i })
	}
	fmt.Println("g2:", len(fs))
	for _, f := range fs {
		fmt.Println("g2v:", f())
	}
}
internal static void g2() {
    var fsᴷ = new stackAppend<Func<nint>>(moves: true);

    slice<Func<nint>> fs = default;
    for (nint iᴛ1 = 0; iᴛ1 < 6; iᴛ1++) {
        var i = iᴛ1;
        if (i % 2 == 0) {
            iᴛ1 = i;
            continue;
        }
        i++;
        fs = append(fsᴷ, fs, () => i);
        iᴛ1 = i;
    }
    fmt.Println("g2:", len(fs));
    fs = move2heap(fsᴷ, fs);
    foreach (var (_, f) in range(fs)) {
        fmt.Println("g2v:", f());
    }
}

fsᴷ is the stack buffer described under g1. Here fs escapes at one statement outside its loops, the range, so Go still lets its appends use the buffer and copies the slice to the heap just before that statement. fs = move2heap(fsᴷ, fs); is that copy; moves: true declares the buffer for it (Names and Glyphs).

A body that takes &i gets a fresh heap box each iteration. Go’s &i needs i to live on the heap, so each iteration’s i is stored in a golib heap box. This Go prints g3: 0 1 2, and so does the C#:

func g3() {
	var ps []*int
	for i := 0; i < 3; i++ {
		ps = append(ps, &i)
	}
	fmt.Println("g3:", *ps[0], *ps[1], *ps[2])
}
internal static void g3() {
    var psᴷ = new stackAppend<ж<nint>>();

    slice<ж<nint>> ps = default;
    for (nint iᴛ1 = 0; iᴛ1 < 3; iᴛ1++) {
        ref var i = ref heap<nint>(out var Ꮡi);
        i = iᴛ1;
        ps = append(psᴷ, ps, Ꮡi);
        iᴛ1 = i;
    }
    fmt.Println("g3:", ps[0].Value, ps[1].Value, ps[2].Value);
}

ж<nint> is golib’s heap box, the C# form of Go’s *int. heap<nint>(out var Ꮡi) makes a new box: Ꮡi is the box itself, Go’s &i, and i is a C# ref to the value inside it. .Value reads through the box like Go’s *p. A boxed i is always copied back, since code holding the pointer can change it (Pointers). psᴷ is the stack buffer described under g1.

Range over a function becomes a foreach over golib’s range helper. In Go, an iterator is a function that takes a yield callback and calls it once per value. A generic named Go func type such as Seq[V] becomes a C# delegate. The iterator’s function literal becomes a lambda that takes Func<nint, bool> yield, C#’s type for a function from nint to bool:

type Seq[V any] func(yield func(V) bool)
…
func countdown(n int) Seq[int] {
	return func(yield func(int) bool) {
		for i := n; i > 0; i-- {
			if !yield(i) {
				return
			}
		}
	}
}
public delegate void Seq<V>(Func<V, bool> yield);
…
internal static Seq<nint> countdown(nint n) {
    return (Func<nint, bool> yield) => {
        for (nint i = n; i > 0; i--) {
            if (!yield(i)) {
                return;
            }
        }
    };
}

The loop hands the iterator to golib’s range helper. The helper takes a plain callback, Action<Func<T, bool>>, not the named Seq<nint> delegate, so the loop passes the delegate’s .Invoke method. C# cannot infer T from a method passed this way, so the element type is written out: range<nint>. This loop sums 5, 4 and 3, then stops:

sum := 0
for v := range countdown(5) {
	if v == 2 {
		break // range-over-func break: yield returns false, producer stops
	}
	sum += v
}
fmt.Println(sum)
nint sum = 0;
foreach (var v in range<nint>(countdown(5).Invoke)) {
    if (v == 2) {
        break;
    }
    sum += v;
}
fmt.Println(sum);

Go runs the iterator as an ordinary call on the same goroutine. A C# foreach pulls each value from an enumerator, while the iterator pushes each one by calling yield, so the iterator needs a stack of its own to pause on. golib runs it on a second goroutine and uses its Coro handoff type to pass each value to the loop (Goroutines). The iterator and the loop take turns and never run at the same time. A break or return in the loop makes yield return false, so the iterator finishes and runs its defers.

A panic in the iterator is rethrown in the ranging function, where its own defers can recover it (Defer / Panic / Recover).

Labels become goto targets. C# break and continue cannot name an outer loop. So the converter keeps the Go label, adds continue_L:; at the end of the labeled loop’s body and break_L:; just after the loop, and turns continue L and break L into goto statements. A labeled switch gets the same break_L:;, placed right after the switch’s closing brace. A plain Go goto L stays goto L;:

	nums := []int{1, 2, 3, 4}
scan:
	for _, n := range nums {
		for _, m := range nums {
			if n == m {
				continue scan
			}
			if n+m > 5 {
				break scan
			}
			fmt.Println("pair", n, m)
		}
	}
…
    var nums = slice<nint>([1, 2, 3, 4]);
scan:
    foreach (var (_, n) in range(nums)) {
        foreach (var (_, m) in range(nums)) {
            if (n == m) {
                goto continue_scan;
            }
            if (n + m > 5) {
                goto break_scan;
            }
            fmt.Println("pair", n, m);
        }
continue_scan:;
    }
break_scan:;

slice<nint>([…]) builds a golib slice.

An if or switch init clause keeps a block of its own when its names would collide. C# if and switch have no init clause. An init whose names occur nowhere else in the enclosing block is simply a statement before the if, on the same line. Here both ifs declare a, so each declaration and its statement share a new pair of braces, which keeps each a scoped to its statement as in Go (Shadowing):

if a := -1; a < 0 {
	fmt.Println("a is less than 0")
}

if a := 1; a > 0 {
	fmt.Println("a is greater than 0")
}
…
    {
        nint a = -1; if (a < 0) {
            fmt.Println("a is less than 0");
        }
    }

    {
        nint a = 1; if (a > 0) {
            fmt.Println("a is greater than 0");
        }
    }

How the switch itself converts is in Expression Switch Statements.

Full detail: Reference → Labeled Control Flow and Loop Variables — the copy-back rules and heap-boxed loop variables, when a range variable is copied, the = range form, range over every integer type, blank range variables, allocation-free enumerators, and labels on empty statements.


Expression Switch Statements

Go’s expression switch runs exactly one case. It tries the cases from top to bottom, and the labels of one case from left to right, and stops at the first match. It never falls into the next case unless that case ends in fallthrough. C# has a switch statement too, but a label that compares values must be a C# compile-time constant. So the converter emits a real C# switch where the labels allow one, and if statements where they do not.

A few names recur in the examples:

Constant labels become a C# switch. This needs every label to be a number or rune literal, or a constant declared with an explicit numeric or boolean type (const n int = 5), and no fallthrough. An untyped constant such as const n = 5 is not a C# const: it becomes a value of a golib wrapper type such as UntypedInt (a property at package level, a local inside a function) (Constant Values). So a switch with such a label takes the if chain described next.

A tag of a named type, such as type Weekday int, also rules out a C# switch. go2cs emits a named type as a C# struct, and a plain number is not a constant of that struct (Named Numeric Types).

Each case body ends in break; (or a return), since C# requires it, and is wrapped in { }, so each case has its own scope as in Go. A list of labels becomes an or pattern: case 4, 5, 6: becomes case 4 or 5 or 6:. A block Go writes inside a case stays a block. The final }} closes the last case block and the switch together:

i := 2
fmt.Print("Write ", i, " as ")
switch i {
case 1:
	fmt.Println("one")
case 2:
	fmt.Println("two")
case 3:
	{
		fmt.Println("three")
	}
case 4, 5, 6:
	fmt.Println("four, five or siz")
default:
	fmt.Println("unknown")
}
…
    nint i = 2;
    fmt.Print("Write ", i, " as ");
    switch (i) {
    case 1: {
        fmt.Println("one");
        break;
    }
    case 2: {
        fmt.Println("two");
        break;
    }
    case 3: {
        {
            fmt.Println("three");
        }
        break;
    }
    case 4 or 5 or 6: {
        fmt.Println("four, five or siz");
        break;
    }
    default: {
        fmt.Println("unknown");
        break;
    }}

Other labels become a pattern switch. Variables, function calls and Go strings are not C# constants. A Go string becomes golib’s @string, a struct, so it cannot be a C# const. Untyped constants are not either, including Go’s true and false, and nor are constants of a named type, such as time.Saturday.

The switch stays a C# switch over the tag. Each case binds the tag to ᴛ and compares in a when guard, case var ᴛ when ᴛ == time.Monday:, or keeps a constant pattern when every label in it is a C# constant (case 0:). A label list joins with ||. C# tries the guards in order and default last, wherever it is written, which is the order Go evaluates them in. A string tag keeps a temporary under switch (ᐧ), case {} when exprᴛ1 == "get"u8:, because C# would otherwise switch on a decoded .NET string (reference):

switch time.Now().Weekday() {
case time.Saturday, time.Sunday:
	fmt.Println("It's the weekend")
case time.Monday: // Case Mon comment
	fmt.Println("Ugh, it's Monday")
default:
	fmt.Println("It's a weekday")
}
…
    switch (time.Now().Weekday()) {
    case var ᴛ when ᴛ == time.Saturday || ᴛ == time.Sunday: {
        fmt.Println("It's the weekend");
        break;
    }
    case var ᴛ when ᴛ == time.Monday: {
        fmt.Println("Ugh, it's Monday");
        break;
    }
    default: {
        fmt.Println("It's a weekday");
        break;
    }}

The Go comment is absent because this test is converted without the -comments option (Comments).

A switch with no tag becomes switch (ᐧ). In Go, switch { … } means switch true { … }. ᐧ (a small raised dot) is golib’s const bool equal to true, used where only the when conditions matter. Each case becomes case {} when <condition>:. The {} pattern matches any value, so the when condition alone decides.

A comparison against a number literal (or a typed constant) may become a C# pattern with the same meaning, such as t.Hour() is < 12:

t := time.Now()
switch {
case t.Hour() < 12: // Before noon
	fmt.Println("It's before noon")
default: // After noon
	fmt.Println("It's after noon")
}
…
    var t = time.Now();
    switch (ᐧ) {
    case {} when t.Hour() is < 12: {
        fmt.Println("It's before noon");
        break;
    }
    default: {
        fmt.Println("It's after noon");
        break;
    }}

A constant true case with cases after it becomes case {} when ᐧᐧ:. Go accepts case true: first; the later cases can never run, but Go still compiles them. C# would reject those later cases as unreachable. So the converter writes ᐧᐧ, golib’s static readonly bool equal to true, which C# cannot evaluate early:

v := 3
switch {
case true:
	fmt.Println("strict checks disabled")
case v > 2:
	fmt.Println("unreachable but compiled")
}
…
    nint v = 3;
    switch (ᐧ) {
    case {} when ᐧᐧ: {
        fmt.Println("strict checks disabled");
        break;
    }
    case {} when v is > 2: {
        fmt.Println("unreachable but compiled");
        break;
    }}

A switch with an init statement declares its variable just before the switch. Go’s switch x := f(); … scopes x to the switch. The C# declares the variable first, then emits the switch. When the name shadows an outer variable, it gets a Δ1 suffix, and a name that would still collide with another in the enclosing block keeps the two in a { } block of their own (Shadowing). Here hourΔ1 collides with nothing, so there is no block, and after the switch the outer hour still holds 1, as in Go.

Several comparisons of one variable with number literals join into one pattern: hour == 1, hour < 12, hour == 2 becomes hourΔ1 is 1 or < 12 or 2. A pattern needs every label in the case to be one comparison of the same left side with a constant. A label joined with || or &&, such as hour == 2 || hour1 == 4, keeps plain ==, || and && operators:

hour := 1
hour1 := time.Now().Hour()

switch hour := time.Now().Hour(); { // missing expression means "true"
case hour == 1, hour < 12, hour == 2:
	fmt.Println("Good morning!")
case hour == 1, hour < 12, hour == 2 || hour1 == 4:
	fmt.Println("Good morning (opt 2)!")
case hour < 17:
	fmt.Println("Good afternoon!")
case hour == 0:
	fmt.Println("Midnight!")
case hour == 0 && hour1 == 1:
	fmt.Println("Midnight (opt 2)!")
default:
	fmt.Println("Good evening!")
}

fmt.Println(hour)
…
    nint hour = 1;
    nint hour1 = time.Now().Hour();

    nint hourΔ1 = time.Now().Hour();
    switch (ᐧ) {
    case {} when hourΔ1 is 1 or < 12 or 2: {
        fmt.Println("Good morning!");
        break;
    }
    case {} when (hourΔ1 == 1) || (hourΔ1 < 12) || (hourΔ1 == 2 || hour1 == 4): {
        fmt.Println("Good morning (opt 2)!");
        break;
    }
    case {} when hourΔ1 is < 17: {
        fmt.Println("Good afternoon!");
        break;
    }
    case {} when hourΔ1 is 0: {
        fmt.Println("Midnight!");
        break;
    }
    case {} when hourΔ1 == 0 && hour1 == 1: {
        fmt.Println("Midnight (opt 2)!");
        break;
    }
    default: {
        fmt.Println("Good evening!");
        break;
    }}

    fmt.Println(hour);

A switch that uses fallthrough becomes a chain of separate if statements. This applies whether the switch has a tag or not, because C# never lets a case run on into the next one, and its goto case needs a constant label, which a case {} when … arm does not have. A case that can be fallen into starts a new if; the other cases join the chain with else if. Two pieces of state drive the chain:

A case that can be fallen into runs when the flag is set, or when nothing has matched yet and its own label matches: fallthrough || (!matchᴛ5 && <label>). C#’s && binds tighter than ||, so a single-label case like Foo(4) needs no parentheses.

default runs when nothing matched, or when the case before it ends in fallthrough. In that second shape it is guarded if (fallthrough || !matchᴛ5). Here the case before default does not fall through, so it is else if (!matchᴛ5):

func Foo(n int) int {
	fmt.Println(n)
	return n
}
switch Foo(2) {
case Foo(1), Foo(2), Foo(3):
fmt.Println("First case")
fallthrough
case Foo(4):
fmt.Println("Second case")
default:
    fmt.Println("Default case")
}
…
    var exprᴛ6 = Foo(2);
    var matchᴛ5 = false;
    if (exprᴛ6 == Foo(1) || exprᴛ6 == Foo(2) || exprᴛ6 == Foo(3)) { matchᴛ5 = true;
        fmt.Println("First case");
        fallthrough = true;
    }
    if (fallthrough || !matchᴛ5 && exprᴛ6 == Foo(4)) {
        fmt.Println("Second case");
    }
    else if (!matchᴛ5) { /* default: */
        fmt.Println("Default case");
    }

Both programs print 2, 1, 2, First case, Second case. C#’s || stops at the first true operand, so Foo(3) and Foo(4) are never called, just as Go stops trying labels once one matches or a case is entered by fallthrough.

Full detail: Reference → Expression Switch Statements — how a break inside a case leaves it, where default may sit when cases fall through, when a condition becomes a C# pattern, and how named-type and uintptr tags and labels compare.


Type Switch Statements

A Go type switch, switch t := i.(type), becomes a C# pattern switch over i.type(). Each Go case type becomes a C# type pattern, such as case bool t:, so C# picks the arm by the value’s dynamic type.

Why i.type() and not just i. type() is a golib method that returns the Go value the interface really holds. Sometimes the value travels inside a small generated wrapper, an adapter, for example when a pointer is stored in a non-empty Go interface (Interfaces). type() removes that wrapper, and turns a .NET string into Go’s string type, so the C# patterns see the Go value.

Each clause is its own braced arm. An arm ends in break or its own return, because Go clauses never fall through. The last arm’s closing brace and the switch’s closing brace share one line, }}. That is only layout; it has no meaning. Expression Switch Statements uses the same arm shape for value switches.

A complete example. whatAmI is a Go function literal that whatAmI := func… declares and that is only ever called, so it becomes a C# local function inside Main (Function Values and Closures). Go’s interface{} becomes C# any, an alias for object (Empty Interface):

whatAmI := func(i interface{}) {
	switch t := i.(type) {
	case nil:
		// A nil interface matches `case nil` — emitted as the C# `case null:` pattern.
		fmt.Println("I'm nil")
	case bool:
		fmt.Println("I'm a bool")
	case int, int64, uint64:
		fmt.Printf("I'm an int, specifically type %T\n", t)
	default:
		fmt.Printf("Don't know type %T\n", t)
	}
}
whatAmI(true)
whatAmI(1)
whatAmI(int64(2))
whatAmI(uint64(2))
whatAmI("hey")
whatAmI(nil)
private static readonly object heyˢ = (@string)"hey"u8;
…
void whatAmI(any i) {
    switch (i.type()) {
    case null: {
        fmt.Println("I'm nil");
        break;
    }
    case bool t: {
        fmt.Println("I'm a bool");
        break;
    }
    case nint _:
    case int64 _:
    case uint64 _: {
        var t = i;
        fmt.Printf("I'm an int, specifically type %T\n"u8, t);
        break;
    }
    default: {
        var t = i;
        fmt.Printf("Don't know type %T\n"u8, t);
        break;
    }}
}
whatAmI(true);
whatAmI((nint)1);
whatAmI((int64)2);
whatAmI((uint64)2);
whatAmI(heyˢ);
whatAmI(default);

Both versions print the same six lines: I'm a bool; I'm an int, specifically type int, then the same line ending in int64 and in uint64; Don't know type string; and I'm nil. golib’s %T prints Go type names, so a C# nint prints as int. A few names in the C# need a word of explanation:

case nil: becomes case null:. A nil interface is a C# null. No C# type pattern matches null, so only this arm catches it.

A single-type case binds its variable at that type. case bool: becomes case bool t:. The arm declares t as Go does, even when its body does not use it. In sizeOf, t is an int32 in one arm and an int64 in the other, as in Go. Each arm is its own braced scope, so both can declare their own sz:

func sizeOf(v any) int {
	switch t := v.(type) {
	case int32:
		sz := int(t) + 1
		return sz
	case int64:
		sz := int(t) + 2
		return sz
	}
	return 0
}
internal static nint sizeOf(any v) {
    switch (v.type()) {
    case int32 t: {
        nint sz = (nint)t + 1;
        return sz;
    }
    case int64 t: {
        nint sz = (nint)t + 2;
        return sz;
    }}
    return 0;
}

Go’s conversion int(t) becomes the C# cast (nint)t (Integer Types).

default and multi-type cases keep the interface value. In Go, the case variable of such a clause has the switch operand’s type. The C# arm therefore opens with var t = i;, so t is still an any. A multi-type case stacks one label per type over a shared body. Each label binds only a discard, _, because no single type fits all of them.

A pointer case matches the pointer’s box. Go’s *T becomes golib’s ж<T>, a heap box read “zhe” (Pointers). case *bool: becomes case ж<bool> t:, and Go’s *t = true writes through the box with t.Value:

func scanInto(v any) {
	switch t := v.(type) {
	case *bool:
		*t = true
	case *int:
		*t = 42
	}
}
internal static void scanInto(any v) {
    switch (v.type()) {
    case ж<bool> t: {
        t.Value = true;
        break;
    }
    case ж<nint> t: {
        t.Value = 42;
        break;
    }}
}

An interface that holds a nil *T is not a nil interface in Go, and it matches case *T: with a nil t. The converted code does the same: such an interface carries golib’s shared nil box, not a C# null, so type() returns that box and the ж<T> pattern matches it. The TypeSwitchNilPointer behavioral test checks this against Go.

case int: matches only a Go int. Go’s int becomes the native-sized nint (Integer Types), so case int: is case nint and case uint: is case nuint. As in Go, an int32 or a rune held in the interface does not take the int arm, and a uint32 does not take the uint arm: each goes to its own case, or to default. The TypeSwitchIntWidthCases behavioral test checks this against Go.

An interface case matches by method set. A C# type pattern only sees the interfaces a class declares. Go satisfies an interface by having its methods, so a case such as case error: asks golib instead (Interfaces). This switch inspects the value that recover() returns inside a deferred function (Defer / Panic / Recover):

p := recover()
switch v := p.(type) {
case nil:
	out = "no panic"
case string:
	out = fmt.Sprintf("string(%s) eq-x=%v", v, v == "x")
case error:
	out = "error(" + v.Error() + ")"
case int:
	out = fmt.Sprintf("int(%d)", v)
default:
	out = "other (not a plain string)"
}
var p = recover();
switch (p.type()) {
case null: {
    @out = "no panic";
    break;
}
case @string v: {
    @out = fmt.Sprintf("string(%s) eq-x=%v"u8, v, v == "x"u8);
    break;
}
case {} Δv when Δv._<error>(out var v): {
    @out = "error("u8 + v.Error() + ")"u8;
    break;
}
case nint v: {
    @out = fmt.Sprintf("int(%d)"u8, v);
    break;
}
default: {
    var v = p;
    @out = "other (not a plain string)";
    break;
}}

case error: becomes case {} Δv when Δv._<error>(out var v):. The {} pattern matches any non-null value and captures it as Δv. Δv is a made-up name: the Δ prefix keeps it from clashing with the v that the same label binds (Names and Glyphs). The when guard calls _<T>, golib’s type assertion, in a form that returns false instead of panicking. On success, v holds the value as an error.

_<T> decides at run time, from the methods of the value’s Go type: it uses the adapter generated for that type and interface when one exists, and otherwise builds one with golib’s AdapterBinder.

The other names in this example:

Full detail: Reference → Type Switch Statements — switches with no case variable, how cases that share one C# type merge, anonymous interface labels, and how interface cases find methods declared on a pointer.


Defer / Panic / Recover

Go’s defer, panic and recover become a C# try/catch/finally plus three calls into golib, go2cs’s runtime library. The function body stays inline, so it still reads like the Go. You will notice a local named ᒐ, defer(…, ref ᒐ) calls, and throw panic(x).

defer, panic and recover are static methods of golib’s builtin class. Every converted project imports that class with a project-wide using static, so the calls need no class name, as in Go (Built-in Functions).

A function that defers wraps its body in try/catch/finally. This is the smallest defer:

func main() {
	fmt.Println("Open file")
	defer fmt.Println("Close file")
	fmt.Println("Write data to file")
}
internal static void Main() {
    GoFrame ᒐ = default;
    try {
        fmt.Println("Open file");
        defer(() => fmt.Println("Close file"), ref ᒐ);
        fmt.Println("Write data to file");
    }
    catch (Exception ᒐex) when (GoFrame.IsPanic(ᒐex, out PanicException? ᒐp)) { GoFrame.Capture(ᒐp); }
    finally { ᒐ.Run(); }
}

Each part has one job:

Any other .NET exception fails the filter and is not caught. The finally still runs the deferred calls, but recover() does not see that exception and returns nil.

So the program prints “Open file”, “Write data to file”, then “Close file”, as in Go.

Every literal here stays a plain C# literal. defer’s lambda calls fmt.Println("Close file") exactly as the direct calls do: "Close file" is a constant, so it has the same value whenever it is evaluated, and it needs no capture. go2cs-gen intercepts each fmt.Println call and passes the literal as a boxed Go string (Strings).

defer evaluates its arguments at once, as Go does; only the call waits. Each argument that is not a constant is passed to defer beside the lambda, and the lambda receives it later as ᴛ1, ᴛ2:

func printSquare(n int) {
	defer fmt.Println("Deferred square:", n*n)
	n++
	fmt.Println("Immediate n:", n)
}
internal static void printSquare(nint n) {
    GoFrame ᒐ = default;
    try {
        defer(ᴛ1 => fmt.Println("Deferred square:", ᴛ1), n * n, ref ᒐ);
        n++;
        fmt.Println("Immediate n:", n);
    }
    catch (Exception ᒐex) when (GoFrame.IsPanic(ᒐex, out PanicException? ᒐp)) { GoFrame.Capture(ᒐp); }
    finally { ᒐ.Run(); }
}

printSquare(5) computes n * n while n is still 5. It prints “Immediate n: 6”, then “Deferred square: 25”, as in Go. nint is Go’s int (Integer Types).

panic(x) becomes throw panic(x), and recover() is a plain call. golib’s panic builds a PanicException that carries x, and throw raises it. A deferred closure calls recover() directly: it reads the panic that the catch parked, and stops it. This function panics and recovers in one place:

// recover sets the named returns on panic; normal path leaves them.
func guarded(boom bool) (code int, msg string) {
	defer func() {
		if r := recover(); r != nil {
			code = -1
			msg = "recovered"
		}
	}()
	if boom {
		panic("kaboom")
	}
	code = 0
	msg = "ok"
	return code, msg
}
internal static (nint code, @string msg) guarded(bool boom) {
    nint code = default;
    @string msg = default;
    GoFrame ᒐ = default;
    try {
        defer(() => {
            var r = recover(); if (r != nil) {
                code = -1;
                msg = "recovered";
            }
        }, ref ᒐ);
        if (boom) {
            throw panic("kaboom");
        }
        code = 0;
        msg = "ok";
    }
    catch (Exception ᒐex) when (GoFrame.IsPanic(ᒐex, out PanicException? ᒐp)) { GoFrame.Capture(ᒐp); }
    finally { ᒐ.Run(); }
    return (code, msg);
}

With boom true, the throw jumps to the catch, which parks the panic. The finally runs the deferred closure, whose recover() returns “kaboom”. The panic stops there, and guarded returns -1, "recovered". With boom false, recover() returns nil, and guarded returns 0, "ok".

recover() stops a panic only when the deferred function calls it directly. As in Go, it stops only the panic whose deferred calls are running, and in the deferred calls of a function that is returning normally it returns nil, even while an outer panic unwinds. A deferred literal, as in guarded, is the deferred function itself, so its recover() is a plain call. A named function can be deferred too, and then Go asks how it was called: deferred, its recover() stops the panic; called by a deferred function, it returns nil and the panic goes on. The converter marks both ends of that question. A named function whose body calls recover() takes golib’s mark as its first line and passes it to each recover, and a defer whose callee may be such a function registers with recovers: true, which tells golib to set the mark just before that call:

func catchError(err *error) {
	if e := recover(); e != nil {
		*err = fmt.Errorf("caught: %v", e)
	}
}

func deferNamedWithPointer() (err error) {
	defer catchError(&err)
	panic("three")
}
internal static void catchError(ж<error> Ꮡerr) {
    GoFrame ᒐ = default;
    try {
        var calledByDefer = GoFrame.TakeDeferredCall(); // Go: is this call the deferred one?
        ref var err = ref Ꮡerr.DerefOrNull();

        var e = recover(calledByDefer); if (e != nil) {
            err = fmt.Errorf("caught: %v"u8, e);
        }
    }
…
internal static error /*err*/ deferNamedWithPointer() {
…
        defer(catchError, Ꮡerr, ref ᒐ, recovers: true);
        throw panic("three");

recover(calledByDefer) returns nil when the mark was not set, so catchError called from inside a deferred literal leaves the panic running, as Go’s does. ж<error> is golib’s form of Go’s pointer *error (Pointers). Three cases are not decided this way. An exported function that calls recover() keeps a plain recover(), because another package can defer it and that package’s defer cannot set the mark; such a function also stops a panic when a deferred function calls it, where Go’s would return nil. Package main exports nothing, so a program’s own functions follow Go. A defer through a func value, a func-typed field or an interface method is marked when its signature matches a recovering function of the package. An unexported recovering function that another package receives as a func value and defers is not marked there, so its recover returns nil.

recover() returns Go’s any, a C# object? that is null when no panic is in flight. Converted code spells Go’s nil and zero values as default!, so r != default! is Go’s r != nil (Nil and Zero Values). Go’s if r := …; r != nil is written on one line, as Go writes it: nothing else in the deferred literal is named r, so the init needs no block of its own (Shadowing).

"recovered" and "ok" are assigned to a string result, so each is a plain C# literal that golib converts once (Strings). The literal passed to panic stays a plain C# string too. golib’s panic turns it into a Go string, so recover() sees Go’s type.

Named results are declared before the try and returned after the finally. Go runs deferred calls after return sets the results, so a deferred call can still change them, as guarded does. A C# finally cannot change a value already returned. So code and msg are plain locals, and the one real return follows the finally.

The deferred lambda captures code and msg as ordinary C# closure variables, so its changes land in the values returned. The C# tuple (nint code, @string msg) carries Go’s two results (Functions and Methods). Each Go return in the body only sets the results: in guarded, return code, msg needs no C# statement, because the results already hold those values.

An early return sets the results and jumps to the label ᒐdone on the final return. Leaving the try this way still runs the finally, so the deferred calls still see and change the results:

func compute(x int) (out int, label string) {
	defer func() { out += 1000 }() // proves the named result is returned post-defer
	if x < 0 {
		out, label = -1, "neg"
		return out, label // early return of the named results (mid-body): keeps `return;`
	}
	return double(x), fmt.Sprintf("v=%d", x)
}
internal static (nint @out, @string label) compute(nint x) {
    nint @out = default;
    @string label = default;
    GoFrame ᒐ = default;
    try {
        defer(() => {
            @out += 1000;
        }, ref ᒐ);
        if (x < 0) {
            (@out, label) = (-1, "neg");
            goto ᒐdone;
        }
        (@out, label) = (@double(x), fmt.Sprintf("v=%d"u8, x));
    }
    catch (Exception ᒐex) when (GoFrame.IsPanic(ᒐex, out PanicException? ᒐp)) { GoFrame.Capture(ᒐp); }
    finally { ᒐ.Run(); }
    ᒐdone: return (@out, label);
}

compute(-5) sets -1, "neg" and jumps to ᒐdone. The finally adds 1000 on the way out, so it returns 999, "neg". compute(3) returns 1006, "v=3". The last Go return needs no jump, because the try simply ends.

@out and @double carry an @ because out and double are C# keywords (Reading Converted Code). double is a small function in the same test that returns n * 2. (@out, label) = (-1, "neg") is Go’s out, label = -1, "neg" (Multi-Assignment).

A function whose results are unnamed returns zero values after a recovered panic. Its catch ends with return default!;, which is Go’s rule. A function with no results needs no return there. Here pick recovers any panic its body raises:

func pick(kind int) Shape {
	defer func() { _ = recover() }()
	if kind == 0 {
		return Circle{R: 2}
	}
	return Square{S: 5}
}
internal static Shape pick(nint kind) {
    GoFrame ᒐ = default;
    try {
        defer(() => {
            _ = recover();
        }, ref ᒐ);
        if (kind == 0) {
            return new Circle(R: 2);
        }
        return new Square(S: 5);
    }
    catch (Exception ᒐex) when (GoFrame.IsPanic(ᒐex, out PanicException? ᒐp)) { GoFrame.Capture(ᒐp); return default; }
    finally { ᒐ.Run(); }
}

Shape is an interface, and Circle and Square are structs that implement it (Interfaces). Unnamed results need no locals, so each Go return stays a C# return inside the try.

If the body panicked, the catch would set the result to nil, the zero Shape. The finally would then run the deferred recover(), and pick would return nil. If no deferred call recovers, ᒐ.Run() throws the panic again, and that return default! never completes.

A panic travels up through callers until a deferred recover() stops it. When no deferred call in a frame recovers, ᒐ.Run() throws the panic again after the deferred calls finish. The caller’s frame then catches it the same way. Here divide has no defer, so it has no GoFrame and no catch, and outerGuard recovers. (quo is golib’s signed division, see Integer Types and Arithmetic.)

func divide(a, b int) int { return a / b }

// the panic propagates across an intermediate (non-defer) frame to an outer recover.
func outerGuard(a, b int) (ok bool) {
	defer func() {
		if recover() != nil {
			ok = false
		}
	}()
	divide(a, b) // panic in divide() unwinds through this frame to the recover above
	return true
}
internal static nint divide(nint a, nint b) { return quo(a, b); }

internal static bool /*ok*/ outerGuard(nint a, nint b) {
    bool ok = default;
    GoFrame ᒐ = default;
    try {
        defer(() => {
            if (recover() != nil) {
                ok = false;
            }
        }, ref ᒐ);
        divide(a, b);
        ok = true;
    }
    catch (Exception ᒐex) when (GoFrame.IsPanic(ᒐex, out PanicException? ᒐp)) { GoFrame.Capture(ᒐp); }
    finally { ᒐ.Run(); }
    return ok;
}

outerGuard(4, 2) returns true, and outerGuard(4, 0) returns false. A single named result keeps its Go name in a comment, bool /*ok*/. Go’s return true becomes ok = true;, because the named result is returned after the finally. A function literal that defers gets its own ᒐ inside its lambda (Function Values and Closures).

Runtime errors are panics too. In divide, a / b with b zero raises .NET’s DivideByZeroException. outerGuard’s catch, the nearest one up the call stack, turns it into Go’s panic, runtime error: integer divide by zero. A nil pointer dereference is mapped the same way, and golib’s own bounds checks raise index-out-of-range panics directly. Each reaches recover() with Go’s runtime error message.

A panic that nothing recovers ends the program as Go does: a panic: … report on stderr and exit code 2, even when it starts in a goroutine. The report opens with Go’s panic chain: each earlier panic still on the stack gets a line of its own, marked [recovered] when a recover() stopped it, or [recovered, repanicked] when it was recovered and then raised again with the same value, which then gets no second line. A traceback with Go’s function names, files and lines follows; Limitations lists where it is shorter than Go’s.

Full detail: Reference → Defer / Panic / Recover — why the body is not a lambda, every named-result form, which deferred calls move into the finally, variadic and value-returning deferred calls, each runtime panic value, and the crash report.


Goroutines

Go’s go statement becomes a call to goǃ, a function in golib, the runtime library every converted program uses (goǃ). goǃ runs the call on a new goroutine. The name’s last character is not an exclamation mark: it is the Latin letter U+01C3, which most fonts draw the same way. That letter keeps the method’s name apart from go, the C# namespace every converted file lives in (glyphs).

Each goroutine runs on an operating-system thread of its own, from start to finish. That is the main difference from Go, and the last part of this section explains what it means for a program.

goǃ takes the function and its arguments separately. Most goǃ overloads take an Action, a C# delegate that returns nothing, followed by the argument values. A matching set of twins takes a Func, a delegate that returns a value, and throws the result away, as Go does; a function literal that returns a value binds one of these. Each overload stores the values and calls the delegate with them on the new goroutine:

    public static void goǃ<T>(Action<T> action, T arg, [CallerFilePath] string ᴛcallerFile = "", [CallerLineNumber] int ᴛcallerLine = 0)
    {
        Goroutine.Start(() => action(arg), action, ᴛcallerFile, ᴛcallerLine);
    }

Overloads like this one exist for up to sixteen arguments. The C# compiler fills in the last two parameters with the file and line of the go statement, so converted code never passes them; golib uses them to record which function started the goroutine.

Arguments are evaluated at the go statement. Because the arguments are passed to goǃ, they are computed before the goroutine starts, as in Go:

func printSquare(n int) {
	go fmt.Println("Go thread square:", n*n)
	n++
	fmt.Println("Immediate n:", n)
}
internal static partial void printSquare(nint n) {
    goǃ(ᴛ1 => fmt.Println("Go thread square:", ᴛ1), n * n);
    n++;
    fmt.Println("Immediate n:", n);
}

Called as printSquare(5), the goroutine prints 25 even when it runs after n has become 6. n * n is computed when goǃ is called, not when the goroutine runs.

A few things in this snippet are explained in other sections:

Some callees are wrapped in a lambda. When the function returns a value, takes a variable number of arguments, or is a builtin, the converter does not pass it to goǃ directly. It writes a lambda over temporary parameters named ᴛ1, ᴛ2, and so on. fmt.Println needs this form twice over: it is variadic, and it returns (n int, err error).

A function with fixed parameters and no result is passed as it is. This test shows both cases side by side:

func sum(out chan int, a, b int) int {
	r := a + b
	out <- r
	return r
}
…
func emit(out chan int) {
	out <- 8
}

func main() {
	out := make(chan int)

	go sum(out, 3, 4) // value-returning, 3 params -> goǃ(ᴛ1 => sum(ᴛ1, 3, 4), out)
	fmt.Println("sum:", <-out)
	…
	go emit(out) // control: void method group, unchanged bare method group
	fmt.Println("emit:", <-out)
	…
}
internal static nint sum(channel<nint> @out, nint a, nint b) {
    nint r = a + b;
    @out.ᐸꟷ(r);
    return r;
}
…
internal static void emit(channel<nint> @out) {
    @out.ᐸꟷ(8);
}
…
internal static partial void Main() {
    var @out = new channel<nint>(0);

    goǃ(ᴛ1 => sum(ᴛ1, 3, 4), @out);
    fmt.Println("sum:", ᐸꟷ(@out));
    …
    goǃ(emit, @out);
    fmt.Println("emit:", ᐸꟷ(@out));
    …
}

The trailing comments in the Go are the test’s own notes; they do not appear in the C#.

sum returns a value, so it gets the lambda. C# lets a lambda whose body is a single call convert to an Action, and the call’s result is then thrown away. That matches Go, which discards whatever a goroutine’s function returns. emit returns nothing, so go emit(out) becomes goǃ(emit, @out).

channel<nint> is golib’s Go channel, and ᐸꟷ is Go’s <- arrow: @out.ᐸꟷ(r) sends and ᐸꟷ(@out) receives (Channels). out is a C# keyword, so it is written @out.

A function literal becomes a lambda, and it shares captured variables. go func() { … }() becomes goǃ(() => { … }). As in Go, the literal captures the variable itself, not a copy of its value:

func main() {
	…
	count := 1
	go func() {
		fmt.Println("Go count (closure):", count)
	}()
	count = 10
	fmt.Println("Count before Go:", count)
	…
}
internal static partial void Main() {
    …
    nint count = 1;
    goǃ(() => {
        fmt.Println("Go count (closure):", count);
    });
    count = 10;
    fmt.Println("Count before Go:", count);
    …
}

The goroutine may print 1 or 10, depending on when it runs, exactly as the Go program may. In other converted code you may see a captured variable read through a copy with a ʗ suffix, such as outʗ1. The converter makes that copy only where it behaves like Go’s shared variable: the variable is not assigned again after the goroutine is created, or it is a loop variable, where the copy stands in for Go’s per-iteration variable (Function Values and Closures).

An unrecovered panic in any goroutine ends the whole program, as in Go. Go’s panic: report goes to standard error and the process exits with code 2. golib’s panic returns an exception, and the converted code throws it (Defer / Panic / Recover):

func main() {
	fmt.Println("before goroutine panic")

	done := make(chan struct{})

	go func() {
		panic("goroutine boom")
	}()

	// Never satisfied: the goroutine's unrecovered panic must crash the whole process with
	// exit code 2 before this receive can proceed.
	<-done
}
internal static partial void Main() {
    fmt.Println("before goroutine panic");

    var done = new channel<EmptyStruct>(0);

    goǃ(() => {
        throw panic("goroutine boom");
    });

    ᐸꟷ(done);
}

EmptyStruct is golib’s stand-in for Go’s struct{} (Struct Types). ᐸꟷ(done) is the receive <-done, which waits until the panic ends the process (Channels).

A goroutine keeps its own thread from start to finish. golib’s Goroutine class runs every goǃ on a background thread that belongs to that goroutine until it ends. A goroutine that blocks on a channel, a lock or a sleep blocks only its own thread. When a goroutine ends, its thread waits for the next goǃ instead of exiting, so a go statement usually hands its goroutine a waiting thread rather than creating one; with none waiting, it creates one. At most 64 threads wait at once, and a thread that waits 10 seconds with no goroutine to run exits. A reused thread carries nothing over: golib resets its thread state between goroutines, and each goroutine runs under the ExecutionContext of the code that started it. A goroutine that ends while locked to its thread by runtime.LockOSThread takes the thread with it, as Go’s does. As in Go, the process exits when main returns, even while goroutines are still running. The one exception, also Go’s, is a goroutine that is panicking: main’s return waits for its report and exits 2 with it (golib’s MainExit; see defer, panic and recover).

A goroutine that cannot recurse starts on a smaller stack. A Go goroutine’s stack grows as it needs, while a .NET thread’s stack is a fixed reservation that ends the process when it overflows. So golib reserves a Go-scale 256 MiB stack for each goroutine by default (GO2CS_GOROUTINE_STACK changes it). That reservation is address space rather than memory, but it is what makes a hundred thousand goroutines parked at once slow to start. The converter proves, for each go statement, whether its goroutine can recurse: every call it can make must resolve to a function the converter can see, and no chain of calls may return to a function already on it. golib runs a goroutine it proves cannot recurse on a 16 MiB stack (GO2CS_GOROUTINE_BOUNDED_STACK changes it). Here produce only computes and sends on a channel, and its go statement reads as any other:

go produce(i, out)
goǃ(produce, i, @out.AsSendOnly());

The mark is a record in the package’s package_info.cs, naming the file, line and column where that goǃ call starts, and which goǃ call of the file it is (here the second, ordinal 1). go2cs-gen intercepts the call there and starts the goroutine on the smaller stack. A record that no longer lands on such a call fails the build (GO2CS0010) rather than start some other goroutine on the small stack. The ordinal is what catches a record a merge left one line off, on a neighbouring goǃ call, which the position alone cannot tell from the right one:

[assembly: GoBoundedGoroutine("main.cs", 40, 9, 1)]

Anything the proof cannot follow keeps the full stack, unrecorded. That includes a call through an interface or a func value, a range-over-func loop, and a goroutine that reaches either through the standard library, so a goroutine that does I/O, formats with fmt or serves a connection keeps today’s reservation. A mistaken mark would crash where Go grows the stack, so the proof gives up rather than guess.

Converted code is ordinary blocking code, not async code. A goroutine pauses by blocking its thread, so each goroutine needs a thread of its own. goǃ does not use the .NET thread pool, because a blocked goroutine would hold a shared pool thread and starve the others. golib cannot run many goroutines on fewer threads either, because .NET cannot move a running call to another stack. That is a property of today’s .NET rather than of the conversion: goroutines are started in one place in golib, so if .NET gains threads its runtime can park cheaply, golib can adopt them and converted code stays as it is.

Each goroutine thread reserves a large stack. Go stacks grow as needed, but a .NET stack overflow ends the process. A goroutine thread therefore reserves 256 MiB of address space, or 16 MiB when the converter proves the goroutine cannot recurse (above); memory is committed only as it is used. The GO2CS_GOROUTINE_STACK environment variable sets a different size.

Operating-system threads bound live goroutines far below Go’s limit. Go’s goroutines reach about a million. The limit comes from how many threads the operating system can keep alive at once, not from the stack reservation. A Go program that starts hundreds of thousands of goroutines at once does not run the same way after conversion.

runtime keeps Go’s goroutine contracts on top of threads. runtime.Goexit runs the goroutine’s deferred calls and ends only that goroutine. Called from main, it runs main’s deferred calls and ends main without returning: the other goroutines keep running, and once none can run the program dies with Go’s fatal error: no goroutines (main called runtime.Goexit) - deadlock!, exit status 2. runtime.LockOSThread holds because each goroutine already owns its thread, and a goroutine that ends while locked retires its thread instead of handing it to the next goroutine. These functions are hand-written C# (Manually-Converted Declarations).

sync and sync/atomic run on .NET primitives. A sync.Mutex that must wait blocks its thread in golib’s runtime semaphore, which waits on a .NET event. Most sync/atomic functions are one line: AddInt32 returns Interlocked.Add(ref addr.Value, delta). Here addr is a ж<int32>, golib’s box for a Go pointer, and .Value is the int32 it points to (Pointers).

Full detail: Reference → Goroutine callees — the other go-statement forms (named function types, builtins, value receivers, multi-value arguments), where captured locals are copied, and the tests that guard each form.


Channels and select

Go’s chan T becomes golib channel<T>. golib is go2cs’s runtime library (The golib Runtime Library). channel<T> is a port of Go’s own channel runtime, so blocking, buffering and closing behave as in Go. What the reader notices is the arrow glyph ᐸꟷ, which spells both send and receive (glyph table).

Send is a method call and receive is a function call. ch <- v becomes ch.ᐸꟷ(v), and <-ch becomes ᐸꟷ(ch). make(chan int, 2) becomes new channel<nint>(2), whose argument is the buffer size. Go’s int is C# nint (Integer Types):

ch := make(chan int, 2)

ch <- 1
ch <- 2

fmt.Println(<-ch)
fmt.Println(<-ch)
var ch = new channel<nint>(2);

ch.ᐸꟷ(1);
ch.ᐸꟷ(2);

fmt.Println(ᐸꟷ(ch));
fmt.Println(ᐸꟷ(ch));

len, cap and close keep their names, and a comma-ok receive adds ꟷ. ꟷ is a golib constant of type bool. Its value does not matter: passing it only selects the overload of ᐸꟷ that returns (value, ok). A comma-ok map read works the same way (comma-ok forms).

A closed channel still yields its buffered values with ok true, then the zero value with ok false. This program prints 7 true, 8 true, then 0 false:

d := make(chan int, 2)
d <- 7
d <- 8
close(d)
v, ok := <-d
fmt.Println(v, ok)
v, ok = <-d
fmt.Println(v, ok)
v, ok = <-d
fmt.Println(v, ok)
var d = new channel<nint>(2);
d.ᐸꟷ(7);
d.ᐸꟷ(8);
close(d);
var (v, ok) = ᐸꟷ(d, ꟷ);
fmt.Println(v, ok);
(v, ok) = ᐸꟷ(d, ꟷ);
fmt.Println(v, ok);
(v, ok) = ᐸꟷ(d, ꟷ);
fmt.Println(v, ok);

Blocking and nil match Go. make(chan int) becomes new channel<nint>(0): an unbuffered channel, so a send waits for a receiver. A nil channel is the C# default!, the zero value of channel<T> (Nil and Zero Values). len and cap of a nil channel are 0. A send or receive on a nil channel blocks forever, as in Go.

A channel’s direction shows as a marker comment. The marker /*<-*/ sits where Go’s arrow sits. Receive-only <-chan T renders as /*<-*/channel<T>, and send-only chan<- T as channel/*<-*/<T>. All three directions are one C# type, so C# does not check the direction. In a method’s signature, a source generator reads the comment and records the direction for reflect.

In this example, "output" is sent on a chan string, so it is a plain C# literal that golib converts once (Strings). @string is golib’s Go-style byte string:

func sendOnly(s chan<- string) {
	s <- "output"
}
internal static void sendOnly(channel/*<-*/<@string> s) {
    s.ᐸꟷ("output");
}

Passing a channel where a directional one is expected keeps the direction in the value. The call re-stamps the argument with AsSendOnly() (AsRecvOnly() for a receive-only parameter), so the channel value knows its direction, as a Go value does. Go converts implicitly; C# needs the call because both sides are channel<T>. goǃ is the go statement. Go comments are dropped unless the converter runs with -comments (Comments), so the Go comment here has no C# line:

mychanl := make(chan string)

// function converts bidirectional channel to send only channel
go sendOnly(mychanl)
var mychanl = new channel<@string>(0);

goǃ(sendOnly, mychanl.AsSendOnly());

range over a channel becomes foreach. The loop receives each value and ends when the channel is closed and drained, as Go’s does (Loops, Range and Labels). Again, the Go comments are dropped by default (Comments). rem(i, prime) is Go’s i % prime: golib’s rem gives Go’s result when the divisor is -1 (Integer Types):

func filter(src <-chan int, dst chan<- int, prime int) {
	for i := range src { // Loop over values received from 'src'.
		if i%prime != 0 {
			dst <- i // Send 'i' to channel 'dst'.
		}
	}
}
internal static void filter(/*<-*/channel<nint> src, channel/*<-*/<nint> dst, nint prime) {
    foreach (var i in src) {
        if (rem(i, prime) != 0) {
            dst.ᐸꟷ(i);
        }
    }
}

select becomes a C# switch over golib’s select(…) call. Four steps happen, in this order:

  1. Each case’s operands are evaluated once, in source order, into a temporary named selᴛN. The ᴛ marks a name the converter makes up, and N counts up through the file (Multi-Assignment). For a receive, the temporary holds the channel. For a send, it holds the registration itself (step 2), which has already captured the channel and the value.
  2. Each case is registered rather than performed: ch.ᐸꟷ(v, ꓸꓸꓸ) for a send and ᐸꟷ(ch, ꓸꓸꓸ) for a receive. ꓸꓸꓸ is a golib marker value. As a lone argument, it only picks the overload that registers the operation, the way ꟷ picks the comma-ok overload (glyph table).
  3. select(…) waits until a case is ready, commits one chosen at random as Go does, and returns its position, counting from 0.
  4. The matching case N: runs. A receive case adds a when guard that calls ꟷᐳ, the receive arrow reversed. For the chosen case the guard is always true. It exists so the received value can be handed to that case alone; out _ discards it.

In this example, for (;;) is Go’s endless for (Loops). "quit" stays a plain C# literal: fmt.Println takes Go’s any, so go2cs-gen intercepts the call and passes it as a boxed Go string (Empty Interface). The closing }} ends the last case and the switch together:

func fibonacci(f, quit chan int) {
	x, y := 0, 1
	for {
		select {
		case f <- x:
			x, y = y, x+y
		case <-quit:
			fmt.Println("quit")
			return
		}
	}
}
internal static void fibonacci(channel<nint> f, channel<nint> quit) {
    nint x = 0;
    nint y = 1;
    for (;;) {
        var selᴛ1 = f.ᐸꟷ(x, ꓸꓸꓸ);
        var selᴛ2 = quit;
        switch (select(selᴛ1, ᐸꟷ(selᴛ2, ꓸꓸꓸ))) {
        case 0: {
            (x, y) = (y, x + y);
            break;
        }
        case 1 when selᴛ2.ꟷᐳ(out _): {
            fmt.Println("quit");
            return;
        }}
    }
}

A receive case that names a variable declares it in the when guard. case m := <-a: becomes case 0 when selᴛ12.ꟷᐳ(out var m):, as in the example that follows.

A comma-ok case adds a second out variable: case v1, ok := <-ch3: becomes case 2 when selᴛ5.ꟷᐳ(out var v1, out var okΔ1):. The Δ1 suffix renames a variable whose name is used again in the same function (Shadowing).

When every case returns, Go needs no return after the select. C# cannot prove the switch always returns, so an unreachable return default!; follows it. The "a:"u8 literal stays inline, because a literal in a concatenation is not hoisted (Strings):

func firstMsg(a, b chan string) string {
	select {
	case m := <-a:
		return "a:" + m
	case m := <-b:
		return "b:" + m
	}
}
internal static @string firstMsg(channel<@string> a, channel<@string> b) {
    var selᴛ12 = a;
    var selᴛ13 = b;
    switch (select(ᐸꟷ(selᴛ12, ꓸꓸꓸ), ᐸꟷ(selᴛ13, ꓸꓸꓸ))) {
    case 0 when selᴛ12.ꟷᐳ(out var m): {
        return "a:"u8 + m;
    }
    case 1 when selᴛ13.ꟷᐳ(out var m): {
        return "b:"u8 + m;
    }}
    return default;
}

With a default: clause, the select calls trySelect instead. It checks the same registrations without waiting and returns -1 when no case is ready. The C# default: label then runs exactly when Go’s would. Go’s struct{} is golib’s EmptyStruct, a struct with no fields:

func poll(done chan struct{}) string {
	select {
	case <-done:
		return "done"
	default:
	}
	return "pending"
}
internal static @string poll(channel<EmptyStruct> done) {
    var selᴛ14 = done;
    switch (trySelect(ᐸꟷ(selᴛ14, ꓸꓸꓸ))) {
    case 0 when selᴛ14.ꟷᐳ(out _): {
        return "done";
    }
    default: {
        break;
    }}
    return "pending";
}

Nil and closed channels inside a select also match Go. A nil channel’s case is never chosen. A closed channel’s receive case is always ready: it yields any values still buffered, then the zero value with ok false.

Full detail: Reference → Maps and Channels — how golib ports Go’s channel and select runtime, the exact operand-evaluation rules, and named channel types.


Struct Types

A Go struct becomes a C# partial struct. The converter writes only the fields, so the declaration reads like the Go original. A source generator completes the other partial half at compile time, adding constructors, equality and more. Like a Go struct, a C# struct is a value type: it is copied on assignment and needs no heap allocation of its own.

The declaration keeps Go’s fields in Go’s order. An exported field (capitalized) is public, and an unexported one is internal. Fields declared together, such as x, y int, stay on one line when they share a type and an access level. A struct literal with field names becomes a constructor call with named arguments:

type mixed struct {
	X, y  int    // differing access -> separate lines
	p, q  int    // uniform -> combined
	label string // single name -> single line
}

func main() {
…
	m := mixed{X: 10, y: 20, p: 30, q: 40, label: "tag"}
	fmt.Println(m.X, m.y, m.p, m.q, m.label)
}
partial struct mixed {
    public nint X;
    internal nint y;
    internal nint p, q;
    internal @string label;
}

internal static void Main() {
…
    var m = new mixed(X: 10, y: 20, p: 30, q: 40, label: "tag");
    fmt.Println(m.X, m.y, m.p, m.q, m.label);
}

Go’s int is C# nint, a native-size integer (Integer Types and Arithmetic). Go’s string is @string, a byte string from golib, go2cs’s runtime library. "tag" is a plain C# literal that golib converts to an @string once, and a literal written "…"u8, such as a Printf format, is a C# UTF-8 literal (Strings).

The generator completes each struct. For every converted struct it adds:

The generator template shows the exact members.

A literal without field names passes the values in field order. An omitted field keeps its zero value, and an empty literal Thing{} becomes new Thing(). A pointer field *T is a golib ж<T> (read “zhe”): a heap box that holds or points at one T and plays the role of Go’s *T (Pointers, glyph table). A nil argument is golib’s nil:

type ColorList struct {
    Total int
    Color string
    Next *ColorList
    NextNext **ColorList
}

func main() {
   red := ColorList{2, "red", nil, nil}
   blue := ColorList{2, "blue", nil, nil}

   red.Next = &blue

   fmt.Printf("Value of red = %v\n", red)
   fmt.Printf("Value of blue = %v\n", blue)
}
partial struct ColorList {
    public nint Total;
    public @string Color;
    public ж<ColorList> Next;
    public ж<ж<ColorList>> NextNext;
}

internal static void Main() {
    var red = new ColorList(2, "red", nil, nil);
    ref var blue = ref heap<ColorList>(new ColorList(2, "blue", nil, nil), out var Ꮡblue);

    red.Next = Ꮡblue;

    fmt.Printf("Value of red = %v\n"u8, red);
    fmt.Printf("Value of blue = %v\n"u8, blue);
}

A struct whose address is taken lives in a box. &blue is stored in the field red.Next, and a C# field cannot hold a ref, so golib’s heap<T>(value, out var Ꮡblue) copies the literal into a new box, stores the box in Ꮡblue, and returns a C# ref to the value inside, in one statement as Go’s blue := … is one. blue reads and writes the boxed value, and Ꮡblue is the box itself, which is what &blue gives (Pointers).

Ꮡ is also a golib function: Ꮡ(value) boxes a new value and returns the pointer. So &T{…} becomes Ꮡ(new T(…)), and &counter{n: 5} becomes Ꮡ(new counter(n: 5)).

&s.f points into the struct’s box. The generator’s accessor Thing.Ꮡval is a static method that returns a ref to the val field of a given Thing. The field’s generated address property applies it through the box method .of(…), so Ꮡx.Ꮡval is a ж<nint> pointing at val inside the box that holds x. p.Value is Go’s *p, so the write reaches x:

type Thing struct {
	val int
}
…
func plainFunc() int {
	x := Thing{val: 7}
	p := &x.val
	*p = 99
	return x.val
}
partial struct Thing {
    internal nint val;
}
…
internal static nint plainFunc() {
    ref var x = ref heap<Thing>(new Thing(val: 7), out var Ꮡx);
    var p = Ꮡx.Ꮡval;
    p.Value = 99;
    return x.val;
}

A declared zero value is default! when C#’s default already equals Go’s zero. default! is C#’s all-zero value. The converter spells it with the ! for every type; on a struct, the ! has no effect (Nil and Zero Values).

A struct with a fixed-size array field is built with new() instead. Golib’s array<T> is Go’s [N]T, and its field initializer new(8) gives it Go’s length 8, but C#’s default skips field initializers. Golib’s slice<T> is Go’s []T (Slices and Arrays):

type holder struct {
	name string
	tbl  [8]int
	tail []int
}
…
type point struct {
	x, y int
}

func main() {
…
	var z holder
…
	var p point
…
}
partial struct holder {
    internal @string name;
    internal array<nint> tbl = new(8);
    internal slice<nint> tail;
}
…
partial struct point {
    internal nint x, y;
}

internal static void Main() {
…
    holder z = new();
…
    point p = default;
…
}

A struct that embeds a defined type or a pointer to one, other than an interface of its own package, is built with new(nil). That generated constructor builds each embedded field, so an embedded type that itself needs construction (one with an array field) is usable, and an embedded pointer holds a nil ж<T> box rather than a C# null.

A struct is copied by value, as in Go. A C# struct assignment copies the fields. But golib’s array<T> is a small struct that points at its element storage, so a plain copy would share the elements. A struct holding an array therefore gets a generated ΔClone() that copies them, and each copy calls it. The Δ prefix keeps the name clear of any Go name.

Here newDigest returns a filled-in digest, bump adds one to its fields through a pointer receiver, and show formats them. d keeps its original values:

type digest struct {
	h  [4]int
	x  [2]byte
	nx int
}

func (d *digest) bump() {
	for i := range d.h {
		d.h[i]++
	}
	d.x[0]++
	d.nx++
}

func (d digest) show() string {
	return fmt.Sprintf("%v %v %d", d.h, d.x, d.nx)
}
…
func newDigest() digest {
	return digest{h: [4]int{1, 2, 3, 4}, x: [2]byte{5, 6}, nx: 7}
}
…
func identCopy() {
	d := newDigest()
	c := d
	c.bump()
	fmt.Println("ident:      ", d.show(), "|", c.show())
}
partial struct digest {
    internal array<nint> h = new(4);
    internal array<byte> x = new(2);
    internal nint nx;
}

internal static void bump(this ref digest d) {
    foreach (var i in range(len(d.h))) {
        d.h[i]++;
    }
    d.x[0]++;
    d.nx++;
}

internal static @string show(this digest d) {
    d = d.ΔClone();

    return fmt.Sprintf("%v %v %d"u8, d.h, d.x, d.nx);
}
…
internal static digest newDigest() {
    return new digest(h: new nint[]{1, 2, 3, 4}.array(), x: new byte[]{5, 6}.array(), nx: 7);
}
…
internal static void identCopy() {
    var d = newDigest();
    var c = d.ΔClone();
    c.bump();
    fmt.Println("ident:      ", d.show(), "|", c.show());
}

c := d becomes d.ΔClone(). A value receiver or value parameter of such a struct is cloned at the top of the function, as show does with d = d.ΔClone();. Methods become C# extension methods, and this ref is a pointer receiver (Functions and Methods). range(d.h) is the index-only range d.h, which reads no element (Loops).

The literals "ident: " and "|" stay plain C# literals. Both are any arguments to fmt.Println, so go2cs-gen, go2cs’s source generators, intercepts the call and passes each as a boxed Go string (Strings, Empty Interface).

A struct type declared inside a function moves to package scope, since C# allows no type in a method body. It takes the function’s name as a prefix, so point in main becomes main_point. Its name is followed by the comment /*dyn*/, which the generator turns into an attribute that golib’s reflection reads: the type was lifted, and its C# name is not its Go name. An anonymous struct is lifted the same way, named after the first variable that uses it. Go treats identical anonymous structs as one type, so a and b share main_a:

func main() {
…
	a := struct{ X int }{X: 1}
	var b struct{ X int }
	b = a
	fmt.Println(a == b)
…
	type point struct{ X, Y int }
	p := point{X: 1, Y: 2}
	fmt.Println(p.X + p.Y)
	fmt.Printf("%T %T\n", p, &p)
…
}
internal partial struct main_a /*dyn*/ {
    public nint X;
}

internal partial struct main_point /*dyn*/ {
    public nint X, Y;
}

internal static void Main() {
    var a = new main_a(X: 1);
    main_a b = default;
    b = a;
    fmt.Println(a == b);
    ref var p = ref heap<main_point>(new main_point(X: 1, Y: 2), out var Ꮡp);
    fmt.Println(p.X + p.Y);
    fmt.Printf("%T %T\n"u8, p, Ꮡp);
…
}

p lives in a box because &p is taken, and Ꮡp is that box. %T still prints Go’s names, main.point *main.point, not the C# names.

An anonymous struct in a parameter takes the function and parameter names, such as processAnonymousStruct_data. A package variable settings of an anonymous struct type gets the type settingsᴛ1: the variable already uses the name settings, so the type takes a numbered ᴛ suffix, the mark of a name the converter makes up (Names and Glyphs).

The empty struct struct{} is never lifted. It is golib’s shared EmptyStruct, so struct{}{} becomes new EmptyStruct().

Full detail: Reference → Struct Types — the rules for combined fields and access modifiers, every zero-value form, how lifted types are found and shared, and the positional-literal edge cases such as a one-field nil literal.


Struct Type Embedding

Go has no inheritance. Instead, a struct can embed another type by listing the type with no field name. The embedded type’s fields and methods are then promoted: they can be used directly on the outer struct. C# structs cannot inherit either. So the converter declares each embed as an ordinary member, and a source generator writes the code that makes the promoted names work.

An embedded struct becomes a field named for its type, where Go declares it. The converter emits /*embed*/ public T T; in the embed’s own position, and /*embed*/ public ж<T> T; for a pointer embed, where ж<T> is the box that golib, go2cs’s runtime library, uses for a Go pointer. The /*embed*/ comment is what tells the generator and reflection that the field is embedded, as Go’s reflect.StructField.Anonymous does. record.Person is the field itself, so, as in Go, it can be assigned, have its address taken, or receive a method call. Because every field sits where Go puts it, a struct whose fields are all plain values is laid out exactly as Go lays it, and a pointer that views raw memory as the struct ((*T)(unsafe.Pointer(p))) reads Go’s bytes.

Promoted fields and methods are used on the outer value directly. For each promoted field the generator adds a ref property to the outer struct, so record.name reads and writes record.Person.name. For each promoted method it adds a forwarding method, so record.IsAdult() calls IsAdult on record.Person. A method the outer type declares itself hides the promoted one, as in Go.

Here Record embeds Person and Employee. Record declares its own IsDr, which also requires an age over 18. So record.IsDr() calls that one and prints false:

type Person struct {
	name string
	age  int32
}

func (p Person) IsDr() bool {
	return strings.HasPrefix(p.name, "Dr")
}

func (p Person) IsAdult() bool {
	return p.age >= 18
}

type Employee struct {
	position string
}

func (e Employee) IsManager() bool {
	return e.position == "manager"
}

type Record struct {
	Person
	Employee
}

func (p Record) IsDr() bool {
	return strings.HasPrefix(p.name, "Dr") && p.age > 18
}
…
func main() {
…
	record := Record{}
	record.name = "Dr. Michał"
	record.age = 18
	record.position = "software engineer"

	fmt.Println(record)             // {{Dr. Michał 18} {software engineer}}
	fmt.Println(record.name)        // Dr. Michał
	fmt.Println(record.age)         // 18
	fmt.Println(record.position)    // software engineer
	fmt.Println(record.IsAdult())   // true
	fmt.Println(record.IsManager()) // false
	fmt.Println(record.IsDr())      // false
…
}
partial struct Person {
    internal @string name;
    internal int32 age;
}

public static bool IsDr(this Person p) {
    return strings.HasPrefix(p.name, "Dr");
}

public static bool IsAdult(this Person p) {
    return p.age >= 18;
}

partial struct Employee {
    internal @string position;
}

public static bool IsManager(this Employee e) {
    return e.position == "manager"u8;
}

partial struct Record {
    /*embed*/ public Person Person;
    /*embed*/ public Employee Employee;
}

public static bool IsDr(this Record p) {
    return strings.HasPrefix(p.name, "Dr") && p.age > 18;
}
…
internal static void Main() {
…
    var record = new Record();
    record.name = "Dr. Michał";
    record.age = 18;
    record.position = "software engineer";

    fmt.Println(record);
    fmt.Println(record.name);
    fmt.Println(record.age);
    fmt.Println(record.position);
    fmt.Println(record.IsAdult());
    fmt.Println(record.IsManager());
    fmt.Println(record.IsDr());
…
}

Some other things in this example that the reader will see throughout converted code:

The embedded struct is stored inline, so copying the outer struct copies it. An embed is a field like any other, not a separate heap object. After b := a, b has its own inner, and writing b.n leaves a.n unchanged. This program prints assign a: 1 a 10, then assign b: 2 b 20:

type inner struct {
	n   int
	tag string
}

type mid struct {
	inner
	extra int
}
…
	a := mid{inner: inner{n: 1, tag: "a"}, extra: 10}
	b := a
	b.n = 2
	b.tag = "b"
	b.extra = 20
	fmt.Println("assign a:", a.n, a.tag, a.extra)
	fmt.Println("assign b:", b.n, b.tag, b.extra)
partial struct inner {
    internal nint n;
    internal @string tag;
}

partial struct mid {
    /*embed*/ internal inner inner;
    internal nint extra;
}
…
    var a = new mid(inner: new inner(n: 1, tag: "a"), extra: 10);
    var b = a;
    b.n = 2;
    b.tag = "b";
    b.extra = 20;
    fmt.Println("assign a:", a.n, a.tag, a.extra);
    fmt.Println("assign b:", b.n, b.tag, b.extra);

var b = a is a plain C# struct copy, and it copies the inline inner too. Every literal is a plain C# literal: "a" and "b" fill string fields, and the two labels are any arguments to fmt.Println (C# object), which go2cs-gen, go2cs’s source generators, boxes as Go strings (Strings, Empty Interface). Go’s int is C# nint (Integer Types).

An embedded pointer becomes a field that holds a ж<T>. ж<T> (read “zhe”) is golib’s heap box for a Go pointer. Ꮡ(…) takes an address, like Go’s &, so &leaf{n: 0} becomes Ꮡ(new leaf(n: 0)) (Pointers). Promoted fields and methods reach through the pointer.

Copying the outer struct copies only the pointer, so both copies share one pointee, as in Go.

Promotion reaches any depth. In the program that follows, top embeds mid, which embeds *leaf. Go’s t.bump() and t.n convert unchanged: top’s generated members reach mid’s, which reach through the pointer. This program’s mid is its own type, unrelated to the mid of the copy example. It prints 2 2 h, then 11 11 t:

type leaf struct{ n int }

func (l *leaf) bump()    { l.n++ }
func (l *leaf) get() int { return l.n }
…
type holder struct {
	*leaf
	tag string
}
…
type mid struct {
	*leaf
}

type top struct {
	mid
	label string
}

func main() {
	h := holder{leaf: &leaf{n: 0}, tag: "h"}
	h.bump() // one-level: h.leaf.bump()
	h.bump()
	fmt.Println(h.get(), h.n, h.tag) // 2 2 h

	t := top{mid: mid{leaf: &leaf{n: 10}}, label: "t"}
	t.bump() // transitive: top -> mid -> *leaf -> leaf.bump()
	fmt.Println(t.get(), t.n, t.label) // 11 11 t
}
partial struct leaf {
    internal nint n;
}

internal static void bump(this ref leaf l) { l.n++; }
internal static nint get(this ref leaf l) { return l.n; }

partial struct holder {
    /*embed*/ internal ж<leaf> leaf;
    internal @string tag;
}

partial struct mid {
    /*embed*/ internal ж<leaf> leaf;
}

partial struct top {
    /*embed*/ internal mid mid;
    internal @string label;
}

internal static void Main() {
    var h = new holder(leaf: Ꮡ(new leaf(n: 0)), tag: "h");
    h.bump();
    h.bump();
    fmt.Println(h.get(), h.n, h.tag);

    var t = new top(mid: new mid(leaf: Ꮡ(new leaf(n: 10))), label: "t");
    t.bump();
    fmt.Println(t.get(), t.n, t.label);
}

bump and get have pointer receivers, (l *leaf). Each becomes a static method whose receiver is this ref leaf l, so it works on the caller’s leaf through a C# ref. For such a method a source generator writes a second overload that takes the box, ж<leaf> (Functions and Methods). The forwarder for the promoted h.bump() calls bump on the leaf that h’s pointer points to.

A pointer-receiver method promoted through a value embed is called on the real field. Go runs o.bump(5) as (&o.inner).bump(5), so the method changes o’s own inner. The converter writes that address out at the call site, as o.of(outer.Ꮡinner).bump(5). So the method always works on o’s own inner field, never on a copy:

type inner struct {
	n int
}

func (i *inner) bump(d int) { i.n += d } // pointer-receiver: MUTATES the embedded value
…
type outer struct {
	tag int
	inner
}
…
	o := &outer{tag: 7} // pointer local (newTimer `t := new(timeTimer)` shape)
	o.bump(5)           // promoted: (&o.inner).bump(5)
	o.bump(3)
partial struct inner {
    internal nint n;
}

internal static void bump(this ref inner i, nint d) { i.n += d; }
…
partial struct outer {
    internal nint tag;
    /*embed*/ internal inner inner;
}
…
    var o = Ꮡ(new outer(tag: 7));
    o.of(outer.Ꮡinner).bump(5);
    o.of(outer.Ꮡinner).bump(3);

o is a Go pointer, so in C# it holds a box: Ꮡ(new outer(tag: 7)) is &outer{tag: 7}. A name that starts with Ꮡ stands for an address or a field reference (Names and Glyphs).

outer.Ꮡinner is a static helper the generator adds to outer for its inner field: it returns a ref to that field. The box’s of method applies it and returns a ж<inner> that points into o. So o.of(outer.Ꮡinner) is Go’s &o.inner (Pointers).

An embedded interface is a plain field. A call through it reads as in Go: r.Interface.Less(j, i). When the outer type is used as that interface, the generator forwards each method the type does not declare itself to that field.

This is Go’s sort.Reverse pattern. Interface is the program’s own copy of sort.Interface. reverse declares its own Less, which swaps the arguments. Len and Swap are not declared on reverse, so calls to them go to the embedded value:

type Interface interface {
	Len() int
	Less(i, j int) bool
	Swap(i, j int)
}
…
type reverse struct {
	Interface
}

func (r reverse) Less(i, j int) bool {
	return r.Interface.Less(j, i)
}

func Reverse(data Interface) Interface {
	return &reverse{data}
}
partial interface Interface {
    nint Len();
    bool Less(nint i, nint j);
    void Swap(nint i, nint j);
}
…
partial struct reverse {
    /*embed*/ public Interface Interface;
}

internal static bool Less(this reverse r, nint i, nint j) {
    return r.Interface.Less(j, i);
}

public static Interface Reverse(Interface data) {
    return new reverseжInterface(Ꮡ(new reverse(data)));
}

A Go interface becomes a C# interface (Interfaces). Reverse returns the pointer &reverse{data}. In Go, a pointer can call its type’s value-receiver methods too, so the pointer satisfies Interface. reverseжInterface is the generated adapter class that lets that pointer be used as an Interface: Less goes to reverse’s own method, and Len and Swap go to r.Interface (Interfaces).

Full detail: Reference → Struct Type Embedding — how embed members are named, embeds from other packages, nil embedded pointers, and how each kind of promoted call is routed.


Interfaces

A Go interface becomes a C# partial interface. In Go, a type satisfies an interface just by having its methods. C# needs each type to declare the interfaces it implements. A source generator closes that gap at compile time, so converted code puts values into interfaces and calls through them as Go does.

What you will notice in converted code: a struct or named number goes into an interface as itself. A pointer, or a named function type, goes in wrapped in a small generated adapter class. A call through an interface is an ordinary C# interface call.

The examples in this section use these names and glyphs (Reading Converted Code lists them all):

An interface keeps its method list, and an embedded interface becomes a C# base interface. Go’s string in a method signature becomes golib’s @string, Go’s byte string:

type rdr interface{ read() string }
type clsr interface{ close() string }

type rdCloser interface {
	rdr
	clsr
}
partial interface rdr {
    @string read();
}
partial interface clsr {
    @string close();
}

partial interface rdCloser :
    rdr,
    clsr
{
}

The empty interface, interface{} or any, becomes C# any, an alias for object: see Empty Interface. Go’s built-in error is golib’s error interface, and it works like any other interface.

A struct value goes into an interface as itself. A Go method becomes a C# extension method whose this parameter is the receiver (Functions and Methods). The generator adds error to MyError’s own declaration and implements Error() by calling that extension method. So returning a MyError as an error is a plain C# conversion:

type MyError struct {
	description string
}

func (err MyError) Error() string {
	return fmt.Sprintf("error: %s", err.description)
}

// error is an interface - MyError is cast to error interface upon return
func f() error {
	return MyError{"foo"}
}
partial struct MyError {
    internal @string description;
}

public static @string Error(this MyError err) {
    return fmt.Sprintf("error: %s"u8, err.description);
}

internal static error f() {
    return new MyError("foo");
}

"foo" is a plain C# literal that golib converts to an @string once (Strings). Converting a struct to an interface copies it in C#, just as Go copies a value into an interface.

A named function type goes in through a value adapter; a named number goes in directly. The generator can add an interface only to a C# struct declared in the package it compiles. A named numeric type is such a struct (Named Numeric Types), so it converts directly. A named function type becomes a C# delegate, and a delegate cannot implement an interface. So the generator writes a small adapter class that holds the delegate and forwards each interface method to it.

Here meter is a function type and gauge a named int. Both have a Value method, so both satisfy valued:

type meter func() int

func (m meter) Value() int { return m() }

type gauge int

func (g gauge) Value() int { return int(g) * 2 }

type valued interface {
	Value() int
}
…
	mv := valued(meter(func() int { return 11 }))
	gv := valued(gauge(4))
	fmt.Println("func-source:", mv.Value(), "numeric-source:", gv.Value())
internal delegate nint meter();

internal static nint Value(this meter m) { return m(); }

partial struct gauge /*num:nint*/;

internal static nint Value(this gauge g) { return (nint)g * 2; }

partial interface valued {
    nint Value();
}
…
    var mv = ((valued)new meterᴠvalued(new meter(() => 11)));
    var gv = ((valued)((gauge)4));
    fmt.Println("func-source:", mv.Value(), "numeric-source:", gv.Value());

Reading the C#:

A struct from another package also goes in through a value adapter, because the generator cannot add an interface to a type compiled elsewhere. Its adapter name starts with the package, such as typelib_Markᴠstamper. When that package’s own conversion already declares the struct as implementing the interface, the value goes in directly.

A pointer goes into an interface wrapped in an adapter. A pointer is golib’s generic box class ж<Counter>, so the generator cannot add a user’s interface to it. Instead it writes CounterжIncrementer (“pointer Counter as interface Incrementer”), a small class that holds the box and implements Incrementer by calling the methods on it.

Counter is a struct with one int field, n. Its methods Inc and Total have pointer receivers, so only *Counter, not Counter, satisfies Incrementer:

type Counter struct {
	n int
}

func addTo(p *int, delta int) {
	*p += delta
}

func (c *Counter) Inc() string {
	addTo(&c.n, 1)
	return "inc"
}

func (c *Counter) Total() int {
	return c.n
}
…
type Incrementer interface {
	Inc() string
	Total() int
}
partial struct Counter {
    internal nint n;
}

internal static void addTo(ref nint p, nint delta) {
    p += delta;
}

public static @string Inc(this ж<Counter> Ꮡc) {
    ref var c = ref Ꮡc.DerefOrNull();

    addTo(ref nonnil(ref c).n, 1);
    return "inc";
}

public static nint Total(this ref Counter c) {
    return c.n;
}
…
partial interface Incrementer {
    @string Inc();
    nint Total();
}

The two pointer-receiver methods take two forms (Functions and Methods):

Putting a *Counter into an Incrementer, then calling through it and asserting it back:

c := &Counter{}
var inc Incrementer = c // interface value holds the POINTER
inc.Inc()
inc.Inc()
fmt.Println("via pointer:", c.Total()) // 2 - interface calls mutated the original

c.n = 10
fmt.Println("via interface:", inc.Total()) // 10 - pointer writes visible through interface

back, ok := inc.(*Counter) // assert back to the pointer
back.Inc()
fmt.Println("assert-back:", ok, c.Total(), back == c) // true 11 true
…
var c = Ꮡ(new Counter());
Incrementer inc = new CounterжIncrementer(c);
inc.Inc();
inc.Inc();
fmt.Println("via pointer:", c.Total());

c.n = 10;
fmt.Println("via interface:", inc.Total());

var (back, ok) = inc._<ж<Counter>>(ᐧ);
back.Inc();
fmt.Println("assert-back:", ok, c.Total(), back == c);

Reading the C#, line by line:

Calls, nil and assertions read as ordinary C#. A call through an interface is a plain C# interface call, such as inc.Inc(). A nil interface is default!, a null reference, so return nil becomes return default!; (Nil and Zero Values). The assertion x.(T) becomes golib’s x._<T>(), which panics if the dynamic type is not T. When T is a named interface, the check runs at run time against the dynamic type’s Go methods, and golib’s AdapterBinder builds the adapter, so a type from a package compiled later still matches (Reference → runtime shells). Adding the argument ᐧ, golib’s constant true, selects the comma-ok form that returns (value, ok) instead (Comma-Ok Forms). A switch x.(type) is covered in Type Switch Statements.

Comparing interface values goes through golib’s AreEqual. Go compares an interface value by its dynamic type and value. C#’s == compares references, and it has no operator between an interface and a struct that implements it. AreEqual does what Go does.

In this example, errno is a named uintptr with an Error method, so it satisfies error. Like gauge, it becomes a C# struct and goes into error directly (Named Numeric Types). The constant errAgain becomes a read-only property (Constant Values):

type errno uintptr

func (e errno) Error() string {
	return "errno"
}

const errAgain errno = 11

func mayFail(n int) error {
	if n > 0 {
		return errAgain
	}
	return nil
}

func checkErr(n int) {
	err := mayFail(n)
	if err == errAgain {
		fmt.Println("got again")
	}
	if err != errAgain {
		fmt.Println("not again")
	}
…
	switch err {
	case errAgain:
		fmt.Println("switch: again")
	case nil:
		fmt.Println("switch: nil")
	default:
		fmt.Println("switch: other")
	}
}
partial struct errno /*num:uintptr*/;

internal static @string Error(this errno e) {
    return "errno";
}

internal static errno errAgain => 11;

internal static error mayFail(nint n) {
    if (n > 0) {
        return errAgain;
    }
    return default;
}

internal static void checkErr(nint n) {
    var err = mayFail(n);
    if (AreEqual(err, errAgain)) {
        fmt.Println("got again");
    }
    if (!AreEqual(err, errAgain)) {
        fmt.Println("not again");
    }
    switch (err) {
    case var ᴛ when AreEqual(ᴛ, errAgain): {
        fmt.Println("switch: again");
        break;
    }
    case var ᴛ when AreEqual(ᴛ, default): {
        fmt.Println("switch: nil");
        break;
    }
    default: {
        fmt.Println("switch: other");
        break;
    }}

}

internal static error release(errno e) {
    fmt.Println("released", (uintptr)e);

== becomes AreEqual(…) and != becomes !AreEqual(…). The switch err becomes a pattern switch, and each case compares through AreEqual in the same way, case var ᴛ when AreEqual(ᴛ, errAgain): (Expression Switch Statements).

AreEqual unwraps adapters first, so an interface holding a pointer equals that pointer: Go’s zoo[0] == f, where zoo[0] is an interface holding the pointer f, becomes AreEqual(zoo[0], f). Comparing two values of a type Go cannot compare, such as a slice or map, panics as it does in Go.

Methods promoted by embedding satisfy interfaces as they do in Go. A struct that embeds another type, or an interface, gains its methods, and the adapters forward to them (Struct Type Embedding).

Full detail: Reference → Interfaces — how the converter records which types satisfy which interfaces across packages, adapter naming and accessibility, value adapters for types from other packages, the run-time interface shells, keyword-named methods, and publicized unexported types.


Reflection (reflect)

Go’s reflect package converts like any other package, but its entry points are hand-written. Go’s reflect digs into the runtime’s memory layout. The C# version asks .NET for the value’s System.Type instead, and golib’s GoReflect class answers every question from that type (The golib Runtime Library).

A few things a CLR type cannot hold, such as a Go struct tag or an array’s length, travel as short comments the converter writes beside the declaration, in Go’s own spelling. In converted code you notice those comments, such as /*`json:"name"`*/, and two naming marks, ᴅ and ᴺ. Every glyph is listed in Reading Converted Code.

Kind follows the C# representation. Each Go kind has its own C# form, so the kind is read off the type. nint is Go int, @string is Go string, and ж<T> is golib’s heap box for a Go pointer *T:

C# type reflect.Kind
bool, nint, nuint, int8 … uint64, uintptr, float32, float64, complex64, complex128 the matching scalar kind (nint is Int)
@string, slice<T>, array<T>, map<K, V>, channel<T> String, Slice, Array, Map, Chan
ж<T>, @unsafe.Pointer Pointer, UnsafePointer
a delegate Func
object (Go any) or a C# interface Interface
a converted partial struct Struct
a named type, such as partial struct counter /*num:nint*/ (Go type counter int) its underlying kind, here Int

The @ prefix lets a C# keyword, such as string or unsafe, serve as a name. A partial type is a type converted from Go, and a source generator completes it. The comment after its name, when present, names the Go type it is declared over; num: marks a numeric one (Named Numeric Types), and /*dyn*/ instead marks a type lifted out of a function (Struct Types). The Pointers and unsafe.Pointer sections explain the pointer rows.

A struct tag is copied verbatim into a comment. A C# field has no place for a Go struct tag, so the converter writes the tag as a comment at the end of the field’s line, in Go’s backquoted spelling. An untagged field gets no comment. A source generator reads the comment and records the tag on the struct:

type record struct {
	Version  int
	Name     string `json:"name" asn1:"optional,explicit,tag:0"`
	Data     []byte `json:"data,omitempty"`
	Untagged bool
}
partial struct record {
    public nint Version;
    public @string Name; /*`json:"name" asn1:"optional,explicit,tag:0"`*/
    public slice<byte> Data; /*`json:"data,omitempty"`*/
    public bool Untagged;
}

StructField.Tag reads the tag back. So reflection code, and the standard library’s tag-driven encoders such as encoding/json and encoding/asn1, see the same tag Go does:

func main() {
	t := reflect.TypeOf(record{})

	for i := 0; i < t.NumField(); i++ {
		f := t.Field(i)
		fmt.Printf("%s raw=%q json=%q asn1=%q\n", f.Name, string(f.Tag), f.Tag.Get("json"), f.Tag.Get("asn1"))
	}

	// Lookup distinguishes an absent key from one explicitly set to the empty string.
	f := t.Field(1)
	v, ok := f.Tag.Lookup("json")
	fmt.Println("lookup json:", v, ok)
	v, ok = f.Tag.Lookup("missing")
	fmt.Println("lookup missing:", v, ok)
…
}
internal static void Main() {
    var t = reflect.TypeOf(new record());

    for (nint i = 0; i < t.NumField(); i++) {
        var fΔ1 = t.Field(i);
        fmt.Printf("%s raw=%q json=%q asn1=%q\n"u8, fΔ1.Name, (@string)fΔ1.Tag, fΔ1.Tag.Get("json"), fΔ1.Tag.Get("asn1"));
    }

    var f = t.Field(1);
    var (v, ok) = f.Tag.Lookup("json");
    fmt.Println("lookup json:", v, ok);
    (v, ok) = f.Tag.Lookup("missing");
    fmt.Println("lookup missing:", v, ok);
…
}

A few names in that C# need a word:

Other comments carry what a CLR type cannot hold. For example, golib’s array<T> does not hold its length in its type, so a comment in Go’s array-prefix form records it wherever reflect may need it:

func bumpParamElem(a [3]int) [3]int {
internal static array<nint> bumpParamElem(/*[3]*/ array<nint> a) {

Similar comments record a channel direction and an embedded field. The reference lists them.

A type defined over an existing interface gets a descriptor carrier, marked ᴅ. Go’s type eface any and type namedIface fmt.Stringer define new types that just reuse an existing interface. The converter writes each as a C# global using alias, another name for object or for Stringer. The alias keeps Go’s rule that any fitting value can be assigned without a conversion. A type declared with its own interface { … } list becomes a real C# interface instead.

An alias leaves nothing in compiled code, so reflect alone could not recover the name eface. The converter therefore also declares an empty interface named with a ᴅ suffix, the carrier, in the project’s generated package_info.cs (Package Conversion). Nothing ever implements it; its [GoLocalName] only holds the Go name. A struct field of the aliased type carries no mark of its own: the struct’s record in the same file names the field and its carrier, and that points reflect at it. The comments in the test name the two shapes that gain a carrier, class (i) and (ii), and the controls that must not:

type eface any               // class (i): defined over the empty interface
type namedIface fmt.Stringer // class (ii): defined over a named NON-empty interface
type realIface interface {   // control: an inline definition is a real C# interface already
	Do()
}
type aliasIface = fmt.Stringer // control: a true Go alias must NOT gain a carrier

type holder struct {
	E eface
	N namedIface
	R realIface
	A aliasIface
}

func main() {
	t := reflect.TypeFor[holder]()

	for i := 0; i < t.NumField(); i++ {
		f := t.Field(i)
		fmt.Printf("%s Name=%q String=%q PkgPath=%q Kind=%v\n",
			f.Name, f.Type.Name(), f.Type.String(), f.Type.PkgPath(), f.Type.Kind())
	}
}
global using eface = object;
global using namedIface = go.fmt_package.Stringer;
global using aliasIface = go.fmt_package.Stringer;
…
public partial interface realIface {
    void Do();
}

partial struct holder {
    public eface E;
    public namedIface N;
    public realIface R;
    public aliasIface A;
}

internal static void Main() {
    var t = reflect.TypeFor<holder>();

    for (nint i = 0; i < t.NumField(); i++) {
        var f = t.Field(i);
        fmt.Printf("%s Name=%q String=%q PkgPath=%q Kind=%v\n"u8,
            f.Name, f.Type.Name(), f.Type.String(), f.Type.PkgPath(), f.Type.Kind());
    }
}

So, as in Go, the program reports field E’s type name as eface and field N’s as namedIface. The carrier is uninhabited: no value ever has its type. The other two fields need no carrier. realIface is a real C# interface with its own name. aliasIface is a true Go alias (=), and Go itself reports the target’s name, Stringer (Type Aliasing).

A generic function that passes its type parameter to reflect.TypeFor gains a companion, marked ᴺ. A generic type argument cannot carry an attribute, and eface and any are both object to the CLR. So nameOf[eface] and nameOf[any] would be one C# instantiation, and reflect could not tell them apart. Such a function gets a second type parameter, Tᴺ, and asks reflect about that one. T stays for every other use of the type parameter:

type eface any                 // class (i): defined over the empty interface
type namedIface fmt.Stringer   // class (ii): defined over a named NON-empty interface
type aliasIface = fmt.Stringer // control: a true Go alias must NOT gain a carrier
type ordinary string           // control: an ordinary defined type
type inlineIface interface {   // control: an inline definition is a real C# interface already
	Do()
}

// nameOf reads a Go NAME off a TYPE PARAMETER — the shape that gains a companion.
func nameOf[T any](label string) {
	t := reflect.TypeFor[T]()
	fmt.Printf("%-11s Name=%q String=%q PkgPath=%q Kind=%v\n", label, t.Name(), t.String(), t.PkgPath(), t.Kind())
}
global using eface = object;
global using namedIface = go.fmt_package.Stringer;
global using aliasIface = go.fmt_package.Stringer;
…

partial struct ordinary /*@string*/;
partial interface inlineIface {
    void Do();
}

internal static void nameOf<T, Tᴺ>(@string label) {
    var t = reflect.TypeFor<Tᴺ>();
    fmt.Printf("%-11s Name=%q String=%q PkgPath=%q Kind=%v\n"u8, label, t.Name(), t.String(), t.PkgPath(), t.Kind());
}

Each call site fills in the companion. It passes the ᴅ carrier as Tᴺ when the type argument is a Go type defined over an existing interface, such as eface. Otherwise it passes the type argument itself, including a true Go alias such as aliasIface. In C#, any is a global alias for object that each converted project file declares (Empty Interface). Each label is a plain C# literal, since label is a string parameter (Strings):

func main() {
	nameOf[eface]("eface")
	nameOf[namedIface]("namedIface")
	nameOf[any]("any")
	nameOf[aliasIface]("aliasIface")
	nameOf[inlineIface]("inlineIface")
	nameOf[ordinary]("ordinary")
…
}
internal static void Main() {
    nameOf<eface, efaceᴅ>("eface");
    nameOf<namedIface, namedIfaceᴅ>("namedIface");
    nameOf<any, any>("any");
    nameOf<aliasIface, aliasIface>("aliasIface");
    nameOf<inlineIface, inlineIface>("inlineIface");
    nameOf<ordinary, ordinary>("ordinary");
…
}

With the companion, nameOf[eface] prints Name="eface" and nameOf[any] prints Name="", as in Go. Any other generic function, including one that calls reflect.TypeOf on a value, keeps its declared type parameters (Generics).

Types built at run time are real CLR types. SliceOf, MapOf, ChanOf and PointerTo return the matching golib generic type, such as slice<T>, and StructOf emits a new .NET value type. So code that receives such a type treats it like a converted one. Emitting a type needs the JIT: a Native AOT program cannot generate code at run time (FAQ: Native AOT).

Call and MakeFunc on a variadic func also work under Native AOT. A converted Go variadic takes its tail as a C# Span<T>, so reflect reaches it through a typed trampoline closed over the func’s signature. Native AOT compiles such a trampoline only for a signature some compiled code names, so go2cs-gen, go2cs’s source generators, registers one for every variadic signature that can reach reflect: a func value converted to an interface, a func nested in such a value’s parameters or results, and a variadic method. Most registrations are queued when the package loads and run on the registry’s first miss. Under Native AOT a variadic signature over value types that nothing registered is refused by name, as a Go panic that recover can catch.

Full detail: Reference → Manually-Converted Declarations: the reflection bridge — the bridge’s rules one by one, from tag reads and type names to assignability, the array-length and channel-direction attributes, and the run-time type constructors.


Pointers

Go’s *T becomes golib ж<T> (read “zhe”): a small heap object, a box, that holds or points at one T. golib is the hand-written C# runtime library that converted code uses. A pointer that points nowhere is written nil when code compares with it, and default! when it is a zero value, as in a declaration or a return nil (Nil and Zero Values).

The glyph Ꮡ (the Cherokee letter “su”, U+13D1) marks an address. Ꮡ(…) makes a pointer, and a name such as Ꮡa is the pointer to a. The glyph table lists every glyph. At a glance:

Go C# What it does
*T ж<T> The pointer type. **T is ж<ж<T>>.
&x Ꮡx The address of a local or package-level variable that lives in a box.
&T{…} Ꮡ(new T(…)) Allocates a value and points at it.
new(T) @new<T>() Allocates a zero value and points at it.
*p, read ~p or p.Value Reads the value; panics on nil.
*p = v p.Value = v Writes the real storage.
&s[i] Ꮡ(s, i) The address of a slice element.
&a[i] Ꮡa.at<T>(i) The address of an array element.
&x.f Ꮡx.Ꮡf The address of a struct field, through its generated address property.

An address-taken local has two names for one storage. A converted Go pointer is a ж<T> box, a heap object, and a heap object cannot point at a C# local. So when Go takes a local’s address, golib’s heap(…) allocates the local in a box, whether or not Go would move it to the heap. The exception, an address that only feeds a ref T parameter, comes later in this section. a is a C# ref alias for ordinary reads and writes, and Ꮡa is the box, used wherever Go writes &a:

	var a int
	var ptr *int
	var pptr **int
	var ppptr ***int

	a = 3000

	/* take the address of var */
	ptr = &a

	/* take the address of ptr using address of operator & */
	pptr = &ptr
	ppptr = &pptr
    ref var a = ref heap(new nint(), out var Ꮡa);
    ref var ptr = ref heap<ж<nint>>(out var Ꮡptr);
    ref var pptr = ref heap<ж<ж<nint>>>(out var Ꮡpptr);
    ж<ж<ж<nint>>> ppptr = default;

    a = 3000;

    ptr = Ꮡa;

    pptr = Ꮡptr;
    ppptr = Ꮡpptr;

a, ptr and pptr have their address taken, so each gets a box. heap(value, out var Ꮡa) boxes a starting value, and heap<T>(out var Ꮡptr) boxes the zero value of T. Both forms give the same pair of names.

ppptr never has its address taken, so it stays a plain local, and default! is its nil zero value. A pointer to a pointer is a box that holds a box. Go’s int is nint (Integer Types and Arithmetic).

A local that a closure, a go statement or a defer statement shares with the function that declares the local can also get this form, so both sides see one variable. Function Values and Closures covers captures.

A package-level variable whose address is taken is declared the same way, as a static box ᏑX and a ref property X => ref ((StaticBox<T>)ᏑX).ValueSlot that reads and writes through it, so &X is ᏑX. Every exported variable outside package main that is not a pointer or an interface is declared so too, because another package may take its address. When go2cs builds the variable on its first read instead of at startup, the [GoLazy] holder class that builds it holds the box, and &X names the same box before and after that read (Package-Level Variable Initialization Order).

&T{…} and new(T) allocate a box directly. Ꮡ(…) boxes a new value, and golib’s @new<T>() boxes a zero value. Here base and c are pointer locals, not boxed variables, so they have one name each:

type inner struct{ k int }

type counter struct {
	n   int
	sub inner
}

…
func get(c *counter) *counter { return c }

func main() {
	base := &counter{n: 5}
	base.sub.k = 3
	c := get(base) // c is a *counter LOCAL (heap box, not a ref-aliased parameter)

	c.n++     // direct field ++ through the pointer local
	c.n++     // 7
	c.sub.k-- // nested field -- through the pointer local

	// Read back through the original pointer to confirm the writes reached real storage.
	fmt.Println(base.n, base.sub.k) // 7 2
}
partial struct inner {
    internal nint k;
}

partial struct counter {
    internal nint n;
    internal inner sub;
}

internal static partial ж<counter> get(ж<counter> c) { return c; }

internal static void Main() {
    var @base = Ꮡ(new counter(n: 5));
    @base.sub.k = 3;
    var c = get(@base);

    c.n++;
    c.n++;
    c.sub.k--;

    fmt.Println(@base.n, @base.sub.k);
}

Each partial struct is a converted Go type (Struct Types). base is a C# keyword, so it is escaped as @base. get returns its pointer as it is, so its parameter stays a plain box; the pointer-parameter rule in this section says when a parameter gets more. get is a partial method because the Go function is marked //go:noinline, and a generator adds the never-inline attribute (A partial method with a body keeps its own frame).

A field through a pointer reads and writes as Go spells it: @base.n, c.n++. Each field is a ref property go2cs generates on the box (Implicit Pointer Dereferencing), so a read, a write, ++ and -- all reach the real storage. The whole value has two forms. ~ is an operator golib defines on ж<T>, standing in for Go’s *: ~p gives a copy of the value. p.Value gives a C# ref to the real storage, so it is the form for a whole-value write.

Every form panics on nil, like Go’s *p. A plain whole-value read can also appear as p.Value: in the pointer-to-pointer program, fmt.Printf(…, *ptr) becomes fmt.Printf(…u8, ptr.Value). Implicit Pointer Dereferencing says where each form appears.

An element or field address points into the real storage. Ꮡ(s, i) is the address of slice element i, so a write through it lands in the slice itself, and every slice that shares that storage sees it:

	s := make([]int, 4, 8)
…
	t := s[1:]
…
	p := &s[1]
	*p = 42
	fmt.Println("write thru :", s[1], t[0])
…
    var s = new slice<nint>(4, 8);
…
    var t = s[1..];
…
    var p = Ꮡ(s, 1);
    p.Value = 42;
    fmt.Println("write thru :", s[1], t[0]);

Both s[1] and t[0] print 42. slice<T> is golib’s Go slice (Slices and Arrays). "write thru :" stays a plain C# literal: fmt.Println takes any arguments, so go2cs-gen, go2cs’s source generators, boxes it as golib’s Go string, @string (Strings, Empty Interface).

An array element’s address, &a[i], uses .at<T>(i) on the array’s pointer. Here g is a pointer to an array, made by new(grid), and &g[j] points into it:

type row [4]uint32

type grid [3]row
…
	g := new(grid)
	for j := 0; j < len(g); j++ {
		populate(&g[j], uint32(j*10))
	}
partial struct row /*[4]uint32*/;

partial struct grid /*[3]row*/;
…
    var g = @new<grid>();
    for (nint j = 0; j < 3; j++) {
        populate(g.at<row>(j), (uint32)(j * 10));
    }

populate takes a *row and writes through it, so its writes land in g’s rows. len of an array is a constant, so len(g) is written 3 (Built-in Functions). For an array local a that lives in a box, the same address is Ꮡa.at<T>(i). A field’s address, &x.f, is Ꮡx.Ꮡf, the field’s generated address property; the pointer-receiver rule in this section shows that form.

A pointer parameter keeps the box and binds a ref alias. The box parameter is named with the Ꮡ prefix. When the body dereferences it, the first line binds the Go name to the value with DerefOrNull(), so the body reads like Go. A comparison with nil uses the box:

var errNilArg = errors.New("nil argument")
…
func checkArg(p *node, op string) error {
	if p == nil {
		return errNilArg
	}
	return nil
}
…
func readName(p *node) string {
	_ = checkArg(p, "readname")
	return p.name
}
internal static error errNilArg = errors.New("nil argument");
…
internal static error checkArg(ж<node> p, @string op) {
    if (p == nil) {
        return errNilArg;
    }
    return default;
}
…
internal static @string readName(ж<node> Ꮡp) {
    ref var p = ref Ꮡp.DerefOrNull();

    _ = checkArg(Ꮡp, "readname");
    return p.name;
}

node is a struct with a name field. checkArg never dereferences p, so it binds no alias, its box keeps the Go name p, and its return nil is the zero value default!. "readname" is a plain C# literal passed to a string parameter (Strings).

For a nil box, DerefOrNull() returns an empty C# ref instead of failing. So readName(nil) still runs its first line, and panics with Go’s nil-dereference message at p.name, just where Go does. Implicit Pointer Dereferencing has more on the alias.

An unexported package-level function that only dereferences a pointer parameter usually takes ref T. A C# ref parameter needs no box. Every caller of an unexported function is in the same package, so every call site can change with it: &x becomes ref x. A local whose address only feeds such calls stays a plain local:

func addTo(out *uint64, v uint64) {
	*out += v
}
…
	var total uint64
	addTo(&total, 5)
	addTo(&total, 7)
	fmt.Println("total:", total)
internal static void addTo(ref uint64 @out, uint64 v) {
    @out += v;
}
…
    uint64 total = default;
    addTo(ref total, 5);
    addTo(ref total, 7);
    fmt.Println("total:", total);

@out is Go’s out, escaped because it is a C# keyword. "total:" stays a plain C# literal, which go2cs-gen boxes as a Go string because fmt.Println takes any arguments.

A defer or go call to such a function works too. Its arguments are stored until the call runs, and C# cannot store a ref, so the statement stores the address as a box when it runs and turns it into a ref only when the call itself runs (Defer / Panic / Recover, Goroutines).

A pointer receiver is this ref T, or the box this ж<T> when the method needs the pointer itself. Methods are C# extension methods, so the receiver is the first parameter (Functions and Methods). A method that only reads and writes through its receiver takes a C# ref to the caller’s storage. A method that returns, stores or compares its receiver, or takes a field’s address, takes the box:

type Counter struct {
	n int32
}

func bump(p *int32, delta int32) int32 {
	*p += delta
	return *p
}

func (c *Counter) Add(delta int32) int32 { return bump(&c.n, delta) }
func (c *Counter) Set(v int32)           { *(&c.n) = v }
func (c *Counter) Get() int32            { return c.n }
partial struct Counter {
    internal int32 n;
}

internal static int32 bump(ref int32 p, int32 delta) {
    p += delta;
    return p;
}

public static int32 Add(this ж<Counter> Ꮡc, int32 delta) {
    ref var c = ref Ꮡc.DerefOrNull();

    return bump(ref nonnil(ref c).n, delta);
}

public static void Set(this ж<Counter> Ꮡc, int32 v) { (Ꮡc.Ꮡn).Value = v; }
public static int32 Get(this ref Counter c) { return c.n; }

Get only reads, so it takes this ref Counter. A source generator adds a matching overload that takes ж<Counter>, so Get can also be called through a pointer.

Set and Add take &c.n, so they take the box. Ꮡc.of(Counter.Ꮡn) is &c.n, where Counter.Ꮡn is a field reference the generator adds to the struct. bump only dereferences its pointer, so it takes ref int32. nonnil(ref c) is golib’s nil check on the alias: if the receiver is nil, it raises Go’s nil panic at &c.n, where Go raises it.

The caller’s var c Counter is a value. Go takes &c for all three calls, but only Set and Add take the box, so c lives in a box, those calls go through Ꮡc, and c.Get() uses the ref alias:

func main() {
	var c Counter // value receiver; pointer-receiver methods called on it
	c.Set(100)
	fmt.Println("after Set:", c.Get())   // 100
	fmt.Println("Add 10:", c.Add(10))    // 110
	fmt.Println("Add 5:", c.Add(5))      // 115
	fmt.Println("final:", c.Get())       // 115
}
internal static void Main() {
    ref var c = ref heap(new Counter(), out var Ꮡc);
    Ꮡc.Set(100);
    fmt.Println("after Set:", c.Get());
    fmt.Println("Add 10:", Ꮡc.Add(10));
    fmt.Println("Add 5:", Ꮡc.Add(5));
    fmt.Println("final:", c.Get());
}

The four labels are plain C# literals, which go2cs-gen boxes as Go strings for fmt.Println (Strings).

A conversion between pointer types with the same underlying type shares the storage. golib’s Reinterpret<T, U>() returns a view of the same box, not a copy. A write through either pointer shows through the other:

type coord struct{ X, Y int }
type point coord
…
func bump(c *coord) {
	p := (*point)(c)
	p.X, p.Y = 3, 4
}
…
	xy := coord{1, 2}
	bump(&xy)
	fmt.Println(xy.X, xy.Y) // 3 4
partial struct coord {
    public nint X, Y;
}
partial struct point /*coord*/;

internal static partial void bump(ж<coord> c) {
    var p = c.Reinterpret<coord, point>();
    (p.Value.X, p.Value.Y) = (3, 4);
}
…
    ref var xy = ref heap<coord>(new coord(1, 2), out var Ꮡxy);
    bump(Ꮡxy);
    fmt.Println(xy.X, xy.Y);

The comment /*coord*/ after its name declares point as a new type over coord, and a source generator fills in its body (Struct Types). xy is boxed at its zero value and then assigned, because bump(&xy) needs its address. The program prints 3 4. bump is partial because the Go function is marked //go:noinline.

Pointers compare by address. Two pointers are equal when they point to the same storage, even when they are taken through different slices: Ꮡ(t, 0) == Ꮡ(s, 1) is true when t is s[1:]. So a pointer works as a map key. Raw addresses are covered in unsafe.Pointer and uintptr.

Full detail: Reference → Pointers — which parameters become ref and their call forms, the deferred nil panic, per-iteration loop boxes, closures over boxed locals, equality and pinning, and unsafe.Pointer conversions.


Implicit Pointer Dereferencing

Go lets you skip the * and the & in two common places. p.f on a pointer p means (*p).f, and calling a pointer method on a variable v means (&v).M(). Converted C# keeps these short forms wherever it can, so most code reads like the Go. In short:

A few names from golib, go2cs’s runtime library, carry this. The glyph table lists them all, and Pointers covers how pointers are made.

A pointer parameter’s body reads like Go. A pointer parameter that stays a box has type ж<T> and takes the Ꮡ prefix, here Ꮡt. When the body uses the value the pointer points at, the function first binds a C# ref alias with the plain Go name, t. The body then uses t exactly as Go does, with no dereference.

type row [4]uint32
…
func populate(t *row, base uint32) {
	for i := 0; i < len(t); i++ {
		t[i] = base + uint32(i)
	}
}
partial struct row /*[4]uint32*/;
…
internal static void populate(ж<row> Ꮡt, uint32 @base) {
    ref var t = ref Ꮡt.DerefOrNull();

    for (nint i = 0; i < 4; i++) {
        t[i] = @base + (uint32)i;
    }
}

t[i] = … writes straight into the caller’s array, because t is a ref to it. For a nil pointer, golib’s DerefOrNull() returns a null ref instead of failing at once. The first read or write through t then raises Go’s nil pointer dereference panic, at the same point Go would.

row is Go’s array type [4]uint32, completed by the source generators. An array’s length is fixed, so len(t) becomes the constant 4 (Slices and Arrays). Go’s int is C#’s nint (Integer Types and Arithmetic). @base is Go’s base, escaped because base is a C# keyword.

Some pointer parameters are a plain C# ref T instead. When an unexported function only dereferences a pointer parameter, the parameter is often a plain C# ref T, with no box and no Ꮡ prefix. The parameter is already the alias, so the body needs no extra line.

func bump(p *int) {
	*p += 10
}
internal static void bump(ref nint p) {
    p += 10;
}

A call passes a C# ref where Go passes an address: bump(&b.R.Min) becomes bump(ref b.R.Min). Either way the body reads like Go. Pointers explains when each form is used.

A pointer local reads and writes a field as Go does, p.f. A local that holds a pointer is the box itself, so it keeps its plain Go name; there is no ref alias to take that name. Its fields are C# 14 extension properties that go2cs-gen emits on the box, one per field of each struct the package reaches through a pointer. Each returns a ref to the field in the real storage, so a read, a write, ++, -- and a compound assignment all spell the field as Go does. The package’s package_info.cs lists those structs, one [assembly: GoFieldAccess(typeof(T))] each, and the property checks nil as p.Value does.

A few fields keep a box form, because no property can serve them as Go spells them: a field named like a member of ж<T> (p.Value.Value), a field promoted from an embedded struct ((~o).Z), and a field of a struct declared in a hand-written file. The reference lists them all.

A field’s address is a property too, p.Ꮡf. Go’s &p.f is p.Ꮡf: go2cs-gen emits a second property beside each field’s, a ж<F> that points at the field in the real storage, so the address reads as the field does with the address glyph in front of the name. The base is whatever the pointer already is: Ꮡb.Ꮡoff through a receiver’s box, q.ᏑX through a pointer local, Ꮡs.ᏑY through a value that lives in a box. Every site in every package hands golib the struct’s one accessor for the field, so &p.f == &p.f holds across packages and the view is cached once per box and field. A field that keeps a box form for reads keeps p.of(T.Ꮡf) for its address too, and so does the field an element address goes through, &p.arr[i], which is p.at(T.Ꮡarr, i).

This complete program shows reads and writes. Ꮡ(new counter(n: 5)) is Go’s &counter{n: 5}: new counter(n: 5) builds the struct value (Struct Types), and Ꮡ(…) boxes it.

type inner struct{ k int }

type counter struct {
	n   int
	sub inner
}

//go:noinline
func get(c *counter) *counter { return c }

func main() {
	base := &counter{n: 5}
	base.sub.k = 3
	c := get(base) // c is a *counter LOCAL (heap box, not a ref-aliased parameter)

	c.n++     // direct field ++ through the pointer local
	c.n++     // 7
	c.sub.k-- // nested field -- through the pointer local

	// Read back through the original pointer to confirm the writes reached real storage.
	fmt.Println(base.n, base.sub.k) // 7 2
}
partial struct inner {
    internal nint k;
}

partial struct counter {
    internal nint n;
    internal inner sub;
}

internal static partial ж<counter> get(ж<counter> c) { return c; }

internal static void Main() {
    var @base = Ꮡ(new counter(n: 5));
    @base.sub.k = 3;
    var c = get(@base);

    c.n++;
    c.n++;
    c.sub.k--;

    fmt.Println(@base.n, @base.sub.k);
}

get returns the pointer itself, so its parameter must stay a box: it is ж<counter> Ꮡc, with the Ꮡ prefix, and get returns Ꮡc unchanged. get never reads the value, so it binds no ref alias.

A pointer laid over native memory reads each field at Go’s offset through the same property. That matters for a struct whose CLR layout is not Go’s, one holding an array, a string or another managed field, laid over bytes by (*T)(unsafe.Pointer(…)). A local whose every assignment is such a reinterpret, or a same-package call that returns one over a byte array or byte slice, also reaches an array field’s elements in place (unsafe.Pointer and uintptr).

A value-receiver method called through a pointer local is written p.M(), as in Go, and still works on its own copy of the value: the method’s generated box twin copies *p after the call’s arguments are evaluated, so an argument that writes through p is seen by the method, as in Go. A method named like a member of ж<T> keeps the explicit copy (~p).M() (Reference → Implicit Pointer Dereferencing).

A pointer method on a variable takes the variable’s address for you. A pointer-receiver method usually becomes a C# extension method whose receiver is this ref T (Functions and Methods). C# passes the variable by reference, so u.start(3) stays u.start(3) and changes u itself. There is no box and no allocation.

type iter struct{ i, n int }

func (it *iter) start(n int) { it.i = 0; it.n = n }
func (it *iter) valid() bool { return it.i < it.n }
func (it *iter) next()       { it.i++ }
…
func main() {
	var u iter
	sum := 0
	for u.start(3); u.valid(); u.next() {
		sum += u.i
	}
	fmt.Println(sum)
	…
}
partial struct iter {
    internal nint i, n;
}

internal static void start(this ref iter it, nint n) {
    it.i = 0;
    it.n = n;
}

internal static bool valid(this ref iter it) { return it.i < it.n; }
internal static void next(this ref iter it) { it.i++; }
…
internal static void Main() {
    iter u = default;
    nint sum = 0;
    for (u.start(3); u.valid(); u.next()) {
        sum += u.i;
    }
    fmt.Println(sum);
    …
}

iter u = default!; is Go’s zero value for var u iter (Nil and Zero Values). The ! only silences C# nullable warnings.

A pointer method on a pointer local is called the same way. For a this ref method the source generators add a second overload whose receiver is the box, this ж<T>. That overload binds a ref to the box’s value and forwards the call. So b.linkTo(c) stays b.linkTo(c) when b is a pointer local.

A method that uses its receiver as a pointer, such as initSelf, which stores r in a pointer field, works the other way round. It takes the box directly, this ж<ring> Ꮡr, and needs no second overload. Its body binds a ref alias r, like a pointer parameter, and a pointer local calls it directly: a.initSelf().

type ring struct {
	data int
	next *ring
}

// receiver assigned to its own pointer field (self-link)
func (r *ring) initSelf() {
	r.next = r
}

// receiver linked to another node
func (r *ring) linkTo(other *ring) {
	r.next = other
}
…
func main() {
	a := &ring{data: 1}
	a.initSelf() // a.next = a
…
	b := &ring{data: 2}
	c := &ring{data: 3}
	b.linkTo(c)
	fmt.Println(b.next.data)         // 3
…
}
partial struct ring {
    internal nint data;
    internal ж<ring> next;
}

internal static void initSelf(this ж<ring> Ꮡr) {
    ref var r = ref Ꮡr.DerefOrNull();

    r.next = Ꮡr;
}

internal static void linkTo(this ref ring r, ж<ring> other) {
    r.next = other;
}
…
internal static void Main() {
    var a = Ꮡ(new ring(data: 1));
    a.initSelf();
…
    var b = Ꮡ(new ring(data: 2));
    var c = Ꮡ(new ring(data: 3));
    b.linkTo(c);
    fmt.Println(b.next.data);
…
}

A chain reads as Go spells it too. Go’s b.next.data passes through two pointers, b and b.next: b.next is the property on b’s box, which answers the next box, and .data is the property on that one.

Full detail: Reference → Implicit Pointer Dereferencing — how the converter decides that a selector base needs a dereference, promoted fields through a pointer local, nested and indexed write targets, and *p.field through parameters and receivers.


unsafe.Pointer and uintptr

Go’s unsafe.Pointer becomes @unsafe.Pointer, a class from go2cs’s hand-written unsafe package. It holds an address as a number. It can also hold the Go pointer it was made from, so the object stays alive and the pointer can be recovered later. Go’s uintptr becomes the uintptr struct of golib, go2cs’s runtime library: an unsigned integer the size of an address.

A few names recur in this section. A Go pointer *T is a golib ж<T> box (read “zhe”), and a name starting with Ꮡ holds such a box. The package alias is @unsafe because unsafe is a C# keyword. The glyph table lists every glyph.

Go’s garbage collector does not move heap objects, and the CLR’s does. So an address only means something in C# while its storage is held still, or “pinned”. The CLR can pin a value only when it holds no managed references: no strings, slices, maps, channels, funcs, interfaces or pointers inside, and no Go array fields, because golib’s Go array wraps a .NET array. The rules below say when pinning happens, and what a value that cannot be pinned gets instead.

Sizeof, Alignof and Offsetof fold to Go’s numbers. The converter computes each one from Go’s layout rules and writes the number into the C#. The Go expression stays beside it as a comment:

	var x struct {
		a int64
		b bool
		c string
	}
	const M, N = unsafe.Sizeof(x.c), unsafe.Sizeof(x)
	fmt.Println(M, N)
    main_x x = default;
    uintptr M = /* unsafe.Sizeof(x.c) */ 16;
    uintptr N = /* unsafe.Sizeof(x) */ 32;
    fmt.Println(M, N);

main_x is the C# struct declared for the anonymous struct { … } type (Struct Types), and default! is its zero value (Nil and Zero Values). The operand is never evaluated, as in Go. The numbers are Go’s for the target architecture, here amd64.

Alignof and Offsetof fold the same way: unsafe.Offsetof(x.c) becomes /* unsafe.Offsetof(x.c) */ (uintptr)16.

These are Go’s numbers, not measurements of the C# struct. Adding them to a real address is meant for storage laid out like Go’s, such as an array of numbers. A struct that holds a managed reference cannot be pinned, and the CLR lays it out in its own order, so its pointers get order tokens (the order-token rule in this section).

Converting a pointer to uintptr gives a real address, when the value it points to holds no managed reference (for one that does, see the order-token rule in this section). Go’s idiom uintptr(unsafe.Pointer(p)) becomes a plain (uintptr) cast of the pointer’s box Ꮡp. The unsafe.Pointer step in the middle leaves no trace in the C#. The cast pins the storage for as long as the box lives, so the number stays valid. A nil pointer gives 0, as in Go:

type point struct{ x, y int32 }
…
func addrOf(p *point) uintptr {
	return uintptr(unsafe.Pointer(p))
}
partial struct point {
    internal int32 x, y;
}
…
internal static uintptr addrOf(ж<point> p) {
    return (uintptr)p;
}

point holds only two int32s, so its storage can be pinned. It is a type declared in Go, which the source generators complete.

Address arithmetic works, and converting back aliases the same memory. Adding an offset to a pinned address gives the address of the neighbouring value. Converting that number back to a pointer and reading through it reads the real storage, as in Go:

	arr := [4]int{1, 2, 3, 4}
	arrptr := &arr[0]

	// Move the pointer to the next element in the array
	nextPtr := unsafe.Pointer(uintptr(unsafe.Pointer(arrptr)) + unsafe.Sizeof(arr[0]))
	fmt.Println("Value of the next element:", *(*int)(nextPtr))
…
    ref var arr = ref heap<array<nint>>(new nint[]{1, 2, 3, 4}.array(), out var Ꮡarr);
    var arrptr = Ꮡarr.at<nint>(0);

    @unsafe.Pointer nextPtr = (@unsafe.Pointer)((uintptr)arrptr + /* unsafe.Sizeof(arr[0]) */ (uintptr)8);
    fmt.Println("Value of the next element:", ~(ж<nint>)(uintptr)(nextPtr));

This prints 2, the second element, under both Go and C#. In the C#:

nextPtr is built from a bare number, so it carries no box. The conversion back finds the element’s storage from its address. As in Go, a number alone does not keep its object alive: this works while arr is still in use.

A pointer to a value that holds a managed reference gets an order token, not an address. The CLR cannot pin such a value, so golib hands out a unique stand-in number instead. A token compares and sorts like an address. It converts back to the same pointer while that pointer is still in use, as an address does in Go.

A token has no memory behind it, so it has no neighbours. Adding an offset to a token and converting the result back to a pointer raises a Go panic that says so. Like any Go panic, recover can catch it (Defer / Panic / Recover).

unsafe.Pointer(p) keeps what it points to alive. A standalone conversion becomes a factory call such as @unsafe.Pointer.FromPinnedBox(e0). The @unsafe.Pointer it builds stores the source box beside the number. Holding the box keeps the referent alive, as Go’s collector does. FromPinnedBox also pins the storage, so converting back to uintptr gives exactly the same address:

	var buf [4]int32
	e0, e2 := &buf[0], &buf[2]
	a0 := uintptr(unsafe.Pointer(e0))
	a2 := uintptr(unsafe.Pointer(e2))
	fmt.Println("stride:", a0 != 0, a2-a0 == 2*unsafe.Sizeof(buf[0]))
…
	up := unsafe.Pointer(e0)
	fmt.Println("round trip:", uintptr(up) == a0)
…
    ref var buf = ref heap(new array<int32>(4), out var Ꮡbuf);
    var (e0, e2) = (Ꮡbuf.at<int32>(0), Ꮡbuf.at<int32>(2));
    var a0 = (uintptr)e0;
    var a2 = (uintptr)e2;
    fmt.Println("stride:", a0 != 0, a2 - a0 == 2 * /* unsafe.Sizeof(buf[0]) */ (uintptr)4);

    @unsafe.Pointer up = @unsafe.Pointer.FromPinnedBox(e0);
    fmt.Println("round trip:", (uintptr)up == a0);

Every comparison prints true, under Go and under the converted C#. The two element addresses are exactly two int32s apart, and the round trip returns the same number. In the C#:

Reading a number’s bits as another number is a bitcast. A value pun between two number types, such as Go’s math.Float64bits, becomes golib’s bitcast<TSrc, TDst>. It copies the bits and boxes nothing. It accepts only plain value types, with no managed reference inside:

func Float64bits(f float64) uint64 {
	return *(*uint64)(unsafe.Pointer(&f))
}
public static uint64 Float64bits(float64 f) {
    return bitcast<float64, uint64>(f);
}

Reading a struct as another struct with the same fields is a Reinterpret. Go’s *(*T2)(unsafe.Pointer(&t1)) becomes ~Ꮡt1.Reinterpret<T1, T2>(): a view of the same box, read as the other type. Nothing is copied until ~ reads the value:

type T1 struct {
	a int32
}

type T2 struct {
	a int32
}
…
	var t1 T1
	t1.a = 42

	// Convert t1 to type T2
	t2 := *(*T2)(unsafe.Pointer(&t1))
	fmt.Println("Value of t2.a:", t2.a)
partial struct T1 {
    internal int32 a;
}

partial struct T2 {
    internal int32 a;
}
…
    ref var t1 = ref heap(new T1(), out var Ꮡt1);
    t1.a = 42;

    var t2 = ~Ꮡt1.Reinterpret<T1, T2>();
    fmt.Println("Value of t2.a:", t2.a);

This prints Value of t2.a: 42 under both Go and C#. t1 is boxed as Ꮡt1 because its address is taken (Pointers). Reinterpret<T1, T2>() returns a ж<T2> over the same storage, and ~ reads through it (Implicit Pointer Dereferencing). The label is a plain C# literal, which go2cs-gen boxes as a Go string (Strings).

A struct laid over raw bytes is read field by field, at Go’s offsets. A pointer made only by (*T)(unsafe.Pointer(…)), directly or returned that way by a same-package function over byte storage, may point into a byte buffer or native memory. When T holds an array, string or other managed field, the CLR lays it out differently from Go, so each field is reached through a property that go2cs generates on the pointer, and the code reads as Go’s does:

	blob := make([]byte, 24)
	rec := (*record)(unsafe.Pointer(&blob[0]))

	rec.Kind = 0x01020304
	rec.Length = 0x0506
	rec.Off = 7
	rec.Off += 1
    var blob = new slice<byte>(24);
    var rec = Ꮡ(blob, 0).Reinterpret<byte, record>();

    rec.Kind = 0x01020304;
    rec.Length = 0x0506;
    rec.Off = 7;
    rec.Off += 1;

rec is a ж<record>, and rec.Kind writes the four bytes at Go’s offset 0 of blob. An element of an array field reads through the field’s view, rec.of(record.ᏑPath).ElementRef(1).

A slice or string header stored through its fields commits to the variable it views. Go code can view a slice variable as its header, hdr := (*sliceHeader)(unsafe.Pointer(&alias)), where sliceHeader is an unsafe.Pointer field Data and two int fields Len and Cap, and then build a slice by storing those fields one at a time. The converter emits Ꮡalias.Reinterpret<slice<byte>, sliceHeader>(), and golib serves it with a header box over the slice variable. For a struct of that shape, go2cs-gen, go2cs’s source generators, gives each field a get/set property instead of a ref one, and the setter commits the header to the variable after each store. So once hdr.Data, hdr.Cap and hdr.Len are stored, alias is the slice they describe and shares its storage, as in Go. A string header, an unsafe.Pointer and one int, is served by its own box in the same way.

The commit has three limits, each stated by golib rather than hidden:

internal/unsafeheader’s own tests, which build a slice and a string this way, pass under go2cs on linux/amd64.

A pointer passed to a system call stays alive until the call returns, as in Go. Go guarantees this for uintptr(unsafe.Pointer(p)) written directly in a syscall.Syscall argument list. The converter moves the pointer into a temporary whose name starts with ᴋ, casts it for the call, then calls GC.KeepAlive on it. The cast pins the storage, and keeping the box alive keeps it pinned, so the kernel writes into storage that cannot move.

In internal/syscall/unix, Go’s GetRandom calls syscall.Syscall(getrandomTrap, uintptr(unsafe.Pointer(unsafe.SliceData(p))), uintptr(len(p)), uintptr(flags)). It becomes:

// GetRandom calls the getrandom system call.
public static (nint n, error err) GetRandom(slice<byte> p, GetRandomFlag flags) {
…
    var ᴋ0 = @unsafe.SliceData(p);
        var (r1, _, errno) = syscall.Syscall(getrandomTrap, (uintptr)ᴋ0, (uintptr)len(p), (uintptr)flags);
    System.GC.KeepAlive(ᴋ0);
…
    return ((nint)r1, default);
}

In the C#:

Full detail: Reference → Converting a Go pointer to unsafe.Pointer — every conversion form and when each factory is used, address arithmetic, pinning, order tokens, layout folding, value puns, the cases where unsafe.Slice or unsafe.String copies instead of aliasing, fields read through a reinterpreted pointer, and the syscall keep-alive rule.


Source Generators

Some Go behavior cannot be written directly in C#, or would need a lot of boilerplate code to write out. The converter therefore emits a short declaration with a marker: an attribute, a partial keyword or a short comment. Roslyn source generators (src/gen/go2cs-gen) write the rest while the project compiles. This keeps the converted code short and close to the Go original.

Generated code is not in the converted .cs files. When converted code uses a constructor, overload, adapter or conversion that you cannot find in any .cs file, a generator wrote it. The compiler saves that output as files you can read. For a single package they are in the project’s Generated folder. For a -recurse conversion, which converts a package together with the packages it imports, they are under .artifacts/gen in the output root.

Two glyphs appear throughout these examples. ж<T> (read “zhe”) is a Go pointer *T: a golib heap box that holds one value. Ꮡ marks an address. As a name prefix it marks a variable that holds a box (Ꮡb) or a field reference (Buffer.Ꮡoff). Called as Ꮡ(v), it is a golib function that puts a value in a new box, the C# form of Go’s &T{…}. See Pointers and Names and Glyphs.

A few golib types stand in for Go’s. @string is Go’s string, slice<T> is []T, and nint is Go’s int. A literal such as "%s=%d"u8 is a C# UTF-8 string literal, which golib accepts as a Go string. See Strings, Slices and Arrays and Integer Types and Arithmetic.

Each generator reacts to a marker the converter emits: an attribute, a partial declaration, a receiver or parameter shape, or a literal in an any slot. These are the generators converted code shows most often:

| Generator | Driven by | Produces | |—|—|—| | TypeGenerator | a partial struct, class or interface declared in the package class | the body of each type: struct constructors, field references, equality and ToString; wrappers for named numeric, slice, array, map and channel types; for an interface, the code that converts a value to it at run time, as a type assertion does (Interfaces); members promoted by struct embedding | | RecvGenerator | a method whose receiver is this ref T | a ж<T> overload of each pointer-receiver method, so it can be called on a box; for a method that must keep its frame, the overload carries the same two attributes as the method (A partial method with a body keeps its own frame) | | ImplementGenerator | [assembly: GoImplement<T, I>] | the code that lets T or *T be used as interface I (Interfaces) | | ImplicitConvGenerator | [assembly: GoImplicitConv<S, T>] | a conversion operator between two types C# cannot convert directly, such as two named numeric types or two structs with the same underlying type; the C# still writes the conversion where Go does, and the operator lets it compile | | StrGenerator | a method with an sstring parameter | for a function that takes its string as an sstring (golib’s stack-only string view, a ref struct), the matching @string overload (Strings) | | PartialStubGenerator | a partial method with no body | a stub that throws, when no hand-written body exists (Functions Without a Go Body) | | NoInliningPartialGenerator | a partial method with a body and no other declaration | the method’s declaring part, carrying [MethodImpl(MethodImplOptions.NoInlining)] and DynamicSecurityMethod, so the JIT never inlines the method, the CLR never tail-calls into it, and runtime.Caller still sees its frame and its caller’s (A partial method with a body keeps its own frame); for a forwarder marked /*linkname*/, [StackTraceHidden] instead (Functions Without a Go Body) | | LiteralInterceptorGenerator | a plain C# string literal passed to an any parameter | an interceptor for that one call that passes a cached, boxed @string in the literal’s place, so the callee sees a Go string rather than a .NET string (Strings) | | LazyHolderGenerator | a [GoLazy] holder class around one package-level var | the var’s ref accessor in the package class, under its Go name, so a var built on its first read is used like any other (A var whose value is pure data is built on its first read) |

A converted struct lists only its fields

The converter writes a Go struct as a C# partial struct that holds only the fields. partial lets TypeGenerator supply the other half: constructors, the == operator, ToString, and one field reference per field, such as Buffer.Ꮡoff (Struct Types).

type Buffer struct {
	buf      []byte
	off      int
	lastRead int8
}
…
	b := Buffer{}
	PrintValPtr(&b.off)
…
func PrintValPtr(ptr *int) {
	fmt.Printf("Value available at *ptr = %d\n", *ptr)
	*ptr++
}
partial struct Buffer {
    internal slice<byte> buf;
    internal nint off;
    internal int8 lastRead;
}
…
    ref var b = ref heap<Buffer>(new Buffer(), out var Ꮡb);
    PrintValPtr(Ꮡb.Ꮡoff);
…
public static void PrintValPtr(ж<nint> Ꮡptr) {
    ref var ptr = ref Ꮡptr.DerefOrNull();

    fmt.Printf("Value available at *ptr = %d\n"u8, ptr);
    ptr++;
}

Reading the C# line by line:

A pointer-receiver method gains a box overload

A Go method with a pointer receiver usually becomes a C# extension method on ref T. The ref lets the method change the caller’s value, as Go’s pointer receiver does (Functions and Methods). A method with a value receiver takes T itself and gets no second overload.

type Mixed struct{ n int }
…
func (m *Mixed) Bump() { m.n++ }
…
	pm := &Mixed{n: 41}
	pm.Bump()
partial struct Mixed {
    internal nint n;
}
…
public static void Bump(this ref Mixed m) { m.n++; }
…
    var pm = Ꮡ(new Mixed(n: 41));
    pm.Bump();

new Mixed(n: 41) is the generated constructor, with each field as a named argument. In Go, pm is a pointer. In C#, Ꮡ(…) puts the new value in a heap box, so pm is a ж<Mixed>. C# cannot pass a box where ref Mixed is expected. RecvGenerator therefore emits an overload of Bump on this ж<Mixed> that dereferences the box and calls the ref version. That overload is what pm.Bump() binds, so the call reads exactly like the Go.

A method that needs its receiver as a real pointer is written on ж<T> directly. Examples are taking a field’s address (&c.n), returning the receiver, storing or comparing it, and using it inside a closure. Such a method needs no generated overload. Here the receiver is stored in a struct’s pointer field:

type mc struct{ n int }
…
type wrap struct {
	p   *mc
	tag int
}

func (c *mc) wrapped() wrap {
	return wrap{c, 7} // positional: the receiver into a pointer field
}
partial struct mc {
    internal nint n;
}
…
partial struct wrap {
    internal ж<mc> p;
    internal nint tag;
}

internal static wrap wrapped(this ж<mc> Ꮡc) {
    return new wrap(Ꮡc, 7);
}

The receiver Ꮡc is the box itself, so the wrap it returns points at the caller’s mc, as in Go (Pointers).

A partial method with a body keeps its own frame

Go code can ask which function is running: runtime.Caller(n) names the function n frames up the stack. That answer depends on each of those functions having a frame of its own. The .NET JIT copies small methods into their callers (inlining), and an inlined method has no frame, so runtime.Caller would name the wrong function. The converter therefore marks every function whose frame a Go stack walk counts, and the mark tells the JIT not to inline it.

A function without the mark can still be inlined, and its frame is then missing from a stack walk. One case is restored: when an inlined function raises an explicit panic, golib reads the call back from its caller’s IL and puts the frame back into runtime.Callers and the crash report, printed name(...) as Go prints an inlined call. It does so only when the statement’s IL holds exactly one call and that call reaches exactly one panic. A fault the CLR raises itself inside an inlined function, such as an integer divide by zero or a nil dereference, keeps the frames the CLR reports.

The mark is the word partial. A converted method with a body that is written partial is one half of a C# partial method. NoInliningPartialGenerator writes the other half, the declaration, with the attribute [MethodImpl(MethodImplOptions.NoInlining)], and C# compiles the two halves into one method that carries the attribute. Here here asks for its caller, and plain is a one-line function that the JIT would otherwise inline:

// here names the function that called it.
func here() string {
	pc, _, _, ok := runtime.Caller(1)
	if !ok {
		return "<no caller>"
	}
	return runtime.FuncForPC(pc).Name()
}

// plain is a thin forwarder to here: the closure marks it, and only the mark keeps its frame.
func plain() string { return here() }
internal static partial @string here() {
    var (pc, _, _, ok) = runtime.Caller(1);
    if (!ok) {
        return "<no caller>";
    }
    return runtime.FuncForPC(pc).Name();
}

internal static partial @string plain() { return here(); }

The generated half, in the project’s Generated folder:

[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.NoInlining), global::System.Security.DynamicSecurityMethod]
internal static partial @string here();
[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.NoInlining), global::System.Security.DynamicSecurityMethod]
internal static partial @string plain();

The generated half also keeps the caller’s frame. Go never makes a tail call. The .NET JIT can: when a method ends by calling another, it may jump to that method instead, and the calling frame is then missing from a stack walk, as an inlined one is. The second attribute, DynamicSecurityMethod, is declared by golib. The C# compiler flags a method that carries it, and the CLR never makes an implicit tail call to a method with that flag. A call through a function value cannot be protected this way, because the method it calls is the delegate’s Invoke. So when a function’s last statement calls a function value that holds a function of the same package that reads its caller’s frame, the converter marks the calling function too: on linux/amd64, a method marked NoInlining was measured to make no implicit tail call. For a marked pointer-receiver method, the ж<T> overload that RecvGenerator writes carries the same two attributes.

A function literal cannot be partial, so a marked literal keeps the attribute itself: var lit = [MethodImpl(MethodImplOptions.NoInlining)] @string () => here();. A marked init keeps the attribute too. C# runs an init (a module initializer) in declaration order, and a partial method’s declaration is the generated half, which comes after every source file, so a partial init would run after the package’s other init functions. A partial method with no body is a different case, a function whose body is elsewhere (Functions Without a Go Body).

The converter decides interface satisfaction; a generator builds it

A Go type satisfies an interface just by having the right methods. A C# type must declare the interfaces it implements. The converter knows, from Go’s type checker, every place a type is used as an interface. It records each distinct pair once, as an assembly attribute in the package’s package_info.cs file (Package Conversion).

type Describer interface {
	Describe() string
}

…
type Setting struct {
	name  string
	value int
}

func (s *Setting) Describe() string {
	return fmt.Sprintf("%s=%d", s.name, s.value)
}

…
type holder struct {
	d     Describer
	label string
}

…
func assignDescriber(h *holder, s *Setting) {
	h.d = s
}
…
	replacement := Setting{name: "assigned", value: 11}
	assignDescriber(h, &replacement)
partial interface Describer {
    @string Describe();
}

partial struct Setting {
    internal @string name;
    internal nint value;
}

public static @string Describe(this ref Setting s) {
    return fmt.Sprintf("%s=%d"u8, s.name, s.value);
}

partial struct holder {
    internal Describer d;
    internal @string label;
}

internal static void assignDescriber(ref holder h, ж<Setting> s) {
    h.d = new SettingжDescriber(s);
}
…
    ref var replacement = ref heap<Setting>(new Setting(name: "assigned", value: 11), out var Ꮡreplacement);
    assignDescriber(ref (h).DerefOrNull(), Ꮡreplacement);

Only *Setting has the Describe method, so package_info.cs records the pair with Pointer = true:

[assembly: GoImplement<Setting, Describer>(Pointer = true)]

From that attribute, ImplementGenerator emits the adapter class SettingжDescriber. The name reads “Setting pointer (ж) as Describer”: the class holds the ж<Setting> box and implements Describer by calling Describe on it. Go’s h.d = s becomes h.d = new SettingжDescriber(Ꮡs). When a value receiver provides the methods, the record has no Pointer = true, and a struct of the same package implements the interface itself. A named func type goes in through a value adapter such as meterᴠvalued, and so does a struct from another package that does not already implement the interface there. Interfaces covers these forms, and how equality and type assertions see through an adapter.

The two pointer parameters take different shapes, depending on how the body uses them:

Run-time registrations wait for the first lookup that misses

Some generated code must also be found at run time, through a lookup in a golib registry. A pointer adapter such as SettingжDescriber is one: a type assertion or type switch that finds a box uses it to wrap the box. The others are factories that build a struct’s Go zero value, and two registries that golib’s native-memory and reflection support read.

The generators do not fill these registries while the program starts. For each registry it fills, a package gets one generated module initializer, code that .NET runs when the assembly loads, and that initializer only queues the package’s registration method on golib’s GoDeferredRegistrations. When a lookup in a registry finds nothing, the registry runs every registration queued so far, then looks again. A program whose lookups never miss runs none of them, so under the JIT less code is compiled at startup.

Full detail: Reference → Source Generators — what each generator emits in depth, which attributes the converter emits and where they land, and each generator’s edge cases.


Functions Without a Go Body

Go lets a function be declared with no body: only a signature, with the code kept somewhere else. The body may be assembly (a .s file), a C function, or another package’s function joined by the //go:linkname directive. The closest C# match is a partial method, a declaration whose body another part of the same class supplies.

So go2cs keeps each bodyless Go function’s name and signature. By default it emits a C# partial method, with no body. A forwarder the converter writes stays a partial method, with that body; a body that raises a panic is written as an ordinary method. The body arrives in one of these ways:

A bodyless declaration stays a partial method. Go’s math package implements archMax in assembly on most 64-bit platforms. That platform’s file declares only the signature, plus a constant telling callers that the fast version exists:

package math

const haveArchMax = true

func archMax(x, y float64) float64
namespace go;

partial class math_package {

internal const bool haveArchMax = true;

internal static partial float64 archMax(float64 x, float64 y);

math_package is the C# class that holds the whole Go package (see Package Conversion). float64 keeps its Go name as an alias for C# double (see Integer Types and Arithmetic).

Because the name and signature do not change, every caller converts as ordinary code. math.Max calls archMax when the constant says the fast version exists. Otherwise it calls max, which here is not Go’s built-in max but math’s own unexported function in dim.go, converted like any other Go code:

func Max(x, y float64) float64 {
	if haveArchMax {
		return archMax(x, y)
	}
	return max(x, y)
}
public static float64 Max(float64 x, float64 y) {
    if (haveArchMax) {
        return archMax(x, y);
    }
    return max(x, y);
}

A hand-written companion supplies the body. C# does not compile a partial method that has an access modifier, such as internal here, until some part implements it. For archMax, a hand-written file named math_impl.cs sits beside the converted files and supplies the implementing half. It calls max, the same portable Go fallback math.Max uses. The file fills archMin and the other arch… functions the same way:

namespace go;

partial class math_package
{
…
    internal static partial float64 archMax(float64 x, float64 y) => max(x, y);

    internal static partial float64 archMin(float64 x, float64 y) => min(x, y);
}

The converter never writes a *_impl.cs file, so converting the package again keeps the body. Manually-Converted Declarations explains these hand-owned files.

//go:linkname joins two functions, in one of two directions. The directive tells Go’s linker that two names are one function, even across packages and even when a name is unexported. Which side writes the full directive decides the direction:

A one-argument form, //go:linkname name, names no other function. It marks name as a linkname endpoint that another package’s directive may reach.

A //go:linkname pull becomes a forwarder. Package time/tzdata uses a pull to call into time, a package it never imports:

// registerLoadFromEmbeddedTZData is defined in package time.
//
//go:linkname registerLoadFromEmbeddedTZData time.registerLoadFromEmbeddedTZData
func registerLoadFromEmbeddedTZData(func(string) (string, error))

func init() {
	registerLoadFromEmbeddedTZData(loadFromEmbeddedTZData)
}
// registerLoadFromEmbeddedTZData is defined in package time.
//
//go:linkname registerLoadFromEmbeddedTZData time.registerLoadFromEmbeddedTZData
/*linkname*/ internal static partial void registerLoadFromEmbeddedTZData(Func<@string, (@string, error)> _) {
    go.time_package.registerLoadFromEmbeddedTZData(_);
}

[GoInit] internal static void init() {
    registerLoadFromEmbeddedTZData(loadFromEmbeddedTZData);
}

Instead of a bodiless stub, the converter writes a partial method with a real body that calls the target. Reading the C#:

The converter cannot tell from the directive alone whether the target has a C# body: in Go, many linkname targets are themselves assembly. So it forwards only to targets on a list, kept inside the converter, of functions known to have one. Any other pull keeps the throwing stub described at the end of this section. A target on the list can be converted Go, as here, or hand-written C#: testing/cryptotest pulls testing.checkParallel, which the hand-written testing package provides, and its forwarder calls that.

The target in time is ordinary Go, marked with the one-argument directive. Each converted package is its own .NET assembly, and a lower-case Go name is normally internal. A function that carries that marker and is on the target list is emitted public instead, so the forwarder in time/tzdata can reach it:

// registerLoadFromEmbeddedTZData is called by the time/tzdata package,
// if it is imported.
//
//go:linkname registerLoadFromEmbeddedTZData
func registerLoadFromEmbeddedTZData(f func(string) (string, error)) {
	loadFromEmbeddedTZData = f
}
// registerLoadFromEmbeddedTZData is called by the time/tzdata package,
// if it is imported.
//
//go:linkname registerLoadFromEmbeddedTZData
public static void registerLoadFromEmbeddedTZData(Func<@string, (@string, error)> f) {
    loadFromEmbeddedTZData = f;
}

A //go:linkname push forwards too, or panics with a reason. Package crypto/internal/sysrand declares a bodyless function under the one-argument marker. Package runtime supplies the body with a push: //go:linkname sysrand_fatal crypto/internal/sysrand.fatal.

// fatal is [runtime.fatal], pushed via linkname.
//
//go:linkname fatal
func fatal(string)

The bodyless declaration in sysrand becomes a forwarder to the converted body in runtime, whose package class it names in full. Go’s unnamed string parameter is written _:

// fatal is [runtime.fatal], pushed via linkname.
//
//go:linkname fatal
/*linkname*/ internal static partial void fatal(@string _) {
    go.runtime_package.sysrand_fatal(_);
}

The converter reads one package at a time, so it cannot see runtime’s directive while converting sysrand. A curated list inside the converter records each push pair instead, and a pair not on the list keeps the throwing stub. Some pushed bodies need runtime machinery go2cs does not model. For those, the declaration raises a Go panic (see Defer / Panic / Recover) whose message names both halves of the pair and the reason.

An assembly trampoline forwards the same way, outside the standard library. Some Go assembly only jumps to another Go function, in one instruction. golang.org/x/sys/unix does this with JMP syscall·Syscall(SB). That says the two names are one function, so the converter writes a forwarder like the linkname one. It does so only in packages outside Go’s standard library, and only when the two signatures match or differ only in pointer parameters to structs with the same field layout, which the forwarder copies across. Inside the standard library, hand-written files cover that assembly. x/sys/unix’s SyscallNoError and RawSyscallNoError are not jumps, since they make the system call themselves, but when their assembly matches the known instruction sequence exactly they forward to syscall’s Syscall or RawSyscall too, dropping its error result: they serve calls that cannot fail, such as Getpid.

In the standard library, purego removes many bodyless declarations. Go often ships a portable Go file beside an assembly one, and chooses between them with the purego build tag. go2cs converts the library as Go builds with -tags purego, so the portable file is the one converted, and the C# method has an ordinary body. This does not cover every case. math’s dim_asm.go is chosen by CPU architecture alone, with no purego tag, so archMax stays bodyless and needs its companion file.

Anything left gets a throwing stub. When no part implements a partial method, a source generator adds one at compile time, so the project still builds. The stub throws NotImplementedException with a message that starts with the function’s name. The gap therefore surfaces at the first call, not at build time.

A function whose assembly jumps into a C library, such as syscall’s libc_getgroups_trampoline on macOS, keeps this stub too. Go code only takes that function’s address, and never calls it. The converter records which C function the jump names, so the address resolves to the real C function.

Full detail: Reference → //go:linkname and assembly forwarders — how pull and push forwarders are chosen and how they bridge types, the curated target lists, assembly-trampoline limits, cgo dynamic-import records, stub addresses, and the guard tests.


Manually-Converted Declarations

Almost all of the C# in go2cs’s converted standard library is written by the converter. A small set of Go declarations becomes C# written by hand instead, and the converter never writes over it. These hand-owned files and functions sit beside converted code and read as ordinary C#.

Hand ownership is mostly a standard-library tool. Every example in this section comes from go2cs’s converted standard library under src/core. A project of your own can use the whole-file form described here: mark a converted .cs file, and later conversions leave it alone. The single-declaration form is a list built into the converter, so it covers only the standard library.

A declaration is hand-owned when Go’s mechanism has no .NET equivalent. Such Go code hides a pointer inside an integer, reads an interface’s internal machine words through unsafe.Pointer, calls a scheduler primitive, or is written in assembly. On .NET, an any is one object reference, with no separate words to read. A Go pointer’s target lives in a ж<T>, golib’s heap box for a pointed-to value; see Pointers. The .NET garbage collector can move that box, and golib can pin it only when its value holds no managed reference (unsafe.Pointer and uintptr). Go code that keeps an address in an integer and compares it later, as sync.Cond’s copy check does, could then see a stale number, so that code is written by hand. The hand-written C# keeps Go’s observable behavior and drops Go’s mechanism. A few hot functions, such as math/bits’ Mul, are hand-owned for speed instead: Go’s compiler replaces each call with a few machine instructions, and the converted body is slower.

Hand ownership comes in two sizes: a whole file, or a single declaration.

A whole file is hand-owned with [module: go.GoManualConversion]. This attribute at the top of a C# file tells the converter to leave the file alone. When the file stands in for a converted Go file, the converter writes its own version beside it as <name>.cs.auto. The project does not compile .cs.auto files; they only show what the automatic conversion would be.

sync/atomic’s Value is one such file. Go stores an any as two words, a type pointer and a data pointer. Go’s Load reinterprets the Value as an efaceWords and reads each of the two words with an atomic load:

type Value struct {
	v any
}

// efaceWords is interface{} internal representation.
type efaceWords struct {
	typ  unsafe.Pointer
	data unsafe.Pointer
}

// Load returns the value set by the most recent Store.
// It returns nil if there has been no call to Store for this Value.
func (v *Value) Load() (val any) {
	vp := (*efaceWords)(unsafe.Pointer(v))
	typ := LoadPointer(&vp.typ)
	if typ == nil || typ == unsafe.Pointer(&firstStoreInProgress) {
		// First store not yet completed.
		return nil
	}
	data := LoadPointer(&vp.data)
	vlp := (*efaceWords)(unsafe.Pointer(&val))
	vlp.typ = typ
	vlp.data = data
	return
}

The hand-written C# keeps the any in its one field. It reads the field with .NET’s Volatile.Read, an atomic, ordered load of one object reference:

[module: go.GoManualConversion]

namespace go.sync;

partial class atomic_package {

// A Value provides an atomic load and store of a consistently typed value.
// The zero value for a Value returns nil from [Value.Load].
//
// A Value must not be copied after first use.
[GoType] partial struct Value {
    internal any v;
}
…
[GoRecv] public static any /*val*/ Load(this ref Value v) {
    return Volatile.Read(ref v.v);
}

The file uses the same shapes the converter emits, so callers see no difference:

Store and Swap read the field with Volatile.Read and replace it with Interlocked.CompareExchange. They keep Go’s panics for a nil or differently typed value.

A frame inside a hand-owned file reports its C# position. runtime.Caller, stack traces and the crash report name a frame by its Go file and line through a position record, an [assembly: go.GoPositionMap(…)] attribute that the converter writes for each file it converts, in the package’s package_info.cs. It writes none for a hand-owned file, so a frame there reports the .cs file and line. The one exception is a go statement, which converted code writes as golib’s goǃ(…) (Goroutines). A hand-owned file that starts a goroutine carries its own record, kept by hand, that maps each goǃ( line to the Go go statement it replaces and no other line. A traceback’s created by line then names the Go position, as Go’s does.

A single declaration is hand-owned through a list in the converter. The converter keeps a list of Go types and functions, by package and name, that it does not emit. In each one’s place it writes a one-line placeholder comment. A hand-written *_impl.cs file in the same package supplies the C# declaration. A converted package is one C# partial class spread over many files, so callers see no difference.

runtime.Gosched is one. Its Go body calls mcall, a runtime primitive that switches to the scheduler’s own stack. .NET has no such primitive:

func Gosched() {
	checkTimeouts()
	mcall(gosched_m)
}

The converted proc.cs keeps only a placeholder where Gosched would be:

// go2cs generated this placeholder — func Gosched is hand-converted with managed semantics in the package's *_impl.cs ([module: GoManualConversion])

The placeholder names the attribute that the *_impl.cs file carries. It is the same attribute as go.GoManualConversion; go is its namespace. The hand-owned managed_impl.cs supplies the method:

// Gosched yields the processor, allowing other goroutines to run. It does not suspend the
// current goroutine, so execution resumes automatically.
public static void Gosched()
{
    …
    golib.GoschedBackoff.Yield();
}

The omitted lines are a comment on why a plain thread yield is not enough on Linux. Each go2cs goroutine keeps one .NET thread while it runs (see Goroutines), so yielding the processor means yielding the thread. golib, go2cs’s hand-written runtime library, provides GoschedBackoff.Yield for this. It yields the current thread, and sleeps briefly when a plain yield lets nothing else run.

A function with no Go body is filled in by a hand-owned file. Go writes some functions in assembly. Like any function without a Go body, such a function converts to a C# partial method with no body. A hand-owned *_impl.cs file supplies the body. When none does, a source generator supplies one that throws, so the project still compiles.

On Linux, Go’s system call wrappers all end in Syscall6, which Go writes in assembly:

// Syscall6 calls system call number 'num' with arguments a1-6.
func Syscall6(num, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, errno uintptr)
// Syscall6 calls system call number 'num' with arguments a1-6.
public static partial (uintptr r1, uintptr r2, uintptr errno) Syscall6(uintptr num, uintptr a1, uintptr a2, uintptr a3, uintptr a4, uintptr a5, uintptr a6);

The three results become a C# tuple; see Multi-Result Values. uintptr is golib’s type for Go’s uintptr, an integer the size of a pointer; see unsafe.Pointer and uintptr.

The hand-owned syscall_linux_impl.cs gives Syscall6 its body. In the ordinary case it hands its arguments to rawSyscall6, which calls glibc’s syscall(2) through one .NET native binding, libc_syscall:

[LibraryImport("libc", EntryPoint = "syscall", SetLastError = true)]
private static partial nint libc_syscall(nint number, nint a1, nint a2, nint a3, nint a4, nint a5, nint a6);
…
private static nint ToNative(uintptr value) => unchecked((nint)(nuint)value);
…
private static (uintptr r1, uintptr r2, uintptr errno) rawSyscall6(uintptr num, uintptr a1, uintptr a2, uintptr a3, uintptr a4, uintptr a5, uintptr a6) {
    nint result = libc_syscall(ToNative(num), ToNative(a1), ToNative(a2), ToNative(a3), ToNative(a4), ToNative(a5), ToNative(a6));

    // r1 is the raw return in both outcomes: on failure libc returns -1 and so does Go's asm
    // (`MOVQ $-1, AX`), so the same expression serves both branches.
    uintptr r1 = new uintptr(unchecked((nuint)result));

    return result == -1
        ? (r1, default(uintptr), new uintptr((nuint)Marshal.GetLastPInvokeError()))
        : (r1, a3, default(uintptr));
}

SetLastError = true makes .NET keep libc’s error number, which Marshal.GetLastPInvokeError() reads back as errno. r2 matches what Go’s own assembly reports: zero on failure, and the unchanged third argument on success. Converted Linux wrappers such as open, read or stat all run on that one binding. Go’s network poller works the same way: its runtime hooks have no Go body, and a hand-owned runtime_netpoll_impl.cs per operating system implements them.

A hand-owned file can be specific to one operating system. Go picks a file per operating system by its name, such as _linux.go, or by a //go:build line. go2cs puts each operating system’s files, converted or hand-owned, in the package’s windows/, linux/ and darwin/ subfolders. An entry in the converter’s list can likewise name the operating systems it covers.

The main hand-owned surfaces implement Go’s contract on .NET primitives:

Full detail: Reference → Manually-Converted Declarations — every hand-owned surface and why, //go:linkname forwarders in both directions, the poller and system call internals on each OS, native bindings, how hand-owned files and list entries are scoped per platform, and the reflection bridge.


The standard library reproduces Go -tags purego

Go writes some of its hottest crypto and hash routines in .s assembly files. The Go source only declares those functions, without a body. A transpiler reads Go source and cannot convert assembly, so the converted standard library reproduces Go built with -tags purego: Go’s own switch for portable, pure-Go code.

A build tag chooses which Go files are compiled. A //go:build line at the top of a Go file is a condition, much like a C# #if around the whole file. The Go toolchain compiles the file only when the condition holds. Passing -tags purego makes the name purego true, so files gated on purego join the build and files gated on !purego leave it.

The default build binds SHA-256 to assembly. On amd64, Go compiles this file. blockAVX2 and blockSHANI have no body, because their code lives in an assembly file; on a CPU with neither feature, block falls back to blockGeneric, SHA-256 written in plain Go. The //go:noescape lines are hints to Go’s compiler and do not matter here:

//go:build !purego
…
//go:noescape
func blockAVX2(dig *Digest, p []byte)

//go:noescape
func blockSHANI(dig *Digest, p []byte)

func block(dig *Digest, p []byte) {
	if useSHANI {
		blockSHANI(dig, p)
	} else if useAVX2 {
		blockAVX2(dig, p)
	} else {
		blockGeneric(dig, p)
	}
}

Under purego, a sibling file with a real body is chosen instead. Its build line is long, but only the || purego at the end matters: it selects the file on every architecture. Its block calls blockGeneric directly:

//go:build (!386 && !amd64 && !arm64 && !loong64 && !ppc64 && !ppc64le && !riscv64 && !s390x) || purego

package sha256

func block(dig *Digest, p []byte) {
	blockGeneric(dig, p)
}

Only the selected file is converted. The assembly-backed file is left out entirely, just as Go leaves it out. The C# keeps the Go build line as a comment. The package path’s parent becomes the namespace (@internal escapes a C# keyword), and the package itself becomes partial class sha256_package; see Package Conversion. The pointer parameter *Digest becomes a C# ref parameter (Pointers). slice<byte> is Go’s []byte, from golib, go2cs’s hand-written C# runtime library (Slices and Arrays):

//go:build (!386 && !amd64 && !arm64 && !loong64 && !ppc64 && !ppc64le && !riscv64 && !s390x) || purego
namespace go.crypto.@internal.fips140;

partial class sha256_package {

internal static void block(ref Digest dig, slice<byte> p) {
    blockGeneric(ref dig, p);
}

} // end sha256_package

The same choice holds on every target. The tag is part of the conversion, so wherever Go has a purego path, every converted platform runs that same portable code. The pure-Go path is usually slower than Go’s hand-tuned assembly. It is the path go2cs can convert and run.

The default is two tags: purego and math/big’s own math_big_pure_go. These are options of the go2cs command itself. A go2cs -stdlib conversion (the whole standard library) or a go2cs -tests conversion (Converted Tests) applies purego,math_big_pure_go by default. math/big names its portable fallback math_big_pure_go rather than purego, so both tags are needed.

Your own tags always win. Passing go2cs’s -tags option replaces the default, and -tags= clears it. A go2cs -recurse conversion of your own module adds only safe, for the reason given at the end of this section, unless you pass -tags or -module-safe-tag=false. Other conversions apply exactly the tags you pass and nothing else.

An assembly-backed declaration ends in one of three ways:

Behavior matches the purego build, including its gaps. go2cs converts what Go builds under purego and does not patch it. Under purego, P-256’s scalar inverse has no fast implementation, so Go selects a stub that returns an error:

//go:build (!amd64 && !arm64) || purego

package nistec

import "errors"

func P256OrdInverse(k []byte) ([]byte, error) {
	return nil, errors.New("unimplemented")
}

In the C#, the import becomes a using alias (Package Conversion). The string literal is hoisted into a static field whose name ends in ˢ. Its type @string is golib’s Go string, built from "…"u8, a C# UTF-8 byte literal (Strings). The two results become a C# tuple (Multi-Result Values), and default! is Go’s nil (Nil and Zero Values):

//go:build (!amd64 && !arm64) || purego
namespace go.crypto.@internal.fips140;

using errors = errors_package;

partial class nistec_package {

// Hoisted @string literals (single allocation; Go keeps these in RODATA)
internal static ref readonly @string unimplementedˢ => ref unimplementedˢᵇ;
private static readonly @string unimplementedˢᵇ = "unimplemented"u8;

public static (slice<byte>, error) P256OrdInverse(slice<byte> k) {
    return (default, errors.New(unimplementedˢ));
}

} // end nistec_package

The gap reaches no panic. The stub’s only caller is ECDSA’s inverse, where c.ordInverse is P256OrdInverse on P-256. It takes the stub’s error as “not implemented here” and computes the inverse by Fermat’s method:

func inverse[P Point[P]](c *Curve[P], kInv, k *bigmod.Nat) {
	if c.ordInverse != nil {
		kBytes, err := c.ordInverse(k.Bytes(c.N))
		// Some platforms don't implement ordInverse, and always return an error.
		if err == nil {
…
	// Calculate the inverse of s in GF(N) using Fermat's method
	// (exponentiation modulo P - 2, per Euler's theorem)
	kInv.Exp(k, c.nMinus2, c.N)
}

A Go panic becomes throw panic(...) (Defer / Panic / Recover). A literal passed straight to panic stays inline as a plain C# string instead of becoming a hoisted ˢ field. The tuple result is unpacked with var (p, err) = …. crypto/elliptic’s ScalarBaseMult shows both:

    var (p, err) = curve.newPoint().ScalarBaseMult(scalar);
    if (err != nil) {
        throw panic("crypto/elliptic: nistec rejected normalized scalar");
    }

A module converts with -tags safe, for a different reason. Some libraries reach into Go’s runtime with unsafe pointer arithmetic, adding a byte offset to a value to read one of its private fields. Converted code keeps its values in .NET’s own layout, so golib refuses that arithmetic rather than read the wrong memory. Libraries that do this usually ship a fallback for builds tagged safe, so a -recurse conversion adds the tag and the fallback is what converts. When a module is validated, Go’s own go test run gets the same tags, so the two sides compile the same files. The tag appengine, which some libraries read the same way, is not added, because it also changes unrelated behavior. -module-safe-tag=false turns safe off. See Reference → Default build tags.

Full detail: Reference → The standard-library conversion applies -tags purego — why the tag is on by default and the alternatives weighed, how -tests shares it, the math/big fallback tag, the three outcomes with more packages named, and the crypto/elliptic gating in full.


Comments

A Go comment that reaches the C# is copied word for word. A // line stays a // line, and a /* … */ block stays a block. Most Go comments reach the C# only when you ask for them. Two kinds always appear: the license header, and short notes that the converter writes itself to explain a conversion choice.

Go’s comments appear only with -comments. The flag is off by default, so an ordinary conversion drops Go’s comments, except the license header. Pass the flag on the command line, as in go2cs -comments <package_dir> <output_dir>. The standard library is converted with go2cs -stdlib -comments, and converted tests, Go _test.go files converted with go2cs -tests, always turn it on.

The other sections of this page quote go2cs’s behavioral tests: small Go programs in the go2cs repository, each stored with the C# the converter emits for it. Those programs are converted without -comments, which is why their C# shows no Go comments.

The license header always survives. A comment group ahead of package that mentions a copyright, a license or an SPDX tag is copied to the top of the C# file. The converted file is a derivative work of the Go source, so its notice travels with it. This program is converted without -comments: the header is kept, and the trailing comments on the two constants are dropped.

// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"fmt"
	"math/rand"
)

const (
	win            = 100 // The winning score in a game of Pig
	gamesPerSeries = 10  // The number of games per series to simulate
)
// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

namespace go;

using fmt = fmt_package;
using rand = math.rand_package;
using math;
using ꓸꓸꓸnint = Span<nint>;

partial class main_package {

internal static UntypedInt win => 100;
internal static UntypedInt gamesPerSeries => 10;

The header lands above the namespace line. The using lines for the imports follow it, then partial class main_package, the class that holds the package’s members (Package Conversion). ꓸꓸꓸnint names the type of a variadic ...int parameter used later in the file (Slices and Arrays). UntypedInt is the type golib, the go2cs runtime library, uses for an untyped Go constant (Constant Values).

The converter’s own notes always appear. They are written with or without -comments, because they explain the C# rather than repeat the Go. In the same program, a named func type action gets no C# declaration of its own, and a converter note takes its place:

// An action transitions stochastically to a resulting score.
type action func(current score) (result score, turnIsOver bool)
…
// A strategy chooses an action for any given score.
type strategy func(score) action
// type action is a methodless func type — rendered inline as its base delegate
…
internal delegate Func<score, (score, bool)> strategy(score _);

The note means that each use of action is written as the C# delegate type of the same shape, Func<score, (score, bool)>. You can see it as the result type of strategy. strategy keeps a delegate declaration because its signature names another func type (Function Values and Closures). Go’s doc comments on both types are dropped, because this program is converted without -comments. A methodless func type declared inside a function body gets the same note, on its own line where Go declares the type, and Go’s blank lines before and after the declaration are kept as they are between statements.

Another common note, // Hoisted @string literals (single allocation; Go keeps these in RODATA), heads a group of hoisted string literals. A hoisted literal is a string literal stored once in a static readonly field, whose generated name ends in ˢ. The standard library hoists every literal used as a value, and -hoist-literals does the same for any package. Elsewhere a literal in a string slot is a plain C# literal, as in joinPair, and so is one in an any slot, where go2cs-gen’s LiteralInterceptorGenerator passes a boxed Go string in its place (Source Generators). Only an any slot that generator cannot reach keeps a hoisted, pre-boxed field. Here is joinPair:

func joinPair(s string, err error) string {
	if err != nil {
		return "err"
	}
	return "got:" + s
}
internal static @string joinPair(@string s, error err) {
    if (err != nil) {
        return "err";
    }
    return "got:"u8 + s;
}

@string is golib’s Go string type, an immutable string of UTF-8 bytes. "err" is a plain C# literal that golib converts once, and "got:"u8 is a C# UTF-8 string literal, whose bytes the concatenation reads in place. RODATA is the read-only data section of a compiled Go program, where Go keeps its literals. default! is Go’s nil (Nil and Zero Values). Which literals are plain and which are hoisted is explained in Strings.

With -comments, doc comments stay above their declarations. They stay plain // lines rather than becoming C# XML documentation comments. That keeps Go’s doc links, like [RuneError], reading exactly as in Go. A comment on its own line inside a function also keeps its line, indented with its block:

// EncodeRune writes into p (which must be large enough) the UTF-8 encoding of the rune.
// If the rune is out of range, it writes the encoding of [RuneError].
// It returns the number of bytes written.
func EncodeRune(p []byte, r rune) int {
	// This function is inlineable for fast handling of ASCII.
	if uint32(r) <= rune1Max {
		p[0] = byte(r)
		return 1
	}
	return encodeRuneNonASCII(p, r)
}
// EncodeRune writes into p (which must be large enough) the UTF-8 encoding of the rune.
// If the rune is out of range, it writes the encoding of [RuneError].
// It returns the number of bytes written.
public static nint EncodeRune(slice<byte> p, rune r) {
    // This function is inlineable for fast handling of ASCII.
    if ((uint32)r <= rune1Max) {
        p[0] = (byte)r;
        return 1;
    }
    return encodeRuneNonASCII(p, r);
}

Reading the C#: nint is Go’s int, and slice<byte> is Go’s []byte (Slices and Arrays). rune and uint32 keep Go’s names as aliases for C# int and uint, declared in each project file (Integer Types and Arithmetic). rune1Max is a constant, and encodeRuneNonASCII a function, of the same package.

A trailing comment stays on its line. A struct field’s trailing comment stays on the field’s line:

// acceptRange gives the range of valid values for the second byte in a UTF-8
// sequence.
type acceptRange struct {
	lo uint8 // lowest value for second byte.
	hi uint8 // highest value for second byte.
}
// acceptRange gives the range of valid values for the second byte in a UTF-8
// sequence.
partial struct acceptRange {
    internal uint8 lo; // lowest value for second byte.
    internal uint8 hi; // highest value for second byte.
}

A source generator completes each converted Go type (Struct Types). uint8 is an alias for C# byte, declared in each project file (Integer Types and Arithmetic).

A statement’s trailing comment follows the statement’s last C# line, after one space. It does not keep Go’s column alignment, because the converted lines have different lengths. Here x is a byte read from the package’s first lookup table and RuneError is the package’s constant for an invalid encoding. The C# spells out the casts that Go’s typed shifts and conversions leave implicit:

		mask := rune(x) << 31 >> 31 // Create 0x0000 or 0xFFFF.
		return rune(p[0])&^mask | RuneError&mask, 1
        var mask = ((rune)x << 31) >> 31; // Create 0x0000 or 0xFFFF.
        return ((rune)p[0] & ~mask | (rune)RuneError & mask, 1);

Blank lines follow Go, in every conversion. Where Go leaves a blank line between two statements of a body, or between two elements of a multi-line composite literal or call argument list, the C# has one; where Go has none, the C# adds none. After the converter’s own lines at the top of a function, such as the locals for named results, there is exactly one blank line before the Go body. Without -comments, a converted switch is still followed by a blank line whether Go has one or not.

With -comments, a comment inside a function lands where Go put it. Go’s parser attaches a comment only to a declaration, a struct field or a spec, and never to a statement, so the converter places each of the others itself: after a statement, after the { of an if, else or for, on or above a case label, above a block’s closing }, between the elements of a multi-line literal, and between operands where Go breaks the line. Here d.nd is the digit count of the number being formatted; the blank line, the comment that opens the group and the one after the { are all where Go has them:

		}
	}

	// e±
	dst = append(dst, fmt)
	exp := d.dp - 1
	if d.nd == 0 { // special case: 0 has exponent 0
		exp = 0
	}
        }
    }

    // e±
    dst = append(dst, fmt);
    nint exp = d.dp - 1;
    if (d.nd == 0) { // special case: 0 has exponent 0
        exp = 0;
    }

A comment on a case line trails the converted case’s header, and no blank line follows the switch’s }} because Go has none there:

// Encode returns the UTF-16 encoding of the Unicode code point sequence s.
func Encode(s []rune) []uint16 {
	n := len(s)
	for _, v := range s {
		if v >= surrSelf {
			n++
		}
	}

	a := make([]uint16, n)
	n = 0
	for _, v := range s {
		switch RuneLen(v) {
		case 1: // normal rune
			a[n] = uint16(v)
			n++
		case 2: // needs surrogate sequence
			r1, r2 := EncodeRune(v)
			a[n] = uint16(r1)
			a[n+1] = uint16(r2)
			n += 2
		default:
			a[n] = uint16(replacementChar)
			n++
		}
	}
	return a[:n]
}
// Encode returns the UTF-16 encoding of the Unicode code point sequence s.
public static slice<uint16> Encode(slice<rune> s) {
    nint n = len(s);
    foreach (var (_, v) in range(s)) {
        if (v >= surrSelf) {
            n++;
        }
    }

    var a = new slice<uint16>(n);
    n = 0;
    foreach (var (_, v) in range(s)) {
        switch (RuneLen(v)) {
        case 1: { // normal rune
            a[n] = (uint16)v;
            n++;
            break;
        }
        case 2: { // needs surrogate sequence
            var (r1, r2) = EncodeRune(v);
            a[n] = (uint16)r1;
            a[n + 1] = (uint16)r2;
            n += 2;
            break;
        }
        default: {
            a[n] = (uint16)replacementChar;
            n++;
            break;
        }}
    }
    return a.slice(0, n);
}

RuneLen, EncodeRune, surrSelf and replacementChar are functions and constants of the same package. rune and uint16 are aliases for C# int and ushort (Integer Types and Arithmetic). foreach is Go’s range loop (Loops, Range and Labels), and new slice<uint16>(n) and a.slice(0, n) are Go’s make and a[:n] (Slices and Arrays). Each case body gets its own braces and a break, and the closing }} is how the converter ends every switch (Expression Switch Statements).

Comments inside a const ( … ) or var ( … ) group keep their places too, and so do the blank lines between the group’s specs. A doc comment directly above const ( is written above the first constant:

const (
	// 0xd800-0xdc00 encodes the high 10 bits of a pair.
	// 0xdc00-0xe000 encodes the low 10 bits of a pair.
	// the value is those 20 bits plus 0x10000.
	surr1 = 0xd800
	surr2 = 0xdc00
	surr3 = 0xe000

	surrSelf = 0x10000
)
// 0xd800-0xdc00 encodes the high 10 bits of a pair.
// 0xdc00-0xe000 encodes the low 10 bits of a pair.
// the value is those 20 bits plus 0x10000.
internal static UntypedInt surr1 => 0xd800;
internal static UntypedInt surr2 => 0xdc00;
internal static UntypedInt surr3 => 0xe000;

internal static UntypedInt surrSelf => 0x10000;

At the edges, placement is best effort. A comment in a shape the converter writes another way, such as a block comment between an if condition’s last operand and its {, is written on its own line nearby; a few are dropped. The code is the same either way. Only the comment moves.

Full detail: Reference → Comments — every placement rule with its Go and C# side by side, the blank-line rules, where a comment still lands elsewhere, and the tests that guard each.


Packages That Do Not Type-Check

Go’s type checker gives every name and expression in a package a type. When it cannot resolve a name, the package does not fully type-check. go2cs still converts such a package, and the run goes on. Only the statements that use the missing name are affected. The rest of the file and the package convert normally.

This mostly happens in application code. go2cs -recurse converts a program’s module and the third-party packages it imports, each into its own C# project (Package Conversion). Some names in those packages may not resolve on the machine doing the conversion.

For example, a function may be defined only in a _linux.go file, and Go skips that file when the conversion runs on Windows. Or a dependency’s own import may have failed to resolve. Go’s standard library always type-checks.

The converter reports, then converts. The functions shown here use two names that exist nowhere: undefinedFunc and UndefinedType. The full test package has a few more unresolved names, such as fmt.NoSuchFunction. The healthy function uses nothing missing.

func addressTaken() {
	x := 1
	undefinedFunc(&x)
	fmt.Println(x)
}
…
func undefinedType() {
	var u UndefinedType
	w := &UndefinedType{Field: 3}
	fmt.Println(u, w)
}
…
func healthy() {
	total := 0
	for i := range 4 {
		total += i * i
	}
	fmt.Println("healthy:", total)
}

The converter prints one warning for the package, then converts every function in it. This is the line that prints the warning:

log.Printf("WARNING: %s did not fully type-check; converting best-effort — code depending on the following is emitted untyped: %v", pkg.PkgPath, pkg.Errors)

On the console, %s becomes the package’s import path, such as example.com/app. %v becomes the list of errors Go reported. Each error names a file, a line and the name that did not resolve. “Emitted untyped” means Go recorded no type for that code, so the converter writes it without one. Each error is then printed again on a line of its own, in the error format compilers and IDEs read, with the code GO2CS1003 for a type error (diagnostic codes).

Healthy code converts in full. healthy becomes an ordinary static method of the package class, the same as in a package with no errors (Functions and Methods). addressTaken and undefinedType become methods too. The converter does not drop the file or the package around them.

Taking an address still converts. addressTaken is in the example for a reason. When code takes the address of a local such as &x, the converter checks how that address is used to decide whether x needs a heap box (Pointers). Here the callee has no type to check, and the converter carries on without it.

A missing type is written as invalid type. Where a type is missing, the converter writes the literal text invalid type. That is how Go’s type checker prints a type it could not resolve. So var u UndefinedType emits invalid type u = default!.

default! is C#’s default (the zero value) with the null-forgiving !, as converted code writes it everywhere (Nil and Zero Values). The rest of the code that uses a missing name is written as close to the Go as the converter can manage.

The C# build stops at those lines. invalid type contains a space, so it is not valid C#. Nothing in C# declares undefinedFunc either. The C# compiler reports errors on the code that uses the missing names.

The project as a whole does not build until those lines are fixed. Everything else in it is already converted correctly. The fix belongs in the Go source or its build setup, followed by a fresh conversion.

A converter fault stays inside one file. If the converter itself fails while converting a file, it skips that file. It prints a warning such as WARNING: visit file error: <fault> in "main.go". The package’s other files still convert.

A failed package stays inside a -recurse run. A package that cannot be loaded at all, or whose conversion fails, is recorded as failed. The next package then proceeds. At the end, the run names any packages that failed.

The standard-library conversion stops instead. go2cs -stdlib converts Go’s own standard library in import order, so each package converts after the packages it depends on. A package that cannot be loaded has no known place in that order. The conversion stops and lists every package that failed to load, all at once.

Full detail: Reference → Packages That Do Not Type-Check — why an unresolved expression has no type, how the converter tolerates it, how one package’s fault stays inside that package, and the guard tests.


Deterministic Output

Converting the same Go source with the same converter build, build tags and target platform produces byte-identical C# every run. Build tags and the target platform matter because they choose which Go files convert, such as file_windows.go or file_linux.go (Package Conversion). Any change in the converted C# comes from a change in the Go, its dependencies, the converter, the build tags or the target platform.

So converted code can be diffed, committed and reviewed like any other source. go2cs’s own golden tests rely on this. Each converted file is compared with its checked-in .cs.target file, byte for byte apart from line endings.

The converter is a Go program, and some things in Go vary from run to run, such as the order a map is walked. Three rules keep the converter’s output stable.

Files convert one at a time, in sorted filename order. Some generated names are numbered by one counter shared across the whole package. So the order the files convert in decides the numbers.

Go’s init function is the case a reader meets most often. Go allows any number of init functions in a package, but C# needs a distinct name for each method. The first init keeps its name, and each later one becomes initΔ1, initΔ2 and so on. Δ marks a renamed name (Names and Glyphs).

Package-level declarations named _ are numbered the same way, with names built from _ᴛ1, _ᴛ2 and so on, because C# cannot declare two members named _. ᴛ marks a name the converter makes up.

This test package has two Go files with two init functions each. a_first.go sorts first:

var order []string

func init() {
	order = append(order, "a_first#1")
}

func init() {
	order = append(order, "a_first#2")
}
internal static slice<@string> order;

[GoInit] internal static void init() {
    order = append(order, (@string)"a_first#1");
}

[GoInit] internal static void initΔ1() {
    order = append(order, (@string)"a_first#2");
}

A few names in this C# come from golib, the go2cs runtime library (The golib Runtime Library):

b_second.go converts next, so its numbering continues from a_first.go and starts at initΔ2:

func init() {
	order = append(order, "b_second#1")
}

func init() {
	order = append(order, "b_second#2")
}
[GoInit] internal static void initΔ2() {
    order = append(order, (@string)"b_second#1");
}

[GoInit] internal static void initΔ3() {
    order = append(order, (@string)"b_second#2");
}

The package’s third file, main.go, sorts last, so its one init becomes initΔ4:

func init() {
	order = append(order, "main#1")
}
[GoInit] internal static void initΔ4() {
    order = append(order, (@string)"main#1");
}

If the files converted in a different order, the same functions would get different numbers. The numbers only name the methods. Go runs init functions file by file in sorted filename order, then in source order within a file. This test’s main prints the order list, and the converted program prints the same list as Go. For how package-level variables get their starting values, see Package-Level Variable Initialization Order.

Packages convert in dependency order. A run can convert many packages at once: the whole standard library, or an application together with its third-party dependencies. Each package then converts after the packages it imports.

This matters because an importer reads each dependency’s converted package_info.cs. That generated file records package-wide names, such as the aliases for renamed types (Package Conversion). So the dependency must be finished first. Packages with no dependency between them go in sorted import-path order, so the queue is the same every run.

Names the converter collects in a map are sorted before they are written. Go’s map iteration order changes from run to run. Anything the converter gathers in a map is sorted first. Without this, two lines that are correct in either order could swap places between runs, and the file would show a diff for no reason.

Here is one place this shows. In Go’s math/big package, (*Int).And swaps its two pointer parameters:

func (z *Int) And(x, y *Int) *Int {
…
	// x.neg != y.neg
	if x.neg {
		x, y = y, x // & is symmetric
	}
…

In the C#, a Go pointer *Int is a ж<ΔInt>: a golib heap box holding an Int value (Pointers). The type is renamed ΔInt because math/big also has a method named Int, on *Float. C# cannot use one name for both a type and a method in the same package class.

A Go method becomes a C# extension method, which is why the receiver Ꮡz has this (Functions and Methods). A parameter that holds a box takes the Ꮡ prefix, as in Ꮡx.

The body also declares x, a C# ref alias to the value inside the box. This lets the body write x.neg just as the Go does (Implicit Pointer Dereferencing). DerefOrNull() returns that reference. For a nil pointer, it lets the first use panic, as Go does.

public static ж<ΔInt> And(this ж<ΔInt> Ꮡz, ж<ΔInt> Ꮡx, ж<ΔInt> Ꮡy) {
    ref var z = ref Ꮡz.DerefOrNull();
    ref var x = ref Ꮡx.DerefOrNull();
    ref var y = ref Ꮡy.DerefOrNull();
…
    // x.neg != y.neg
    if (x.neg) {
        (Ꮡx, Ꮡy) = (Ꮡy, Ꮡx); x = ref Ꮡx.DerefOrNull(); y = ref Ꮡy.DerefOrNull(); // & is symmetric
    }
…

The swap exchanges the two boxes, so the aliases x and y must then be pointed at the new boxes. Either order of those two statements is correct C#. The converter collects them in a map and writes them in sorted name order. So x always comes before y, and the line is the same every run.

Full detail: Reference → Deterministic Output — which shared converter state each rule protects, and the unstable or broken output it prevents.