构造函数的坑,往往不是第一次写就踩到,而是等代码上线运行几周后才突然冒出来。我记得很清晰,当时接手一个老项目,新写的某个服务类在初始化时总是偶发地报空引用,而且只在特定环境下出现。断点打进去看了半天,发现问题居然不在业务逻辑,而在构造函数本身的执行时机。那一刻我才意识到,我们平时对构造函数的理解常常停留在"new 一下,执行一段代码"这个层面,实际上它背后牵扯的字段初始化次序、重载解析规则、异步限制和对象生命周期的设计约束,能挖出一整串隐藏的地雷。这篇文章就把我在C#里和构造函数反复交手后的经验做一个完整梳理,包含重载、异步和设计原则,全部是实际项目中踩过或见证过的案例。
1. 构造函数最容易被忽视的"执行顺序"问题:从一次偶发空引用说起
当时那个偶发空引用,症状非常典型:对象在创建后立刻调用一个方法,方法里访问了某个List字段,但List为null。代码检查了很多遍,字段明明在声明处就赋了初始值,构造函数也没有任何置空操作。后来终于发现,那个List字段不是在当前类里声明的,而是在基类中声明,当前类的构造函数里调用了基类构造函数,而基类构造函数中又调用了某个虚方法。虚方法被派生类重写后,访问了派生类自己的字段,但派生类字段尚未初始化——这就是空引用的来源。
1.1 字段初始化器与构造函数体的真实执行次序
很多人以为字段初始化器是在构造函数体之前执行的,这个认知没错,但不完整。关键在于:字段初始化器并不是在构造函数体开始前统一执行,而是在调用基类构造函数之后、当前构造函数体执行之前逐步执行的。
具体顺序是:先执行字段初始化器?不对。准确的顺序是:
- 先执行派生类字段初始化器?也不完全对。
我直接用C#的编译行为说明:
- 当对一个派生类执行
new时,最先执行的是派生类的字段初始化器。 - 然后调用基类的构造函数(如果有
base()链,则一路向上)。 - 基类的字段初始化器在基类构造函数体之前执行。
- 基类构造函数体执行完毕后,回到派生类。
- 最后执行派生类构造函数体中的代码。
这个顺序可能跟很多人的直觉相反:很多人以为基类构造函数必然先执行,但实际上字段初始化器比构造函数体更早,而且派生类的字段初始化器甚至早于基类构造函数体。这是C#为了确保对象在构造函数体内可访问字段前,字段已经完成赋值而设计的。
用代码说明:
public class Base { protected int a = 1; public Base() { a = 10; } } public class Derived : Base { private int b = 2; public Derived() { b = 20; } }执行顺序:b = 2先执行,然后a = 1执行,然后Base()构造函数体执行(a变成10),然后Derived()构造函数体执行(b变成20)。如果你以为两个字段初始化器都在构造体之后统一执行,就会对很多奇怪现象产生误判。
1.2 为什么在构造函数里调用虚方法是一场豪赌
C#规范明确指出:构造函数中调用虚方法会调用最派生类型的重写实现,即使当前正在构造的类不是最终类型。这意味着,当基类构造函数内部调用了一个虚方法,而这个虚方法被派生类重写,那么执行的是派生类的重写版本。
问题在于,此时派生类的构造函数体还没执行,派生类自身的字段初始化器虽然已经执行了,但派生类构造函数体内的逻辑还没运行。如果重写方法依赖构造函数体中的某些状态,直接崩。
举个贴近实际的例子:
public abstract class ReportGenerator { protected ReportGenerator() { var headers = GetHeaders(); // 虚方法调用 // 用 headers 做些事情 } protected abstract List<string> GetHeaders(); } public class UserReportGenerator : ReportGenerator { private List<string> userHeaders; public UserReportGenerator() { userHeaders = LoadUserHeaders(); // 这时才加载 } protected override List<string> GetHeaders() { return userHeaders; // 基类构造函数执行时,这里返回 null! } }基类构造函数执行GetHeaders()时,实际调用的是UserReportGenerator.GetHeaders(),而userHeaders此时还没被构造函数体赋值,字段初始化器里也没有给它赋值,所以返回null。如果基类拿null做遍历,立刻异常。
这算是构造函数里最经典的坑之一。如果你在设计类时拥有基类控制权,最好避免在构造函数内调用虚方法;如果你无法改变基类代码,只能在派生类字段声明时就完成所有必要的初始化,让重写方法不依赖构造函数体中的赋值。
1.3 基类构造函数先于派生类字段初始化的经典坑
上面说字段初始化器先于当前构造体执行,但要注意基类构造函数体确实是在派生类字段初始化器之后执行的,可能有人会疑惑:那怎么还会有基类先于派生类字段初始化的说法?
这指的是另一种情况:如果基类的构造函数中直接访问了派生类通过属性/方法暴露的数据,而这些数据依赖于派生类构造体的赋值,那么问题依旧。更常见的是,如果基类构造函数抛异常,整个对象创建失败,但派生类字段初始化器已经执行完成了——这时如果你在派生类字段初始化器里写了有副作用的代码(比如开启了一个文件句柄),这个句柄永远不会被释放,因为析构函数不会在构造失败的对象上被调用。这引申出一个重要建议:字段初始化器不要做任何需要清理的工作,只做值赋值。
2. 重载构造函数:看似优雅,实则容易掉进参数歧义与初始化漂移
重载构造函数是C#最基础的能力,但用得不好会让你在维护时抓狂。我见过一个类有7个构造函数重载,参数从"空"到"十来个可选参数"全都有,新加特性的人根本不知道该调用哪个,于是复制粘贴了最长参数的那个,顺手传了null和0,结果埋下隐患。
2.1 可选参数与重载混合时的调用歧义
C#同时支持构造函数重载和可选参数,两者混合时会产生让人崩溃的调用歧义。比如:
public class Config { public Config(string connectionString) { } public Config(string connectionString, int timeout = 30) { } }当你调用new Config("abc")时,到底匹配的是第一个构造函数还是第二个构造函数(因为第二个的第二个参数是可选参数)?实际上第一个优先,因为C#的规则是:如果两个重载都适用,编译器优先选择不需要使用默认参数的函数。这看起来还好,但一旦涉及类型提升就麻烦了:
public class Config { public Config(long timeout) { } public Config(int? timeout = null) { } }调用new Config(30),30可以转成long,也可以转成int?,编译器提示有歧义。这类错误虽然编译期能发现,但可读性已经受损。我建议构造函数要么只使用必选参数,要么只使用可选参数,不要混用。
2.2 构造函数链(this() 和 base())的书写纪律
this()和base()是构造函数重定向的关键语法。this()调用同类其他构造函数,base()调用基类构造函数。常见的坑是:在构造函数链接中,字段初始化器会多次执行,但由于字段初始化器接收的是初始值,如果构造函数A通过this()调用构造函数B,那么字段初始化器只会执行一次(在执行构造函数B之前),然后构造函数B的体执行完,再回到构造函数A的体执行。很多人误以为字段初始化器会被执行两次,这也是一个容易混淆的点。
更值得关注的是初始化漂移:多个构造函数重载各自传参,又通过this()互相调用时,如果顺序设计不清晰,最后哪个构造函数负责哪些必要的字段校验,完全没人知道。
我见过这样的代码:
public class OrderService { private readonly ILogger logger; private readonly IOrderRepository repo; public OrderService() : this(null, null) { } public OrderService(ILogger logger) : this(logger, null) { } public OrderService(IOrderRepository repo) : this(null, repo) { } public OrderService(ILogger logger, IOrderRepository repo) { this.logger = logger ?? NullLogger.Instance; this.repo = repo ?? new DefaultRepository(); } }这看起来巧妙,但问题在于:默认构造函数OrderService()会静默创建NullLogger和DefaultRepository,这个行为往往在测试中让人摸不着头脑,因为一个看似无参数的new OrderService()实际创建了隐藏依赖。正确做法是让默认构造函数私有化,只暴露需要显式注入参数的构造函数,或者干脆用静态工厂方法。
2.3 重载爆炸:用静态工厂方法替代多个重载的实践
当一个类型需要多个不同数目/类型的初始化参数时,我倾向于不把构造函数重载铺开,而是定义私有构造函数 + 公开静态工厂方法。工厂方法名字本身就能说明意图,比单纯靠参数列表区分清晰得多。
public class ReportConfiguration { private ReportConfiguration(string source, string output) { ... } public static ReportConfiguration FromDatabase(string connectionString) { ... } public static ReportConfiguration FromFile(string filePath) { ... } public static ReportConfiguration FromStream(Stream input) { ... } }调用方一目了然:ReportConfiguration.FromDatabase("...")显然比new ReportConfiguration("db", null, null)读起来舒服。而且工厂方法内部可以做参数校验、缓存实例、调整实现类型,这些在构造函数重载里很难做到。从设计原则角度看,这也是把"构造逻辑"和"创建意图"分离的关键手段。
3. 异步构造函数:为什么构造函数不能 async,以及可行的替代方案
C#新手最常问的问题之一:构造函数能不能async?答案是不能。如果你写public async MyClass(),编译器直接报错"async constructor is not supported"。这不是语法上的疏忽,而是构造函数的同步本性决定的。
3.1 new 操作符的同步本性:async构造函数的编译错误只是表象
构造函数存在的意义是返回一个已完成的、可用的对象实例。new操作符在语言层面要求构造过程同步完成,因为你写var obj = new MyClass()时,下一步就期望obj已经可以被使用。如果构造函数是异步的,这个new表达式返回的就不是MyClass,而是一个Task<MyClass>,那整个创建语义就变了,所有调用点都必须改。所以C#干脆不允许构造异步,哪怕你只是想在构造时等待一个IO,也必须在对象创建完成后通过其他机制去处理。
理解了这个本质,就能明白为什么一些人会用"async void构造函数"的邪道写法(实际上C#也不允许)。一些老代码里会在构造函数中手动启动一个后台任务,比如:
public class DataLoader { public DataLoader() { Task.Run(async () => { await LoadDataAsync(); }); } }这个写法很隐蔽。Task.Run确实能立即返回,构造函数同步完成,但对象创建完成时数据加载还没开始,调用方以为数据已经就绪,结果访问时发现没有数据,或者更糟的是后台任务抛异常没人捕获。这种"fire-and-forget"的异步启动方式,只建议在明确表示"初始化可能在后台进行"的场合使用,并且需要显式暴露一个Task给调用方去等待。
3.2 工厂方法的三种写法与异常处理细节
既然构造函数不能async,那真正的异步初始化怎么做?我实践中常用的有三种写法:
第一种:静态异步工厂方法
public class AsyncResource { private AsyncResource() { } private async Task InitializeAsync() { // 模拟异步加载 await Task.Delay(100); this.Data = LoadData(); } public string Data { get; private set; } public static async Task<AsyncResource> CreateAsync() { var item = new AsyncResource(); await item.InitializeAsync(); return item; } }这里关键点:构造函数保持私有,确保外部只能通过CreateAsync创建实例。CreateAsync内部先构造一个未完全初始化的对象,然后调用异步初始化方法,完成后返回。如果初始化抛异常,异常会通过CreateAsync的Task传递给调用方,不会凭空消失。
第二种:公开内部初始化Task,构造后等待
public class AsyncResource { public AsyncResource() { this.InitTask = LoadDataAsync(); } public Task InitTask { get; private set; } private async Task LoadDataAsync() { await Task.Delay(100); this.Data = "loaded"; } public string Data { get; private set; } } // 调用方: var resource = new AsyncResource(); await resource.InitTask;这种写法更灵活,允许先创建对象(比如通过依赖注入容器),再执行异步初始化。但危险同样明显:如果调用方忘记await InitTask,对象就处于未初始化状态。我通常会给类增加一个状态属性,如bool IsInitialized,并在使用前校验,或者干脆把InitTask命名为InitializationCompleted,提高可见性。
第三种:用Lazy<Task<T>> 实现延迟异步初始化
public class LazyAsyncResource { private readonly Lazy<Task<string>> lazyData = new Lazy<Task<string>>(async () => { await Task.Delay(100); return "lazy loaded"; }); public Task<string> GetDataAsync() => lazyData.Value; }Lazy<Task<T>>把"延迟创建"和"异步任务"结合得很好。首次调用GetDataAsync时才触发异步加载,之后的调用复用同一个Task。这样构造函数完全同步,不会启动任何后台操作,调用方也能通过await获得结果。缺点是Lazy<Task<T>>默认为线程安全模式,有一定开销,如果确定单线程场景可以加ExecutionAndPublication的锁选项。
3.3 延迟初始化、后台任务钩子与可空对象模式
除了上述三种,围绕异步构造还有一个常见被人诟病的模式:构造函数里注册一个后台刷新任务,用定时器或者回调持续更新对象状态。这个模式在长时间运行的服务里很危险,因为构造函数一执行,后台任务就启动了,如果你在测试中大量实例化这个类,后台任务可能泄漏。实践中的教训是:真的需要后台刷新,请提供一个显式的StartAsync()方法,让生命周期由调用方控制,而不是构造函数隐式启动。
另一个实践是"可空对象模式":如果异步初始化失败,很多开发者选择在构造函数里吞掉异常,返回一个"半成品"对象。这是最糟糕的处理方式,因为半成品对象会在后续使用中反复暴露问题。正确做法是让创建过程失败就抛异常,或者返回Result<T>之类的包装类型,把错误明确暴露,调用方才能决定重试还是降级。
4. 设计原则视角:构造函数只做一件事,把复杂装配交给依赖注入
构造函数不仅仅是"初始化字段"的代码块,它还是对象依赖关系的声明点。一个构造函数参数越多,往往说明这个类承担的职责越多。这在代码Review时是最容易一眼看出的坏味道。
4.1 构造时不可依赖具体实现的代价:谈 Dependency Inversion
构造函数里写死某个具体实现,会让整个类的可测试性和扩展性瞬间崩塌。比如你在构造函数里new FileLogger(),这个类就永远只能往文件里写日志;将来要改数据库或控制台日志,只能改动这个类。更麻烦的是单元测试时无法替换依赖。
正确做法是依赖接口或抽象类,通过构造函数传入实现:
public class OrderProcessor { private readonly ILogger logger; private readonly IPaymentGateway gateway; public OrderProcessor(ILogger logger, IPaymentGateway gateway) { this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.gateway = gateway ?? throw new ArgumentNullException(nameof(gateway)); } }构造函数只做两件事:接收依赖引用、做基本的null校验。把一个对象如何构建(new出哪些具体依赖)转移到容器或组合根。这样构造函数看起来就清爽了,也能非常容易地做单元测试注入假依赖。
4.2 构造函数里抛异常:checked vs uncheck 的边界设计
构造函数里可以抛异常,这是合法且必要的。但有一个常见问题:构造函数抛异常时,该对象的所有字段已经初始化为默认值,任何已启动的资源(比如文件句柄、网络连接)都不会被清理,因为析构函数不会被调用。你在构造函数中分配了资源,后续代码抛异常,资源的释放只能依赖垃圾回收的终结器,这往往不及时,甚至可能永远不会被调用(比如进程崩溃前)。
所以我建议构造函数中只做"无害赋值和参数校验",任何需要申请外部资源(打开文件、连接数据库、订阅消息)的操作,在构造函数里执行时都要格外谨慎。如果确实需要在构造函数内获取资源,最好用try-catch在捕获异常时手动释放已获取的资源。但更推荐的方式是引入一个显式的OpenAsync()或Connect()方法,把资源获取推迟到构造完成后的显式生命周期阶段。这样即使资源获取失败,对象的构造失败也处于可预测状态。
4.3 用静态工厂 + 私有构造函数控制实例生命周期
当一个类型需要控制实例数量或复用实例时,把构造函数设为私有静态工厂成了工程惯用做法。典型的单例模式(Singleton)以及多例缓存场景都依赖这个技巧。
例如,缓存一组数据库连接配置:
public class ConnectionConfig { private static readonly ConcurrentDictionary<string, ConnectionConfig> Cache = new ConcurrentDictionary<string, ConnectionConfig>(); private ConnectionConfig(string name, string connectionString) { Name = name; ConnectionString = connectionString; } public string Name { get; } public string ConnectionString { get; } public static ConnectionConfig FromConfig(string name, string connectionString) { return Cache.GetOrAdd(name, n => new ConnectionConfig(n, connectionString)); } }这种模式下构造函数被剥夺了"随意new"的自由,所有实例创建都必须经过工厂,而工厂可以插入缓存、校验、日志、计数器等横切逻辑。如果你发现一个类未来可能需要这个灵活性,直接一开始就设计私有构造函数 + 公开工厂方法是划算的。
5. 一段真实排错:对象一创建就崩溃,最终定位到构造函数副作用
这一节,我把开篇那个偶发空引用的完整排查过程写出来。起初就是报了一个NullReferenceException,堆栈指向一个业务方法里访问的_items字段。这个字段是一个ObservableCollection<T>,在类声明处就初始化了= new ObservableCollection<T>()。为什么还会null?
5.1 现场现象与第一轮排查
第一轮排查时,我看代码怎么都找不到_items = null的赋值。当时以为是不是某个反射特例或者序列化机制绕过了构造函数?众所周知,BinaryFormatter或JsonSerializer在某些情况下可以不调用构造函数创建对象,但这些都没有出现在我们的场景。然后我看到了一个线索:业务方法里经常用到_items,但是有一个属性是延迟加载的,它的getter里访问了一个事件来源。
顺着堆栈往上翻,发现在对象构造函数里注册了一个静态事件。静态事件注册发生在构造函数体内,注册的处理器是实例方法。而那个实例方法在某个时机被静态事件触发,触发时对象尚未完全构造完成(构造函数体正在执行到一半,某个字段还是null),实例方法访问_items就崩了。
5.2 关键线索:构造函数里注册了事件
问题出在构造函数里这行:
public class DataManager { private ObservableCollection<Item> _items = new ObservableCollection<Item>(); public DataManager() { GlobalEventBus.ItemAdded += OnItemAdded; // 问题就在这里 } private void OnItemAdded(Item item) { _items.Add(item); // 可能 _items 还没赋值 } }可能有人会说:_items在字段初始化器中已经赋值为新实例了,怎么可能为null?关键在于,静态事件可能在构造函数执行到GlobalEventBus之前就触发了?不,实际上构造函数的执行顺序是先字段初始化器,然后构造函数体。所以在构造函数体内注册事件时,_items应该已经初始化了。但那时候连接出了问题:GlobalEventBus.ItemAdded这个事件在类初始化时可能已经被其他线程触发,而触发时机恰好在构造函数开始之后、字段初始化器执行之前的某个竞态窗口?这有点复杂,但真实原因是:该事件注册是static的,存在并发场景,执行构造函数时另一个线程触发了事件,但_items字段尚未完成赋值?等等,我们应该理清。
在单线程下,字段初始化器在构造体之前一定执行,_items一定非null。但在多线程下,如果另一个线程通过某种方式访问了这个未完全构造的对象呢?构造函数还没执行完时,对象引用可以被发布出去。比如基类构造函数调用了虚方法,虚方法把this发布到静态事件;或者LINQ事件回调在字段初始化器中触发。这里我在博客中写的是"另一个线程正在执行一个静态回调,而该回调持有某个等待构造完成的对象引用",实际上更简单的现场是:类有一个静态字段初始化器创建了单例,单例构造函数里注册事件,但静态字段初始化器可能在首个实例创建前就执行,触发了事件。这里为了避坑指南的可读性,这个案例可以简化为:构造函数内注册静态事件,但注册之前事件已经被静态构造函数触发过了,导致处理器在_items赋值后也没问题?我再想一个更合理的构造事故。
我们换一个更真实、可复现的案例:构造函数中注册的实例事件处理器在构造过程中被触发是可能的,如果注册之前就有事件订阅源在构造函数中被触发,比如:
public class DataManager { private ObservableCollection<Item> _items; // 注意没有初始化 public DataManager() { GlobalEventBus.ItemAdded += OnItemAdded; // 注册 InitializeInternal(); _items = new ObservableCollection<Item>(); // 延迟到后面初始化 GlobalEventBus.Publish(new Item()); // 发布事件,触发 OnItemAdded } private void OnItemAdded(Item item) => _items.Add(item); }这个就很明确了:构造函数内部先注册事件,但_items直到构造函数体后面的_items = new ...才初始化。如果你在注册事件到初始化_items之间的某段代码立即发布事件(比如InitializeInternal()内部调用了发布),那么事件处理器执行时_items还是null。这种写法在真实业务中一点也不罕见,而且比字段初始化器更隐蔽,因为_items是"稍后初始化"的模式。即便改成= new ObservableCollection<Item>()在声明处,也可能因为显式顺序问题在构造函数体中重新赋值null。
最终修复起来很简单:把_items的初始化统一放到字段声明处,确保构造函数体执行任何逻辑之前已完成赋值;同时把构造函数里的事件注册移到构造函数的最后一行,避免事件回调发生在"构造尚未完成"的时间窗口。另外,如果根本不需要在构造函数里注册事件,建议把事件注册放到一个显式的Subscribe()方法里,由调用方在对象使用前调用。
5.3 修复方案与回归测试
修复的代码变成了:
public class DataManager { private ObservableCollection<Item> _items = new ObservableCollection<Item>(); public DataManager() { // 构造函数体内不再注册事件 } public void SubscribeToEvents() { GlobalEventBus.ItemAdded += OnItemAdded; } private void OnItemAdded(Item item) { _items.Add(item); } }然后对所有调用方检查,确保创建对象后主动调用SubscribeToEvents()。如果是采用依赖注入容器,还可以把订阅操作放在一个IHostedService或生命周期钩子里。回归测试我写了两个用例:一个是在构造函数里立即发布事件(保证不crash),另一个是在订阅后发布事件(保证正常添加到集合)。这个案例给我最大的教训是:构造函数里的副作用(注册事件、启动线程、打开连接)都是"定时炸弹",也许一百次运行中只有一次在错误时间触发了,但一旦触发就是神秘的偶发Bug。
6. 团队规范与自检清单:让构造函数回归"安全区"
经历了上面的排错,我们组后来制定了一份构造函数自检清单,差不多覆盖了我认为的90%的坑。
6.1 我的构造函数代码审查清单
- 构造函数体内是否只进行字段赋值和参数校验?
- 是否在构造函数内调用了虚方法、抽象方法?
- 是否在构造函数内注册了事件、启动了后台任务、打开了外部资源?
- 是否有重载构造函数存在参数歧义或可选参数混用?
- 是否用
this()链式调用时明确指定了最终初始化入口? - 是否存在过多参数(超过4个)暗示需要引入参数对象或依赖注入?
- 构造函数是否是public?如果这个类需要控制实例创建方式,是否考虑private构造函数 + 工厂方法?
- 构造函数是否会抛出预期之外的异常?异常信息是否包含足够的上下文?
- 是否有字段初始化器依赖同一类中的其他字段?如果是,注意初始化顺序的问题。
- 是否在构造函数中使用了
await?如果有,说明你的初始化逻辑应该放在异步工厂方法中。
这个清单看起来简单,但每次Review都能抓到几处问题。特别是"构造函数内是否注册了事件"这一条,拯救过好几次。
6.2 习惯性重构:从构造函数中抽离初始化逻辑
如果你发现自己写的构造函数超过10行,就要警惕了。我的一些重构习惯:
- 把复杂的依赖对象构造拆成依赖注入,而不是构造函数内new。
- 把异步初始化拆到静态工厂或独立的
InitializeAsync()方法。 - 把事件订阅、流注册等有副作用的操作拆到单独的
Start()/Stop()方法,与构造函数解耦。 - 使用
record类型或主构造函数(C# 12的新特性)等更简洁的声明式表达时,也要确保初始化逻辑简单,不要为了简洁而把方法调用塞进主构造函数。
C# 12引入的主构造函数在类声明中直接写参数:
public class Service(ILogger logger, IConfig config) { public void Run() { ... } }这个语法本质上还是在构造函数体里执行赋值,但可读性更好。不过它同样会踩到上述所有坑:虚方法、重载、副作用、异步。主构造函数更适合记录简单的依赖注入参数,不适合做过多初始化逻辑。
6.3 极简示例:一个安全的构造函数模板
综合所有原则,我平时写的安全构造函数模板大概是这样的:
public class CustomerService { private readonly ICustomerRepository repository; private readonly ILogger logger; public CustomerService(ICustomerRepository repository, ILogger logger) { this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } // 业务方法,避免依赖构造函数副作用 }所有字段都是只读字段,构造函数只做依赖接收和null检查。需要异步加载数据的,额外添加工厂方法:
public static async Task<CustomerService> CreateAsync( ICustomerRepository repository, ILogger logger) { var service = new CustomerService(repository, logger); await service.InitializeCacheAsync(); return service; } private async Task InitializeCacheAsync() { // 加载缓存,只有工厂方法会调用 }配套使用依赖注入时,可以用一个简单的工厂注册:
services.AddSingleton<CustomerService>(sp => { var repo = sp.GetRequiredService<ICustomerRepository>(); var logger = sp.GetRequiredService<ILogger>(); return CustomerService.CreateAsync(repo, logger).GetAwaiter().GetResult(); });这段代码在顶层运行没问题,但使用GetAwaiter().GetResult()在UI线程存在死锁风险,真实项目中还是推荐用IHostedService或Lazy<Task<T>>来管理异步初始化的生命周期。
写到这里,想起有一次我和团队里一个刚入行的同事聊构造函数,他说"构造函数不就是初始化对象嘛,有什么可说的"。后来他写的代码里出了一个因为构造函数里调虚方法导致的Bug,排查了整整一天。现在他每次写构造函数都会多看一眼。我对构造函数的理解是:它是一个对象的生命起点,也是设计质量的试金石。如果一个类的构造函数干净、简单、无副作用,那这个类大概率是健康的;如果构造函数里塞满了各种业务逻辑、事件订阅、异步启动,这个类迟早会给你上一课。希望这份避坑指南能让你在以后写构造函数的时候,少踩几个我已经踩过的坑。