Conversion Strategies
How
go2csturns 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
- Start here: At a glance · Reading Converted Code: Names and Glyphs · The golib Runtime Library
- Packages & projects: Package Conversion · Package-Level Variable Initialization Order · Converted Tests · Compiled Library versus Source Code
- Numbers, constants & nil: Constant Values · Integer Types and Arithmetic · Named Numeric Types and Constant Contexts · Floating-Point Values · Nil and Zero Values · Built-in Functions · Empty Interface (
any) - Assignment & scope: Multi-Assignment and Evaluation Order · Short Variable Redeclaration (Shadowing) · Multi-Result Values and Comma-Ok Forms
- Composite types: Slices and Arrays · Strings (
@stringandsstring) · Maps · Generics · Type Aliasing - Functions & control flow: Functions and Methods · Function Values and Closures · Loops, Range and Labels · Expression Switch Statements · Type Switch Statements · Defer / Panic / Recover
- Concurrency: Goroutines · Channels and
select - Types & polymorphism: Struct Types · Struct Type Embedding · Interfaces · Reflection (
reflect) - Pointers & memory: Pointers · Implicit Pointer Dereferencing ·
unsafe.Pointeranduintptr - The machinery: Source Generators · Functions Without a Go Body · Manually-Converted Declarations · The standard library reproduces Go
-tags purego· Comments · Packages That Do Not Type-Check · Deterministic Output
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:
- The package is a class.
package mainbecomespartial class main_packageinnamespace go, and each function is astaticmethod of it.func mainbecomesMain, the C# entry point (Package Conversion). - An import is a
usingalias.import "fmt"becomesusing fmt = fmt_package;, so calls still readfmt.Println(…). - Go types map to golib types.
map[string]intbecomes golib’smap<@string, nint>.@stringis Go’sstring, a byte string; the@lets C# use its keywordstringas a name.nintis C#’s native-sized integer, which is Go’sint(Maps, Strings, Integer Types). - String literals are plain C# literals where Go’s type is
string. golib converts each one to an@stringonce. Where the bytes are read in place, as in a comparison,"a"u8is a C# UTF-8 literal. - Comma-ok is a two-value indexer.
v, ok := m["a"]becomesvar (v, ok) = m["a", ꟷ];. The glyphꟷis a golib constant. Passing it picks the comma-ok form, which returns the value and a found flag (Comma-Ok Forms). - A literal passed as
anyis a plain C# literal too.fmt.Printlntakesanyarguments, and in converted codeanyis an alias for C#’sobject, where a C# string would arrive asSystem.String. So go2cs-gen intercepts the callfmt.Println("z absent")at compile time and passes the literal as a boxed Gostring, andfmtprints it as one (Strings, Empty Interface). - An
ifwith an init statement reads as Go writes it. The init and theifstay on one line. Nothing else inmainuses the nameok4, so the init is simply a statement ofmain; an init whose names would collide keeps a block of its own (Shadowing). default!is the zero value.var w intbecomesnint w = default!;. The!is C#’s null-forgiving operator; it only silences a nullable warning (Nil and Zero Values).- Go comments are dropped by default. This conversion ran without the
-commentsoption, which keeps them (Comments).
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 ®{}
}
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:
- A struct holds only its fields. A source generator adds the
rest at compile time: constructors,
==and a Go-styleToString()(Struct Types). Lower-case Go names becomeinternal, and capitalized ones becomepublic. - A slice is golib’s
slice<T>.[]stringbecomesslice<@string>(Slices and Arrays). - An empty struct literal reads
new T(). Go’sreg{}becomesnew reg(). It calls the generated constructor that builds the zeroreg, with every field at its zero value (Struct Types). - A pointer is golib’s heap box
ж<T>(read “zhe”).*regbecomesж<reg>. In®{}, the functionᏑ(…)takes the address: it boxes the new value on the heap and returns theж<reg>. A name that starts withᏑ, such asᏑxin the table, is a different use of the glyph: it is the box that holds a local whose address is taken (Pointers). - A pointer-receiver method is an extension method on
ref.this ref reg rlets the method change the caller’s struct without a copy. A generator adds an overload that takes aж<reg>, so the method also works on a pointer (Functions and Methods). - Built-ins keep their Go names.
appendhere, likelen,capandcopy, is a golib function that behaves as Go’s does (Built-in Functions).
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:
- All of this sits inside
partial class main_package, the one static class that holds a Go package (Package Conversion). partial struct accumis the Go struct. A source generator completes the other half of thepartialtype: constructors,==and more (Struct Types).nintis Go’sint: both are the size of a pointer (Integer Types and Arithmetic).- The method becomes a static extension method. Its pointer receiver
(a *accum)is writtenthis ref accum a, so the method changes the caller’s value (Functions and Methods). - From the
this refreceiver, the source generator writes a second overload that takes the boxж<accum>and forwards to therefmethod. That overload is what makesa.add(p1)work. ж<accum> ais Go’sa *accum.ж<T>(read “zhe”) is golib’s heap box for a Go pointer. The parameter keeps its Go name because the body never reads through it; a body that does names the boxᏑaand bindsato the value (Pointers)."bound add:"stays a plain C# literal, thoughfmt.Printlntakes...any, which is C#’sobject. A C# string there would arrive asSystem.String, so go2cs-gen intercepts the call at compile time and passes the literal as golib’s Go string,@string(Strings, Empty Interface).- The method value
a.addbecomes a lambda that callsaddona.p1is a parameter name the converter makes up (Function Values and Closures).
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	go2cs/BuildInfoSettings mod	go2cs/BuildInfoSettings	(devel)	 </_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):
ж<T>is a heap box that stands in for a Go pointer*T. AᏑprefix marks a variable that holds one, so Go’st *testing.Tbecomesж<testing.T> Ꮡt(Pointers).@stringis Go’sstring, a byte string. A literal such as"path.go"u8is its UTF-8 bytes (Strings).slice<T>is Go’s[]T(Slices and Arrays), andnintis Go’sint(Integer Types and Arithmetic).
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#:
- A Go package becomes a C# class named
<pkg>_package. Every file of the package opens the samepartial class(Package Conversion). - A plain import becomes an alias:
"testing"becomesusing testing = testing_package;, sotesting.Tkeeps its Go spelling. The dot import. "path"becomesusing static path_package;, soExtneeds no qualifier (Package Conversion). - A Go struct is
partialso a source generator can add Go members to it, such as the constructor that each table entry calls,new("path.go"u8, ".go"u8)(Struct Types). - The table is a bare collection expression,
[ … ], which C# turns into the Go slice the declared type names (Slices and Arrays). rangebecomesforeachoverrange(exttests). A slice enumerates as(index, value)pairs, sofor _, test := rangebecomesvar (_, test), with_discarding the index as in Go (Loops, Range and Labels).- The init clause of
if x := Ext(test.path); …becomes a statement of the loop body,@string x = …; if (…), because nothing else in that block namesx; where something does, the init keeps a block of its own. The declaration and theifshare one line on purpose, to mirror Go’s layout. t.ErrorfbecomesᏑt.Errorf. Go methods become C# extension methods, andErrorfhas one whosethisisж<testing.T>. So the method is called through the box, as Go calls it through the pointer (Functions and Methods).
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:
TestRegistryandTestHostlive ingo.testing_runtime, a namespace inside the hand-writtentestingpackage.- The registry gets the Go import path, used when it reports results, and a list of the package’s files.
- A Go test runs in its package directory and may read files there by relative path. The host copies the listed files into a separate run directory, so a test sees the same files and cannot change the source tree.
registry.Addnames the test, the C# method to call, and the Go file and line where the test is declared.
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:
- A name starting with
Ꮡholds a box (glyphs).heap<Buffer>(out var Ꮡb)creates the boxᏑband returns arefto the value inside it. Sobreads and writes like the Go local. b = new Buffer();is Go’sb := Buffer{}, kept as its own line. It calls the generated constructor that sets every field to its Go zero value (Nil and Zero Values).- A source generator adds that constructor. It
also adds
Buffer.Ꮡoff, a static accessor that names theofffield (Struct Types). Ꮡb.Ꮡoffis Go’s&b.off: a pointer to theofffield inside the boxᏑb, through the field’s generated address property.- In
PrintValPtr,DerefOrNull()bindsptrto the value the box holds. The body then usesptrwhere Go writes*ptr. For a nil pointer therefis null, and the first read or write through it raises Go’s nil-dereference panic (Implicit Pointer Dereferencing). nintis Go’sint(Integer Types and Arithmetic), and"…"u8is a UTF-8 string literal (Strings).
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#:
@outis Go’sout, escaped with@becauseoutis a C# keyword.uint64 total = default!;is Go’svar total uint64:defaultis the zero value, and the!only silences C# nullable warnings (Nil and Zero Values)."total:"stays a plain C# literal.fmt.Printlntakes Go’s...any, so go2cs-gen intercepts the call and passes the literal as golib’s Go string type@string, a byte string (Strings, Empty Interface).
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):
- a C#
const, for a constant of a basic numeric type such asintorfloat64, and for any boolean constant, typed or untyped; - a get-only static property,
static T name => value;, for a constant C# cannot declareconst: an untyped number, a named type, auintptr(a golib struct), a complex number, or anintoruintvalue too wide for 32 bits; - a
static readonlyfield, for any package-level string constant, typed or untyped, and for an integer constant too wide for 64 bits, which is golib’sGoBigConst(C#’sBigInteger). In the standard library, and in a package converted with-hoist-literals, the value is read through an accessor under the constant’s own name,static ref readonly @string name => ref nameᵇ;, over the private fieldnameᵇ. The value is a struct, the JIT will not inline a function that copies a struct out of a static field, and a read through the accessor is a reference it can inline; every use still spells the constant’s name. Other packages keep the one field.
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:
- The comment
/*num:int64*/says the struct wraps anint64. Go’sintanduintappear asnum:nintandnum:nuint, C#’s native-sized integers (Integer Types and Arithmetic). - Each constant is a static get-only property typed
Duration. Its value is computed ahead of time, and the Go expression stays beside it as a/* … */comment (Constant Values). - The method is a static extension method,
this Duration d, so a call still readsd.Seconds()(Functions and Methods). - A
Δprefix marks a renamed name (Names and Glyphs). All of a package’s top-level names, its types, constants, functions and methods, are members of one C# class,time_package(Package Conversion). Go’sTimetype has aSecond()method, so the constantSecondwould clash with it and becomesΔSecond.Microsecondhas no such twin and keeps its name.
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:
Ꮡ(…)is Go’s&. It places the value in a heap box from golib, the go2cs runtime library. Soehas typeж<edge>, golib’s pointer type, even thoughvarhides it (Pointers).e.Valueand~eboth reach the struct thatepoints to.e.Valueis a C#refto the real struct, so the converter uses it where the struct is written.~ereturns a copy, which is enough where the struct is only read (Implicit Pointer Dereferencing).@outis the Go fieldout;@escapes a C# keyword (Reading Converted Code).partial structis a Go struct type (Struct Types).
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:
map<@string, nint>is golib’s Go map,map[string]int, where@stringis golib’s Gostring."a"is a Go string literal, a plain C# literal (see Maps and Strings).m["b", ꟷ]is the comma-ok lookupv, ok := m["b"];ꟷselects the form that also returnsok(see Multi-Result Values and Comma-Ok Forms).- The
ifwith an init sits on one line,var (vΔ1, okΔ1) = …; if (okΔ1) {, as Go writes it. Its renamed names occur nowhere else in the loop body, so the init needs no block of its own.
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.
- The comment
/*num:float64*/after a struct’s name gives its underlying type,float64. A source generator fills in its value, operators and conversions. Freezingis declared to returnTemperature, and C# reads that asCelsius, exactly as Go does.100Dis a C#doubleliteral, the type Go’sfloat64maps to.- The target is spelled out in full,
go.CrossPkgLib_package.Celsius. The rule An alias target is written out in full, in this section, explains why.
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());
}
- Go’s
anyis C#object(Empty Interface), soTokenis an alias ofobject. Namedis defined over the named interfaceStringer, so it is an alias ofStringer.main_packageis the class that holds packagemain.Stringeritself becomes a C# interface (Interfaces). Thepointstruct satisfies it through itsStringmethod, which becomes a static extension method (Functions and Methods).new point(3, 4)is the Go composite literalpoint{3, 4}(Struct Types).@stringis the Go-style byte string of golib, the go2cs runtime library, and"(%d,%d)"u8is a UTF-8 literal that becomes one (Strings).nintis Go’sint(Integer Types).
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;
}
- Golib types carry their
go.path:go.@string,go.error, and the slice and map typesgo.slice<T>andgo.map<K, V>. - Go’s built-in numbers become C# keywords:
intisnint,int64islonganduint8isbyte(Integer Types).anybecomesobject. - .NET types carry their
System.path.complex128isSystem.Numerics.Complex. - A func type becomes a
System.FuncorSystem.Actiondelegate (Function Values and Closures). Several results become a C# tuple (Multi-Result Values). Header, a struct in the same package, is written through its package class:go.main_package.Header.
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);
using AliasImportLib = AliasImportLib_package;letsAliasImportLib.name the library’s package class.Boxis a real type declared inside that class, soAliasImportLib.Boxkeeps its dot.- An alias is not a member of any class, so C# cannot reach it as
AliasImportLib.IntFn. Theꓸcopy stands in. - The Go func literals become C# lambdas (Function Values and Closures), and the
struct literal
Box{V: …}becomesnew Box(V: …)(Struct Types).
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:
heap(new Counter(), out var Ꮡc)putscin a box.cis arefto the boxed value for ordinary use, andᏑcis the box thatSetandAddreceive (Pointers).Counter.Ꮡnis a static accessor that a source generator adds toCounterfor its fieldn.Ꮡc.of(Counter.Ꮡn)is Go’s&c.n, a pointer to that field inside the box, and.Valueis Go’s*.Setuses only the box, so it binds noref var c.bumponly dereferences its pointer parameter, so it takesref int32 p, andAddpassesref nonnil(ref c).n. Go panics at&c.nwhencis nil, before the call. A C#refto a field of a nullrefdoes not fail when formed, so golib’snonnilchecks first and raises Go’s nil-pointer panic.initSelfneeds no such check, because its plain field write fails at once.- The string literals passed to
fmt.Printlnstay plain C# literals. Each is ananyargument, so go2cs-gen, go2cs’s source generators, intercepts the call and passes the literal as a boxed Go@string, boxed once per file rather than on every call (Strings, Empty Interface).
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:
- Every string literal stays a plain C# literal. Each is an argument to
fmt.Printorfmt.Println, which takeany, so go2cs-gen intercepts the call and passes the literal as a boxed Go string (Strings, Empty Interface). @stringis Go’sstring, a UTF-8 byte string. It comes from golib, the go2cs runtime library (golib).- A name ending in
ᴛand a number, such asexprᴛ1, is a temporary the converter makes up. The number only keeps names unique within a function; it carries no meaning. nintis Go’sint(Integer Types).
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:
matchᴛ5is a local that records that some case has already matched. A matching case sets it withmatchᴛ5 = true;, written on the same line as itsif. The last case before a finaldefaultskips it when nothing reads the flag afterwards, asFoo(4)does below.fallthroughis a golib flag, kept per thread. A case that ends infallthroughsets it totrue. Reading it returns its value and clears it.
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:
- The literals passed to
fmt.Printlnstay plain C# literals: go2cs-gen intercepts each of those calls and passes the literal as a boxed Go string.whatAmIis a variable, not a declared function or method, and go2cs-gen intercepts only calls to those, so"hey"is created once in the static fieldheyˢinstead, already boxed as anobject. Theˢsuffix marks such a generated name, and the field holds a golib@string, Go’s byte string (Strings). "…"u8is a C# UTF-8 literal. A format string, like the one passed toPrintf, keeps that form. In the standard library every other literal that becomes a string value moves into aˢfield, but a format string stays inline: a field name made from it would not read well, and the formatting itself costs far more than the literal.(nint)1boxes the untyped constant1at Go’s default typeint, which is C#nint. A bare C#1would box as a 32-bitintinstead (Empty Interface).default!is Go’snil. It is C#’s default value, herenull; the!only silences a nullable warning (Nil and Zero Values).
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:
@outis Go’sout, escaped with@becauseoutis a C# keyword."no panic"and"other (not a plain string)"are plain C# literals, because they are assigned to a string variable; golib converts each once (Strings).case string:becomescase @string v:, since Go’sstringis golib’s@string. An@stringcompares and concatenates directly with"…"u8literals, sov == "x"u8and"error("u8 + …read like the Go.
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:
GoFrame ᒐ = default;declares this call’s list of deferred calls. golib’sGoFrameis a smallref structthat lives on the stack. The glyphᒐmarks this frame and every name that belongs to it, such asᒐex,ᒐpand the labelᒐdone(Reading Converted Code).defer(…, ref ᒐ)adds one call to that list.ᒐis passed byref, so the call lands in this frame and not in a copy.- The
tryblock is the Go body, statement for statement. - The
catchfilter,GoFrame.IsPanic, accepts only Go panics. A Go panic is a golibPanicException; the filter also turns a few .NET exceptions, such asDivideByZeroException, into Go runtime errors.GoFrame.Capturethen parks the panic whererecover()can find it. - The
finallycallsᒐ.Run(). It runs the deferred calls, last added first, on every exit: the end of the body, areturn, or a panic.
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:
ᴛ1is a lambda parameter the converter names. Onlyn * nis passed togoǃ: the constant"Go thread square:"has the same value whenever it is evaluated, so it stays inside the call, as Go wrote it. Both literals are plain C# literals that go2cs-gen boxes as Go strings forfmt.Println’sanyparameters (Strings, Empty Interface).- Go’s
intbecomesnint, a native-sized integer (Integer Types).
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:
- Each case’s operands are evaluated once, in source order, into a temporary named
selᴛN. Theᴛmarks a name the converter makes up, andNcounts 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. - 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). select(…)waits until a case is ready, commits one chosen at random as Go does, and returns its position, counting from 0.- The matching
case N:runs. A receive case adds awhenguard 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:
- a constructor that takes every field as an optional argument, a parameterless one, and one that takes
niland gives the zero value; ==and!=, which compare field by field, as Go’s==does (a C# struct has no==of its own);- a static accessor,
Ꮡname, for each field, which&s.nameuses (Ꮡmarks an address); - a
ToString()that prints the fields in Go’s%vform:Person{name: "Dr. Michał", age: 29}prints{Dr. Michał 29}.
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:
- Each
partial structis a converted Go struct that the generator completes (Struct Types). - A capitalized (exported) Go name becomes
public, and any other name becomesinternal. So thePersonembed ispublic, and thenamefield isinternal(Names and Glyphs). - A Go method becomes a static C# extension method, and
this Person pis Go’s value receiver (Functions and Methods). @stringis golib’s Go string. A literal in astringslot, such as"Dr. Michał"or the"Dr"argument, is a plain C# literal that golib converts once and shares; a literal in a comparison, such as"manager"u8, is a C# UTF-8 literal compared in place (Strings).new Record()is Go’s empty literalRecord{}. Every empty struct literal calls the struct’s generated parameterless constructor, which builds the zero value. A declaredvar r Recordis built the same way, by the generatednew(nil)constructor, instead of C#default, becauseRecordembeds (Struct Types).
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):
ж<T>is Go’s pointer*T: the heap box of golib, go2cs’s runtime library, that holds or points at oneT(read “zhe”).Ꮡ(…)takes an address and yields that box (Pointers).XжIandXᴠIare generated adapter classes: pointer*X(ж) or valueX(ᴠ) as interfaceI.- A string literal passed to
fmt.Printlnstays a plain C# literal, which go2cs-gen, go2cs’s source generators, boxes as a Go string (Strings). ᐧis golib’s constanttrue. Passed to a type assertion, it selects the comma-ok form.ᴛmarks a temporary the converter makes up, such asexprᴛ1.- A
partialtype is a converted Go type, andthis refa pointer receiver. A generator completes both (Source Generators).
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#:
- Go’s
intis C#nint(Integer Types). - Go’s conversion
valued(x)stays a C# cast,(valued)x. meterᴠvalued(“valuemeteras interfacevalued”) is the generated adapter. ItsValue()calls themeterdelegate it holds.gaugeneeds no adapter: the generator addsvaluedto thegaugestruct itself.- The printed labels stay plain C# literals. They are passed to
fmt.Println’sanyparameters, so go2cs-gen intercepts the call and boxes each as a Go string.
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):
Inctakes&c.n, so it takes the box itself,this ж<Counter> Ꮡc, as does any method that takes the address of a receiver field.DerefOrNull()bindscto the struct inside the box.ref nonnil(ref c).nis Go’s&c.n, andnonnilpanics as Go does ifcis nil (Pointers).Totalonly reads the struct, so it takesthis ref Counter, with no box. The generator adds an overload that takesж<Counter>, soTotal()can be called on a box too.
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:
- The printed labels are plain C# literals, which go2cs-gen boxes as Go strings for
fmt.Println(Strings). new Counter()is the empty literalCounter{}: it calls the generated constructor that makes the zero value (Struct Types).Ꮡ(…)boxes it, socis aж<Counter>.c.Valueis the struct inside the box, soc.Value.n = 10is Go’sc.n = 10.- Because the adapter holds the box itself,
inc.Inc()changes theCounterthatcpoints to, as in Go. inc._<ж<Counter>>(ᐧ)is the assertioninc.(*Counter), described under Calls, nil and assertions below. It returns that same box, soback == cis true.
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:
- The string literals are plain C# literals. golib converts one passed to a
stringparameter, such as"json", to an@string; go2cs-gen, go2cs’s source generators, boxes one passed tofmt.Println, whose arguments are Goany, as a Go string (Strings, Empty Interface). "…"u8is a C# UTF-8 literal, which golib reads as a Go string.new record()is Go’s empty literalrecord{}. It calls the constructor the generator adds to every struct for the zero value (Struct Types).fΔ1is the loop’sf. It is renamed because the function declares anotherfafter the loop, and C# does not allow an inner block to reuse a name its enclosing block declares (Shadowing).(@string)fΔ1.Tagis Go’s conversionstring(f.Tag), andvar (v, ok) = …is Go’s two-result assignment (Comma-Ok Forms).
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:
- Pointer parameters and pointer receivers: the body reads exactly like Go, through a C#
refalias. - Pointer locals: a field reads and writes as Go spells it,
p.f, through a property on the box. The whole value is~pto read andp.Valueto write. - Pointer method calls:
v.M()andp.M()both stay as written. A method that needs the pointer itself is called on a boxed variable’s box:c.Set(100)becomesᏑc.Set(100)(Pointers).
A few names from golib, go2cs’s runtime library, carry this. The glyph table lists them all, and Pointers covers how pointers are made.
ж<T>(read “zhe”) is Go’s pointer type*T: a reference-type box that holds, or points at, oneT.Ꮡhas two roles. As a name prefix, as inᏑt, it marks a variable that holds the box. As a golib function,Ꮡ(value)boxes a copy of a value, like Go’s&T{…}.~pis Go’s*p. golib overloads C#’s unary~operator, normally bitwise complement, onж<T>to mean “dereference”. It returns a copy of the value and panics on a nil pointer, as Go does.p.Valueis a C#refto the value’s real storage inside the box, so writes through it stick.
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#:
array<nint>is golib’s fixed-size Go array, here[4]int, andnintis Go’sint(Slices and Arrays, Integer Types and Arithmetic).heap<T>(value, out var Ꮡarr)putsarrin a box,Ꮡarr, because its address is taken, starting from its initial value (Pointers).Ꮡarr.at<nint>(0)is&arr[0], a pointer to one array element.(ж<nint>)(uintptr)(nextPtr)is Go’s(*int)(nextPtr). The converter goes through the number:(uintptr)takes the address out of the@unsafe.Pointer, and(ж<nint>)makes a pointer from it.~reads through a pointer, like Go’s*p(Implicit Pointer Dereferencing)."Value of the next element:"stays a plain C# literal.fmt.Printlntakesanyvalues, so go2cs-gen, go2cs’s source generators, intercepts the call and passes it as a boxed Go string (Strings, Empty Interface (any)).
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#:
array<int32>is golib’s fixed-size Go array[4]int32(Slices and Arrays).heap(value, out var Ꮡbuf)putsbufin a box,Ꮡbuf, starting from the given value (Pointers).e0ande2areж<int32>boxes for&buf[0]and&buf[2]. The tuple assignment is Go’se0, e2 := …(Multi-Assignment and Evaluation Order).(uintptr)e0is the collapseduintptr(unsafe.Pointer(e0)), as in theaddrOfexample.- The two labels are plain C# literals, which go2cs-gen boxes as Go strings for
fmt.Println(Strings).
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:
- A store that bypasses the setter, such as one through
&hdr.Len, commits nothing, and the next access panics naming it. Arefto such a field does not compile. - A header that does not yet describe a valid slice, such as
LenaboveCap, is held, and the variable keeps its last valid value until a later store makes the header valid. - A
Datathat names neither an element of the slice’s own element type nor native memory is refused by name, as a Go panic, at the store.
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#:
pis a Go[]byte, golib’sslice<byte>(Slices and Arrays).@unsafe.SliceData(p)is Go’sunsafe.SliceData, soᴋ0is aж<byte>box forp’s first element.(uintptr)ᴋ0is the collapseduintptr(unsafe.Pointer(…)), which pins the byte storage.- The three
ᴋ0lines run in order, in one block. The deeper indent of theSyscallline is only how the text is laid out. getrandomTrapis the package’s system-call number. The first omitted lines try a faster path, and the later ones turn a failure number into a Go error.- The tuple result is Go’s multiple return (Multi-Result Values).
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:
- Go takes the address of
b.off, sobneeds a box. golib’sheap(…)allocates a boxᏑb(aж<Buffer>), andbis a C#refto the value inside it (Pointers). new Buffer()is a generated constructor. It builds the zero value, as Go’sBuffer{}does. Unlike C#’sdefault, it also allocates what a Go zero value needs, such as the storage of an array field (Nil and Zero Values).Buffer.Ꮡoffis a generated field reference: a static accessor that returns arefto theofffield of aBuffer.Ꮡb.Ꮡoff, the field’s generated address property, uses it to make aж<nint>that points atoffinside the box, so a write through it changesb.off. That is Go’s&b.off.PrintValPtrtakes that box, Go’s*int. It bindsptras arefto the value in the box, so the body reads like Go’s*ptr(Implicit Pointer Dereferencing).
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:
s *Settingstays a box,ж<Setting> Ꮡs, because the interface keeps the pointer.h *holderis only used to reach its value, so it becomesref holder h. Inmain,his a box made byh := &holder{…}. The call passesref (h).DerefOrNull(), arefto the value inside that box, so the write toh.dlands in the caller’sholder(Pointers).
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 hand-written companion file supplies it;
- the converter writes a one-line forwarder that calls the real function, when a
//go:linknamedirective names a function known to have a C# body, or, outside the standard library, when a one-instruction assembly jump (a trampoline) names a Go function with a matching signature; - in the standard library, Go’s portable Go file is converted in place of the assembly declaration;
- for a
//go:linknamepush that go2cs cannot support, the converter writes a body that raises a Go panic explaining why; - failing all of those, a generated stub throws when the function is called.
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:
- In a pull, the bodyless side names the body it wants:
//go:linkname local pkg.nameon a bodylesslocalsays “my body isnamein packagepkg”. - In a push, the side that owns the body names the bodyless declaration it fills, in another package.
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#:
go.time_packageis the class for packagetime, spelled in full because the Go file has no import to alias.Func<@string, (@string, error)>is Go’sfunc(string) (string, error).@stringis Go’s string type from golib, go2cs’s hand-written runtime library (Strings). A C# tuple carries several results (Multi-Result Values), and a func type becomes aFunc<…>delegate (Function Values and Closures).- Go’s unnamed parameter is named
_in C#. /*linkname*/asks a source generator to write the method’s declaring part with[StackTraceHidden], which keeps the forwarder out of .NET stack traces, because in Go the two names are one function.[GoInit]marks Go’sinitfunction (Package-Level Variable Initialization Order).
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:
atomic_packageis thestatic partial classthat holds the Go package’s members, so a Go method becomes a C# extension method. See Package Conversion.- A hand-written file keeps two attributes that converted code does not carry:
[GoType]marks a Go type, and[GoRecv]a method with a Go pointer receiver, writtenthis ref Value v. A source generator adds the overload that takes a pointer. See Functions and Methods and Source Generators.
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:
sync.Pool, andsync.Cond’s copy check, which in Go stores theCond’s own address as an integer.runtime.SetFinalizer,runtime.AddCleanupandweak.Pointer, on .NET object lifetime.runtime.GOMAXPROCS,Gosched,LockOSThreadandGoexit, on .NET threads; see Goroutines.runtime.GCandruntime.ReadMemStats, on the .NET garbage collector.time’s timers, on golib’sGoTimertimer service.internal/sync.HashTrieMap, the map undersync.Mapandunique, on .NET’sConcurrentDictionary.runtime’s execution tracer (StartTrace,StopTrace,ReadTrace) andruntime/trace’s flight recorder, on golib’s managed tracer. A hand-ownedflightrecorder_impl.csbodies the recorder’s two//go:linknamepulls. Its third link, a push fromruntime, is a converter-written forwarder inflightrecorder.cs:runtime_traceClockUnitsPerSecondcalls the hand-ownedruntime.traceClockUnitsPerSecond.hash/crc32, on .NET hardware intrinsics.math/bits’sAdd,SubandMuland their 64-bit formsAdd64,Sub64andMul64, as small functions the JIT inlines into their callers, withMulandMul64on .NET’sMath.BigMul.reflectandinternal/reflectlite; see Reflection.- The network poller’s runtime hooks and Linux’s
Syscall6, at the operating-system boundary.
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:
- A
puregosibling exists. The tag selects the real body, as for SHA-256. This is the common case. - The code is gated on architecture alone. Some files, such as
hash/crc32’s amd64 file, have nopuregoalternative. Hand-written C# supplies the body, as a companion file or a whole-file replacement; see Manually-Converted Declarations. - Nothing supplies a body. The declaration compiles, but throws a
NotImplementedExceptionnaming the function when it is called; see Functions Without a Go Body.
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):
slice<@string>is Go’s[]string: a golib slice of golib strings (Slices and Arrays, Strings).appendis golib’s version of Go’s built-inappend(Built-in Functions)."a_first#1"u8is a C# UTF-8 string literal. It becomes an@string, which holds bytes as Go’s string does.[GoInit]marks a method that runs as package initialization, beforeMain(Package Conversion).
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.