【C#】 主构造函数(Primary Constructors)的来龙去脉

C# 主构造函数(Primary Constructors)的来龙去脉

一句话概括:它看起来像是在类名后面声明了一个函数,但其实只是参数------这很合理。


一、问题的起点:C# 的构造函数一直"太啰嗦"

在 C# 漫长的历史中,构造函数的书写一直是样板代码的重灾区。

1.1 传统写法:三步走

csharp 复制代码
public class OrderService
{
    // 第1步:声明私有字段
    private readonly ILogger<OrderService> _logger;
    private readonly IOrderRepository _repository;
    private readonly IEventBus _eventBus;

    // 第2步:写构造函数
    public OrderService(
        ILogger<OrderService> logger,
        IOrderRepository repository,
        IEventBus eventBus)
    {
        // 第3步:逐个赋值
        _logger = logger;
        _repository = repository;
        _eventBus = eventBus;
    }
}

问题很明显:

  • 参数列表要写一遍
  • 字段声明要写一遍
  • 构造函数体里还要再赋值一遍
  • 三个依赖 = 9 行代码,其中 6 行是纯粹的"搬运工"

ASP.NET Core 普及后,依赖注入成为常态,一个服务类往往有 3~5 个注入依赖,这种重复代码的问题被放大了无数倍。


