NET10之C# Primary Constructor 深度指南

Primary Constructor(主构造函数)是C# 12(.NET 8 及以上版本) 引入的核心语法特性,将原本仅支持 record 类型的主构造函数能力扩展到了所有 classstruct 类型,提供了极简的构造函数声明语法,同时让构造参数在整个类型的生命周期内可访问。

一、核心基础功能与语法规则

1. 基础定义与声明语法

主构造函数允许你直接在类/结构体名称后的括号中声明构造函数参数,无需单独编写构造函数体。主构造函数参数的作用域覆盖整个类型的定义体,可用于初始化字段、属性,或在实例方法、属性访问器中直接使用。

最简声明示例:

csharp 复制代码
// 主构造函数最简声明
public class User(string name, int age)
{
    // 主构造参数name、age在整个类体内可直接访问
    public string Name => name;
    public int Age => age;
}

2. 官方核心语法规则

微软官方文档明确了主构造函数的核心行为规则,这是理解其特性的关键:

规则项 详细说明
参数本质 主构造参数是方法参数 ,而非类型的成员,无法通过 this.参数名 访问
字段生成规则 仅当参数在实例方法/属性访问器中被引用时,编译器才会生成隐藏的私有字段存储参数;若仅用于初始化属性/字段,不会生成额外字段,无性能损耗
属性生成规则 record 类型会为主构造参数自动生成公共 init-only 属性;普通 class/struct 不会自动生成任何属性
构造函数约束 类型中所有额外声明的构造函数,必须通过 this(...) 直接或间接调用主构造函数,确保参数被正确初始化
无参构造函数 普通 class 声明主构造函数后,编译器不再生成隐式无参构造函数;struct 始终保留隐式无参构造函数,且会将主构造参数初始化为默认值
可修改性 主构造参数可以被重新赋值,支持可变状态场景

3. 与传统构造函数的对比

通过对比可以直观看到主构造函数对样板代码的消除效果:

❌ C# 11 及以前传统写法
csharp 复制代码
public class Product
{
    // 1. 声明私有字段
    private readonly string _id;
    private readonly string _name;
    private readonly decimal _price;

    // 2. 声明构造函数
    public Product(string id, string name, decimal price)
    {
        // 3. 字段赋值(重复代码)
        _id = id;
        _name = name;
        _price = price;
    }

    // 4. 暴露属性
    public string Id => _id;
    public string Name => _name;
    public decimal Price => _price;
}
✅ C# 12 主构造函数写法
csharp 复制代码
public class Product(string id, string name, decimal price)
{
    // 无需声明私有字段、无需构造函数体赋值
    public string Id => id;
    public string Name => name;
    public decimal Price => price;
}

二、主构造函数解决的核心问题

1. 彻底消除冗余样板代码

传统构造函数中,「声明字段→构造函数传参→字段赋值」的三段式代码存在大量重复,尤其是依赖注入场景中多参数的服务类,代码冗余度极高。主构造函数将这一过程简化为一行声明,代码量减少50%以上,同时提升可读性。

2. 强制类型的必填依赖声明

主构造函数明确了类型实例化必须提供的核心参数,从语法层面杜绝了「实例化后遗漏初始化」的问题。所有构造函数必须调用主构造,确保初始化逻辑的一致性,避免多构造函数场景下的初始化遗漏bug。

3. 简化继承链的构造函数传递

传统继承场景中,派生类需要重复声明基类的构造参数,再通过 base() 传递,代码冗余且易出错。主构造函数允许派生类直接在类型声明中传递参数给基类主构造,大幅简化继承链的初始化逻辑。

4. 统一类型内的参数访问范围

主构造参数在整个类型体内生效,无论是属性初始化、实例方法、还是嵌套代码块,都可以直接访问,无需反复通过字段中转,让代码逻辑更连贯。

三、生产环境高频使用场景(附完整可运行代码)

以下场景均为.NET生产环境中的主流用法,所有代码均可直接在 .NET 8/9 SDK 中编译运行。

