<< Home

Chapter 7: Objects, Classes, and Structs

C# is an object-oriented language with pragmatic features that help you model real systems. In this chapter you define types with class and struct, add state with fields and properties, provide behavior with methods and indexers, and apply modern features such as object initializers, deconstruction, and tuples. By the end you will be comfortable choosing between classes and structs and using encapsulation to keep your code robust.

Defining classes and fields

A class defines a reference type. You use it to model entities that have identity and often longer lifetimes. Fields hold raw data inside the type; in most designs fields are private and surfaced through properties. Start with the smallest viable shape and evolve as behavior becomes clear.

Declaring a basic class

At minimum a class has a name and an accessibility modifier. You can add members such as fields, methods, and properties as needed.

public class Person {
  private string _firstName;
  private string _lastName;

  public string FullName() { return _firstName + " " + _lastName; }
}

Field design and accessibility

Prefer private fields with clear names. Expose data through properties to maintain invariants. Use const for compile-time constants and static readonly for runtime constants.

public class Meter {
  public const double InchesPerMeter = 39.3700787;
  private readonly double _value;

  public Meter(double value) { _value = value; }
}
💡 Use the underscore prefix convention for private fields; it is widely adopted in C# codebases.

Choosing between fields and properties

Fields are implementation details; properties are part of the public contract. Switching a field to a property is a breaking change for consumers, so default to properties for public or protected data.

public class Customer {
  // Bad public field; callers can assign invalid values.
  // public int Age;

  // Good property; you can validate inside set.
  private int _age;
  public int Age {
    get { return _age; }
    set {
      if (value < 0) throw new ArgumentOutOfRangeException(nameof(value));
      _age = value;
    }
  }
}

Constructors and object initializers

Constructors establish valid state. C# also supports object initializers which set properties directly after construction. Combine them to keep required settings enforced and optional settings flexible.

Declaring constructors

Constructors share the type name and cannot return a value. Use the shortest constructor that guarantees validity; chain with this(...) to avoid duplication.

public class ConnectionOptions {
  public string Host { get; }
  public int Port { get; }

  public ConnectionOptions(string host) : this(host, 443) {}
  public ConnectionOptions(string host, int port) {
    Host = host ?? throw new ArgumentNullException(nameof(host));
    Port = port;
  }
}

Object initializers for clarity

Object initializers assign properties and fields immediately after construction. They improve readability when there are many optional members.

var req = new HttpRequest("GET", "/") {
  Timeout = TimeSpan.FromSeconds(10),
  Headers = { ["Accept"] = "application/json" }
};

Primary constructors for concise models

In modern C#, you can declare primary constructors on classes to capture constructor parameters and use them directly in member initializers and the body. This keeps data-carrying types concise.

public class Rectangle(double width, double height) {
  public double Width { get; } = width;
  public double Height { get; } = height;
  public double Area() { return Width * Height; }
}
⚠️ Keep required invariants in the constructor or in property setters; do not rely on object initializers to rescue invalid state.

Properties, indexers, and encapsulation

Properties wrap fields with controlled access. Indexers generalize property access with a parameter list, which lets your type feel like a collection. Use get, set, and init accessors to formalize mutability.

Auto-implemented properties and validation

Auto-implemented properties are concise. When you need validation, expand them to backed properties with a private field.

public class Product {
  public string Sku { get; init; } = "";
  private decimal _price;
  public decimal Price {
    get { return _price; }
    set {
      if (value < 0m) throw new ArgumentOutOfRangeException(nameof(value));
      _price = value;
    }
  }
}

Read-only and init-only patterns

Use read-only properties for values that must not change. Use init to allow assignment during initialization only.

var p = new Product { Sku = "ABC-123", Price = 9.99m };
// p.Sku = "XYZ"; // Not allowed after initialization when using init

Indexers for collection-like access

An indexer is declared with the this keyword and parameters. It allows clients to use bracket syntax. Validate indices and keys to protect invariants.

