Ruby.CodeCompared.To/C#

An interactive executable cheatsheet for Rubyists learning C#

Ruby 4.0 C# 13
Variables & Types
Type inference with var
# Ruby is dynamically typed β€” types are always inferred greeting = "Hello" count = 42 temperature = 98.6 puts "#{greeting}, count=#{count}, temp=#{temperature}"
// C# is statically typed; var infers the type from the right-hand side var greeting = "Hello"; var count = 42; var temperature = 98.6; Console.WriteLine($"{greeting}, count={count}, temp={temperature}");
var in C# tells the compiler to infer the type β€” it is still statically typed. Once assigned, the variable's type is fixed. This is very different from Ruby, where any variable can hold any value at any time.
Explicit type declarations
# Ruby has no type declarations age = 30 is_active = true initial = 'A' puts age.class # Integer puts is_active.class # TrueClass
// C# requires knowing the type at compile time int age = 30; bool isActive = true; char initial = 'A'; double price = 9.99; Console.WriteLine(age.GetType().Name); // Int32 Console.WriteLine(isActive.GetType().Name); // Boolean
C# primitive types map directly to .NET types: int is System.Int32, bool is System.Boolean, char holds a single UTF-16 character (use double quotes for string). Every type has GetType() just like Ruby's .class.
Constants
# Ruby: ALL_CAPS constant (convention, not enforced by the language) MAX_RETRIES = 3 PI = 3.14159 puts MAX_RETRIES puts PI
// C# const: evaluated at compile time, must be a literal value const int MaxRetries = 3; const double Pi = 3.14159; Console.WriteLine(MaxRetries); Console.WriteLine(Pi);
const in C# is a compile-time constant β€” the value is embedded directly into the compiled code wherever it is used. Unlike Ruby constants, reassigning a const is a compile error, not just a warning. For values known only at runtime, use readonly on a field instead.
Type conversion
number_text = "42" parsed = number_text.to_i as_float = parsed.to_f puts parsed.class # Integer puts as_float.class # Float puts parsed + 8 # 50
string numberText = "42"; int parsed = int.Parse(numberText); // throws on invalid input double asDouble = (double)parsed; // explicit cast Console.WriteLine(parsed.GetType().Name); // Int32 Console.WriteLine(parsed + 8); // 50 // Safe parsing: returns false instead of throwing bool success = int.TryParse("abc", out int result); Console.WriteLine(success); // False
C# offers two patterns for parsing: int.Parse() throws FormatException on invalid input (like Ruby's Integer()), while int.TryParse() returns false and outputs the default (like Ruby's to_i returning 0). The out keyword passes a variable by reference to receive the parsed result.
The object type
# In Ruby, every value is already an Object value = 42 value = "now a string" value = [1, 2, 3] puts value.inspect
// object is the root of all C# types β€” like Ruby's Object object value = 42; Console.WriteLine(value); // 42 value = "now a string"; Console.WriteLine(value); // now a string value = new int[] { 1, 2, 3 }; Console.WriteLine(value.GetType().Name); // Int32[]
object is the C# equivalent of Ruby's Object β€” it can hold any value. Using object defeats static typing and often requires boxing (wrapping value types in a heap allocation), so it should be avoided in favor of generics. In practice, you encounter object in older APIs and in switch pattern matching.
String Operations
String interpolation
name = "Alice" score = 95 puts "Player #{name} scored #{score} points." puts "Double: #{score * 2}"
string name = "Alice"; int score = 95; Console.WriteLine($"Player {name} scored {score} points."); Console.WriteLine($"Double: {score * 2}");
C# interpolated strings use $"..." with {expression} β€” essentially the same idea as Ruby's #{ }. Any valid C# expression can appear inside the braces, including method calls and arithmetic. The $ prefix must immediately precede the opening quote.
Common string methods
message = "Hello, World!" puts message.length # 13 puts message.upcase # HELLO, WORLD! puts message.include?("World") # true puts message.gsub("World", "C#")
string message = "Hello, World!"; Console.WriteLine(message.Length); // 13 Console.WriteLine(message.ToUpper()); // HELLO, WORLD! Console.WriteLine(message.Contains("World")); // True Console.WriteLine(message.Replace("World", "C#")); // Hello, C#!
C# string methods are PascalCase (ToUpper, Contains, Replace) rather than snake_case. Strings are immutable in both languages β€” all methods return a new string. Note that Length is a property, not a method call, so it has no parentheses.
Split and Join
csv = "apple,banana,cherry" fruits = csv.split(",") puts fruits.inspect # ["apple", "banana", "cherry"] puts fruits.join(" | ") # apple | banana | cherry
string csv = "apple,banana,cherry"; string[] fruits = csv.Split(','); Console.WriteLine(fruits.Length); // 3 Console.WriteLine(string.Join(" | ", fruits)); // apple | banana | cherry
Split is an instance method; Join is a static method on the string class (written string.Join). This is the opposite of Ruby where both split and join are instance methods. The separator is the first argument to string.Join.
Verbatim strings
# Ruby: single quotes suppress most escape sequences path = 'C:\Users\Alice\Documents' puts path # Heredoc for multi-line text = <<~HEREDOC Line one Line two HEREDOC puts text
// Verbatim strings: @ prefix β€” backslashes are literal, no escape processing string path = @"C:UsersAliceDocuments"; Console.WriteLine(path); // Raw string literals (C# 11): triple quotes, no escaping needed at all string json = """ {"name": "Alice", "score": 100} """; Console.WriteLine(json.Trim());
The @"..." verbatim string treats every backslash as a literal character β€” useful for Windows paths and regular expressions. C# 11 added raw string literals ("""...""") that also support embedded double quotes and interpolation when prefixed with $"""...""".
StringBuilder for concatenation
# Ruby strings are mutable β€” << appends in-place parts = [] 5.times { |index| parts << "item#{index}" } puts parts.join(", ")
// Repeated string + creates many intermediate strings; StringBuilder avoids that var builder = new System.Text.StringBuilder(); for (int index = 0; index < 5; index++) { if (index > 0) builder.Append(", "); builder.Append($"item{index}"); } Console.WriteLine(builder.ToString());
In C#, strings are immutable and + in a loop allocates a new string on every iteration β€” O(nΒ²) behavior. StringBuilder maintains a mutable buffer and is the idiomatic solution for building strings incrementally, much like Ruby's StringIO or an array of parts joined at the end.
Collections
List<T> β€” dynamic array
numbers = [1, 2, 3, 4, 5] numbers << 6 numbers.delete(3) puts numbers.inspect # [1, 2, 4, 5, 6] puts numbers.length # 5
var numbers = new List<int> { 1, 2, 3, 4, 5 }; numbers.Add(6); numbers.Remove(3); Console.WriteLine(string.Join(", ", numbers)); // 1, 2, 4, 5, 6 Console.WriteLine(numbers.Count); // 5
List<T> is the closest C# equivalent to Ruby's Array β€” it grows dynamically and provides Add, Remove, Insert, and indexing. The <T> is a type parameter that enforces that every element is the same type. Count is the property name (not Length β€” that is for fixed arrays).
Dictionary<K,V> β€” hash map
scores = { "Alice" => 95, "Bob" => 87 } scores["Charlie"] = 92 puts scores["Alice"] # 95 scores.each do |name, score| puts "#{name}: #{score}" end
var scores = new Dictionary<string, int> { ["Alice"] = 95, ["Bob"] = 87, }; scores["Charlie"] = 92; Console.WriteLine(scores["Alice"]); // 95 foreach (var pair in scores) Console.WriteLine($"{pair.Key}: {pair.Value}");
Dictionary<K,V> is C#'s equivalent of Ruby's Hash. Accessing a missing key throws KeyNotFoundException (unlike Ruby, which returns nil); use TryGetValue or GetValueOrDefault for safe access. Iteration yields KeyValuePair<K,V> structs with .Key and .Value properties.
HashSet<T> β€” unique values
require "set" colors = Set["red", "blue", "green"] colors.add("red") # already present β€” no duplicate puts colors.length # 3 puts colors.include?("blue") # true
var colors = new HashSet<string> { "red", "blue", "green" }; colors.Add("red"); // already present β€” silently ignored Console.WriteLine(colors.Count); // 3 Console.WriteLine(colors.Contains("blue")); // True
HashSet<T> is the direct equivalent of Ruby's Set. It guarantees uniqueness and provides O(1) Contains checks. It also supports set operations: UnionWith, IntersectWith, ExceptWith, and IsSubsetOf β€” mirroring Ruby's |, &, -, and subset?.
Arrays β€” fixed-size
# Ruby arrays are always dynamic primes = [2, 3, 5, 7, 11] puts primes[0] # 2 puts primes.length # 5
// C# arrays are fixed-size β€” size cannot change after creation int[] primes = { 2, 3, 5, 7, 11 }; Console.WriteLine(primes[0]); // 2 Console.WriteLine(primes.Length); // 5 // Two-dimensional array int[,] grid = { {1, 2}, {3, 4} }; Console.WriteLine(grid[1, 0]); // 3
C# arrays are fixed in size after creation β€” to add or remove elements you need a List<T>. The Length property works on arrays (not Count). C# also supports multi-dimensional arrays (int[,]) and jagged arrays (int[][]), which Ruby handles with nested arrays.
Tuples
# Ruby: return multiple values as an array, or use a Struct point = [3, 4] puts "x=#{point[0]}, y=#{point[1]}" # Named with struct-like syntax: person = { name: "Alice", age: 30 } puts person[:name]
// C# tuples: lightweight, named, value-type groupings var point = (X: 3, Y: 4); Console.WriteLine($"x={point.X}, y={point.Y}"); // Tuples can be destructured var (name, age) = ("Alice", 30); Console.WriteLine($"{name} is {age}"); // Methods can return tuples static (double Min, double Max) MinMax(int[] numbers) => (numbers.Min(), numbers.Max()); var result = MinMax(new[] { 3, 1, 4, 1, 5, 9 }); Console.WriteLine($"min={result.Min}, max={result.Max}");
C# tuples (introduced in C# 7) are value types with named fields β€” no separate Struct definition needed. They are ideal for returning multiple values from a method without creating a class. Tuple elements can be destructured with var (a, b) = tuple, similar to Ruby's multiple assignment.
Control Flow & Loops
if / else if / else
temperature = 22 if temperature > 30 puts "Hot" elsif temperature > 20 puts "Comfortable" else puts "Cold" end
int temperature = 22; if (temperature > 30) { Console.WriteLine("Hot"); } else if (temperature > 20) { Console.WriteLine("Comfortable"); } else { Console.WriteLine("Cold"); }
C# uses else if (two words) and curly-brace blocks; Ruby uses elsif (no space, no e on else) and end. The condition must be wrapped in parentheses in C#. Unlike Ruby, if in C# is a statement, not an expression β€” it cannot appear on the right side of an assignment (use the ternary ? : instead).
Ternary operator
score = 75 grade = score >= 60 ? "Pass" : "Fail" puts grade # Ruby also has a one-liner if label = if score >= 90 then "Excellent" else "OK" end puts label
int score = 75; string grade = score >= 60 ? "Pass" : "Fail"; Console.WriteLine(grade); // For complex branching, switch expression is cleaner (see Pattern Matching) string label = score >= 90 ? "Excellent" : "OK"; Console.WriteLine(label);
The ternary operator works identically in both languages: condition ? value_if_true : value_if_false. In C#, this is the primary way to do inline conditional assignment since if is a statement. For multi-branch inline logic, prefer the switch expression (see the Pattern Matching section).
foreach loop
fruits = ["apple", "banana", "cherry"] fruits.each do |fruit| puts fruit end # Ruby's each_with_index fruits.each_with_index do |fruit, index| puts "#{index}: #{fruit}" end
var fruits = new[] { "apple", "banana", "cherry" }; foreach (var fruit in fruits) Console.WriteLine(fruit); // Index tracking: no built-in enumerate; use a counter or LINQ int index = 0; foreach (var fruit in fruits) { Console.WriteLine($"{index}: {fruit}"); index++; }
foreach in C# is the equivalent of Ruby's each β€” it iterates over any IEnumerable<T>. Unlike Ruby, there is no built-in each_with_index; you maintain a counter manually, or use LINQ's Select((item, i) => ...) to project index alongside the element.
for and while loops
# C-style for loop equivalent 5.times { |index| print "#{index} " } puts # while count = 0 while count < 5 print "#{count} " count += 1 end puts
// Classic C-style for loop for (int index = 0; index < 5; index++) Console.Write(index + " "); Console.WriteLine(); // while loop int count = 0; while (count < 5) { Console.Write(count + " "); count++; } Console.WriteLine();
C# has a classic C-style for loop with init, condition, and increment β€” a construct that Ruby deliberately omitted in favor of block-based iteration. The while loop is syntactically very similar to Ruby's. C# also has do { } while (condition); which always executes the body at least once.
Pattern Matching
is β€” type check with binding
value = 42 if value.is_a?(Integer) puts "It's an integer: #{value * 2}" end # Ruby 3.0 one-liner: value in Integer => n
object value = 42; if (value is int number) { Console.WriteLine($"It's an int: {number * 2}"); // 84 } // Negation pattern if (value is not string) { Console.WriteLine("Not a string"); }
The is pattern in C# combines a type check with a variable binding β€” if the match succeeds, number is declared and assigned in one step. This is more concise than Ruby's is_a? + local assignment. C# 9 added is not for negated patterns, replacing the verbose !(x is T).
switch expression
score = 85 grade = case score when 90..Float::INFINITY then "A" when 80...90 then "B" when 70...80 then "C" when 60...70 then "D" else "F" end puts grade
int score = 85; string grade = score switch { >= 90 => "A", >= 80 => "B", >= 70 => "C", >= 60 => "D", _ => "F", }; Console.WriteLine(grade);
The switch expression (C# 8+) is an expression β€” it produces a value directly, just like Ruby's case/when. Arms use => (fat arrow) and are separated by commas. The discard pattern _ plays the role of Ruby's else. The compiler warns if the arms are not exhaustive.
Property patterns
# Ruby 3.x deconstruct_keys enables pattern matching person = { name: "Alice", age: 30 } case person in { age: (18..), name: "Alice" } puts "Alice is an adult" in { age: (18..) } puts "An adult" else puts "A minor" end
var person = (Name: "Alice", Age: 30); string description = person switch { { Age: >= 18, Name: "Alice" } => "Alice is an adult", { Age: >= 18 } => "An adult", _ => "A minor", }; Console.WriteLine(description);
Property patterns match against the values of object properties or tuple fields β€” very similar to Ruby 3's hash pattern matching with in { key: pattern }. Patterns can nest: you can write { Address: { City: "Paris" } } to match a nested property. Combined with relational patterns (>= 18), they express complex conditions concisely.
Type-based switch
items = [42, "hello", 3.14, true] items.each do |item| desc = case item when Integer then "int: #{item}" when String then "string: #{item}" when Float then "float: #{item}" else "other" end puts desc end
object[] items = { 42, "hello", 3.14, true }; foreach (var item in items) { string description = item switch { int intValue => $"int: {intValue}", string text => $"string: {text}", double doubleValue => $"double: {doubleValue}", _ => "other", }; Console.WriteLine(description); }
C# switch expressions can dispatch on type, replicating Ruby's case/when with class arguments. Each arm binds the value to a named variable (intValue, text) for use in the expression. This is far more powerful than a traditional is chain because it is exhaustive and composable with property patterns.
Methods & Functions
Method syntax
def add(first, second) first + second end puts add(3, 4) # One-liner (Ruby 3.0+) def double(number) = number * 2 puts double(5)
// Regular method body static int Add(int first, int second) { return first + second; } Console.WriteLine(Add(3, 4)); // Expression-bodied method (C# 6+) β€” like Ruby 3.0 one-liners static int Double(int number) => number * 2; Console.WriteLine(Double(5));
C# methods require a return type declaration (or void). Expression-bodied methods using => are the C# equivalent of Ruby 3's one-liner method syntax, and are idiomatic for short, single-expression methods. Top-level functions (used in these examples) are syntactic sugar for a Main method in a hidden Program class.
Optional parameters
def greet(name, greeting: "Hello") "#{greeting}, #{name}!" end puts greet("Alice") puts greet("Bob", greeting: "Hi")
static string Greet(string name, string greeting = "Hello") => $"{greeting}, {name}!"; Console.WriteLine(Greet("Alice")); Console.WriteLine(Greet("Bob", "Hi"));
C# optional parameters are declared with a default value in the method signature β€” syntactically similar to Ruby keyword arguments with defaults. Unlike Ruby, optional parameters cannot be sprinkled between required ones arbitrarily; all optional parameters must come after all required ones. The caller can omit trailing optional arguments from right to left.
Named arguments
def format_person(first_name:, last_name:, age:) "#{first_name} #{last_name}, age #{age}" end puts format_person(age: 30, first_name: "Alice", last_name: "Smith")
static string FormatPerson(string firstName, string lastName, int age) => $"{firstName} {lastName}, age {age}"; // Named arguments can appear in any order Console.WriteLine(FormatPerson(age: 30, firstName: "Alice", lastName: "Smith"));
C# named arguments let callers pass arguments by name in any order, similar to Ruby keyword arguments. However, unlike Ruby, named arguments are opt-in at the call site β€” the method signature uses normal positional parameters. This is a useful self-documenting technique when a method has several parameters of the same type (e.g., multiple bool flags).
Variable argument lists (params)
def sum(*numbers) numbers.sum end puts sum(1, 2, 3, 4, 5) # 15
static int Sum(params int[] numbers) => numbers.Sum(); Console.WriteLine(Sum(1, 2, 3, 4, 5)); // 15 // params can also accept an existing array int[] values = { 10, 20, 30 }; Console.WriteLine(Sum(values)); // 60
The params keyword is C#'s equivalent of Ruby's splat operator (*args). It must be the last parameter and produces an array inside the method. The caller can pass individual arguments or an existing array β€” either works. Unlike Ruby's splat, params is typed: params int[] only accepts integers.
Lambdas & Delegates
Func<T> β€” named function type
# Ruby proc / lambda doubler = ->(number) { number * 2 } puts doubler.call(5) # 10 adder = ->(first, second) { first + second } puts adder.call(3, 7) # 10
// Func<TInput, TOutput> holds a lambda that returns a value Func<int, int> doubler = number => number * 2; Console.WriteLine(doubler(5)); // 10 // Func<T1, T2, TOutput> for two parameters Func<int, int, int> adder = (first, second) => first + second; Console.WriteLine(adder(3, 7)); // 10
Func<T, TResult> is a generic delegate type for lambdas that return a value β€” the last type parameter is always the return type. Func<int> takes no arguments; Func<int, int, bool> takes two ints and returns bool. C# lambdas are called directly with (), not with .call().
Action<T> β€” void lambdas
# Ruby: any proc can return nil (void-like) shout = ->(message) { puts message.upcase + "!" } shout.call("hello") printer = -> { puts "done" } printer.call
// Action<T> is a delegate that returns void (no return value) Action<string> shout = message => Console.WriteLine(message.ToUpper() + "!"); shout("hello"); // HELLO! Action greet = () => Console.WriteLine("Hello!"); greet(); // Hello!
Action<T> is used when the lambda produces no return value. Action (no type parameter) takes no arguments. Choosing between Func and Action is purely about whether the delegate returns a value β€” there is no ambiguity because the type system enforces it.
Closures
factor = 3 multiplier = ->(number) { number * factor } puts multiplier.call(5) # 15 factor = 10 puts multiplier.call(5) # 50 β€” closes over variable, not value
int factor = 3; Func<int, int> multiplier = number => number * factor; Console.WriteLine(multiplier(5)); // 15 factor = 10; Console.WriteLine(multiplier(5)); // 50 β€” same behavior as Ruby
C# closures capture variables by reference, just like Ruby lambdas. If the captured variable changes after the lambda is created, the lambda sees the new value. This is a common source of bugs in loops β€” each iteration of a for loop shares the same loop variable, so all closures created in the loop see the final value.
Local functions
def fibonacci(n) fib = ->(n) { n <= 1 ? n : fib.call(n - 1) + fib.call(n - 2) } fib.call(n) end puts fibonacci(10) # 55
static int Fibonacci(int n) { // Local function: named, can be recursive, no Func<> allocation static int fib(int n) => n <= 1 ? n : fib(n - 1) + fib(n - 2); return fib(n); } Console.WriteLine(Fibonacci(10)); // 55
Local functions (C# 7+) are named functions defined inside another method. Unlike lambdas (Func/Action), local functions support recursion naturally, can have static to prevent accidental closure capture, and incur no delegate allocation. They are the preferred way to define a helper that is only used within one method.
LINQ
Select β€” map over a collection
numbers = [1, 2, 3, 4, 5] doubled = numbers.map { |n| n * 2 } puts doubled.inspect # [2, 4, 6, 8, 10]
var numbers = new[] { 1, 2, 3, 4, 5 }; var doubled = numbers.Select(number => number * 2).ToList(); Console.WriteLine(string.Join(", ", doubled)); // 2, 4, 6, 8, 10
Select is Ruby's map β€” it projects each element through a function and returns a new sequence. LINQ methods return lazy IEnumerable<T> sequences; call ToList() or ToArray() to materialize the results into a concrete collection. Skipping ToList() is fine when you will immediately iterate or chain more LINQ operators.
Where β€” filter a collection
numbers = [1, 2, 3, 4, 5, 6] evens = numbers.select { |n| n.even? } puts evens.inspect # [2, 4, 6]
var numbers = new[] { 1, 2, 3, 4, 5, 6 }; var evens = numbers.Where(number => number % 2 == 0).ToList(); Console.WriteLine(string.Join(", ", evens)); // 2, 4, 6
Where is Ruby's select/filter β€” it retains only elements for which the predicate returns true. The naming difference (Where vs select) is intentional: LINQ borrows SQL vocabulary (SELECT, WHERE, GROUP BY) since one goal was to make database queries and in-memory queries look the same.
Any, All, Count
numbers = [1, 2, 3, 4, 5] puts numbers.any? { |n| n > 4 } # true puts numbers.all? { |n| n > 0 } # true puts numbers.count { |n| n.even? } # 2
var numbers = new[] { 1, 2, 3, 4, 5 }; Console.WriteLine(numbers.Any(n => n > 4)); // True Console.WriteLine(numbers.All(n => n > 0)); // True Console.WriteLine(numbers.Count(n => n % 2 == 0)); // 2
These three LINQ methods mirror Ruby's any?, all?, and count with a block almost exactly. Any() with no predicate checks whether the sequence is non-empty. Count() with no predicate returns the total element count β€” but prefer .Count (property) over .Count() (LINQ method) on lists, since the property is O(1) while the method is O(n).
OrderBy β€” sorting
words = ["banana", "apple", "cherry", "date"] by_length = words.sort_by { |word| [word.length, word] } puts by_length.inspect
var words = new[] { "banana", "apple", "cherry", "date" }; var sorted = words .OrderBy(word => word.Length) .ThenBy(word => word) .ToList(); Console.WriteLine(string.Join(", ", sorted));
OrderBy sorts ascending (like Ruby's sort_by); OrderByDescending reverses the order. ThenBy adds a secondary sort key β€” equivalent to Ruby's multi-key sort using an array: sort_by { |w| [w.length, w] }. LINQ's sort is stable: elements that compare equal retain their original relative order.
GroupBy
words = ["apple", "banana", "avocado", "blueberry", "cherry"] grouped = words.group_by { |word| word[0] } grouped.each do |letter, group| puts "#{letter}: #{group.join(', ')}" end
var words = new[] { "apple", "banana", "avocado", "blueberry", "cherry" }; var grouped = words.GroupBy(word => word[0]); foreach (var group in grouped) Console.WriteLine($"{group.Key}: {string.Join(", ", group)}");
GroupBy is Ruby's group_by β€” it groups elements by a key function and returns a sequence of IGrouping<TKey, TElement>. Each grouping has a Key property and is itself an enumerable of the matching elements. You can chain LINQ after GroupBy to compute aggregates per group.
Aggregate β€” reduce / fold
numbers = [1, 2, 3, 4, 5] total = numbers.reduce(0) { |sum, n| sum + n } product = numbers.reduce(1, :*) puts total # 15 puts product # 120
var numbers = new[] { 1, 2, 3, 4, 5 }; int total = numbers.Aggregate(0, (accumulator, n) => accumulator + n); int product = numbers.Aggregate(1, (accumulator, n) => accumulator * n); Console.WriteLine(total); // 15 Console.WriteLine(product); // 120 // Common aggregates have dedicated methods Console.WriteLine(numbers.Sum()); // 15 Console.WriteLine(numbers.Max()); // 5
Aggregate is Ruby's reduce / inject. The first argument is the seed (initial accumulator value). For common operations, LINQ provides optimized shortcuts: Sum, Min, Max, Average β€” prefer these over Aggregate when they apply.
Classes & OOP
Defining a class
class Person attr_reader :first_name, :last_name, :age def initialize(first_name, last_name, age) @first_name = first_name @last_name = last_name @age = age end def full_name = "#{first_name} #{last_name}" end person = Person.new("Alice", "Smith", 30) puts person.full_name
var person = new Person("Alice", "Smith", 30); Console.WriteLine(person.FullName); person.Age++; Console.WriteLine(person.Age); class Person { public string FirstName { get; init; } public string LastName { get; init; } public int Age { get; set; } public Person(string firstName, string lastName, int age) { FirstName = firstName; LastName = lastName; Age = age; } public string FullName => $"{FirstName} {LastName}"; }
C# properties ({ get; set; }) replace Ruby's attr_reader/attr_accessor. The init accessor (C# 9+) allows setting a property only in the constructor or an object initializer β€” it is immutable after construction, like attr_reader with a private setter. Expression-bodied properties (=>) replace simple getter methods.
Inheritance
class Animal attr_reader :name def initialize(name) = @name = name def speak = "..." end class Dog < Animal def speak = "Woof!" end dog = Dog.new("Rex") puts "#{dog.name} says: #{dog.speak}"
var dog = new Dog("Rex"); Console.WriteLine($"{dog.Name} says: {dog.Speak()}"); class Animal { public string Name { get; init; } public Animal(string name) => Name = name; public virtual string Speak() => "..."; } class Dog : Animal { public Dog(string name) : base(name) {} public override string Speak() => "Woof!"; }
C# uses : for inheritance (not <). Overridable methods must be declared virtual in the base class, and override in the subclass β€” unlike Ruby where all methods are implicitly overridable. The base(name) call invokes the parent constructor, equivalent to Ruby's super.
Access modifiers
class BankAccount def initialize(balance) @balance = balance end def deposit(amount) = @balance += amount def balance = @balance # public private def secret = "hidden" end account = BankAccount.new(100) account.deposit(50) puts account.balance # 150
var account = new BankAccount(100); account.Deposit(50); Console.WriteLine(account.Balance); // 150 class BankAccount { private decimal balance; public BankAccount(decimal initialBalance) { balance = initialBalance; } public void Deposit(decimal amount) => balance += amount; public decimal Balance => balance; }
C# access modifiers (public, private, protected, internal) are declared per member and enforced at compile time β€” unlike Ruby where private applies to everything below it and is only enforced at runtime. The internal modifier means "visible within the same assembly (project)" β€” a concept with no Ruby equivalent.
Static members
class Counter @@total = 0 attr_reader :value def initialize = @value = 0 def increment @value += 1 @@total += 1 end def self.total = @@total end counter = Counter.new counter.increment counter.increment puts Counter.total # 2
var counter = new Counter(); counter.Increment(); counter.Increment(); Console.WriteLine($"Value: {counter.Value}, Total: {Counter.Total}"); class Counter { private static int total = 0; public int Value { get; private set; } public void Increment() { Value++; total++; } public static int Total => total; }
C# static members belong to the class itself, not to any instance β€” the same as Ruby's class variables (@@name) and class methods (def self.name). Static members are accessed via the class name (Counter.Total), never via an instance. A class with only static members can be declared static class to prevent instantiation.
Interfaces
Defining and implementing an interface
# Ruby uses duck typing β€” no interface keyword module Greetable def greet = raise NotImplementedError end class EnglishGreeter include Greetable def greet = "Hello!" end class SpanishGreeter include Greetable def greet = "Β‘Hola!" end [EnglishGreeter.new, SpanishGreeter.new].each do |greeter| puts greeter.greet end
IGreetable[] greeters = { new EnglishGreeter(), new SpanishGreeter() }; foreach (var greeter in greeters) Console.WriteLine(greeter.Greet()); interface IGreetable { string Greet(); } class EnglishGreeter : IGreetable { public string Greet() => "Hello!"; } class SpanishGreeter : IGreetable { public string Greet() => "Β‘Hola!"; }
C# interfaces define a contract β€” a set of methods and properties that implementors must provide. Ruby achieves the same goal through duck typing and modules. The key difference: in C#, the contract is verified at compile time; in Ruby, a missing method only fails at runtime. A C# class can implement multiple interfaces (unlike single inheritance for classes).
Multiple interface implementation
# Ruby: include multiple modules freely module Serializable def serialize = to_s end module Printable def print_self = puts serialize end class Report include Serializable include Printable def to_s = "Report contents" end Report.new.print_self
var report = new Report(); report.Print(); interface ISerializable { string Serialize(); } interface IPrintable { void Print(); } class Report : ISerializable, IPrintable { public string Serialize() => "Report contents"; public void Print() => Console.WriteLine(Serialize()); }
A C# class can implement multiple interfaces by listing them with commas after the colon. This gives C# a form of multiple "type membership" similar to Ruby's multiple mixins, but without inheriting any implementation. C# 8 added default interface methods β€” interface members with a body β€” which allows some code sharing similar to Ruby module methods.
Abstract class vs interface
# Ruby: abstract pattern via modules or raising NotImplementedError class Shape def area raise NotImplementedError, "Subclass must implement area" end def describe = "I am a shape with area #{area}" end class Circle < Shape def initialize(radius) = @radius = radius def area = Math::PI * @radius ** 2 end puts Circle.new(5).describe
Console.WriteLine(new Circle(5).Describe()); abstract class Shape { public abstract double Area(); public string Describe() => $"I am a shape with area {Area():F2}"; } class Circle : Shape { private readonly double radius; public Circle(double r) => radius = r; public override double Area() => Math.PI * radius * radius; }
An abstract class can have both abstract methods (no body β€” subclasses must implement) and concrete methods (with body β€” shared implementation). Unlike an interface, an abstract class can have state (fields) and constructors. The rule of thumb: use an interface when you have a pure contract; use an abstract class when you also want to share implementation.
Records & Structs
Records β€” immutable data classes
# Ruby 3.2+ Data class for immutable value objects Point = Data.define(:x, :y) point = Point.new(x: 3, y: 4) puts point.x # 3 puts point.inspect # #<data Point x=3, y=4>
var point = new Point(3, 4); Console.WriteLine(point.X); Console.WriteLine(point); Console.WriteLine(point == new Point(3, 4)); record Point(int X, int Y);
Records (C# 9+) are reference types that get value-based equality, a generated ToString(), and Deconstruct for free β€” similar to Ruby's Data.define. A positional record like record Point(int X, int Y) generates the constructor, properties, and Deconstruct method automatically. Records are immutable by default when using init-only properties.
with β€” non-destructive update
Point = Data.define(:x, :y) origin = Point.new(x: 0, y: 0) moved = origin.with(x: 5) puts origin.inspect # unchanged puts moved.inspect # x=5, y=0
var origin = new Point(0, 0); var moved = origin with { X = 5 }; Console.WriteLine(origin); Console.WriteLine(moved); record Point(int X, int Y);
The with expression creates a copy of a record with some properties changed β€” the original is untouched. This is the idiomatic way to "update" immutable data in C#, and it mirrors Ruby's Data#with method. The syntax is existingRecord with { Property = newValue }.
Struct β€” value-type records
# Ruby structs are reference types Point = Struct.new(:x, :y) a = Point.new(1, 2) b = a # both refer to the same object b.x = 99 puts a.x # 99 β€” mutation affects original
var pointA = new Point(1, 2); var pointB = pointA; Console.WriteLine(pointA.X); Console.WriteLine(pointA == pointB); record struct Point(int X, int Y);
A record struct is a value type β€” assignment copies the entire value rather than sharing a reference. This contrasts with Ruby Struct, which is a reference type (mutations through one alias are visible through another). Value semantics eliminate a whole class of aliasing bugs but require awareness of copying costs for large structs.
Error Handling
try / catch / finally
begin result = 10 / 0 rescue ZeroDivisionError => error puts "Error: #{error.message}" ensure puts "Always runs" end
int divisor = 0; try { int result = 10 / divisor; } catch (DivideByZeroException error) { Console.WriteLine($"Error: {error.Message}"); } finally { Console.WriteLine("Always runs"); }
The structure is nearly identical to Ruby's begin/rescue/ensure. The key differences: C# uses catch (not rescue), requires the exception type in the catch clause (no bare rescue), and uses finally instead of ensure. Multiple catch blocks handle different exception types, like multiple rescue clauses in Ruby.
Custom exceptions
class InsufficientFundsError < StandardError def initialize(amount) super("Insufficient funds: need #{amount} more") end end begin raise InsufficientFundsError.new(50) rescue InsufficientFundsError => error puts error.message end
try { throw new InsufficientFundsException(50); } catch (InsufficientFundsException error) { Console.WriteLine(error.Message); } class InsufficientFundsException : Exception { public InsufficientFundsException(decimal amount) : base($"Insufficient funds: need {amount} more") {} }
Custom exceptions inherit from Exception (or a more specific base class). The convention is to end the class name with Exception. Pass a descriptive message to the base constructor via : base(message). C# uses throw new Exception() where Ruby uses raise.
Exception filters with when
def process(code) raise "HTTP #{code}" if code >= 400 rescue => error if error.message.include?("503") puts "Retrying: #{error.message}" else puts "Fatal: #{error.message}" end end process(503) process(404)
static void Process(int statusCode) { try { if (statusCode >= 400) throw new Exception($"HTTP {statusCode}"); } catch (Exception error) when (error.Message.Contains("503")) { Console.WriteLine($"Retrying: {error.Message}"); } catch (Exception error) { Console.WriteLine($"Fatal: {error.Message}"); } } Process(503); Process(404);
Exception filters (C# 6+) add a when (condition) clause to a catch block. If the condition is false, the exception is not caught and continues to the next handler. Unlike putting a condition inside the catch body and re-throwing, filters do not unwind the stack β€” debuggers can still see the original call site.
Nullable & Null Safety
Nullable value types
# In Ruby, any variable can be nil age = nil puts age.nil? # true puts age.to_i # 0 β€” safe default age = 30 puts age
// Value types (int, double, bool) cannot be null by default int? age = null; // int? is shorthand for Nullable<int> Console.WriteLine(age.HasValue); // False Console.WriteLine(age.GetValueOrDefault()); // 0 age = 30; Console.WriteLine(age.Value); // 30 Console.WriteLine(age.HasValue); // True
Value types in C# (int, bool, double, structs) cannot hold null by default β€” this is a key difference from Ruby where every object can be nil. Adding ? creates a nullable wrapper: int? is Nullable<int>. Check with .HasValue and retrieve with .Value (throws if null) or .GetValueOrDefault().
Null-coalescing ??
name = nil display_name = name || "Anonymous" puts display_name # Anonymous # Ruby's ||= assigns only when nil or false greeting = nil greeting ||= "Hello" puts greeting
string? name = null; string displayName = name ?? "Anonymous"; Console.WriteLine(displayName); // Anonymous // ??= assigns only when left side is null (like Ruby's ||=) string? greeting = null; greeting ??= "Hello"; Console.WriteLine(greeting); // Hello
The null-coalescing operator ?? returns the left operand if it is non-null, otherwise the right operand β€” very similar to Ruby's ||, but importantly, it only triggers on null (not on false). The ??= operator (C# 8+) assigns the right operand only if the left is null, mirroring Ruby's ||= but without the false-value edge case.
Null-conditional operator ?.
user = nil puts user&.name # nil β€” safe navigation operator puts user&.name&.upcase # nil user = Struct.new(:name).new("Alice") puts user&.name
// ?. short-circuits to null if the left side is null string? userName = null; Console.WriteLine(userName?.Length); // (nothing printed β€” null) Console.WriteLine(userName?.ToUpper()); // (nothing printed β€” null) userName = "Alice"; Console.WriteLine(userName?.Length); // 5
The null-conditional operator ?. is C#'s equivalent of Ruby's safe navigation operator &. β€” it returns null immediately if the left side is null, rather than throwing NullReferenceException. It can be chained: user?.Address?.City. The result type becomes nullable; combine with ?? to provide a default: user?.Name ?? "Unknown".
Async & Await
async / await basics
# Ruby has no built-in async/await β€” sequential by default def fetch_greeting(name) # Imagine this takes time (network call) "Hello, #{name}!" end result = fetch_greeting("Alice") puts result
// async methods return Task or Task<T> instead of the raw value static async Task<string> FetchGreeting(string name) { await Task.Delay(1); // simulate async work (1 ms) return $"Hello, {name}!"; } string result = await FetchGreeting("Alice"); Console.WriteLine(result);
C#'s async/await is the primary concurrency model for I/O-bound work. An async method returns a Task (void) or Task<T> (with result); await suspends the current method until the task completes without blocking the thread. Top-level statements in C# 9+ support await directly β€” no outer async Main needed.
Task.WhenAll β€” parallel async
# Ruby: threads for parallelism results = [ Thread.new { "result A" }, Thread.new { "result B" }, Thread.new { "result C" }, ].map(&:value) puts results.inspect
static async Task<string> FetchData(string label, int delayMs) { await Task.Delay(delayMs); return $"result {label}"; } // Run all three concurrently, then wait for all to finish var tasks = new[] { FetchData("A", 10), FetchData("B", 5), FetchData("C", 15), }; string[] results = await Task.WhenAll(tasks); foreach (var result in results) Console.WriteLine(result);
Task.WhenAll runs multiple async operations concurrently and waits for all of them to complete, collecting their results in an array. This is the async equivalent of Ruby's thread join. Task.WhenAny instead returns the result of whichever task finishes first β€” useful for timeouts or racing multiple data sources.
Task.Run β€” CPU-bound work
# Ruby: threads for parallel work worker = Thread.new do (1..10_000).sum end puts worker.value # 50005000
// Task.Run offloads CPU-bound work to a thread-pool thread var sumTask = Task.Run(() => { long total = 0; for (int i = 1; i <= 10_000; i++) total += i; return total; }); long result = await sumTask; Console.WriteLine(result); // 50005000
Task.Run is used for CPU-bound work that should not block the calling thread β€” it submits the work to the .NET thread pool. For I/O-bound work (network, file), use await with genuinely async APIs instead (e.g., File.ReadAllTextAsync) β€” those do not need Task.Run and are more efficient.
I/O & Formatting
Console output and format specifiers
pi = Math::PI puts pi # 3.141592653589793 printf("%.2f ", pi) # 3.14 printf("%10.4f ", pi) # " 3.1416" name = "Alice" age = 30 printf("%-10s %d ", name, age)
double pi = Math.PI; Console.WriteLine(pi); // 3.141592653589793 Console.WriteLine($"{pi:F2}"); // 3.14 Console.WriteLine($"{pi,10:F4}"); // " 3.1416" string name = "Alice"; int age = 30; Console.WriteLine($"{name,-10} {age}"); // "Alice 30"
C# string interpolation supports format specifiers after a colon: {value:F2} formats to 2 decimal places, {value:N0} adds thousands separators, {value:X} formats as hex. A width can precede the colon: {value,10} right-aligns in 10 characters; {value,-10} left-aligns. This replaces Ruby's printf style.
File I/O
File.write("greeting.txt", "Hello, file!") content = File.read("greeting.txt") puts content
File.WriteAllText("greeting.txt", "Hello, file!"); string content = File.ReadAllText("greeting.txt"); Console.WriteLine(content);
The static File class provides simple one-liners for reading and writing files, mirroring Ruby's File.read and File.write. For large files, prefer the streaming APIs (File.OpenRead, StreamReader) to avoid loading the entire file into memory. Async variants (File.ReadAllTextAsync) are available for use with await.
Number and date formatting
pi = Math::PI puts pi printf("%.2f ", pi) printf("%10.4f ", pi) require "time" now = Time.now puts now.strftime("%Y-%m-%d")
decimal price = 1234.5m; Console.WriteLine(price.ToString("C2")); // currency: $1,234.50 Console.WriteLine(1_000_000.ToString("N0")); // 1,000,000 DateTime now = DateTime.Now; Console.WriteLine(now.ToString("yyyy-MM-dd")); Console.WriteLine(now.ToString("D")); // long date format
C# format strings use standard and custom format specifiers: "C2" is currency with 2 decimals, "N0" is a number with thousands separators and no decimals, "F3" is fixed-point with 3 decimals. Date formatting uses DateTime.ToString("pattern") where patterns like "yyyy-MM-dd" work similarly to Ruby's strftime.