场景1:ASP.NET Core 依赖注入(DI)------ 最高频生产场景

主构造函数是ASP.NET Core服务、Controller开发的最佳实践,完美适配依赖注入模式,大幅简化服务依赖的声明与注入。

完整可运行示例
csharp 复制代码
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

// 1. 服务接口定义
public interface IOrderService
{
    string GetOrderInfo(string orderId);
}

// 2. 服务实现(主构造函数注入依赖)
public class OrderService(ILogger<OrderService> logger, IConfiguration config) : IOrderService
{
    // 主构造参数logger、config在整个类中可直接使用
    private readonly string _defaultCurrency = config["Order:DefaultCurrency"] ?? "CNY";

    public string GetOrderInfo(string orderId)
    {
        logger.LogInformation("查询订单信息,订单ID:{OrderId}", orderId);
        return $"订单[{orderId}],结算币种:{_defaultCurrency}";
    }
}

// 3. Controller(主构造函数注入服务)
[ApiController]
[Route("api/[controller]")]
public class OrderController(IOrderService orderService) : ControllerBase
{
    [HttpGet("{orderId}")]
    public IActionResult GetOrder(string orderId)
    {
        var result = orderService.GetOrderInfo(orderId);
        return Ok(result);
    }
}

// 4. 程序入口(Program.cs)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddScoped<IOrderService, OrderService>();

var app = builder.Build();
app.MapControllers();
app.Run();

场景2:不可变值对象与数据模型(DDD领域驱动设计)

主构造函数完美适配DDD中的值对象、不可变数据模型,结合 readonly struct/readonly class 可以极简实现线程安全的不可变类型。

完整可运行示例
csharp 复制代码
using System;

// 不可变值对象(readonly struct + 主构造函数)
public readonly struct GeoCoordinate(double latitude, double longitude)
{
    // 初始化时直接使用主构造参数进行计算与校验
    public double Latitude { get; } = 
        latitude is < -90 or > 90 
            ? throw new ArgumentOutOfRangeException(nameof(latitude), "纬度必须在-90~90之间") 
            : latitude;

    public double Longitude { get; } = 
        longitude is < -180 or > 180 
            ? throw new ArgumentOutOfRangeException(nameof(longitude), "经度必须在-180~180之间") 
            : longitude;

    // 计算两点间距离,直接使用主构造参数
    public double DistanceTo(GeoCoordinate other)
    {
        const double EarthRadiusKm = 6371;
        var latDiff = ToRadians(other.Latitude - Latitude);
        var lonDiff = ToRadians(other.Longitude - Longitude);

        var a = Math.Sin(latDiff / 2) * Math.Sin(latDiff / 2) +
                Math.Cos(ToRadians(Latitude)) * Math.Cos(ToRadians(other.Latitude)) *
                Math.Sin(lonDiff / 2) * Math.Sin(lonDiff / 2);
        
        return EarthRadiusKm * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
    }

    private static double ToRadians(double degrees) => degrees * Math.PI / 180;

    public override string ToString() => $"({Latitude:F6}, {Longitude:F6})";
}

// 控制台程序入口
class Program
{
    static void Main()
    {
        var beijing = new GeoCoordinate(39.9042, 116.4074);
        var shanghai = new GeoCoordinate(31.2304, 121.4737);
        
        Console.WriteLine($"北京坐标:{beijing}");
        Console.WriteLine($"上海坐标:{shanghai}");
        Console.WriteLine($"两地距离:{beijing.DistanceTo(shanghai):F2} km");
    }
}

场景3:类继承层次结构设计

主构造函数大幅简化类继承链的构造函数管理,派生类可以直接在类型声明中传递参数给基类,无需重复编写构造函数体。

完整可运行示例
csharp 复制代码
using System;

// 基类:银行账户(主构造函数定义核心必填参数)
public class BankAccount(string accountId, string owner)
{
    // 初始化时进行参数校验
    public string AccountId { get; } = 
        accountId?.Length == 10 && accountId.All(char.IsDigit)
            ? accountId
            : throw new ArgumentException("账号必须为10位数字", nameof(accountId));

