PONY λ M2 Modula-2

C#.CodeCompared.To/Go

An interactive executable cheatsheet comparing C# and Go

C# 13 Go 1.26.2
Hello World & Output
Hello, World
Console.WriteLine("Hello, World!");
fmt.Println("Hello, World!")
Go's fmt.Println adds a newline, just like Console.WriteLine. The fmt package is automatically imported by the runner; in a real program you write import "fmt".
Formatted output
var name = "Gopher"; var score = 99; Console.WriteLine($"Hello, {name}! Score: {score}");
name := "Gopher" score := 99 fmt.Printf("Hello, %s! Score: %d\n", name, score)
Go uses C-style fmt.Printf with format verbs: %s for strings, %d for integers, %f for floats, %v for any value in its default format, %T for the type. There is no string interpolation syntax.
Build a formatted string
var count = 3; var item = "apple"; var message = $"{count} {item}s"; Console.WriteLine(message);
count := 3 item := "apple" message := fmt.Sprintf("%d %ss", count, item) fmt.Println(message)
fmt.Sprintf returns a formatted string without printing it — the equivalent of C#'s string.Format or an interpolated string assigned to a variable.
Print any value
var numbers = new[] { 1, 2, 3 }; Console.WriteLine(string.Join(", ", numbers)); Console.WriteLine(numbers.Length);
numbers := []int{1, 2, 3} fmt.Println(numbers) fmt.Println(len(numbers))
fmt.Println formats slices as [1 2 3] with space-separated elements. Use %v in Printf for the same default format, %+v to include struct field names, or %#v for Go-syntax representation.
Variables & Types
Type inference
var greeting = "Hello"; var count = 42; var ratio = 3.14; Console.WriteLine($"{greeting} {count} {ratio}");
greeting := "Hello" count := 42 ratio := 3.14 fmt.Println(greeting, count, ratio)
The := short variable declaration infers the type from the right-hand side. It can only be used inside functions. For package-level variables, use var name = value or var name Type = value.
Explicit type declarations
int counter = 0; string label = "items"; bool active = true; Console.WriteLine($"{counter} {label}, active={active}");
var counter int = 0 var label string = "items" var active bool = true fmt.Println(counter, label, active)
Go types come after variable names. Zero values are automatic: int0, string"", boolfalse, pointers/slices/maps → nil. Declaring var counter int alone is valid and yields 0.
Constants
const double Pi = 3.14159; const string AppName = "MyApp"; Console.WriteLine($"{AppName}: Pi = {Pi}");
const Pi = 3.14159 const AppName = "MyApp" fmt.Printf("%s: Pi = %f\n", AppName, Pi)
Go constants can be untypedconst Pi = 3.14159 has enough precision to be used as any numeric type. The iota enumerator generates incrementing integer constants inside a const block, replacing C#'s enum for simple integer sets.
Multiple return values
static (int Quotient, int Remainder) Divide(int dividend, int divisor) => (dividend / divisor, dividend % divisor); var (quotient, remainder) = Divide(17, 5); Console.WriteLine($"{quotient} remainder {remainder}");
package main import "fmt" func divide(dividend, divisor int) (int, int) { return dividend / divisor, dividend % divisor } func main() { quotient, remainder := divide(17, 5) fmt.Printf("%d remainder %d\n", quotient, remainder) }
Multiple return values are a first-class language feature in Go — not a tuple struct. The idiomatic pattern is to return (result, error), and callers check err != nil before using the result. Use _ to discard a return value you don't need.
Zero values (no null for most types)
int counter = default; bool flag = default; string text = default; Console.WriteLine($"{counter}, {flag}, {text == null}");
var counter int var flag bool var text string fmt.Println(counter, flag, text == "")
Every Go type has a zero value: int0, boolfalse, string"", structs → all fields zeroed. Pointers, slices, maps, channels, and function values have zero value nil. There is no uninitialized memory.
Strings
String basics
var greeting = "Hello, World!"; Console.WriteLine(greeting.Length); Console.WriteLine(greeting[0]);
greeting := "Hello, World!" fmt.Println(len(greeting)) fmt.Println(greeting[0])
len(s) returns the byte count, not the Unicode character (rune) count. greeting[0] returns a byte (uint8), not a char. For Unicode-aware iteration use for _, rune := range greeting or utf8.RuneCountInString(greeting).
Concatenation and building
var parts = new[] { "Hello", ", ", "World", "!" }; var result = string.Join("", parts); Console.WriteLine(result);
parts := []string{"Hello", ", ", "World", "!"} result := strings.Join(parts, "") fmt.Println(result)
The + operator concatenates strings in Go just like in C#. For building strings from many pieces, use strings.Builder — the equivalent of C#'s StringBuilder — to avoid allocating a new string on every concatenation.
String operations
var sentence = " Hello, Go! "; Console.WriteLine(sentence.Trim()); Console.WriteLine(sentence.Trim().ToUpper()); Console.WriteLine(sentence.Trim().Replace("Go", "World")); Console.WriteLine(sentence.Trim().Contains("Go"));
sentence := " Hello, Go! " fmt.Println(strings.TrimSpace(sentence)) fmt.Println(strings.ToUpper(strings.TrimSpace(sentence))) fmt.Println(strings.Replace(strings.TrimSpace(sentence), "Go", "World", 1)) fmt.Println(strings.Contains(strings.TrimSpace(sentence), "Go"))
String methods in C# are on the object itself; in Go they are standalone functions in the strings package. All functions return new strings — Go strings are immutable, just like C# strings.
Raw / verbatim strings
var windowsPath = @"C:UsersAliceDocuments"; var multiline = @"Line one Line two Line three"; Console.WriteLine(windowsPath); Console.WriteLine(multiline);
windowsPath := `C:UsersAliceDocuments` multiline := `Line one Line two Line three` fmt.Println(windowsPath) fmt.Println(multiline)
Go backtick raw string literals correspond to C#'s @"..." verbatim strings and """...""" raw string literals. Backtick strings can span multiple lines and treat backslashes literally.
String/number conversion
var text = "42"; var number = int.Parse(text); Console.WriteLine(number + 1); Console.WriteLine(number.ToString());
text := "42" number, err := strconv.Atoi(text) if err == nil { fmt.Println(number + 1) } fmt.Println(strconv.Itoa(number))
strconv.Atoi returns (int, error) — the Go equivalent of int.TryParse. strconv.Itoa converts int to string. fmt.Sprintf("%d", n) also works for formatting numbers as strings.
Numbers & Math
Basic arithmetic
Console.WriteLine(10 + 3); Console.WriteLine(10 - 3); Console.WriteLine(10 * 3); Console.WriteLine(10 / 3); Console.WriteLine(10 % 3);
fmt.Println(10 + 3) fmt.Println(10 - 3) fmt.Println(10 * 3) fmt.Println(10 / 3) fmt.Println(10 % 3)
Integer arithmetic works identically. Go has explicit integer types: int (platform-sized, 64-bit on modern systems), int8, int16, int32, int64, and unsigned variants uint, uint8, uint32, uint64. There is no implicit conversion between numeric types.
Floating-point arithmetic
double width = 5.5; double height = 3.2; Console.WriteLine(width * height); Console.WriteLine(Math.Round(width * height, 2));
width := 5.5 height := 3.2 fmt.Println(width * height) fmt.Printf("%.2f\n", width*height)
Go's default floating-point type is float64 (equivalent to C#'s double). float32 is available but rarely needed. Use fmt.Printf("%.2f", v) for fixed-precision formatting instead of Math.Round.
Math functions
Console.WriteLine(Math.Sqrt(16)); Console.WriteLine(Math.Pow(2, 10)); Console.WriteLine(Math.Abs(-42.5)); Console.WriteLine(Math.Max(10, 20));
fmt.Println(math.Sqrt(16)) fmt.Println(math.Pow(2, 10)) fmt.Println(math.Abs(-42.5)) fmt.Println(math.Max(10, 20))
Go's math package functions take and return float64 only — there is no overloaded math.Abs(int). To find the absolute value of an integer, use a conditional. The lack of function overloading is intentional: Go favors explicit naming.
Numeric type conversion
double ratio = 3.7; int truncated = (int)ratio; double restored = truncated; Console.WriteLine($"{ratio} → {truncated} → {restored}");
ratio := 3.7 truncated := int(ratio) restored := float64(truncated) fmt.Printf("%v → %d → %v\n", ratio, truncated, restored)
Go requires explicit conversion between all numeric types — there are no implicit widening conversions as in C#. int(3.7) truncates toward zero, matching C#'s cast (int)3.7. All conversions use function-call syntax: float64(n), int32(n).
Arrays & Slices
Slice literals
var numbers = new[] { 1, 2, 3, 4, 5 }; Console.WriteLine(numbers.Length); Console.WriteLine(numbers[0]);
numbers := []int{1, 2, 3, 4, 5} fmt.Println(len(numbers)) fmt.Println(numbers[0])
Go slices are the primary sequence type — dynamically sized, backed by an array. They correspond to C#'s List<T> for most purposes. A fixed-size Go array is declared [5]int{1,2,3,4,5} but is rarely used; slices are idiomatic.
Appending elements
var numbers = new List<int> { 1, 2 }; numbers.Add(3); numbers.AddRange(new[] { 4, 5 }); Console.WriteLine(numbers.Count);
numbers := []int{1, 2} numbers = append(numbers, 3) numbers = append(numbers, 4, 5) fmt.Println(len(numbers))
append returns a new slice (possibly with a new backing array) — you must reassign the result. Unlike C#'s list.Add(), append never mutates in place. You can append multiple elements at once, or spread another slice: append(slice1, slice2...).
Sub-slices and ranges
var numbers = new[] { 1, 2, 3, 4, 5 }; var middle = numbers[1..4]; Console.WriteLine(string.Join(", ", middle));
numbers := []int{1, 2, 3, 4, 5} middle := numbers[1:4] fmt.Println(middle)
Go slice expressions numbers[1:4] give elements at indices 1, 2, 3 (exclusive end), matching C#'s [1..4] range. The sub-slice shares the same backing array — modifying it modifies the original. Use copy() to make an independent copy.
Iterating over a slice
var fruits = new[] { "apple", "banana", "cherry" }; foreach (var fruit in fruits) Console.WriteLine(fruit);
fruits := []string{"apple", "banana", "cherry"} for _, fruit := range fruits { fmt.Println(fruit) }
Go's for _, value := range slice corresponds to C#'s foreach. The range clause yields (index, value); use _ to discard whichever you don't need. To iterate with the index: for i, fruit := range fruits.
Creating slices with make
var buffer = new List<int>(capacity: 100); // Fill with zeros: var zeros = Enumerable.Repeat(0, 10).ToList(); Console.WriteLine(zeros.Count);
buffer := make([]int, 0, 100) fmt.Println(cap(buffer)) zeros := make([]int, 10) fmt.Println(len(zeros), cap(zeros))
make([]T, length, capacity) creates a slice with a given length and optional capacity. make([]int, 10) creates a slice of 10 zeros. Go slices expose both len() (current elements) and cap() (backing array size) — C#'s List<T>.Capacity is analogous.
Maps
Map literals
var scores = new Dictionary<string, int> { { "Alice", 95 }, { "Bob", 87 }, }; Console.WriteLine(scores["Alice"]);
scores := map[string]int{"Alice": 95, "Bob": 87} fmt.Println(scores["Alice"])
Go maps are unordered key-value stores equivalent to C#'s Dictionary<K,V>. The type syntax is map[KeyType]ValueType. Maps must be initialized before use — a nil map (var m map[string]int) panics on write.
Safe lookup (ok idiom)
var scores = new Dictionary<string, int> { { "Alice", 95 } }; if (scores.TryGetValue("Alice", out var aliceScore)) Console.WriteLine(aliceScore); if (!scores.TryGetValue("Charlie", out _)) Console.WriteLine("Charlie not found");
scores := map[string]int{"Alice": 95} aliceScore, found := scores["Alice"] if found { fmt.Println(aliceScore) } charlieScore, found := scores["Charlie"] if !found { fmt.Println("Charlie not found") } _ = charlieScore
Map lookup returns (value, ok). If the key is absent, value is the zero value for its type and ok is false — there is no KeyNotFoundException. Check ok to distinguish a missing key from a key with a zero value.
Adding, updating, and deleting
var inventory = new Dictionary<string, int>(); inventory["apples"] = 10; inventory["apples"] = 12; inventory.Remove("apples"); Console.WriteLine(inventory.Count);
inventory := make(map[string]int) inventory["apples"] = 10 inventory["apples"] = 12 delete(inventory, "apples") fmt.Println(len(inventory))
Use make(map[K]V) for an empty map (preferred over map[K]V{} to signal "no initial entries"). Assignment adds or updates. delete(map, key) removes a key — no error is returned if the key is absent.
Iterating over a map
var capitals = new Dictionary<string, string> { { "France", "Paris" }, { "Germany", "Berlin" }, }; foreach (var (country, capital) in capitals) Console.WriteLine($"{country}: {capital}");
capitals := map[string]string{ "France": "Paris", "Germany": "Berlin", } for country, capital := range capitals { fmt.Printf("%s: %s\n", country, capital) }
Map iteration order in Go is deliberately randomized on every run. If order matters, collect the keys into a slice, sort it with sort.Strings(keys), then iterate the sorted keys. This differs from C#'s SortedDictionary, which maintains insertion/sorted order.
Control Flow
If / else
var temperature = 22; if (temperature > 30) Console.WriteLine("Hot"); else if (temperature > 20) Console.WriteLine("Warm"); else Console.WriteLine("Cool");
temperature := 22 if temperature > 30 { fmt.Println("Hot") } else if temperature > 20 { fmt.Println("Warm") } else { fmt.Println("Cool") }
Go omits parentheses around conditions, but braces are mandatory even for single-line bodies. There is no ternary operator — use a full if/else.
If with initialization
if (int.TryParse("42", out var number)) Console.WriteLine($"Parsed: {number}"); else Console.WriteLine("Parse failed");
if number, err := strconv.Atoi("42"); err == nil { fmt.Printf("Parsed: %d\n", number) } else { fmt.Println("Parse failed:", err) }
Go's if init; condition form is idiomatic for operations that return (value, error). The variable declared in the initialization statement is scoped to the entire if/else if/else block — not visible outside.
Switch
var day = "Monday"; switch (day) { case "Saturday": case "Sunday": Console.WriteLine("Weekend"); break; case "Monday": case "Tuesday": case "Wednesday": case "Thursday": case "Friday": Console.WriteLine("Weekday"); break; }
day := "Monday" switch day { case "Saturday", "Sunday": fmt.Println("Weekend") case "Monday", "Tuesday", "Wednesday", "Thursday", "Friday": fmt.Println("Weekday") }
Go's switch does not fall through by default — no break needed. Cases can list multiple values separated by commas. A switch with no expression is equivalent to if/else if. Use fallthrough to explicitly continue to the next case.
Defer (cleanup on exit)
// C# uses using/IDisposable for deterministic cleanup: // using var resource = OpenResource(); // resource is auto-disposed at end of scope. // For multi-step cleanup, use try/finally: Console.WriteLine("Start"); try { Console.WriteLine("Working"); } finally { Console.WriteLine("Cleanup — runs even on exception"); }
fmt.Println("Start") defer fmt.Println("Deferred — runs when main returns") fmt.Println("Working")
defer schedules a function call to run when the surrounding function returns — after both normal returns and panics. Multiple defers execute in LIFO order. The idiomatic use is defer file.Close() immediately after opening, so the close cannot be forgotten.
Loops & Range
For loop
for (var i = 0; i < 5; i++) Console.WriteLine(i);
for i := 0; i < 5; i++ { fmt.Println(i) }
Go has one loop keyword: for. The three-clause form mirrors C#'s for loop exactly, minus the parentheses and with mandatory braces.
While-style loop
var count = 0; while (count < 5) count++; Console.WriteLine(count);
count := 0 for count < 5 { count++ } fmt.Println(count)
Go has no while keyword. The single-condition for condition form is Go's while loop. A bare for { } (no condition) is an infinite loop — equivalent to C#'s while (true).
Range over a slice
var colors = new[] { "red", "green", "blue" }; foreach (var color in colors) Console.WriteLine(color);
colors := []string{"red", "green", "blue"} for _, color := range colors { fmt.Println(color) }
range over a slice yields (index, value) on each iteration. Use _ to ignore the index. range also works on strings (iterating runes), maps (key/value), and channels (values until closed).
Break and continue
for (var i = 0; i < 10; i++) { if (i == 3) continue; if (i == 6) break; Console.WriteLine(i); }
for i := 0; i < 10; i++ { if i == 3 { continue } if i == 6 { break } fmt.Println(i) }
break and continue work as in C#. Go also supports labeled breaks to exit nested loops: outer: for { for { break outer } } — useful when breaking from an inner loop needs to exit the outer loop as well.
Functions
Basic functions
static int Add(int first, int second) => first + second; Console.WriteLine(Add(3, 4));
package main import "fmt" func add(first, second int) int { return first + second } func main() { fmt.Println(add(3, 4)) }
In Go, the return type follows the parameter list. When consecutive parameters share a type, they can be grouped: func add(first, second int). Functions whose names start with a lowercase letter are package-private (unexported); uppercase names are exported.
Functions returning errors
static double SafeSqrt(double value) { if (value < 0) throw new ArgumentException("Value must be non-negative"); return Math.Sqrt(value); } try { Console.WriteLine(SafeSqrt(9)); } catch (ArgumentException ex) { Console.WriteLine(ex.Message); }
package main import ( "fmt" "math" ) func safeSqrt(value float64) (float64, error) { if value < 0 { return 0, fmt.Errorf("value must be non-negative, got %v", value) } return math.Sqrt(value), nil } func main() { result, err := safeSqrt(9) if err != nil { fmt.Println(err) return } fmt.Println(result) }
Go returns errors as values — there are no exceptions. The convention is (result, error) as the last two return values. Callers check err != nil immediately. Ignoring an error is a deliberate choice that is visible in the code.
Variadic functions
static int Sum(params int[] numbers) => numbers.Sum(); Console.WriteLine(Sum(1, 2, 3, 4));
package main import "fmt" func sum(numbers ...int) int { total := 0 for _, number := range numbers { total += number } return total } func main() { fmt.Println(sum(1, 2, 3, 4)) numbers := []int{1, 2, 3, 4} fmt.Println(sum(numbers...)) }
Go's ...T variadic parameter corresponds to C#'s params T[]. To pass an existing slice, use the spread syntax slice.... The variadic parameter is received as a slice of type []T inside the function.
Functions as first-class values
Func<int, int> doubler = x => x * 2; Func<int, bool> isEven = x => x % 2 == 0; Console.WriteLine(doubler(5)); Console.WriteLine(isEven(4));
doubler := func(number int) int { return number * 2 } isEven := func(number int) bool { return number%2 == 0 } fmt.Println(doubler(5)) fmt.Println(isEven(4))
Functions are first-class values in Go — they can be assigned to variables, passed as arguments, and returned from other functions. The function type is written func(int) int, matching C#'s Func<int, int>.
Named return values
// C# doesn't have named return values; // use out parameters or tuples: static (int Min, int Max) MinMax(int[] numbers) => (numbers.Min(), numbers.Max()); var (minimum, maximum) = MinMax(new[] { 3, 1, 4, 1, 5, 9 }); Console.WriteLine($"{minimum}, {maximum}");
package main import "fmt" func minMax(numbers []int) (minimum, maximum int) { minimum, maximum = numbers[0], numbers[0] for _, number := range numbers { if number < minimum { minimum = number } if number > maximum { maximum = number } } return } func main() { minimum, maximum := minMax([]int{3, 1, 4, 1, 5, 9}) fmt.Println(minimum, maximum) }
Named return values declare variables that the function's bare return statement returns. They are mostly used with defer to modify return values on error — for example, closing a resource and setting an error at the same time.
Closures
Basic closures
var offset = 10; Func<int, int> addOffset = x => x + offset; Console.WriteLine(addOffset(5)); offset = 20; Console.WriteLine(addOffset(5));
offset := 10 addOffset := func(number int) int { return number + offset } fmt.Println(addOffset(5)) offset = 20 fmt.Println(addOffset(5))
Go closures capture variables by reference, not by value — the same behavior as C# lambda expressions. A closure sees the current value of a captured variable, not the value at the time the closure was created.
Stateful closure / counter
static Func<int> MakeCounter() { var count = 0; return () => ++count; } var counter = MakeCounter(); Console.WriteLine(counter()); Console.WriteLine(counter()); Console.WriteLine(counter());
package main import "fmt" func makeCounter() func() int { count := 0 return func() int { count++ return count } } func main() { counter := makeCounter() fmt.Println(counter()) fmt.Println(counter()) fmt.Println(counter()) }
Each call to makeCounter() creates an independent count variable. The returned closure keeps the variable alive — Go's garbage collector holds it as long as the closure is reachable. This is Go's idiomatic way to produce factory functions.
Higher-order functions
var numbers = new[] { 1, 2, 3, 4, 5, 6 }; var evens = numbers.Where(n => n % 2 == 0).ToArray(); Console.WriteLine(string.Join(", ", evens));
package main import "fmt" func filter(numbers []int, predicate func(int) bool) []int { result := []int{} for _, number := range numbers { if predicate(number) { result = append(result, number) } } return result } func main() { numbers := []int{1, 2, 3, 4, 5, 6} evens := filter(numbers, func(n int) bool { return n%2 == 0 }) fmt.Println(evens) }
Go accepts function values as parameters using concrete function types like func(int) bool. The standard library's slices package (added in Go 1.21) provides slices.Collect, slices.All, and helper iterators that resemble LINQ.
Error Handling
Errors are values (no exceptions)
try { var number = int.Parse("abc"); Console.WriteLine(number); } catch (FormatException exception) { Console.WriteLine($"Parse error: {exception.Message}"); }
number, err := strconv.Atoi("abc") if err != nil { fmt.Println("Parse error:", err) } else { fmt.Println(number) }
Go has no exceptions. Functions that can fail return an error as their last return value. Check err != nil immediately after every call that can fail. Forgetting to check is a silent bug — it compiles, but the program continues with a zero value.
Creating errors
static void ValidateAge(int age) { if (age < 0) throw new ArgumentException("Age cannot be negative"); if (age > 150) throw new ArgumentOutOfRangeException(nameof(age), $"Age {age} is unrealistically large"); } try { ValidateAge(-5); } catch (ArgumentException ex) { Console.WriteLine(ex.Message); }
package main import ( "errors" "fmt" ) func validateAge(age int) error { if age < 0 { return errors.New("age cannot be negative") } if age > 150 { return fmt.Errorf("age %d is unrealistically large", age) } return nil } func main() { if err := validateAge(-5); err != nil { fmt.Println(err) } }
errors.New creates a simple error string. fmt.Errorf formats a message and optionally wraps another error with %w. There is no exception class hierarchy — any value implementing Error() string satisfies the error interface.
Error wrapping and unwrapping
// C# inner exceptions: // throw new IOException("Read failed", innerException); // exception.InnerException // Use ArgumentException, InvalidOperationException for typed errors. Console.WriteLine("C# uses inner exceptions to chain errors");
package main import ( "errors" "fmt" ) var ErrNotFound = errors.New("not found") func findUser(userID int) error { return fmt.Errorf("findUser %d: %w", userID, ErrNotFound) } func main() { err := findUser(42) fmt.Println(err) fmt.Println(errors.Is(err, ErrNotFound)) }
fmt.Errorf with %w wraps an error, preserving the original for inspection. errors.Is(err, target) traverses the whole chain — analogous to checking C#'s InnerException chain. Sentinel errors (var ErrX = errors.New(...)) are the idiomatic way to make error conditions testable.
Panic and recover
// C# unhandled exceptions propagate up the stack. // AppDomain.UnhandledException or top-level try/catch handles truly fatal errors. Console.WriteLine("C# uses exception propagation for fatal errors");
package main import "fmt" func safeOperation(operation func()) (err error) { defer func() { if recovered := recover(); recovered != nil { err = fmt.Errorf("recovered from panic: %v", recovered) } }() operation() return nil } func main() { err := safeOperation(func() { panic("something went very wrong") }) fmt.Println(err) }
panic is the Go equivalent of an uncaught exception — it unwinds the stack. recover() in a deferred function catches it, like a top-level catch. Go convention: never use panic for normal error paths. Panics are for programming errors (nil pointer dereference, index out of bounds) or truly unrecoverable situations.
Structs & Interfaces
Struct definition
var person = new Person("Alice", 30); Console.WriteLine($"{person.Name}, age {person.Age}"); record Person(string Name, int Age);
package main import "fmt" type Person struct { Name string Age int } func main() { person := Person{Name: "Alice", Age: 30} fmt.Printf("%s, age %d\n", person.Name, person.Age) }
Go structs are value types — assignment copies all fields. Exported fields start with uppercase. Field names in struct literals are required for clarity. Go has no inheritance, no abstract, no virtual. Composition via embedding is Go's alternative.
Methods on structs
var rect = new Rectangle(5, 3); Console.WriteLine(rect.Area()); Console.WriteLine(rect); class Rectangle { public double Width { get; } public double Height { get; } public Rectangle(double width, double height) { Width = width; Height = height; } public double Area() => Width * Height; public override string ToString() => $"{Width}x{Height}"; }
package main import "fmt" type Rectangle struct { Width float64 Height float64 } func (rect Rectangle) Area() float64 { return rect.Width * rect.Height } func (rect Rectangle) String() string { return fmt.Sprintf("%.0fx%.0f", rect.Width, rect.Height) } func main() { rect := Rectangle{Width: 5, Height: 3} fmt.Println(rect.Area()) fmt.Println(rect) }
Methods have a receiver parameter instead of living inside a class body. The receiver name is conventionally the first letter of the type — not this. Implementing String() string is Go's equivalent of ToString(); fmt.Println calls it automatically.
Interfaces (implicit satisfaction)
static void PrintArea(IShape shape) => Console.WriteLine($"Area: {shape.Area():F2}"); PrintArea(new Circle(5)); interface IShape { double Area(); } class Circle : IShape { public double Radius { get; } public Circle(double radius) { Radius = radius; } public double Area() => Math.PI * Radius * Radius; }
package main import ( "fmt" "math" ) type Shape interface { Area() float64 } type Circle struct { Radius float64 } func (circle Circle) Area() float64 { return math.Pi * circle.Radius * circle.Radius } func printArea(shape Shape) { fmt.Printf("Area: %.2f\n", shape.Area()) } func main() { circle := Circle{Radius: 5} printArea(circle) }
Go interfaces are satisfied implicitlyCircle implements Shape simply by having an Area() float64 method. No implements keyword. This lets you define an interface for a type from another package without modifying it — a pattern called "duck typing with compile-time safety."
Embedding (composition over inheritance)
Dog dog = new("Rex"); Console.WriteLine(dog.Speak()); Console.WriteLine(dog.Name); class Animal { public string Name { get; } public Animal(string name) { Name = name; } public virtual string Speak() => $"{Name} makes a sound"; } class Dog : Animal { public Dog(string name) : base(name) { } public override string Speak() => $"{Name} says: Woof!"; }
package main import "fmt" type Animal struct { Name string } func (animal Animal) Speak() string { return animal.Name + " makes a sound" } type Dog struct { Animal Breed string } func (dog Dog) Speak() string { return dog.Name + " says: Woof!" } func main() { dog := Dog{Animal: Animal{Name: "Rex"}, Breed: "Husky"} fmt.Println(dog.Speak()) fmt.Println(dog.Name) }
Go uses embedding for code reuse — Dog embeds Animal, promoting all its fields and methods. dog.Name works directly. There is no base, no virtual, no override. Overriding means defining a method with the same name on the outer type.
Pointer receivers (mutation)
// C# classes are reference types — methods always see the same instance: var counter = new Counter(); counter.Increment(); counter.Increment(); Console.WriteLine(counter.Value); class Counter { public int Value { get; private set; } public void Increment() => Value++; }
package main import "fmt" type Counter struct { Value int } func (counter *Counter) Increment() { counter.Value++ } func (counter Counter) Get() int { return counter.Value } func main() { counter := Counter{} counter.Increment() counter.Increment() fmt.Println(counter.Get()) }
Go structs are value types — methods with value receivers receive a copy. Use a pointer receiver (*T) when the method must modify the struct, or when the struct is large (to avoid copying). Be consistent: if any method on a type uses a pointer receiver, all methods should.
Goroutines & Channels
Basic goroutine
await Task.Run(() => Console.WriteLine("Running concurrently")); Console.WriteLine("Main continues");
package main import ( "fmt" "sync" ) func main() { var waitGroup sync.WaitGroup waitGroup.Add(1) go func() { defer waitGroup.Done() fmt.Println("Running concurrently") }() fmt.Println("Main continues") waitGroup.Wait() }
go f() launches f as a goroutine — a lightweight coroutine managed by the Go runtime. Goroutines start at ~2KB (vs ~1MB for OS threads) and are multiplexed across OS threads automatically. sync.WaitGroup waits for goroutines to finish, replacing Task.WhenAll.
Channels
var channel = System.Threading.Channels.Channel.CreateBounded<int>(3); await channel.Writer.WriteAsync(1); await channel.Writer.WriteAsync(2); await channel.Writer.WriteAsync(3); channel.Writer.Complete(); await foreach (var value in channel.Reader.ReadAllAsync()) Console.WriteLine(value);
package main import "fmt" func main() { numbers := make(chan int, 3) numbers <- 1 numbers <- 2 numbers <- 3 close(numbers) for number := range numbers { fmt.Println(number) } }
Channels are typed conduits between goroutines. A buffered channel (make(chan int, 3)) accepts values without blocking until full. Closing a channel signals "no more values"; range over a channel drains it until closed — the idiomatic producer/consumer pattern.
Select (multiplex channels)
// C# uses Task.WhenAny to wait on the first of multiple tasks: // var winner = await Task.WhenAny(task1, task2); Console.WriteLine("C# uses Task.WhenAny for first-ready");
package main import "fmt" func main() { channel1 := make(chan string, 1) channel2 := make(chan string, 1) channel1 <- "one" channel2 <- "two" select { case message := <-channel1: fmt.Println("Received from channel1:", message) case message := <-channel2: fmt.Println("Received from channel2:", message) } }
select waits on multiple channel operations and runs the first one that is ready. If multiple are ready simultaneously, one is chosen at random. A default case makes it non-blocking. This is Go's equivalent of Task.WhenAny.
Worker pool
// C# Parallel.ForEach or Task.WhenAll: var numbers = Enumerable.Range(1, 5).ToArray(); var squares = await Task.WhenAll(numbers.Select(n => Task.Run(() => n * n))); foreach (var square in squares) Console.WriteLine(square);
package main import ( "fmt" "sync" ) func worker(jobs <-chan int, results chan<- int, waitGroup *sync.WaitGroup) { defer waitGroup.Done() for job := range jobs { results <- job * job } } func main() { jobs := make(chan int, 5) results := make(chan int, 5) var waitGroup sync.WaitGroup for workerID := 0; workerID < 3; workerID++ { waitGroup.Add(1) go worker(jobs, results, &waitGroup) } for job := 1; job <= 5; job++ { jobs <- job } close(jobs) go func() { waitGroup.Wait(); close(results) }() for result := range results { fmt.Println(result) } }
The worker pool distributes tasks over goroutines via an input channel and collects results via an output channel. Go's motto for concurrency is "do not communicate by sharing memory; instead, share memory by communicating" — channels make that concrete.
⚠ Gotchas for C# Developers
:= versus = (declaration vs assignment)
// C# separates declaration (var x = ...) from assignment (x = ...) clearly. // In C# you can always tell which is which from context. var answer = 42; answer = 43; Console.WriteLine(answer);
answer := 42 // declare and assign (new variable) answer = 43 // assign to existing variable fmt.Println(answer) // := can redeclare if at least one variable on the left is new: value, err := 10, error(nil) value, err = 20, error(nil) _ = err fmt.Println(value)
:= declares and assigns; = assigns to an already-declared variable. Inside a block, := can redeclare a variable if at least one name on the left is new to that scope — this is a common source of subtle shadowing bugs when a new err is created instead of reusing an outer one.
Nil slice vs empty slice
// C# null vs empty List<int>: List<int> nullList = null; List<int> emptyList = new(); Console.WriteLine(nullList == null); // true Console.WriteLine(emptyList == null); // false Console.WriteLine(emptyList.Count); // 0
var nilSlice []int emptySlice := []int{} fmt.Println(nilSlice == nil) // true fmt.Println(emptySlice == nil) // false fmt.Println(len(nilSlice), len(emptySlice)) // 0 0 nilSlice = append(nilSlice, 1) fmt.Println(nilSlice)
A nil slice is safe: append, len, and range all work on it. The distinction matters mainly for JSON encoding (nilnull, empty → []). Most Go code treats nil and empty slices identically and does not check which it has.
No function overloading
// C# allows methods with the same name and different signatures: Printer.Display(42); Printer.Display("hello"); static class Printer { public static void Display(int value) => Console.WriteLine($"int: {value}"); public static void Display(string value) => Console.WriteLine($"string: {value}"); }
package main import "fmt" func displayInt(value int) { fmt.Printf("int: %d\n", value) } func displayString(value string) { fmt.Printf("string: %s\n", value) } func displayAny(value any) { fmt.Printf("any: %v\n", value) } func main() { displayInt(42) displayString("hello") displayAny(3.14) }
Go has no function or method overloading. Use distinct names (displayInt, displayString) or accept any (the empty interface, formerly interface{}) and type-switch inside the function. This is a deliberate simplicity trade-off: every call site unambiguously names what it calls.
Generics are limited compared to C#
static T[] Filter<T>(T[] items, Func<T, bool> predicate) => items.Where(predicate).ToArray(); var evens = Filter(new[] { 1, 2, 3, 4 }, n => n % 2 == 0); Console.WriteLine(string.Join(", ", evens));
package main import ( "fmt" "slices" ) func filter[Element any](items []Element, predicate func(Element) bool) []Element { result := []Element{} for _, item := range items { if predicate(item) { result = append(result, item) } } return result } func main() { evens := filter([]int{1, 2, 3, 4}, func(n int) bool { return n%2 == 0 }) fmt.Println(evens) fmt.Println(slices.Contains([]string{"a", "b"}, "a")) }
Go added generics in 1.18 with type parameters. The constraint any means "any type" (comparable means equality-testable). Go generics are less powerful than C#'s — no operator overloading on type parameters, no where T : new(). The slices and maps packages (Go 1.21) cover most common generic operations.