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.