    public string Owner { get; } = 
        string.IsNullOrWhiteSpace(owner)
            ? throw new ArgumentException("户主姓名不能为空", nameof(owner))
            : owner;

    public decimal Balance { get; protected set; }

    public virtual void Deposit(decimal amount)
    {
        if (amount < 0)
            throw new ArgumentOutOfRangeException(nameof(amount), "存款金额必须为正数");
        Balance += amount;
    }

    public override string ToString() => $"账号:{AccountId},户主:{Owner},余额:{Balance:C}";
}

// 派生类:支票账户(继承基类主构造函数,扩展专属参数)
public class CheckingAccount(string accountId, string owner, decimal overdraftLimit = 0) 
    : BankAccount(accountId, owner)
{
    public override void Deposit(decimal amount)
    {
        base.Deposit(amount);
        Console.WriteLine($"支票账户[{AccountId}]到账:{amount:C},当前余额:{Balance:C}");
    }

    public void Withdraw(decimal amount)
    {
        if (amount < 0)
            throw new ArgumentOutOfRangeException(nameof(amount), "取款金额必须为正数");
        if (Balance - amount < -overdraftLimit)
            throw new InvalidOperationException("超出透支额度,取款失败");
        
        Balance -= amount;
        Console.WriteLine($"支票账户[{AccountId}]取款:{amount:C},当前余额:{Balance:C}");
    }
}

// 控制台程序入口
class Program
{
    static void Main()
    {
        var account = new CheckingAccount("1234567890", "张三", 5000);
        account.Deposit(10000);
        account.Withdraw(12000); // 正常透支
        Console.WriteLine(account);

        try
        {
            account.Withdraw(4000); // 超出透支额度,触发异常
        }
        catch (Exception ex)
        {
            Console.WriteLine($"操作失败:{ex.Message}");
        }
    }
}

场景4:轻量级工具类与可复用服务

主构造函数非常适合实现轻量级的工具类、配置包装类、日志辅助类等,无需冗余代码即可实现参数的全局复用。

完整可运行示例
csharp 复制代码
using System;
using System.IO;

// 配置文件操作工具类(主构造函数注入根路径)
public class ConfigFileManager(string rootPath, string fileExtension = ".json")
{
    // 初始化时确保目录存在
    private readonly string _rootPath = 
        Directory.Exists(rootPath) ? rootPath : Directory.CreateDirectory(rootPath).FullName;

    public string ReadConfig(string fileName)
    {
        var fullPath = Path.Combine(_rootPath, $"{fileName}{fileExtension}");
        if (!File.Exists(fullPath))
            throw new FileNotFoundException("配置文件不存在", fullPath);
        
        return File.ReadAllText(fullPath);
    }

    public void WriteConfig(string fileName, string content)
    {
        var fullPath = Path.Combine(_rootPath, $"{fileName}{fileExtension}");
        File.WriteAllText(fullPath, content);
        Console.WriteLine($"配置文件已保存:{fullPath}");
    }
}

// 控制台程序入口
class Program
{
    static void Main()
    {
        // 初始化工具类,指定配置根目录
        var configManager = new ConfigFileManager("./AppConfig");
        
        // 写入配置
        configManager.WriteConfig("appsettings", "{\"Version\":\"1.0.0\",\"Environment\":\"Production\"}");
        
        // 读取配置
        var config = configManager.ReadConfig("appsettings");
        Console.WriteLine($"读取到的配置:{config}");
    }
}

四、官方最佳实践与避坑指南

1. 核心最佳实践

  1. 优先用于不可变场景:主构造参数优先用于初始化只读属性/字段,避免随意修改,减少意外副作用,符合.NET的不可变设计最佳实践。
  2. DI场景优先使用ASP.NET Core的Controller、服务类中,主构造函数是注入依赖的首选方式,代码最简洁且意图明确。
  3. 继承场景优先使用基类属性:派生类中应优先访问基类暴露的公共/受保护属性,而非重复使用主构造参数,避免编译器生成重复的隐藏字段。
  4. 参数校验放在属性初始化中:主构造参数的校验逻辑建议放在属性的初始化器中,确保实例化时立即执行校验,提前拦截非法参数。

