不是"Record 的构造函数搬到 class"——它是一套全新的参数捕获机制
public class OrderService
{
private readonly ILogger<OrderService> _logger;
private readonly IOrderRepo _repo;
private readonly IEmailService _email;
public OrderService(
ILogger<OrderService> logger,
IOrderRepo repo,
IEmailService email)
{
_logger = logger;
_repo = repo;
_email = email;
}
// ... 实际业务方法 ...
}
public class OrderService(
ILogger<OrderService> _logger,
IOrderRepo _repo,
IEmailService _email)
{
public async Task Place(Order o)
{
_logger.LogInfo("Placing...");
await _repo.SaveAsync(o);
await _email.SendAsync(o);
}
}
这不是"少写几行"的问题——是构造函数从代码的"结构噪音"变成了类型签名的一部分。读代码的人一眼看到"这个服务依赖三个东西",而不是跳过 10 行样板才看到业务逻辑。
你已经学过 Lesson 07(Records):record Person(string Name, int Age); 一行,编译器自动生成 Name、Age 两个 init-only 属性 + 构造函数 + Equals + Deconstruct……
C# 12 把 (参数) 这个语法也给了 class 和 struct——但编译器行为完全不同。如果你带着 Record 的预期去看 class 的主构造函数,一定会踩坑。
// ===== Record(C# 9+)=====
public record Person(string Name, int Age);
// ↑ Name 和 Age 是 init-only public 属性——外部可直接访问
// person.Name → "Alice" ✅
// ===== Class(C# 12)=====
public class Person(string name, int age)
{
// name 和 age 不是属性!外部访问不到。
// 你要暴露?自己写:
public string Name => name; // ← 手写属性,引用主构造参数
public int Age => age; // ← 同上
}
// new Person("Alice", 30).name → ❌ 编译错误:name 不存在
// new Person("Alice", 30).Name → ✅ "Alice" (手写的属性)
这个设计是有意为之——class 不是数据载体(那是 Record 的职责),class 是行为载体。它的参数通常是依赖(如 ILogger),不应该暴露为公开属性。
这是 Primary Constructor 最关键的内部机制。参数不一定被存为字段——编译器做逃逸分析:
public class Widget(string name, int width, int height, int depth)
: NamedItem(name) // ← name 只在这里出现 → 不捕获为字段
{
public int WidthInCM => width; // ← width 出现在属性体 → 捕获
public int HeightInCM => height; // ← height 出现在属性体 → 捕获
public int DepthInCM => depth; // ← depth 出现在属性体 → 捕获
public int Volume => width * height * depth;
}
// 到 SharpLab 验证——编译器生成的 IL 近似:
// .class public Widget extends NamedItem {
// .field private int32 '<width>P' // ← 编译器命名,不可在 C# 中直接引用
// .field private int32 '<height>P'
// .field private int32 '<depth>P'
//
// .method public ctor(string name, int32 width, int32 height, int32 depth) {
// ldarg.0; ldarg.1; call base(name) // name 直接传给 base,不存字段
// ldarg.0; ldarg.2; stfld '<width>P' // width 存入私有字段
// ldarg.0; ldarg.3; stfld '<height>P' // height 存入私有字段
// ldarg.0; ldarg 4; stfld '<depth>P' // depth 存入私有字段
// ret
// }
// // name 没有任何字段!因为它只出现在 base() 调用中
// }
| 参数在哪里被引用 | 编译器行为 | 有字段分配? |
|---|---|---|
仅 base(...) 或 this(...) | 直接在调用链中传递,不存字段 | ❌ 不分配 |
| 仅属性/字段初始化器 如 public string X { get; } = x; | 不存字段——初始化器在构造时执行,之后不需要 x | ❌ 不分配 |
| 方法体、属性 getter、实例成员 | 捕获为私有字段,IL 名如 <param>P | ✅ 分配 |
| 未被任何成员引用 | 编译警告——参数未被使用 | ❌ 不分配 |
public string Name { get; } = name;(注意是 { get; } =,不是 =>)只在构造函数执行时读一次 name,赋给属性自己的 <Name>k__BackingField。之后再也不需要 name 了——所以编译器不单独存它。public string Name => name;(表达式体属性),那 name 出现在 getter 方法体中,每次访问属性都会读 name——编译器必须把 name 捕获为字段。同理,在 ToString() 方法体里写 name 也一样捕获。
打开 sharplab.io,贴入上面的 Widget 代码,分别看:
name——编译结果多出一个 <name>P 字段这就是"编译器比你更精确"的体现——你不用操心内存,编译器帮你做逃逸分析。
ASP.NET Core 的 DI 容器不关心构造函数语法——它看到构造函数参数就解析依赖,不管参数写在 class 行还是构造函数体里。这意味着 Primary Constructor 和 DI 是天然搭档:
// ===== 典型的 Controller/Service:每个依赖减少 4 行样板 =====
// 之前(C# 11)——16 行,其中 10 行是样板
public class ProductsController : ControllerBase
{
private readonly IProductService _service;
private readonly IMapper _mapper;
private readonly ILogger<ProductsController> _logger;
public ProductsController(
IProductService service, IMapper mapper,
ILogger<ProductsController> logger)
{
_service = service; _mapper = mapper; _logger = logger;
}
public IActionResult Get(int id)
{
_logger.LogInformation("Get {Id}", id);
var dto = _mapper.Map<ProductDto>(_service.Get(id));
return Ok(dto);
}
}
// 现在(C# 12)——9 行,样板清零
public class ProductsController(
IProductService _service,
IMapper _mapper,
ILogger<ProductsController> _logger) : ControllerBase
{
public IActionResult Get(int id)
{
_logger.LogInformation("Get {Id}", id);
var dto = _mapper.Map<ProductDto>(_service.Get(id));
return Ok(dto);
}
}
_logger 不仅是命名约定——它告诉读者"这是被捕获的依赖字段"。在 Primary Constructor 中你直接写 _logger 作为参数名,编译器捕获它,类体内直接使用,完全没有命名转换——名字即是名字。
Primary Constructor 参数不是 readonly。你可以在方法体内修改它——但如果只有一个赋值点,编译器可能优化掉字段:
// ✅ 可以改——但改了之后参数的"依赖"语义就崩塌了
public class Counter(int start)
{
public int Next() => start++; // start 被修改 → 捕获为字段
}
// ⚠ 危险操作——参数成了可变状态
public class Service(IServiceProvider sp)
{
public T Resolve<T>() => sp.GetService<T>(); // ✅ 只读
// sp = null; ← 如果你写这句,代码审查会被拒绝
}
readonly 修饰主构造函数参数。你不能写 class Foo(readonly int x)。语言团队在 C# 13 中也没有加这个——还在讨论。readonly 关键词给了你编译器保证:一旦在构造函数中赋值,任何重新赋值都会报编译错误。Primary Constructor 丢掉了这个保证——你写了 _logger = null; 编译器也不会吭声。如果你必须要有 readonly 的编译器强制,目前只能退回传统构造函数写法。
有了主构造函数后,所有显式构造函数必须通过 this(...) 调用主构造函数——这是编译器的硬性要求,确保参数"一定被赋值":
public class BankAccount(string id, string owner)
{
public string Id { get; } = id;
public string Owner { get; } = owner;
public decimal Balance { get; private set; }
// 无参构造:给主构造参数提供默认值
public BankAccount() : this("0000", "Unknown") { }
// 带初始余额的构造:必须先 this(id, owner),再做额外工作
public BankAccount(string id, string owner, decimal balance)
: this(id, owner)
{
Balance = balance;
}
}
new BankAccount()?必须像上面那样显式写一个带 this(...) 的无参构造。
这不是 C# 编译器的特殊待遇,根源在 CLR 类型系统。
var p = default(Point); // 所有字段零初始化,不调用任何构造函数
Point[] arr = new Point[100]; // 100 个 Point,全是 default,没调过构造函数
CLR 的 initobj 指令直接把内存块清零,根本不过构造函数。struct 必须在这个前提下存活,所以运行时强制要求无参构造函数存在——不管 C# 语法层面你写不写。C# 10 之前甚至不允许你自己写 struct 的无参构造(因为写了也可能被绕过),C# 10 才放开,但语义未变:default 和数组初始化依然不会调用它。
new 的诞生路径var s = default(OrderService); // null——根本没有对象诞生
class 对象的诞生只有一条路:new。编译器的逻辑是:
new T())这是 C# 1.0 就有的规则。主构造函数不过是"另一种写法"的构造函数——编译器看到它的反应跟你写了一个带参构造函数完全一样。
| struct | class | |
|---|---|---|
default(T) | 零初始化的值 | null |
| 无参构造 | CLR 类型系统强制存在 | 编译器看你有没有写构造 |
| 主构造函数影响? | 不影响——CLR 兜底 | 是的——编译器认为你写了构造 |
struct 的无参构造是运行时的承诺,class 的无参构造是编译器的便利。
public struct Point(double x, double y)
{
public double X => x;
public double Y => y;
}
var p1 = new Point(3, 4); // x=3, y=4
var p2 = new Point(); // x=0, y=0——struct 始终有无参构造,初始化为 default
// 注意:p2 中主构造参数被初始化为 default(double) = 0,不是主构造里传的值
第六节讲了 this(...)——同一个类内部的构造函数链。base(...) 是跨类的链。规则只有一条:
base(...) 把基类主构造函数所需的参数传上去。编译器不允许你跳过。
// 基类有主构造函数——species 可能被基类捕获为字段
public class Animal(string species)
{
public string Species => species;
}
// 派生类——必须 base(...) 传参
public class Dog(string name) : Animal("Canine") // 硬编码
{
public string Name => name;
}
public class Cat(string species, string name) : Animal(species) // 透传
{
public string Name => name;
}
public class Bird(string species, string name) : Animal(species)
{
public string SpeciesName => species; // species 在这里被引用 → Bird 捕获
public string Name => name; // name 被引用 → Bird 捕获
}
species 出现了两次:一次在 base(species) 中——传给基类构造函数;一次在 SpeciesName 的 getter 中——被 Bird 自己的成员引用。编译器为 Bird 生成一个 <species>P 字段来捕获它。至于基类 Animal——它收到的 species 值来自 base(species) 调用,Animal 内部是否捕获、是否生成字段,是 Animal 自己的事,跟 Bird 无关。
public class Animal(string name) // ← 基类的 name
{
public string Name => name;
}
public class Dog(string name) : Animal(name) // ← 派生类的 name——同名!
{
public string DisplayName => name; // 这是 Dog 的 name
}
两个 name 各是各的。base(name) 把 Dog 的 name 传给 Animal 的构造函数。编译器为 Animal 生成一个 <name>P 字段,为 Dog 生成另一个 <name>P 字段——两个字段,两个存储位置。代码能跑,但读者要花额外心智去分辨哪个 name 是哪个。
name,派生类用 displayName。省下的几个字符不值得后续的阅读成本。
适合——派生类只透传依赖,自己不添加新参数:
public class AnimalService(ILogger<AnimalService> logger) : BaseService(logger)
{
// logger 只透传给基类,AnimalService 自己的成员没有引用 → 不捕获
public void DoWork() { /* ... */ }
}
不适合——基类和派生类各有多个参数,继承链 ≥ 2 层:
// 😣 可读性灾难——哪些参数属于哪一层?
public class PremiumCustomerService(
ILogger<PremiumCustomerService> _logger,
ICustomerRepo _repo,
IPremiumCalculator _calc,
IEmailService _email
) : BaseCustomerService(_logger, _repo)
{
// _logger 和 _repo 去了基类,"剩下的"是这层的——但读代码的人能立刻分清吗?
public void Process(Customer c)
{
_calc.Calculate(c); // _calc 是这层自己的
_email.SendAsync(c); // _email 也是这层的
// _logger 和 _repo 已被基类捕获——这层还能访问它们吗?
// 能——如果基类把它们暴露为 protected 属性的话。但主构造函数参数默认是 private!
}
}
protected 属性或字段。这一点跟传统构造函数完全一样——但用主构造函数时更容易忽略,因为你看不到字段声明。
主构造函数本质上是一个方法——你可以用 method: 目标给它加 Attribute:
// [Obsolete] 放在 class 上 → 标记类本身过时
[Obsolete("Use V2")]
public class OldService(IDb db) { }
// 用 method: 目标 → 标记的是构造函数本身
[method: Obsolete("Use V2 ctor")]
public class OldService(IDb db) { }
// 实际应用——JetBrains 注解标记构造方法的纯函数性:
[method: JetBrains.Annotations.Pure]
public class MyService(IDb db) { }
readonly 字段。// ⚠ 反面例子:参数太多,还不如传统写法清晰
public class ReportGenerator(
IDbContext _db, IMapper _mapper, IEmailService _email,
ITemplateEngine _engine, IConfiguration _config,
ILogger<ReportGenerator> _logger)
{
// 这行长度已经超过了合理的可读范围
// 但这种依赖数可能提示另一个问题——这个类是否违反了 SRP?
}
[1, 2, 3] 一个语法统一数组、List、Span。