PONY λ M2 Modula-2

Java.CodeCompared.To/C#

An interactive executable cheatsheet comparing Java and C#

Java 26 C# 13
Variables & Types
var — type inference
class Main { public static void main(String[] args) { var count = 42; // Java 10+ — inferred as int var greeting = "Hello"; // inferred as String System.out.println(count); System.out.println(greeting); } }
var count = 42; // inferred as int var greeting = "Hello"; // inferred as string Console.WriteLine(count); Console.WriteLine(greeting);
Both languages support var for local type inference. C# uses var much more pervasively — it is the idiomatic style for most local variable declarations. In Java, var (added in Java 10) only works for local variables and cannot be used for fields or method parameters.
final vs const / readonly
class Main { static final double PI = 3.14159; // Java class constant public static void main(String[] args) { final int maxRetries = 3; // local final — cannot be reassigned System.out.println(PI); System.out.println(maxRetries); } }
const double PI = 3.14159; // compile-time constant — must be a literal value int maxRetries = 3; // local variables don't need const; just don't reassign Console.WriteLine(PI); Console.WriteLine(maxRetries);
Java's final prevents reassignment but does not require a compile-time value. C# splits this into two keywords: const (compile-time constant, implicitly static) and readonly (set-once at runtime, used on fields). For local variables, const is rarely needed — the compiler already warns if you assign a variable only once and never mutate it.
Nullable reference types
class Main { public static void main(String[] args) { String name = null; // any reference type can hold null — no compiler warning String displayName = (name != null) ? name : "(unknown)"; System.out.println(displayName); } }
string? name = null; // string? signals intentional nullability string displayName = name ?? "(unknown)"; // ?? provides a non-null fallback Console.WriteLine(displayName);
In Java, any reference type silently holds null — the compiler does not prevent null dereferences. C# 8 introduced nullable reference types: string should never be null, while string? explicitly opts in to nullability. The compiler warns when a nullable value is used without a null check. Enable project-wide with <Nullable>enable</Nullable> in the .csproj.
Value types (struct)
class Main { record Point(int x, int y) {} // Java record — reference type, lives on the heap public static void main(String[] args) { var point1 = new Point(1, 2); var point2 = point1; // both reference the same object System.out.println(point1); System.out.println(point1 == point2); // true — same reference } }
var point1 = new Point(1, 2); var point2 = point1; // point2 is an independent copy Console.WriteLine(point1); Console.WriteLine(point1 == point2); // true — structural equality record struct Point(int X, int Y); // record struct — value type, copied on assignment
Java has no user-defined value types — all objects live on the heap and are accessed by reference. C# has struct, which creates a value type that is stored inline (often on the stack) and copied on assignment. record struct combines immutability and structural equality with value-type semantics. This matters for performance-sensitive code like game loops or numeric processing where avoiding heap allocations is critical.
Null-coalescing operator ??
class Main { static String findUserName() { return null; } // returns null public static void main(String[] args) { var userName = findUserName(); var displayName = (userName != null) ? userName : "Guest"; // explicit null check System.out.println(displayName); } }
string? findUserName() => null; // returns null var userName = findUserName(); var displayName = userName ?? "Guest"; // ?? returns left side if non-null, right otherwise Console.WriteLine(displayName); // ??= assigns the fallback only when the variable is null string? configValue = null; configValue ??= "default"; Console.WriteLine(configValue);
Java has no null-coalescing operator — the ternary a != null ? a : b or Optional.orElse("default") fill this role. C#'s ?? does the same thing concisely. The companion ??= assigns the right side only if the left side is currently null — a common pattern for lazy initialization.
Null-conditional operator ?.
class Main { record Address(String city) {} record Person(String name, Address address) {} public static void main(String[] args) { var person = new Person("Alice", null); // Must guard every step in the chain explicitly String city = (person.address() != null) ? person.address().city() : null; System.out.println(city); } }
var person = new Person("Alice", null); string? city = person.Address?.City; // ?. short-circuits to null if Address is null Console.WriteLine(city ?? "(no city)"); record Address(string City); record Person(string Name, Address? Address);
Java has no null-conditional operator — every step in a property chain must be checked explicitly. C#'s ?. operator (the "null-conditional" or "safe navigation" operator) returns null immediately if the left side is null, without evaluating the right side. Chaining with ?? to provide a fallback is idiomatic: person?.Address?.City ?? "(no city)" replaces three null checks in one expression.
Strings
String interpolation
class Main { public static void main(String[] args) { var firstName = "Alice"; var age = 30; // Java: String.formatted() (Java 15+) or String.format() var message = "Hello, %s! You are %d years old.".formatted(firstName, age); System.out.println(message); } }
var firstName = "Alice"; var age = 30; var message = $"Hello, {firstName}! You are {age} years old."; // $ prefix enables interpolation Console.WriteLine(message); // Expressions work too Console.WriteLine($"Next year: {age + 1}");
Java uses printf-style %s/%d placeholders in String.format() or .formatted(). C# uses interpolated strings prefixed with $: any expression inside {} is evaluated and embedded. The $ prefix composes with others — $@"C:\Users\{name}" creates a verbatim interpolated string with both backslash literals and embedded expressions.
Multi-line strings (text blocks / raw strings)
class Main { public static void main(String[] args) { // Java 13+ text block — triple quotes, leading whitespace trimmed String json = """ { "name": "Alice", "age": 30 } """; System.out.println(json); } }
// C# 11+ raw string literal — same triple-quote syntax var json = """ { "name": "Alice", "age": 30 } """; Console.WriteLine(json);
Both languages use triple-quote syntax for multi-line strings and both strip common leading whitespace. The indentation trimming rule is the same: the position of the closing """ determines how much leading whitespace is removed. Neither interpolates by default — in C# prefix with $""" to enable interpolation inside a raw string literal.
String comparison — == vs .equals()
class Main { public static void main(String[] args) { var hello1 = new String("hello"); // force separate object var hello2 = new String("hello"); System.out.println(hello1 == hello2); // false — == compares references System.out.println(hello1.equals(hello2)); // true — .equals() compares content System.out.println("hello".equalsIgnoreCase("HELLO")); } }
var hello1 = "hello"; var hello2 = "hello"; Console.WriteLine(hello1 == hello2); // true — == is overloaded for string value equality Console.WriteLine(string.Equals(hello1, hello2, StringComparison.OrdinalIgnoreCase));
This is one of Java's most notorious pitfalls: == on strings compares object identity (are these the same object in memory?), not value. C# overloads == for string to compare value, so "hello" == "hello" is always true. The idiomatic Java rule — always use .equals() for strings — simply does not apply in C#.
Verbatim strings (@ prefix)
class Main { public static void main(String[] args) { // Java: backslashes must always be escaped — no verbatim prefix exists var path = "C:\\Users\\Alice\\Documents\\report.pdf"; var pattern = "\\d{4}-\\d{2}-\\d{2}"; // regex: double-escaped System.out.println(path); System.out.println(pattern); } }
var path = @"C:UsersAliceDocuments eport.pdf"; // @ prefix — backslashes are literal var pattern = @"d{4}-d{2}-d{2}"; // regex — no double-escaping needed Console.WriteLine(path); Console.WriteLine(pattern);
Java has no verbatim string prefix — every backslash in a string literal must be doubled. C#'s @"..." verbatim string treats backslashes as literal characters, making Windows file paths and regular expression patterns far more readable. To embed a double-quote inside a verbatim string, double it: @"say ""hello""".
StringBuilder
class Main { public static void main(String[] args) { var builder = new StringBuilder(); builder.append("Hello"); builder.append(", "); builder.append("World"); builder.append("!"); System.out.println(builder.toString()); System.out.println(builder.length()); } }
var builder = new System.Text.StringBuilder(); builder.Append("Hello"); builder.Append(", "); builder.Append("World"); builder.Append("!"); Console.WriteLine(builder.ToString()); Console.WriteLine(builder.Length);
StringBuilder works the same way in both languages — mutable string building without the allocation overhead of repeated concatenation. The C# API is nearly identical to Java's but uses PascalCase method names (Append, ToString, Length) instead of Java's camelCase. This naming convention difference — PascalCase for all public members — applies throughout the entire C# standard library.
Common string methods
class Main { public static void main(String[] args) { var text = " Hello, World! "; System.out.println(text.strip()); // Unicode-aware trim (Java 11+) System.out.println("hello".toUpperCase()); System.out.println("HELLO".toLowerCase()); System.out.println("hello world".contains("world")); System.out.println("hello".startsWith("hel")); System.out.println("hello".replace("l", "r")); System.out.println(String.join(", ", "one", "two", "three")); } }
var text = " Hello, World! "; Console.WriteLine(text.Trim()); // Unicode-aware — always has been Console.WriteLine("hello".ToUpper()); Console.WriteLine("HELLO".ToLower()); Console.WriteLine("hello world".Contains("world")); Console.WriteLine("hello".StartsWith("hel")); Console.WriteLine("hello".Replace("l", "r")); Console.WriteLine(string.Join(", ", "one", "two", "three"));
The string methods are functionally identical — the only real difference is PascalCase in C#. C#'s Trim() has always been Unicode-aware; Java's original trim() only handled ASCII whitespace (the Unicode-aware strip() was added in Java 11). Both languages' Replace replaces all occurrences by default, not just the first.
Collections
List — creation and basic operations
class Main { public static void main(String[] args) { var numbers = new java.util.ArrayList<Integer>(java.util.List.of(1, 2, 3, 4, 5)); numbers.add(6); System.out.println(numbers); System.out.println(numbers.size()); System.out.println(numbers.get(0)); } }
var numbers = new List<int> { 1, 2, 3, 4, 5 }; // collection initializer numbers.Add(6); Console.WriteLine(string.Join(", ", numbers)); Console.WriteLine(numbers.Count); Console.WriteLine(numbers[0]);
C# uses Count where Java uses size(); C# uses indexer brackets list[0] where Java uses list.get(0). The crucial difference: C#'s List<int> uses the primitive int, while Java's List<Integer> must box primitives because Java generics cannot hold primitive types. C#'s collection initializer { 1, 2, 3 } is also more concise than Java's List.of(...).
Dictionary vs HashMap
class Main { public static void main(String[] args) { var scores = new java.util.HashMap<String, Integer>(); scores.put("Alice", 95); scores.put("Bob", 82); System.out.println(scores.get("Alice")); System.out.println(scores.getOrDefault("Charlie", 0)); System.out.println(scores.containsKey("Bob")); System.out.println(scores.size()); } }
var scores = new Dictionary<string, int> { { "Alice", 95 }, { "Bob", 82 }, }; Console.WriteLine(scores["Alice"]); Console.WriteLine(scores.GetValueOrDefault("Charlie", 0)); Console.WriteLine(scores.ContainsKey("Bob")); Console.WriteLine(scores.Count);
The data structure is conceptually identical but the API naming differs. Java's put(key, value) and get(key) become C#'s indexer dict["key"] = value and dict["key"]. The C# indexer throws KeyNotFoundException if the key is missing — use TryGetValue or GetValueOrDefault for safe access, matching Java's getOrDefault.
HashSet
class Main { public static void main(String[] args) { var visited = new java.util.HashSet<String>(); visited.add("home"); visited.add("about"); visited.add("home"); // duplicate — silently ignored, returns false System.out.println(visited.size()); // 2 System.out.println(visited.contains("about")); } }
var visited = new HashSet<string>(); visited.Add("home"); visited.Add("about"); visited.Add("home"); // duplicate — returns false, set unchanged Console.WriteLine(visited.Count); // 2 Console.WriteLine(visited.Contains("about"));
HashSet is identical in both languages. C#'s Add returns bool (true if the element was new, false if already present), as does Java's Set.add. For an ordered set, Java uses TreeSet and C# uses SortedSet<T>. C# also provides set operations as methods: UnionWith, IntersectWith, and ExceptWith.
Collection and object initializers
class Main { record Person(String name, int age) {} public static void main(String[] args) { // Java — no object initializer syntax; constructors are the only option var people = java.util.List.of( new Person("Alice", 30), new Person("Bob", 25), new Person("Carol", 35) ); people.forEach(person -> System.out.println(person.name() + ": " + person.age())); } }
var people = new List<Person> { new("Alice", 30), // C# 9+ target-typed new — type inferred from List<Person> new("Bob", 25), new("Carol", 35), }; foreach (var person in people) Console.WriteLine($"{person.Name}: {person.Age}"); record Person(string Name, int Age);
C# 9 introduced target-typed new — when the type is clear from context, write new(args) without repeating the type name. Collection initializers work for any type implementing IEnumerable with an Add method, not just List<T>. Java has no equivalent initializer syntax — post-construction setup must happen line by line.
Tuples
class Main { // Java has no lightweight tuple — use a record, array, or Map.Entry record Pair<A, B>(A first, B second) {} public static void main(String[] args) { var result = new Pair<String, Integer>("Alice", 30); System.out.println(result.first()); System.out.println(result.second()); } }
// C# value tuples — no class definition needed var result = ("Alice", 30); Console.WriteLine(result.Item1); // positional access Console.WriteLine(result.Item2); // Named tuple elements — strongly preferred (string Name, int Age) person = ("Alice", 30); Console.WriteLine(person.Name); Console.WriteLine(person.Age);
Java has no built-in tuple type — developers define a record or use Map.Entry<K,V>. C# value tuples are a first-class language feature: (string, int) is a value-type struct. Naming the elements — (string Name, int Age) — is strongly preferred over positional Item1/Item2 access. Tuples are especially useful for returning multiple values from a method without defining a separate class.
Control Flow
Switch expression
class Main { public static void main(String[] args) { int day = 3; String dayName = switch (day) { // Java 14+: switch expression with arrow cases case 1 -> "Monday"; case 2 -> "Tuesday"; case 3 -> "Wednesday"; case 4 -> "Thursday"; case 5 -> "Friday"; default -> "Weekend"; }; System.out.println(dayName); } }
int day = 3; string dayName = day switch // C#: value comes first, then switch keyword { 1 => "Monday", 2 => "Tuesday", 3 => "Wednesday", 4 => "Thursday", 5 => "Friday", _ => "Weekend", // _ is the discard/default pattern in C# }; Console.WriteLine(dayName);
Both Java 14+ and C# have switch expressions that return a value. The syntax is mirror-reversed: Java writes switch (value) { case x -> result } while C# writes value switch { x => result }. C# uses _ as the default/discard arm; Java uses default. C# switch expressions go further — they support property patterns, positional patterns, and type patterns in addition to simple values.
Null-conditional method calls
class Main { public static void main(String[] args) { java.util.List<String> items = null; // Java: explicit null check required before any method call int count = (items != null) ? items.size() : 0; System.out.println(count); } }
List<string>? items = null; int count = items?.Count ?? 0; // ?. short-circuits to null; ?? provides the fallback Console.WriteLine(count);
C#'s ?. null-conditional operator returns null (rather than throwing NullReferenceException) when the left side is null. Chaining with ?? to provide a non-null fallback is idiomatic: items?.Count ?? 0 means "get Count if items is not null, otherwise use 0." The entire expression replaces the multi-line null-guard required in Java.
Pattern matching in if
class Main { sealed interface Shape permits Circle, Rectangle {} record Circle(double radius) implements Shape {} record Rectangle(double width, double height) implements Shape {} public static void main(String[] args) { Shape shape = new Circle(5.0); if (shape instanceof Circle circle) { // Java 16+: type pattern in instanceof System.out.printf("Circle, radius: %.1f%n", circle.radius()); } else if (shape instanceof Rectangle rectangle) { System.out.printf("Rectangle: %.1f x %.1f%n", rectangle.width(), rectangle.height()); } } }
Shape shape = new Circle(5.0); if (shape is Circle circle) Console.WriteLine($"Circle, radius: {circle.Radius:F1}"); else if (shape is Rectangle rectangle) Console.WriteLine($"Rectangle: {rectangle.Width:F1} x {rectangle.Height:F1}"); abstract record Shape; record Circle(double Radius) : Shape; record Rectangle(double Width, double Height) : Shape;
Both languages support type patterns in if. Java uses instanceof Type varName; C# uses is Type varName. The semantics are identical: if the check succeeds, the variable is bound to the cast value for the rest of that branch. The C# keyword is is rather than instanceof.
foreach / enhanced for
class Main { public static void main(String[] args) { var languages = java.util.List.of("Java", "C#", "Kotlin", "Swift"); for (String language : languages) { // Java: for (Type item : collection) System.out.println(language); } } }
var languages = new List<string> { "Java", "C#", "Kotlin", "Swift" }; foreach (var language in languages) // C#: foreach (var item in collection) Console.WriteLine(language);
Java's enhanced for loop uses a colon (for (Type item : collection)); C# uses the foreach/in keyword pair. Both iterate any Iterable/IEnumerable implementation. C# foreach also works with duck-typing — any type with a GetEnumerator() method can be iterated even without implementing the interface.
Range and index-from-end operators
class Main { public static void main(String[] args) { var numbers = new int[]{10, 20, 30, 40, 50}; // Java: manual index arithmetic for last element int last = numbers[numbers.length - 1]; int[] middle = java.util.Arrays.copyOfRange(numbers, 1, 4); System.out.println(last); System.out.println(java.util.Arrays.toString(middle)); } }
var numbers = new[] { 10, 20, 30, 40, 50 }; int last = numbers[^1]; // ^1 = index from the end (C# 8+) int[] middle = numbers[1..4]; // range: indices 1 up to (not including) 4 Console.WriteLine(last); Console.WriteLine(string.Join(", ", middle));
C# 8 introduced index-from-end (^n) and range (a..b) operators. ^1 is the last element, ^2 is second-to-last, and so on. The range 1..4 creates a slice from index 1 up to (but not including) index 4. Java requires manual arithmetic (array.length - 1) and Arrays.copyOfRange for the same operations.
Methods & Functions
Optional / default parameters
class Main { // Java has no default parameter values — must use overloaded methods static void greet(String name, String greeting) { System.out.println(greeting + ", " + name + "!"); } static void greet(String name) { greet(name, "Hello"); // overload delegates to the full version } public static void main(String[] args) { greet("Alice"); greet("Bob", "Hi"); } }
void greet(string name, string greeting = "Hello") // default parameter value => Console.WriteLine($"{greeting}, {name}!"); greet("Alice"); greet("Bob", "Hi"); greet("Carol", greeting: "Hey"); // named argument — call site is self-documenting
Java has no default parameter values — the overload pattern is the only option. C# supports optional parameters directly in the signature and named arguments at the call site. Named arguments allow passing parameters out of order and make long method calls self-documenting. Optional parameters must appear after required ones.
Expression-bodied methods
class Main { static int square(int number) { return number * number; // Java always requires a block body and explicit return } static String classify(int number) { return number > 0 ? "positive" : number < 0 ? "negative" : "zero"; } public static void main(String[] args) { System.out.println(square(5)); System.out.println(classify(-3)); } }
int square(int number) => number * number; // no braces, no return keyword string classify(int number) => number > 0 ? "positive" : number < 0 ? "negative" : "zero"; Console.WriteLine(square(5)); Console.WriteLine(classify(-3));
C# allows a => expression body for any method, property, or constructor whose logic is a single expression. Java requires braces and an explicit return statement even for one-liners. Expression-bodied members are idiomatic C# — any method that fits on one line is conventionally written this way.
out parameters — multiple return values
class Main { // Java has no out parameters — use a record to return multiple values record ParseResult(boolean success, int value) {} static ParseResult tryParse(String input) { try { return new ParseResult(true, Integer.parseInt(input)); } catch (NumberFormatException error) { return new ParseResult(false, 0); } } public static void main(String[] args) { var result = tryParse("42"); if (result.success()) System.out.println(result.value()); } }
// out parameter: method writes a value through the parameter slot bool success = int.TryParse("42", out int parsedValue); if (success) Console.WriteLine(parsedValue); // Alternatively, destructure a tuple return value (bool ok, int number) tryParseNumber(string input) => int.TryParse(input, out var parsed) ? (true, parsed) : (false, 0); var (ok, number) = tryParseNumber("42"); if (ok) Console.WriteLine(number);
C# out parameters let a method write a value through a parameter in addition to its return value. This is used throughout the standard library for "try" patterns: int.TryParse, Dictionary.TryGetValue, etc. return bool and write the result via out. Java has no equivalent — the common workaround is returning a record, throwing an exception, or using an array argument.
Local functions
class Main { public static void main(String[] args) { // Java: no local function syntax — use a lambda stored in a variable java.util.function.IntUnaryOperator doubleValue = number -> number * 2; java.util.function.IntUnaryOperator triple = number -> number * 3; System.out.println(doubleValue.applyAsInt(5)); System.out.println(triple.applyAsInt(5)); } }
// C# local functions — declared inside a method with full method syntax int doubleValue(int number) => number * 2; int triple(int number) => number * 3; Console.WriteLine(doubleValue(5)); Console.WriteLine(triple(5));
C# supports local functions — methods declared inside another method. Unlike lambdas, local functions support recursion, generic parameters, and the static modifier, and are not boxed as delegates. Java has no local function syntax — the closest equivalent is a lambda stored in a variable, which lacks some of those capabilities.
params / varargs
class Main { static int sum(int... numbers) { // Java varargs — trailing ellipsis int total = 0; for (int number : numbers) total += number; return total; } public static void main(String[] args) { System.out.println(sum(1, 2, 3)); System.out.println(sum(1, 2, 3, 4, 5)); } }
int sum(params int[] numbers) // C# params — explicit array type + params keyword { int total = 0; foreach (int number in numbers) total += number; return total; } Console.WriteLine(sum(1, 2, 3)); Console.WriteLine(sum(1, 2, 3, 4, 5));
Java uses Type... name (trailing ellipsis) for varargs; C# uses params Type[] name with an explicit array type and the params keyword. Both must be the last parameter, and both allow calling with individual arguments or passing an existing array. The mechanics are equivalent — the difference is syntax only.
Properties
Auto-properties vs getters / setters
class Main { static class Person { private String name; private int age; // Java requires explicit getter and setter methods for every field public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } public Person(String name, int age) { this.name = name; this.age = age; } } public static void main(String[] args) { var person = new Person("Alice", 30); System.out.println(person.getName()); person.setName("Bob"); System.out.println(person.getName()); } }
var person = new Person("Alice", 30); Console.WriteLine(person.Name); // field-like access, no parentheses person.Name = "Bob"; Console.WriteLine(person.Name); class Person { public string Name { get; set; } // auto-property — compiler generates the backing field public int Age { get; set; } public Person(string name, int age) { Name = name; Age = age; } }
Java's JavaBeans convention requires explicit getName() and setName() for every field — a well-known source of boilerplate. C# properties replace getters and setters: at the call site, person.Name = "Bob" replaces person.setName("Bob"), and person.Name replaces person.getName(). Auto-properties like public string Name { get; set; } let the compiler generate the backing field automatically. This is one of the most immediately noticeable quality-of-life improvements in C#.
Init-only properties and with-expressions
class Main { record Person(String name, int age) {} // Java record — immutable by construction public static void main(String[] args) { var person = new Person("Alice", 30); // Must construct a new record to "update" a field var updated = new Person("Bob", person.age()); System.out.println(updated); } }
var person = new Person { Name = "Alice", Age = 30 }; // person.Name = "Bob"; // compile error — init is write-once var updated = person with { Name = "Bob" }; // with-expression creates a modified copy Console.WriteLine(updated.Name); Console.WriteLine(updated.Age); record class Person { public string Name { get; init; } = ""; // init-only: set during construction only public int Age { get; init; } }
C# 9 introduced init — a setter that can only be called during object construction or initialization, making properties effectively immutable after the object is built. The with expression creates a shallow copy with specific properties changed, making immutable updates ergonomic without repeating all unchanged fields. Java's record achieves similar immutability but uses constructor arguments rather than an initializer syntax.
Computed (expression-bodied) properties
class Main { record Circle(double radius) { // Java records: computed values are exposed as methods double area() { return Math.PI * radius * radius; } double circumference() { return 2 * Math.PI * radius; } } public static void main(String[] args) { var circle = new Circle(5.0); System.out.printf("Area: %.2f%n", circle.area()); System.out.printf("Circumference: %.2f%n", circle.circumference()); } }
var circle = new Circle(5.0); Console.WriteLine($"Area: {circle.Area:F2}"); Console.WriteLine($"Circumference: {circle.Circumference:F2}"); record Circle(double Radius) { // C#: computed values exposed as properties — accessed without parentheses public double Area => Math.PI * Radius * Radius; public double Circumference => 2 * Math.PI * Radius; }
In Java, computed values must be exposed as methods (circle.area()). C# expression-bodied properties allow computed values to be accessed as if they were fields (circle.Area), reading more naturally for values that describe the object. The :F2 in {circle.Area:F2} is a format specifier directly inside the interpolated expression — it formats the value to two decimal places.
Private setter
class Main { static class Counter { private int count = 0; public int getCount() { return count; } // public read public void increment() { count++; } // controlled write } public static void main(String[] args) { var counter = new Counter(); counter.increment(); counter.increment(); System.out.println(counter.getCount()); } }
var counter = new Counter(); counter.Increment(); counter.Increment(); Console.WriteLine(counter.Count); class Counter { public int Count { get; private set; } = 0; // readable everywhere, settable inside class only public void Increment() => Count++; }
C# { get; private set; } combines public read access with private write access in a single declaration. The Java equivalent requires a private field plus a separate public getter method. The pattern { get; private set; } is very common in C# for values that should be readable from outside the class but only mutated internally.
Classes & OOP
Records — immutable data classes
class Main { record Point(int x, int y) {} // Java 16+: auto-generates equals, hashCode, toString public static void main(String[] args) { var point = new Point(3, 4); System.out.println(point); // Point[x=3, y=4] System.out.println(point.x()); // accessor method — called with () var other = new Point(3, 4); System.out.println(point.equals(other)); // true — structural equality System.out.println(point == other); // false — == compares references in Java } }
var point = new Point(3, 4); Console.WriteLine(point); // Point { X = 3, Y = 4 } Console.WriteLine(point.X); // property — accessed without parentheses var other = new Point(3, 4); Console.WriteLine(point.Equals(other)); // true Console.WriteLine(point == other); // true — == is structural for C# records record Point(int X, int Y); // C# 9+: positional record — same auto-generation
Both languages support records as concise immutable data classes with compiler-generated equality, hash codes, and string representation. Key differences: Java record accessors use method syntax (point.x()), C# records use property syntax (point.X). C# == for records compares by value; Java == always compares by reference. C# records also support inheritance (record Child(...) : Parent(...)), which Java records do not.
Object initializers
class Main { static class Config { String host = ""; int port; boolean secure; } public static void main(String[] args) { // Java: no object initializer syntax — each property set separately var config = new Config(); config.host = "example.com"; config.port = 443; config.secure = true; System.out.println(config.host + ":" + config.port + " secure=" + config.secure); } }
// Object initializer — set properties inline at the construction site var config = new Config { Host = "example.com", Port = 443, Secure = true }; Console.WriteLine($"{config.Host}:{config.Port} secure={config.Secure}"); class Config { public string Host { get; set; } = ""; public int Port { get; set; } public bool Secure { get; set; } }
C# object initializers allow setting properties inline after the constructor call using { Property = Value } syntax. In Java, post-construction assignment must happen on separate statements. C# object initializers are especially powerful when combined with init properties, where the initializer is the only allowed time to set a value.
Sealed classes and discriminated unions
class Main { sealed interface Result<T> permits Success, Failure {} record Success<T>(T value) implements Result<T> {} record Failure<T>(String message) implements Result<T> {} static <T> void show(Result<T> result) { switch (result) { case Success<T> success -> System.out.println("OK: " + success.value()); case Failure<T> failure -> System.out.println("Error: " + failure.message()); } } public static void main(String[] args) { show(new Success<>("data")); show(new Failure<>("not found")); } }
show(new Success<string>("data")); show(new Failure<string>("not found")); void show<T>(Result<T> result) { Console.WriteLine(result switch { Success<T> success => $"OK: {success.Value}", Failure<T> failure => $"Error: {failure.Message}", _ => throw new InvalidOperationException("Unknown result"), }); } abstract record Result<T>; record Success<T>(T Value) : Result<T>; record Failure<T>(string Message) : Result<T>;
Java 17+ sealed interfaces explicitly declare permitted implementations, enabling exhaustive switch expressions without a default case. C# achieves the same with an abstract record base, but the compiler cannot verify exhaustiveness over a non-sealed hierarchy — the discard arm _ => throw ... is conventionally added. Both patterns model discriminated unions (also called sum types) and are the basis of idiomatic pattern matching in each language.
Static (utility) classes
class Main { // Java: private constructor prevents instantiation; final prevents subclassing static final class MathHelper { private MathHelper() {} static int clamp(int value, int minimum, int maximum) { return Math.max(minimum, Math.min(maximum, value)); } } public static void main(String[] args) { System.out.println(MathHelper.clamp(150, 0, 100)); System.out.println(MathHelper.clamp(-10, 0, 100)); } }
Console.WriteLine(MathHelper.Clamp(150, 0, 100)); Console.WriteLine(MathHelper.Clamp(-10, 0, 100)); static class MathHelper // static modifier prevents instantiation and inheritance { public static int Clamp(int value, int minimum, int maximum) => Math.Max(minimum, Math.Min(maximum, value)); }
C# has a static class modifier that prevents instantiation and inheritance at the compiler level. Java has no such keyword — the convention is a private constructor (and often final to prevent subclassing). Static classes in C# are also the mandatory home of extension methods.
Attributes vs annotations
class Main { @Deprecated // Java annotation — @AnnotationName before the declaration static void oldMethod() { System.out.println("old method"); } @SuppressWarnings("deprecation") public static void main(String[] args) { oldMethod(); } }
[Obsolete("Use NewMethod instead")] // C# attribute — [AttributeName] before the declaration static void OldMethod() => Console.WriteLine("old method"); static void NewMethod() => Console.WriteLine("new method"); #pragma warning disable CS0612 OldMethod(); #pragma warning restore CS0612
Java annotations use @Name syntax; C# attributes use [Name] square brackets. Both are metadata attached to declarations and accessible via reflection. C# attributes are classes that inherit from System.Attribute. Their names conventionally end in Attribute, but the suffix is dropped when applying: [Obsolete] rather than [ObsoleteAttribute].
Operator overloading
class Main { // Java deliberately does not support operator overloading record Vector(double x, double y) { Vector add(Vector other) { return new Vector(x + other.x, y + other.y); } @Override public String toString() { return "(" + x + ", " + y + ")"; } } public static void main(String[] args) { var velocity = new Vector(3.0, 4.0); var acceleration = new Vector(1.5, 0.5); System.out.println(velocity.add(acceleration)); // must call .add() explicitly } }
var velocity = new Vector(3.0, 4.0); var acceleration = new Vector(1.5, 0.5); Console.WriteLine(velocity + acceleration); // natural + syntax on custom types record Vector(double X, double Y) { public static Vector operator +(Vector left, Vector right) // overloaded + operator => new(left.X + right.X, left.Y + right.Y); }
C# allows overloading operators for custom types using the static operator +(T left, T right) syntax. Java deliberately excluded operator overloading to avoid the abuses common in C++. For numeric and mathematical types like Vector, Matrix, Complex, and Money, C# operator overloading makes code read naturally. LINQ's Expression trees use overloaded operators to build query expression trees.
Extension Methods
Extension methods
class Main { // Java: no extension methods — static utility classes are the only option static class StringUtils { static boolean isPalindrome(String input) { var reversed = new StringBuilder(input).reverse().toString(); return input.equals(reversed); } } public static void main(String[] args) { System.out.println(StringUtils.isPalindrome("racecar")); System.out.println(StringUtils.isPalindrome("hello")); } }
Console.WriteLine("racecar".IsPalindrome()); // called as if it were a string method Console.WriteLine("hello".IsPalindrome()); static class StringExtensions { // Extension method — 'this' before the first parameter marks the extended type public static bool IsPalindrome(this string input) { var characters = input.ToCharArray(); Array.Reverse(characters); return input == new string(characters); } }
Extension methods are declared as static methods with this T parameterName as the first parameter. They appear in IntelliSense as if they belong to the type and can be called with method syntax. Java has no equivalent — utility methods must be called with the class name prefix: StringUtils.isPalindrome(str) instead of str.IsPalindrome(). All of LINQ is built on extension methods: list.Where(...) and list.Select(...) are extension methods on IEnumerable<T>.
Extension methods powering LINQ
class Main { public static void main(String[] args) { var numbers = java.util.List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); int sumOfEvenSquares = numbers.stream() .filter(number -> number % 2 == 0) .mapToInt(number -> number * number) .sum(); System.out.println(sumOfEvenSquares); } }
var numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; int sumOfEvenSquares = numbers .Where(number => number % 2 == 0) .Select(number => number * number) .Sum(); Console.WriteLine(sumOfEvenSquares);
LINQ operations like Where, Select, and Sum are extension methods in System.Linq that work on any IEnumerable<T> — lists, arrays, strings, database queries, and custom types. Java Streams are functionally equivalent but require explicitly calling .stream() to enter the pipeline. LINQ extension methods work directly on IEnumerable<T> without a conversion step.
Generics
Generic type constraints
class Main { // Java: bounded type parameters use 'extends' for both class and interface bounds static <T extends Comparable<T>> T maximum(T first, T second) { return first.compareTo(second) >= 0 ? first : second; } public static void main(String[] args) { System.out.println(maximum(42, 17)); System.out.println(maximum("apple", "banana")); } }
// C#: 'where' clause appears after the parameter list T maximum<T>(T first, T second) where T : IComparable<T> => first.CompareTo(second) >= 0 ? first : second; Console.WriteLine(maximum(42, 17)); Console.WriteLine(maximum("apple", "banana"));
Java uses <T extends SomeInterface> for bounded type parameters; C# uses a where T : SomeInterface clause after the parameter list. Both use the same syntax for interface and class bounds. C# also supports constraints that Java doesn't: where T : new() (must have a parameterless constructor), where T : class (reference types only), where T : struct (value types only), and where T : notnull.
Generic type information at runtime
class Main { // Java erases generics at runtime — List<String> and List<Integer> are the same class static <T> void printTypeInfo(java.util.List<T> items) { System.out.println(items.getClass().getSimpleName()); // always "ArrayList" — T is gone if (!items.isEmpty()) System.out.println(items.get(0).getClass().getSimpleName()); } public static void main(String[] args) { printTypeInfo(java.util.List.of("hello", "world")); } }
// C# retains full generic type info at runtime — no erasure void printTypeInfo<T>(List<T> items) { Console.WriteLine(typeof(T).Name); // "String" — T is known at runtime Console.WriteLine(typeof(List<T>).Name); // "List`1" — full generic type retained } printTypeInfo(new List<string> { "hello", "world" });
Java uses type erasure — generic type parameters are removed at compile time, so List<String> and List<Integer> are the same class at runtime (causing the notorious "unchecked cast" warnings). C# uses reification — every List<string> and List<int> is a distinct runtime type, and typeof(T) returns the actual type argument. This enables patterns impossible in Java, such as instantiating T at runtime and using it in type-check expressions.
Generic class with index-from-end
class Main { static class Stack<T> { private java.util.ArrayList<T> items = new java.util.ArrayList<>(); public void push(T item) { items.add(item); } public T pop() { if (items.isEmpty()) throw new java.util.NoSuchElementException("Stack is empty"); return items.remove(items.size() - 1); // manual "last element" arithmetic } public int size() { return items.size(); } } public static void main(String[] args) { var stack = new Stack<String>(); stack.push("first"); stack.push("second"); System.out.println(stack.pop()); System.out.println(stack.size()); } }
var stack = new Stack<string>(); stack.Push("first"); stack.Push("second"); Console.WriteLine(stack.Pop()); Console.WriteLine(stack.Count); class Stack<T> { private readonly List<T> items = new(); // target-typed new — type inferred from field public void Push(T item) => items.Add(item); public T Pop() { if (items.Count == 0) throw new InvalidOperationException("Stack is empty"); var last = items[^1]; // ^1 = index from the end (C# 8+) items.RemoveAt(items.Count - 1); return last; } public int Count => items.Count; }
Generic class syntax is nearly identical between the two languages. Notable C#-specific features in this example: new() (target-typed new — type inferred from the field declaration) and [^1] (the index-from-end operator — ^1 is the last element, ^2 the second-to-last, without needing count - 1 arithmetic).
Lambdas & Delegates
Lambda syntax — -> vs =>
class Main { public static void main(String[] args) { // Java lambda — (params) -> expression or (params) -> { body } java.util.function.IntUnaryOperator doubleValue = number -> number * 2; java.util.function.BinaryOperator<Integer> add = (first, second) -> first + second; System.out.println(doubleValue.applyAsInt(5)); System.out.println(add.apply(3, 4)); } }
// C# lambda — params => expression or (params) => { body } Func<int, int> doubleValue = number => number * 2; Func<int, int, int> add = (first, second) => first + second; Console.WriteLine(doubleValue(5)); Console.WriteLine(add(3, 4));
Java uses -> in lambdas; C# uses =>. Otherwise the syntax is nearly identical. The functional interface types differ: Java has Function<T,R>, Supplier<T>, Consumer<T>, and Predicate<T>; C# uses Func<...,TResult>, Action<...>, and Predicate<T>. C# lambdas can also be assigned to named delegate types, which are explicit type aliases for specific function signatures.
Method references and method groups
class Main { public static void main(String[] args) { var words = java.util.List.of("hello", "world", "java"); // Java :: shorthand creates a method reference without a lambda wrapper words.stream() .map(String::toUpperCase) .forEach(System.out::println); } }
var words = new List<string> { "hello", "world", "csharp" }; // C#: no :: shorthand for instance methods — use a lambda or method group words.Select(word => word.ToUpper()) .ToList() .ForEach(Console.WriteLine); // Console.WriteLine as a method group matches Action<string>
Java's ClassName::method syntax creates a method reference without a lambda wrapper. C# does not have the :: shorthand for instance methods, but method groups — writing just the method name without arguments — work as delegates for static methods and some instance calls. Console.WriteLine used as a value is a method group matching Action<string>. For instance methods, a lambda wrapper is the idiomatic C# choice.
Delegates — named function types
class Main { @FunctionalInterface interface Validator<T> { boolean validate(T value); // Java: single-abstract-method interface } static void checkAll(java.util.List<String> items, Validator<String> validator) { for (String item : items) System.out.println(item + ": " + (validator.validate(item) ? "valid" : "invalid")); } public static void main(String[] args) { checkAll(java.util.List.of("hello", "", "world"), input -> !input.isEmpty()); } }
checkAll(new List<string> { "hello", "", "world" }, input => !string.IsNullOrEmpty(input)); void checkAll(List<string> items, Validator<string> validator) { foreach (var item in items) Console.WriteLine($"{item}: {(validator(item) ? "valid" : "invalid")}"); } // C# delegate — a named type alias for a specific function signature delegate bool Validator<T>(T value);
Java's @FunctionalInterface is a single-abstract-method interface used as the target type for lambdas. C# delegates are similar but declared as first-class type aliases for a specific function signature. In practice, most C# code uses Func<T,R> and Action<T> rather than declaring custom delegates — custom delegates are reserved for cases where a named type conveys domain intent (like EventHandler or Comparison<T>).
Events
class Main { // Java has no built-in event system — implement the observer pattern manually interface ClickListener { void onClick(String buttonLabel); } static class Button { private final String label; private ClickListener listener; Button(String label) { this.label = label; } void setOnClickListener(ClickListener listener) { this.listener = listener; } void click() { if (listener != null) listener.onClick(label); } } public static void main(String[] args) { var button = new Button("Submit"); button.setOnClickListener(label -> System.out.println("Clicked: " + label)); button.click(); } }
var button = new Button("Submit"); button.Clicked += label => Console.WriteLine($"Clicked: {label}"); button.Clicked += label => Console.WriteLine($"Also handled: {label}"); // multiple subscribers button.Click(); class Button { public string Label { get; } public event Action<string>? Clicked; // event — subscribers use += and -= public Button(string label) => Label = label; public void Click() => Clicked?.Invoke(Label); }
C#'s event keyword builds the observer pattern into the type system. Multiple handlers subscribe with += and unsubscribe with -=. Events can only be raised from inside the declaring class, but any code can add or remove subscribers. Java has no built-in event mechanism — the observer pattern is implemented manually with listener interfaces, typically allowing only one listener per setter call.
LINQ vs Streams
Where / Select (filter / map)
class Main { public static void main(String[] args) { var numbers = java.util.List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); var result = numbers.stream() .filter(number -> number % 2 == 0) .map(number -> number * number) .collect(java.util.stream.Collectors.toList()); System.out.println(result); } }
var numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; var result = numbers .Where(number => number % 2 == 0) .Select(number => number * number) .ToList(); Console.WriteLine(string.Join(", ", result));
Java Streams and LINQ are functionally equivalent — both provide lazy, chainable operations over sequences. The terminology differs: Java uses filter/map/collect; LINQ uses Where/Select/ToList. The key structural difference is that Java requires .stream() to enter the pipeline from a collection, while LINQ extension methods work directly on IEnumerable<T> without a conversion step.
LINQ query syntax
class Main { record Product(String name, double price, String category) {} public static void main(String[] args) { var products = java.util.List.of( new Product("Widget", 9.99, "Hardware"), new Product("Doohickey", 4.99, "Hardware"), new Product("Thingamajig", 19.99, "Software") ); // Java has no query syntax — method chaining is the only option products.stream() .filter(product -> "Hardware".equals(product.category()) && product.price() < 10.0) .sorted(java.util.Comparator.comparing(Product::name)) .forEach(product -> System.out.println(product.name() + ": $" + product.price())); } }
var products = new List<Product> { new("Widget", 9.99, "Hardware"), new("Doohickey", 4.99, "Hardware"), new("Thingamajig", 19.99, "Software"), }; // LINQ query syntax — SQL-like, compiles to the same method calls as chaining var cheapHardware = from product in products where product.Category == "Hardware" && product.Price < 10.0 orderby product.Name select product; foreach (var product in cheapHardware) Console.WriteLine($"{product.Name}: ${product.Price}"); record Product(string Name, double Price, string Category);
LINQ has two syntaxes: method chaining (Where, Select, etc.) and query syntax (the from/where/orderby/select form). Both compile to identical code. Query syntax is often more readable for complex joins and groupings — it was modeled on SQL. Java has no query syntax equivalent; stream method chaining is the only option.
LINQ aggregation — Sum, Average, Max
class Main { public static void main(String[] args) { var numbers = java.util.List.of(3, 1, 4, 1, 5, 9, 2, 6); // Java requires conversion to a primitive stream for numeric aggregation int sum = numbers.stream().mapToInt(Integer::intValue).sum(); double avg = numbers.stream().mapToInt(Integer::intValue).average().orElse(0); int maximum = numbers.stream().mapToInt(Integer::intValue).max().orElse(0); System.out.println("Sum: " + sum); System.out.printf("Average: %.2f%n", avg); System.out.println("Max: " + maximum); } }
var numbers = new List<int> { 3, 1, 4, 1, 5, 9, 2, 6 }; // LINQ aggregation works directly on typed collections — no primitive stream conversion Console.WriteLine($"Sum: {numbers.Sum()}"); Console.WriteLine($"Average: {numbers.Average():F2}"); Console.WriteLine($"Max: {numbers.Max()}");
LINQ provides Sum(), Average(), Min(), Max(), Count(), and Aggregate() directly on IEnumerable<T>. Java requires first converting to a primitive stream (mapToInt, mapToDouble) before aggregation methods are available, adding verbosity. LINQ equivalents work directly on typed collections and return the expected numeric type.
GroupBy
class Main { record Employee(String name, String department) {} public static void main(String[] args) { var employees = java.util.List.of( new Employee("Alice", "Engineering"), new Employee("Bob", "Marketing"), new Employee("Carol", "Engineering"), new Employee("Dave", "Marketing") ); employees.stream() .collect(java.util.stream.Collectors.groupingBy(Employee::department)) .forEach((department, members) -> { var names = members.stream().map(Employee::name) .collect(java.util.stream.Collectors.joining(", ")); System.out.println(department + ": " + names); }); } }
var employees = new List<Employee> { new("Alice", "Engineering"), new("Bob", "Marketing"), new("Carol", "Engineering"), new("Dave", "Marketing"), }; var byDepartment = employees.GroupBy(employee => employee.Department); foreach (var group in byDepartment) Console.WriteLine($"{group.Key}: {string.Join(", ", group.Select(employee => employee.Name))}"); record Employee(string Name, string Department);
LINQ's GroupBy returns IEnumerable<IGrouping<TKey, TElement>> — each group has a Key property and is itself an IEnumerable<T> of its members. Java's Collectors.groupingBy returns a Map<K, List<V>> — the groups are materialized immediately. LINQ groups are lazy and chain naturally with further LINQ operations before materializing.
Pattern Matching
Type patterns in switch
class Main { sealed interface Shape permits Circle, Rectangle, Triangle {} record Circle(double radius) implements Shape {} record Rectangle(double width, double height) implements Shape {} record Triangle(double base, double height) implements Shape {} static double area(Shape shape) { return switch (shape) { case Circle circle -> Math.PI * circle.radius() * circle.radius(); case Rectangle rectangle -> rectangle.width() * rectangle.height(); case Triangle triangle -> 0.5 * triangle.base() * triangle.height(); }; } public static void main(String[] args) { System.out.printf("%.2f%n", area(new Circle(5))); System.out.printf("%.2f%n", area(new Rectangle(4, 6))); } }
Console.WriteLine($"{area(new Circle(5)):F2}"); Console.WriteLine($"{area(new Rectangle(4, 6)):F2}"); double area(Shape shape) => shape switch { Circle circle => Math.PI * circle.Radius * circle.Radius, Rectangle rectangle => rectangle.Width * rectangle.Height, Triangle triangle => 0.5 * triangle.Base * triangle.Height, _ => throw new InvalidOperationException("Unknown shape"), }; abstract record Shape; record Circle(double Radius) : Shape; record Rectangle(double Width, double Height) : Shape; record Triangle(double Base, double Height) : Shape;
Both languages support type patterns in switch expressions. Java sealed interfaces provide compiler-enforced exhaustiveness — omitting a case is a compile error. C# switch expressions on non-sealed types require a discard arm _ => throw ... to handle unknown subtypes. The syntactic difference is case Circle circle -> in Java versus Circle circle => in C#.
Property patterns
class Main { record Order(String status, double total) {} static String classify(Order order) { return switch (order) { case Order o when "COMPLETED".equals(o.status()) && o.total() > 1000 -> "VIP order"; case Order o when "COMPLETED".equals(o.status()) -> "Regular order"; case Order o when "PENDING".equals(o.status()) -> "Pending order"; default -> "Unknown order"; }; } public static void main(String[] args) { System.out.println(classify(new Order("COMPLETED", 1500))); System.out.println(classify(new Order("PENDING", 50))); } }
Console.WriteLine(classify(new Order("COMPLETED", 1500))); Console.WriteLine(classify(new Order("PENDING", 50))); string classify(Order order) => order switch { { Status: "COMPLETED", Total: > 1000 } => "VIP order", { Status: "COMPLETED" } => "Regular order", { Status: "PENDING" } => "Pending order", _ => "Unknown order", }; record Order(string Status, double Total);
C# property patterns { Property: value } match against property values directly in the switch arm, without binding a variable first. Java does not have property patterns — it uses when guard clauses that access properties through a bound variable. C# property patterns are more concise for multi-condition checks and compose naturally with nested patterns.
Deconstruction and tuple patterns
class Main { record Point(int x, int y) {} public static void main(String[] args) { var point = new Point(3, 4); // Java: access fields individually by calling accessor methods int xCoord = point.x(); int yCoord = point.y(); System.out.println("x=" + xCoord + ", y=" + yCoord); } }
var point = new Point(3, 4); var (xCoord, yCoord) = point; // deconstruction assignment — auto-generated by record Console.WriteLine($"x={xCoord}, y={yCoord}"); // Positional patterns in switch deconstruct the record inline string quadrant = point switch { ( > 0, > 0) => "first", ( < 0, > 0) => "second", ( < 0, < 0) => "third", _ => "other", }; Console.WriteLine(quadrant); record Point(int X, int Y);
C# records auto-generate Deconstruct methods for positional properties, enabling destructuring assignment var (x, y) = point. Positional patterns in switch expressions match against the deconstructed values directly. Java records have accessor methods (point.x()) but no destructuring assignment syntax or positional patterns — each field must be accessed individually by name.
Error Handling
No checked exceptions
class Main { // Java: methods that throw checked exceptions must declare them with 'throws' static void riskyOperation() throws java.io.IOException { // The compiler forces every caller to handle or propagate this exception throw new java.io.IOException("Something went wrong"); } public static void main(String[] args) { try { riskyOperation(); } catch (java.io.IOException error) { System.out.println("Caught: " + error.getMessage()); } } }
// C# has no checked exceptions — every exception is unchecked void riskyOperation() // no 'throws' declaration — not required or supported { throw new IOException("Something went wrong"); // IOException is unchecked } try { riskyOperation(); } catch (IOException error) { Console.WriteLine($"Caught: {error.Message}"); }
Java's checked exceptions — where the compiler forces callers to handle or declare exceptions — are one of the language's most controversial design decisions. C# deliberately omitted checked exceptions: every exception is unchecked, as in Python and Ruby. This simplifies API design (no throws propagation chains) but means callers must know from documentation what a method might throw. Anders Hejlsberg (C#'s designer) argued that checked exceptions don't scale well in large systems.
using statement vs try-with-resources
class Main { public static void main(String[] args) throws java.io.IOException { // Java: try-with-resources closes AutoCloseable resources automatically try (var writer = new java.io.StringWriter()) { writer.write("Hello, World!"); System.out.println(writer); } // writer.close() called here — even if an exception is thrown } }
// C# using declaration — disposes the resource at end of the enclosing scope (C# 8+) using var writer = new System.IO.StringWriter(); writer.Write("Hello, World!"); Console.WriteLine(writer); // writer.Dispose() called here — equivalent to Java's try-with-resources
Java's try-with-resources closes AutoCloseable resources at the end of the block. C# has two forms: the block form using (var resource = ...) { } and the declaration form using var resource = ... (C# 8+), which disposes the resource at the end of the enclosing scope. The declaration form eliminates one level of nesting for the common single-resource case.
Exception filters — when
class Main { static void processRequest(int errorCode) throws Exception { throw new Exception("Request failed: " + errorCode); } public static void main(String[] args) { // Java has no exception filters — catch everything and check manually try { processRequest(503); } catch (Exception error) { if (error.getMessage().contains("503")) { System.out.println("Service unavailable, retrying: " + error.getMessage()); } else { throw new RuntimeException(error); // rethrow (alters stack trace) } } } }
void processRequest(int errorCode) => throw new Exception($"Request failed: {errorCode}"); try { processRequest(503); } catch (Exception error) when (error.Message.Contains("503")) // filter evaluated before catching { Console.WriteLine($"Service unavailable, retrying: {error.Message}"); } catch (Exception error) { Console.WriteLine($"Unhandled error: {error.Message}"); }
C# catch (Exception e) when (condition) evaluates the filter before entering the catch block — if the condition is false, the runtime continues up the call stack as if the catch did not exist, without unwinding the stack. Java has no exception filter syntax; developers catch and rethrow, which changes the stack trace origin. The when filter preserves the original stack frame, making debugging easier.
Custom exceptions
class Main { static class InsufficientFundsException extends RuntimeException { private final double shortfall; InsufficientFundsException(double shortfall) { super("Insufficient funds: need $" + shortfall + " more"); this.shortfall = shortfall; } double getShortfall() { return shortfall; } } static void withdraw(double balance, double amount) { if (amount > balance) throw new InsufficientFundsException(amount - balance); System.out.println("Withdrew $" + amount); } public static void main(String[] args) { try { withdraw(50.0, 100.0); } catch (InsufficientFundsException error) { System.out.println(error.getMessage()); } } }
void withdraw(double balance, double amount) { if (amount > balance) throw new InsufficientFundsException(amount - balance); Console.WriteLine($"Withdrew ${amount}"); } try { withdraw(50.0, 100.0); } catch (InsufficientFundsException error) { Console.WriteLine(error.Message); Console.WriteLine($"Shortfall: ${error.Shortfall}"); } // C# 12 primary constructor syntax keeps custom exceptions concise class InsufficientFundsException(double shortfall) : Exception($"Insufficient funds: need ${shortfall} more") { public double Shortfall { get; } = shortfall; }
Custom exceptions in C# inherit from Exception. C# 12 primary constructor syntax reduces the boilerplate significantly — the base class call, field assignment, and property definition fit in a few lines. In Java, custom runtime exceptions extend RuntimeException to avoid being checked; in C# all exceptions are unchecked, so any subclass of Exception works.
Async & Await
async / await
class Main { // Java: CompletableFuture for async — no native async/await keywords static java.util.concurrent.CompletableFuture<String> fetchDataAsync() { return java.util.concurrent.CompletableFuture.completedFuture("data from server"); } public static void main(String[] args) throws Exception { // .get() blocks until the future resolves — Java's closest to await String result = fetchDataAsync().get(); System.out.println(result); } }
// C#: async methods return Task or Task<T>; await suspends without blocking a thread static async Task<string> FetchDataAsync() { await Task.Delay(1); // simulate 1 ms of async work return "data from server"; } string result = await FetchDataAsync(); Console.WriteLine(result);
C# has first-class async/await built into the language since C# 5 (2012). An async method returns Task (no result) or Task<T> (a value). Java introduced CompletableFuture in Java 8 and virtual threads in Java 21, but there is no async/await syntax — composition uses .thenApply, .thenCompose, and .get() for blocking. The C# compiler transforms await into a state machine transparently, making async code look and read like synchronous code.
Task.WhenAll — parallel async tasks
class Main { static java.util.concurrent.CompletableFuture<String> fetchUser(int userId) { return java.util.concurrent.CompletableFuture.completedFuture("User " + userId); } public static void main(String[] args) throws Exception { var future1 = fetchUser(1); var future2 = fetchUser(2); java.util.concurrent.CompletableFuture.allOf(future1, future2).get(); // allOf returns void — must query each future individually for its result System.out.println(future1.get()); System.out.println(future2.get()); } }
static Task<string> FetchUser(int userId) => Task.FromResult($"User {userId}"); var task1 = FetchUser(1); var task2 = FetchUser(2); // WhenAll awaits all tasks in parallel and returns results in the same order string[] results = await Task.WhenAll(task1, task2); foreach (var userResult in results) Console.WriteLine(userResult);
Task.WhenAll awaits multiple tasks concurrently and returns an array of results in input order. Java's CompletableFuture.allOf awaits all completions but returns void — each future must be queried individually for its result afterward. The C# idiom is more ergonomic: one await, one result array, with ordering guaranteed.
Cancellation — CancellationToken
class Main { public static void main(String[] args) { // Java: cooperative cancellation uses volatile flags or AtomicBoolean var cancelFlag = new java.util.concurrent.atomic.AtomicBoolean(true); if (cancelFlag.get()) { System.out.println("Task was canceled"); } else { System.out.println("Done"); } } }
// C#: CancellationToken flows through async calls — checked cooperatively using var tokenSource = new System.Threading.CancellationTokenSource(); tokenSource.Cancel(); // cancel immediately var token = tokenSource.Token; try { token.ThrowIfCancellationRequested(); // throws OperationCanceledException Console.WriteLine("Done"); } catch (OperationCanceledException) { Console.WriteLine("Task was canceled"); }
C# uses CancellationToken — a value type passed through async call chains — to signal cancellation cooperatively. Any operation that accepts a CancellationToken can be canceled without interrupting a thread. Java's approach uses thread interruption (Thread.interrupt()) or manual AtomicBoolean flags, which are coarser and harder to compose. The CancellationToken model integrates directly with Task.Delay, HTTP clients, and file I/O operations.