二、先行探索:record 的启示(C# 9, 2020)

2.1 record 率先破局

C# 9 引入 record 时,带来了一种全新的构造函数写法:

csharp 复制代码
public record Person(string Name, int Age);

这里 string Name, int Age 被放在了类名后面,编译器自动:

  1. 生成同签名的构造函数
  2. 为每个参数生成 init-only 属性
  3. 生成值语义相等性比较

这证明了:类名后面放参数,语法上是完全可行的。

2.2 但 record 有它的局限

csharp 复制代码
public record OrderService(ILogger<OrderService> logger, IOrderRepository repo);

这样写会自动把 loggerrepo 暴露为公共属性------这在很多场景下并不合适:

  • 依赖注入的接口实例不应该被外部直接访问
  • 有些参数只是内部使用的配置值
  • 需要更精细地控制可变性

所以开发者开始问:这种简洁的语法,能不能用在普通的 class 上?


三、社区呼声与设计演进

3.1 长期存在的痛点

在 GitHub 的 dotnet/csharplang 仓库中,关于简化构造函数语法的讨论由来已久:

  • "Constructor parameter propagation" --- 希望参数能自动变成字段
  • "Primary constructors for classes" --- 明确要求把 record 的语法扩展到 class
  • "Target-typed new + parameter inference" --- 各种试图减少样板代码的提案

3.2 设计团队的核心考量

C# 语言设计团队在推进这个特性时,面临几个关键决策:

问题 选择 理由
是否自动生成属性? record 区分,给开发者控制权
参数作用域多大? 整个类体 最实用,避免重复声明字段
是否支持 readonly 通过 readonly struct 或手动控制
与其他构造函数的关系? 必须链式调用 保证主构造函数的唯一入口

四、正式落地:C# 12 的主构造函数(.NET 8, 2023)

4.1 语法定稿

csharp 复制代码
public class OrderService(ILogger<OrderService> logger, IOrderRepository repository)
{
    public async Task<Order> CreateAsync(CreateOrderRequest request)
    {
        logger.LogInformation("Creating order...");  // 参数直接在方法中使用
        return await repository.CreateAsync(request);
    }
}

关键设计:

  • OrderService(...) 这里不是声明了一个方法
  • 它只是在主构造函数的位置声明了参数
  • 这些参数的作用域是整个类体,相当于"隐式的、只读的、类级别的参数"

4.2 为什么是"参数"而不是"自动属性"

这是与 record 最核心的区别:

csharp 复制代码
// record:参数 = 自动属性(public)
public record Person(string Name, int Age);  // Name, Age 是公共属性

// class 主构造函数:参数 = 参数(作用域内可用,不自动暴露)
public class Person(string name, int age)      // name, age 只是参数
{
    public string Name => name;               // 开发者决定如何暴露
    public int Age => age;
}

这个设计非常合理:

  1. record 的定位是"数据载体",公开属性符合其语义
  2. class 的定位是"行为封装",隐藏内部依赖符合 OOP 原则
  3. 开发者拥有完全的控制权------想暴露就写属性,不想暴露就内部使用

五、深入理解:"看起来像函数,其实是参数"

5.1 语法层面的澄清

csharp 复制代码
public class MyClass(int x, string y)  // ← 这不是方法声明!
{
    // x 和 y 在这里整个类体中可用
    public int X => x;
    public string Y => y;

    public void DoSomething() => Console.WriteLine(x + y);
}

编译器视角:

  • 它生成了一个构造函数 public MyClass(int x, string y)
  • 参数 xy 被提升到类作用域
  • 但没有生成对应的字段(除非你手动赋值给字段/属性)

5.2 与真正构造函数的关系

csharp 复制代码
public class BaseEntity(Guid id);

public class Customer(Guid id, string email) : BaseEntity(id)  // 调用基类主构造函数
{
    // id 和 email 在 Customer 的整个类体中可用
    public string Email => email;
}

这里:

  • BaseEntity(Guid id) 是基类的主构造函数
  • Customer(Guid id, string email) 是派生类的主构造函数
  • : BaseEntity(id) 是显式的基类构造函数调用
  • 语法上看起来像是"函数套函数",但本质上都是参数声明

六、对比:三种写法的演进

6.1 传统写法(C# 1~11)

csharp 复制代码
public class OrderService
{
    private readonly ILogger<OrderService> _logger;
    private readonly IOrderRepository _repository;

    public OrderService(ILogger<OrderService> logger, IOrderRepository repository)
    {
        _logger = logger;
        _repository = repository;
    }

    public async Task<Order> GetOrderAsync(Guid id)
    {
        _logger.LogInformation("Getting order {Id}", id);
        return await _repository.GetByIdAsync(id);
    }
}

行数:15 行

6.2 record 写法(C# 9+)

csharp 复制代码
public record OrderService(ILogger<OrderService> Logger, IOrderRepository Repository);
// 自动生成属性,但所有参数都变成 public

行数:1 行,但失去了封装性

6.3 主构造函数写法(C# 12+)

csharp 复制代码
public class OrderService(ILogger<OrderService> logger, IOrderRepository repository)
{
    public async Task<Order> GetOrderAsync(Guid id)
    {
        logger.LogInformation("Getting order {Id}", id);
        return await repository.GetByIdAsync(id);
    }
}

行数:8 行,保留了封装,去掉了样板代码


七、设计哲学:为什么这样设计最合理

7.1 "参数"而非"字段"的选择

csharp 复制代码
public class Service(ILogger<Service> logger)
{
    // 方案A(未采用):自动生成私有字段 _logger
    // 方案B(未采用):自动生成公共属性 Logger
    // 方案C(实际采用):logger 作为类作用域参数,由开发者决定

    private readonly ILogger<Service> _logger = logger;  // 想存字段?自己写
    public ILogger<Service> Logger => logger;            // 想暴露?自己写
}

方案C 的优势:

  • 零魔法:没有隐藏的字段生成,代码所见即所得
  • 灵活性:可以赋值给字段、属性、计算表达式
  • 一致性:与方法的参数语义一致------传入、使用、不自动持久化

7.2 作用域设计的合理性

csharp 复制代码
public class Example(int value)
{
    private int _cached = value * 2;        // 可以在字段初始化器中使用
    public int Value => value;              // 可以在属性中使用
    public int Double() => value * 2;       // 可以在方法中使用
    public Example() : this(0) { }          // 其他构造函数通过 this 调用
}

参数作用域覆盖整个类体,这符合直觉:

  • 构造函数参数本来就应该在构造后的对象中可用
  • 传统写法中之所以要赋值给字段,只是因为参数作用域仅限构造函数体
  • 主构造函数只是扩大了参数的自然作用域

十、主构造函数与其他构造函数的关系

10.1 核心规则:其他构造函数必须调用主构造函数

如果类中定义了主构造函数,那么所有其他构造函数都必须通过 this(...) 链式调用主构造函数

csharp 复制代码
public class Person(string name, int age)
{
    // ✅ 正确:通过 this(...) 调用主构造函数
    public Person(string name) : this(name, 0) { }

    // ✅ 正确:无参构造函数也必须链式调用
    public Person() : this("Unknown", 0) { }

    public string Name => name;
    public int Age => age;
}

10.2 ❌ 错误写法

csharp 复制代码
public class Person(string name, int age)
{
    // ❌ 编译错误!不能绕过主构造函数
    public Person() 
    {
        // 没有 : this(...)
    }
}

编译报错: CS8982: 主构造函数的后备字段必须由主构造函数初始化

10.3 为什么这样设计?

主构造函数是唯一的入口,所有对象创建都必须经过它。这保证了:

  1. 参数 nameage 一定有值 ------ 不会存在未初始化的状态
  2. 逻辑集中 ------ 初始化逻辑只写在一处
  3. record 保持一致 ------ record 的构造函数也是这个规则

10.4 实际应用场景

csharp 复制代码
public class ConnectionOptions(string connectionString, int timeoutMs = 5000)
{
    // 提供便捷的重载,但都必须走到主构造函数
    public ConnectionOptions(string connectionString) 
        : this(connectionString, 5000) { }

    public string ConnectionString => connectionString;
    public int TimeoutMs => timeoutMs;
}

10.5 一个细节:主构造函数本身没有方法体

csharp 复制代码
public class Service(ILogger<Service> logger)
{
    // ❌ 错误:主构造函数不能有方法体
    // 你不能在类名后面的参数列表后面写 { ... }

    // 如果需要在构造时执行逻辑,可以用字段初始化器:
    public string InitializedAt { get; } = DateTime.Now.ToString();

    // 或者在额外构造函数中,调用主构造函数后执行逻辑:
    public Service() : this(NullLogger<Service>.Instance)
    {
        // 这里可以写逻辑,但必须先调用 this(...)
        Console.WriteLine("使用默认日志器初始化");
    }
}

10.6 与基类主构造函数的交互

csharp 复制代码
public abstract class Entity(Guid id)
{
    public Guid Id => id;
}

// 派生类既要调用基类主构造函数,又有自己的主构造函数
public class Customer(Guid id, string email) : Entity(id)
{
    // id 来自主构造函数参数,传给基类 Entity(id)
    // email 是 Customer 自己的参数

    // ✅ 额外构造函数必须调用自己的主构造函数
    public Customer(string email) : this(Guid.NewGuid(), email) { }

    public string Email => email;
}

注意:

  • : Entity(id) 调用基类主构造函数
  • : this(...) 调用自己的主构造函数
  • 两者不能同时出现在同一个构造函数声明中(C# 语法限制)

10.7 总结

情况 规则
只有一个主构造函数 ✅ 最简形式,参数在类体中随处可用
需要额外构造函数 必须用 : this(...) 调用主构造函数
主构造函数能否有方法体 ❌ 不能,初始化逻辑用字段初始化器或额外构造函数
基类也有主构造函数 : BaseClass(...) 调用,由编译器自动处理参数传递

这个设计确保了主构造函数是对象创建的唯一真实入口,避免了传统多构造函数中常见的"某些字段忘记初始化"的 bug。


八、使用建议与最佳实践

8.1 适合使用主构造函数的场景

场景 示例 理由
依赖注入 public class Service(ILogger<Service> logger, IRepo repo) 减少样板代码
配置类 public class Options(string connectionString) 简单封装配置值
DTO(内部使用) public class QueryResult(IEnumerable<Item> items) 不需要公开属性
结构体 public struct Point(double x, double y) 简洁的不可变结构

8.2 不适合的场景

场景 建议 理由
需要复杂构造逻辑 使用传统构造函数 主构造函数不能有方法体
需要 required 成员 使用传统属性 两者不兼容
公共 API 的数据类 使用 record 自动生成属性更便利

8.3 一个实用的模式

csharp 复制代码
// 主构造函数 + 手动属性:最灵活的模式
public class UserService(
    ILogger<UserService> logger,
    IUserRepository repository,
    IEmailService emailService)
{
    // 选择性暴露(只暴露需要被外部知道的)
    public IEmailService EmailService => emailService;

    // 内部使用,不暴露
    public async Task<User> RegisterAsync(string email)
    {
        logger.LogInformation("Registering user {Email}", email);

        var user = await repository.CreateAsync(email);
        await emailService.SendWelcomeEmailAsync(user.Email);

        return user;
    }
}

九、总结

C# 主构造函数的演进,是语言设计团队对**"减少样板代码""保持显式控制"**之间平衡的精妙把握:

  1. record 借鉴了语法形式 ------ 类名后面放参数,简洁直观
  2. 选择了"参数"语义而非"自动属性" ------ 避免隐藏魔法,保持 class 的封装性
  3. 扩大了参数的自然作用域 ------ 让构造函数参数在整个类体中可用,符合直觉

它看起来像是在类名后面声明了一个函数,但其实只是参数------这很合理。

因为构造函数的本质就是"接收参数来初始化对象",主构造函数只是把这一步做得更直接、更简洁,而没有引入额外的抽象。


参考

相关推荐
小柯南敲键盘1 小时前
AI批量翻译Temu商品标题的Python实践
开发语言·人工智能·python
逆境不可逃1 小时前
Java JUC 同步工具类一次讲透:CountDownLatch、CyclicBarrier、Semaphore、Phaser 与 AQS 共享模式
java·开发语言·python
大尚来也1 小时前
项目上线前必须检查的清单:避开线上重大事故
开发语言
q27551300421 小时前
AS717 Type-C转DP 8K方案外维少 AS717电路图
c语言·开发语言
CappuccinoRose2 小时前
Rust学习文档(二)
开发语言·后端·学习·rust
草莓熊Lotso2 小时前
【LangChain】核心组件详解:文档加载器(Document Loaders)
开发语言·c++·python·langchain·软件工程
czhc11400756632 小时前
725: [property: XmlIgnore]
c#
kingwebo'sZone2 小时前
C#事件声明办法
java·前端·c#
你怎么知道我是队长3 小时前
JavaScript的自适应效果
开发语言·javascript·ecmascript