Every C# type is either a value type or a reference type, and that decides what happens when you assign, pass, or compare values. This chapter covers that split, classes, structs, records, properties, common collections, and null safety.
Value Types vs Reference Types
Built-in types such as int, double, bool, decimal, and DateTime, along with any struct or enum, are value types: the variable holds the data itself, so assignment copies it. Classes, string, arrays, and record classes are reference types: the variable holds a reference to an object, so assignment makes two variables point at the same object.
var p1 = new PointStruct { X = 1 };
var p2 = p1; // copies the value
p2.X = 99;
Console.WriteLine(p1.X); // 1
var c1 = new PointClass { X = 1 };
var c2 = c1; // copies the reference
c2.X = 99;
Console.WriteLine(c1.X); // 99
struct PointStruct { public int X; }
class PointClass { public int X; }
string is a reference type, but it is immutable and == compares contents, so it behaves like a value. Use structs for small, immutable data such as coordinates; classes are the default otherwise.
Classes and Properties
A class bundles data (fields and properties) with behavior (methods). Public data is exposed through properties rather than raw fields. A property has get and set accessors, so you can add validation later without breaking any calling code.
var account = new Account("Dana") { Number = "110-123-456" };
account.Deposit(500m);
Console.WriteLine($"{account.Owner}: {account.Balance:F2}");
public class Account
{
private decimal _balance;
public Account(string owner) => Owner = owner;
public string Owner { get; } // read-only property
public string? Memo { get; set; } // auto-implemented property
public required string Number { get; init; } // must be set at creation, then frozen
public decimal Balance => _balance; // computed property
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount), "Deposit must be positive.");
_balance += amount;
}
}
An init accessor allows assignment only during object initialization (the { Number = ... } part), and required turns a forgotten assignment into a compile error. Notice that the statements come first and the class declaration follows: in a file with top-level statements, type declarations must come after the statements.
Records
Records are a concise way to declare types whose main job is carrying data. A positional record gets a constructor, read-only properties, value-based equality, and a readable ToString for free.
var a = new Product("Mouse", 25.00m);
var b = new Product("Mouse", 25.00m);
Console.WriteLine(a == b); // True: same contents means equal
Console.WriteLine(a); // Product { Name = Mouse, Price = 25.00 }
var onSale = a with { Price = 19.99m }; // copy with one property changed
public record Product(string Name, decimal Price);
A with expression leaves the original untouched and returns a new record with the listed properties replaced. Plain record declares a reference type; write record struct when you want a value type instead.
Collections
The workhorses live in System.Collections.Generic: List<T> and Dictionary<TKey, TValue>. The type parameters in angle brackets let the compiler check what goes in and what comes out.
List<string> langs = ["C#", "F#", "Rust"]; // collection expression
langs.Add("Go");
langs.Remove("F#");
Console.WriteLine($"{langs.Count} items, first: {langs[0]}");
var stock = new Dictionary<string, int>
{
["apple"] = 10,
["pear"] = 4,
};
stock["plum"] = 7; // adds or overwrites
if (stock.TryGetValue("pear", out var pears))
Console.WriteLine($"Pears in stock: {pears}");
foreach (var (fruit, qty) in stock)
Console.WriteLine($"{fruit} = {qty}");
Reading a missing key with the indexer throws KeyNotFoundException, so use TryGetValue when a key may be absent. Other handy collections include HashSet<T> for unique items, Queue<T> for first-in-first-out, and Stack<T> for last-in-first-out. Fixed-size data fits naturally in an array such as int[].
Nullable Reference Types
With <Nullable>enable</Nullable> in the project file, the compiler tracks whether reference-type variables might be null. Plain string means "never null", while string? explicitly allows null. Using a maybe-null value without checking it produces a warning.
string? FindNickname(int userId) => userId == 1 ? "Sky" : null;
string? nick = FindNickname(2);
Console.WriteLine(nick?.Length); // null if nick is null, no exception
Console.WriteLine(nick ?? "(no nickname)"); // fallback value
nick ??= "Guest"; // assign only when null
if (nick is not null)
Console.WriteLine(nick.ToUpper()); // compiler knows nick is non-null here
int? age = null; // nullable value type
Console.WriteLine(age.HasValue ? age.Value : -1);
?. skips the member access when the target is null, ?? supplies a default, and ??= fills in a value only if one is missing. When you are certain a value isn't null, the null-forgiving operator ! silences the warning, but it performs no runtime check, so use it sparingly.
Summary
- Assigning a value type copies the data; assigning a reference type copies the reference.
- Classes combine properties and methods, and
init and required express how objects must be created. - Records give you value-based equality and non-destructive updates with
with. List<T> and Dictionary<TKey, TValue> cover most needs; use TryGetValue for safe lookups.- Nullable reference types, together with
string?, ?., and ??, catch many null bugs at compile time.
0 comments
Sign in · Sign in to leave a comment.