Lesson 14: C# 12 — 主构造函数 Primary Constructors

不是"Record 的构造函数搬到 class"——它是一套全新的参数捕获机制

前置:已完成 Lesson 07(C# 9 Records)——理解 Record 的 positional 参数和编译器自动生成属性。
C# 12 ↔ .NET 8 LTS LTS2023.11 发布,三年支持。本课只讲 Primary Constructors——C# 12 的 Collection Expressions、ref readonly、Inline Arrays 等将在后续课程中逐一深入。
阅读:.NET Blog: Announcing C# 12 · Microsoft Learn: C# 12

一、这 7 行,你写过多少次?

😣 C# 11:构造函数样板

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;
    }
    // ... 实际业务方法 ...
}

😎 C# 12:声明即注入

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 行样板才看到业务逻辑。

二、第一性原理——它不是 Record 的那套

你已经学过 Lesson 07(Records)record Person(string Name, int Age); 一行,编译器自动生成 NameAge 两个 init-only 属性 + 构造函数 + Equals + Deconstruct……

C# 12 把 (参数) 这个语法也给了 class 和 struct——但编译器行为完全不同。如果你带着 Record 的预期去看 class 的主构造函数,一定会踩坑。

核心差异:Record 的主构造函数生成 public property。Class/struct 的主构造函数什么属性都不生成——参数只是"类的全局变量",编译器按需捕获为私有字段。
// ===== 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),不应该暴露为公开属性。

三、捕获语义 Capture Semantics——编译器什么时候分配字段?

这是 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 代码,分别看:

  1. 无成员引用 name 时——编译结果中只有 width/height/depth 三个字段
  2. 在 ToString() 中加一句 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;  ← 如果你写这句,代码审查会被拒绝
}
当前限制:C# 12 不支持 readonly 修饰主构造函数参数。你不能写 class Foo(readonly int x)。语言团队在 C# 13 中也没有加这个——还在讨论。
实践建议:把主构造函数参数当作 "构造后不可变" 来用——但这全靠纪律,编译器不兜底。传统 DI 写法中 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;
    }
}
重要:class 有主构造函数后,编译器不再生成隐式无参构造函数。你需要 new BankAccount()?必须像上面那样显式写一个带 this(...) 的无参构造。

struct 的例外——为什么 struct 永远有无参构造?

这不是 C# 编译器的特殊待遇,根源在 CLR 类型系统

struct 是值类型 → 可以不经构造函数诞生

var p = default(Point);        // 所有字段零初始化,不调用任何构造函数
Point[] arr = new Point[100];  // 100 个 Point,全是 default,没调过构造函数

CLR 的 initobj 指令直接把内存块清零,根本不过构造函数。struct 必须在这个前提下存活,所以运行时强制要求无参构造函数存在——不管 C# 语法层面你写不写。C# 10 之前甚至不允许你自己写 struct 的无参构造(因为写了也可能被绕过),C# 10 才放开,但语义未变:default 和数组初始化依然不会调用它。

class 是引用类型 → 没有不经 new 的诞生路径

var s = default(OrderService);  // null——根本没有对象诞生

class 对象的诞生只有一条路:new。编译器的逻辑是:

这是 C# 1.0 就有的规则。主构造函数不过是"另一种写法"的构造函数——编译器看到它的反应跟你写了一个带参构造函数完全一样。

一句话

structclass
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)Dogname 传给 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!
    }
}
重要陷阱:基类的主构造函数参数被捕获后,生成的是 private 字段。派生类无法直接访问基类捕获的字段。如果你需要派生类能用到基类的依赖,必须在基类中手动暴露为 protected 属性或字段。这一点跟传统构造函数完全一样——但用主构造函数时更容易忽略,因为你看不到字段声明。

八、给主构造函数加 Attribute

主构造函数本质上是一个方法——你可以用 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) { }

九、什么时候不应该用主构造函数?

  1. 构造函数体有复杂逻辑——主构造函数没有"函数体",初始化逻辑只能写在字段初始化器中。如果需要参数校验、抛出具体异常、或者多步骤初始化——用传统构造函数。
  2. 参数过多(4+)——class 声明行变得很长,反而降低可读性。传统构造函数有明确的函数体,可以格式化、加注释。
  3. 需要 readonly 保证——如果你必须确保某个依赖字段不被修改,目前只能靠传统构造函数 + readonly 字段。
  4. 继承层次中参数混乱——基类和派生类都有主构造函数时,参数到底是谁的?详情见第七节:每个类独立捕获、参数重名、派生类无法访问基类的 private 捕获字段。
// ⚠ 反面例子:参数太多,还不如传统写法清晰
public class ReportGenerator(
    IDbContext _db, IMapper _mapper, IEmailService _email,
    ITemplateEngine _engine, IConfiguration _config,
    ILogger<ReportGenerator> _logger)
{
    // 这行长度已经超过了合理的可读范围
    // 但这种依赖数可能提示另一个问题——这个类是否违反了 SRP?
}

十、小测验

1/3 · C# 12 中,class 的主构造函数参数和 record 的主构造函数参数最大的区别是?
2/3 · 关于捕获语义——以下哪个参数需要编译器分配私有字段?
3/3 · 一个 class 有主构造函数后,以下哪个说法正确?

十一、下一步

💬 有问题?捕获语义没有完全理解?不确定该不该用 Primary Constructor?想知道在继承层次中怎么处理?随时追问。