2. 高频踩坑点与解决方案

坑1:派生类重复捕获主构造参数

问题:派生类中直接使用传递给基类的主构造参数,会导致编译器在派生类中生成重复的隐藏字段,造成内存浪费。

csharp 复制代码
// 错误写法
public class SavingsAccount(string accountId, string owner, decimal interestRate) 
    : BankAccount(accountId, owner)
{
    // 重复使用accountId、owner,编译器会生成重复字段
    public override string ToString() => $"账号:{accountId},户主:{owner},利率:{interestRate}";
}

// 正确写法
public class SavingsAccount(string accountId, string owner, decimal interestRate) 
    : BankAccount(accountId, owner)
{
    // 优先使用基类的属性,不会生成重复字段
    public override string ToString() => $"账号:{AccountId},户主:{Owner},利率:{interestRate}";
}
坑2:误以为主构造参数会自动生成属性

问题:普通class/struct的主构造参数不会自动生成属性,仅record类型会有此行为,直接通过实例访问参数名会编译报错。

csharp 复制代码
public class User(string name, int age);

class Program
{
    static void Main()
    {
        var user = new User("张三", 20);
        Console.WriteLine(user.name); // 编译报错!普通class不会生成公共属性
    }
}
坑3:主构造参数的生命周期与捕获问题

问题 :主构造参数在匿名函数、异步方法中被引用时,编译器会始终生成隐藏字段,即使后续不再使用,也不会被GC回收,可能导致内存泄漏。
解决方案:若参数在异步/匿名函数中使用,建议显式声明私有只读字段进行捕获,明确生命周期。

坑4:struct无参构造函数的行为差异

问题 :struct声明主构造函数后,编译器仍会生成隐式无参构造函数,将主构造参数初始化为默认值,可能导致校验逻辑失效。
解决方案:struct中显式声明无参构造函数并调用主构造,传入合法的默认值。

csharp 复制代码
public struct User(string name, int age)
{
    // 显式声明无参构造,调用主构造传入默认值
    public User() : this("Unknown", 0) { }
    
    public string Name { get; } = string.IsNullOrWhiteSpace(name) ? "Unknown" : name;
    public int Age { get; } = age < 0 ? 0 : age;
}

五、与record类型主构造函数的核心区别

很多开发者会混淆普通class与record的主构造函数行为,微软官方文档明确了二者的核心差异:

特性 普通class/struct主构造函数 record类型主构造函数
属性生成 不会自动生成任何属性 自动为每个参数生成公共init-only属性
字段生成 仅当参数在实例成员中使用时,才生成隐藏私有字段 始终为参数生成 backing field,支持值比较
相等性支持 无额外生成,默认引用类型相等性 自动生成基于值的相等性比较逻辑
解构支持 无自动生成 自动生成与主构造参数匹配的解构函数
不可变性 无强制,参数可修改 默认不可变,init-only属性无法修改
相关推荐
Omics Pro2 小时前
基因集(模块)活性量化:R语言+Java原生
大数据·开发语言·前端·javascript·数据库·r语言·aigc
RDCJM2 小时前
index.php 和 php
开发语言·php
sycmancia2 小时前
C++——Qt中的消息处理
开发语言·qt
biter down2 小时前
深入浅出 C++ string 类:从原理到实战
开发语言·c++
okiseethenwhat2 小时前
反射在 JVM 层面的实现原理
开发语言·jvm·python
星梦清河2 小时前
Java并发编程
java·开发语言
XiYang-DING2 小时前
【Java SE】sealed关键字
java·开发语言·python
祈澈菇凉3 小时前
Next.js + OpenAI API 跑通一个带流式输出的聊天机器人
开发语言·javascript·机器人
lsx2024063 小时前
MySQL 删除数据表
开发语言