public class Settings {
  private readonly Dictionary<string,string> _values = new();
  public string this[string key] {
    get { return _values.TryGetValue(key, out var v) ? v : ""; }
    set { _values[key] = value; }
  }
}
💡 Favor properties for cheap operations; reserve methods for work that clearly performs an action or could be expensive.

Structs, readonly members, and performance

A struct defines a value type that is typically small and immutable. Value types are copied by value, which can be faster and can reduce allocations. Keep structs small; large structs increase copying costs.

Declaring a struct

Use structs for small mathematical types, coordinates, and identifiers. Prefer immutability to avoid surprising copy semantics.

public readonly struct Point2D {
  public double X { get; }
  public double Y { get; }
  public Point2D(double x, double y) { X = x; Y = y; }
  public Point2D Offset(double dx, double dy) { return new Point2D(X + dx, Y + dy); }
}

readonly and defensive design

Mark the struct or members as readonly to prevent accidental mutation and to enable optimizations. A readonly struct guarantees that instance members do not modify state.

public readonly struct Angle {
  public double Radians { get; }
  public Angle(double radians) { Radians = radians; }
  public double Degrees() { return Radians * (180.0 / Math.PI); }
}

When to choose a class instead

Choose a class when the type is large, mutable, or widely shared. Classes avoid repeated copies and allow polymorphism through inheritance and interfaces.

⚠️ Avoid putting parameterless public constructors on structs that represent arbitrary numeric values; uninitialized default values can be misleading. Prefer explicit construction that makes the state clear.

Partial types and file-scoped namespaces

Partial types let you split a type across files. This is helpful when code is generated or when separating concerns. File-scoped namespace declarations shorten indentation and improve readability in single-namespace files.

Declaring a partial type

Use the partial modifier on each part. The compiler merges parts at build time. Keep member visibility consistent and avoid conflicting definitions.

public partial class ApiClient {
  public Task ConnectAsync() { /* … */ return Task.CompletedTask; }
}

// In another file
public partial class ApiClient {
  public Task DisconnectAsync() { /* … */ return Task.CompletedTask; }
}

Using file-scoped namespace

With file-scoped namespaces you declare the namespace once at the top of the file followed by a semicolon; the rest of the file belongs to that namespace without braces.

namespace Contoso.Networking;

public class Packet { /* … */ }
public class Frame { /* … */ }
💡 Combine file-scoped namespaces with one-type-per-file to keep diffs small and navigation simple.

Generated code and partial methods

Generated code often uses partial types and partial methods so that you can extend behavior without touching the generated file. Partial methods can be declared without an implementation; if unused they are removed at compile time.

public partial class Model {
  partial void OnValidated();

  public void Validate() {
    // validate …
    OnValidated();
  }
}

// Your extension file
public partial class Model {
  partial void OnValidated() { Console.WriteLine("Model validated"); }
}

Deconstruction and tuples

Tuples provide lightweight groupings of values. Deconstruction unpacks a type into separate variables. Together they let you return multiple values without defining a dedicated type, while keeping code readable.

Working with (...) tuples

Create tuples with parentheses and optional element names. Access elements by name or by position. Tuples are value types with structural equality.

(int code, string message) result = (200, "OK");
Console.WriteLine(result.code);     // 200
Console.WriteLine(result.message);  // OK

Returning multiple values

Functions can return a tuple to provide several results in one call. Choose clear element names to improve readability.

public static (bool found, string value) TryGet(IDictionary<string,string> map, string key) {
  if (map.TryGetValue(key, out var v)) return (true, v);
  return (false, "");
}

Deconstruction patterns

Deconstruction assigns tuple elements to individual variables. You can also add a Deconstruct method to your own types to enable the same syntax.

var (ok, value) = TryGet(new Dictionary<string,string> { ["a"] = "alpha" }, "a");
if (ok) Console.WriteLine(value);

public class Range {
  public int Start { get; }
  public int End { get; }
  public Range(int start, int end) { Start = start; End = end; }
  public void Deconstruct(out int start, out int end) { start = Start; end = End; }
}

var r = new Range(10, 20);
var (s, e) = r; // uses Deconstruct