当程序只有几十行时,数据放在变量里、逻辑放在方法里通常够用。规模一旦增长,真正难的不是再写一个 if,而是回答三个问题:谁拥有这份状态?谁有权修改它?修改后必须继续满足哪些规则?
C# 的类把相关状态与行为组织为一个明确的责任单元。本章会沿着一个账户案例逐步搭建类、对象、构造函数、属性、封装、访问控制、实例与静态成员,并进一步理解引用身份、可空引用、record、required、init 和主构造函数。

本章示例以 .NET 8 / C# 12 语法为基准。若项目使用旧语言版本,
required、集合表达式[]和主构造函数可能需要改写。
你将按下面的顺序建立完整心智模型:
new 创建实例。null 风险。record。学习时不要只记关键词。每看到一个成员,都问自己:“它属于哪个对象?谁能访问?什么时候允许改变?”
类定义对象拥有哪些状态和行为;对象是运行时真正存在的实例。下面的 Member 类描述会员,而两个 new 表达式创建两个互不相同的对象。
public sealed class Member
{
public Member(string name)
{
Name = string.IsNullOrWhiteSpace(name)
? throw new ArgumentException("姓名不能为空。", nameof(name))
: name.Trim();
}
public string Name { get; }
public int Points { get; private set; }
public void Earn(int points)
{
if (points <= 0)
throw new ArgumentOutOfRangeException(nameof(points));
Points += points;
}
public string Describe() => $"{Name}: {Points} 分";
}
var xiaoke = new Member(" 小可 ");
var xiaohu = new Member("小虎");
xiaoke.Earn(20);
xiaohu.Earn(5);
Console.WriteLine(xiaoke.Describe());
Console.WriteLine(xiaohu.Describe());输出:
小可: 20 分
小虎: 5 分Member 只定义规则,本身不是某个具体会员。xiaoke 与 xiaohu 分别引用不同实例,因此各自保存一份 Points。sealed 表示暂不允许其他类继承它,让本章先聚焦对象自身;继承留到后续章节。
构造函数在 new 时运行,最适合完成“创建对象不可缺少”的验证和初始化。让错误尽早发生,比允许一个坏对象在系统里传播更容易定位。
public sealed class BankAccount
{
public BankAccount(string owner, decimal openingBalance)
{
if (string.IsNullOrWhiteSpace(owner))
throw new ArgumentException("户主不能为空。", nameof(owner));
if (openingBalance < 0)
throw new ArgumentOutOfRangeException(
nameof(openingBalance),
此时 new BankAccount("", 100) 和 new BankAccount("小可", -1) 都会立即失败,调用方拿不到无效实例。构造函数应保持轻量、确定;联网、读大文件、等待用户输入等昂贵或可能长期失败的工作,通常更适合放在异步工厂或显式方法中。
当参数含义容易混淆时,可以用命名工厂提升调用点的可读性:
public sealed class Temperature
{
private Temperature(double celsius) => Celsius = celsius;
public double Celsius { get; }
public static Temperature FromCelsius(double value) => new(value);
public static Temperature FromFahrenheit(double value) =>
输出:
37.0 °C字段是真正的存储位置,通常保持 private;属性是类型对外公布的读取或写入契约。自动属性由编译器生成隐藏的后备字段,适合没有额外访问逻辑的状态。
public sealed class Product
{
private decimal _price;
public required string Name { get; init; }
public string? Description { get; init; }
public decimal Price
{
get => _price;
init => _price = value >= 0
输出:
机械键盘 / ¥399.00required 要求调用方在创建时提供成员值,init 允许在构造阶段或对象初始化器中赋值,之后不能普通改写。Label 是计算属性,不额外保存重复状态。注意:required 主要是编译期完整性检查,像“价格必须非负”这样的运行时规则仍需验证。

封装不是简单地把字段写成 private,而是只开放能够维持业务规则的操作。继续完善账户:余额不能为负,交易必须留下记录。
public sealed class BankAccount
{
private readonly List<decimal> _transactions = [];
public BankAccount(string owner, decimal openingBalance)
{
if (string.IsNullOrWhiteSpace(owner))
throw new ArgumentException("户主不能为空。", nameof(owner));
if (openingBalance <
调用方不能执行 account.Balance = -999,也拿不到可随意 Add 的内部列表。它只能通过 Deposit 与 TryWithdraw 表达意图,对象则集中执行规则。
默认从最小可见范围开始,确有调用需求再放宽。可见性越小,未来替换内部实现的自由度通常越高。

实例成员属于某个对象;静态成员属于类型本身,整个进程中的同一加载上下文通常共享一份。
public sealed class BankAccount
{
public BankAccount(string owner)
{
Owner = owner;
Id = ++_lastId;
}
private static int _lastId;
public int Id { get; }
public string Owner { get; }
public decimal Balance
输出:
1, 2
2first.Balance 和 second.Balance 各属于一个对象,而 _lastId 与 CreatedCount 属于 BankAccount 类型。静态状态会让测试隔离、并发和生命周期更复杂;适合无状态工具、常量式数据或明确的进程内共享信息,不应被当作“随手可用的全局变量”。并发递增计数时还要考虑 Interlocked.Increment 等同步机制。
类是引用类型。给另一个变量赋值时,复制的是引用,因此两个变量可能指向同一个对象。
public sealed class Profile
{
public string DisplayName { get; set; } = "";
}
var a = new Profile { DisplayName = "小可" };
var b = a;
b.DisplayName = "小虎";
Console.WriteLine(a.DisplayName);
Console.WriteLine(ReferenceEquals输出:
小虎
True这叫“别名”:a 与 b 是两个变量,却通向同一对象。它不是自动复制对象;如需独立副本,必须定义明确的复制策略。
现代项目通常启用可空引用类型。string 表达“按设计不为 null”,string? 表达“缺少值是合法状态”。
public sealed class Contact
{
public required string Name { get; init; }
public string? Email { get; init; }
public string EmailDomain => Email is null
? "(未提供)"
: Email[(Email.IndexOf('@') + 1)..
编译器会根据 is null、is not null 等分支做流分析。?. 用于安全成员访问,?? 用于提供后备值。! 只是让编译器停止警告,不会在运行时把 null 变成有效对象,应当谨慎使用。
当一个类型主要表达数据值,并希望按内容比较时,record 往往比普通类更贴切。
public sealed record ShippingAddress(
string City,
string Street,
string PostalCode);
var home = new ShippingAddress("上海", "示例路 8 号", "200000");
var same = new ShippingAddress("上海", "示例路 8 号", "200000");
var office = home
输出形如:
True
ShippingAddress { City = 上海, Street = 协作路 20 号, PostalCode = 200000 }普通类默认更强调对象身份;record 默认提供基于成员的值相等、可读的 ToString、解构和 with 非破坏性复制。它很适合 DTO、消息、配置和值对象,但不自动保证深度不可变:如果成员是可变列表,列表内容仍可能被修改。
C# 12 还允许普通类使用主构造函数,适合参数少、初始化关系直接的类型:
public sealed class Distance(double meters)
{
public double Meters { get; } = meters >= 0
? meters
: throw new ArgumentOutOfRangeException(nameof(meters));
public double Kilometers => Meters / 1000;
}主构造函数参数不是自动属性。是否公开、如何验证、是否保存,仍要由类型设计明确决定。

public set。this.Property 明确指向成员;更重要的是避免把未验证参数直接写入状态。readonly 字段表示字段引用只能在声明或构造阶段赋值,不代表它指向的对象不可变。IReadOnlyList<T>、副本或领域操作方法。null! 或随处使用 ! 来“消灭警告”;先确认对象为何可能缺失。TryXxx 适合失败是正常分支的操作;参数违反编程契约时使用异常更清楚。下面的失败示例看似方便,却绕开了账户边界:
public decimal Balance { get; set; }
// 任何调用方都能写出:
account.Balance = -1_000_000;修复不是“再提醒调用方小心”,而是从类型设计上移除非法入口。
请实现 Wallet,并依次完成这些约束:
owner 与非负初始余额,非法输入立即拒绝。Owner 创建后不可修改,Balance 只能在类内部修改。AddMoney 只接受正数。TrySpend 在金额无效或余额不足时返回 false,且不改变状态。List<decimal>。record Transaction(string Kind, decimal Amount) 替换裸 decimal 日志。建议至少验证以下测试:初始余额为负、存入零、超额消费、消费成功、别名修改、记录不可被外部追加。每个测试都应明确“操作前状态、行为、操作后状态”。
类不是装变量的盒子,而是一个有边界的责任单元。构造函数保证初始有效,字段保存内部细节,属性提供稳定契约,方法表达合法行为,访问修饰符则决定边界能否被绕开。
还要记住两组关键区别:实例成员属于对象,静态成员属于类型;普通类通常强调引用身份,record 更适合表达值。可空引用分析让“可能缺失”成为类型契约的一部分,而 required、init 与主构造函数让现代 C# 能更简洁地表达初始化规则。
学完本章,你应该能看见一段类代码背后的设计:什么状态必须存在、什么变化被允许、什么入口应当隐藏。下一步再学习继承、接口和多态时,这套边界意识仍然是判断设计质量的基础。