ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++23继承CTAD:让派生类模板参数推导更简洁

C++23继承CTAD:让派生类模板参数推导更简洁 1. 项目概述C23中的继承CTAD如果你写过C模板尤其是涉及类模板时肯定对每次实例化都要在尖括号里重复写一堆类型参数感到头疼。C17引入的CTADClass Template Argument Deduction类模板参数推导是个救星它让编译器能根据构造函数的实参自动推导出模板参数写起来清爽多了。但很快大家发现这个“救星”有个明显的短板它不认“儿子”。当你的类模板是从另一个类模板继承而来时CTAD就失灵了你依然得手动写上那一长串类型。这就像给你一辆自动挡的车但换挡杆只在你开原厂车时工作一旦你给车加了个拖斗继承就得切回手动模式颇为不便。C23终于补上了这块拼图允许在继承场景下使用CTAD。这个看似微小的语法糖背后涉及模板推导、继承关系、构造函数转发等一系列复杂机制的协同。它不仅仅是少打几个字那么简单更是对C泛型编程体验的一次重要打磨能让基于模板的库代码如各种容器、智能指针的包装器更加简洁、直观减少因冗长类型声明导致的错误。无论你是库的开发者希望提供更友好的API还是库的使用者厌倦了模板套模板的“俄罗斯套娃”式类型声明这个新特性都值得深入了解。2. 核心原理与演进从C17 CTAD到C23的继承扩展2.1 C17 CTAD的机制与限制要理解C23的扩展必须先回顾C17 CTAD是怎么工作的。其核心是“推导指南”Deduction Guide。编译器在尝试推导类模板TemplateName的类型时会考虑以下因素主类模板的构造函数编译器会检查所有构造函数尝试将调用实参与构造函数形参进行匹配和推导。用户定义的推导指南使用TemplateName - TemplateName形式的声明可以显式指导编译器如何进行推导。隐式生成的推导指南对于没有用户定义推导指南的类模板编译器会为每个构造函数生成一个隐式的推导指南。举个例子C17标准库中的std::pair// C17 之前 std::pairint, std::string p1(42, “hello”); // C17 CTAD std::pair p2(42, “hello”); // 推导为 std::pairint, const char* std::pair p3(42, std::string(“hello”)); // 推导为 std::pairint, std::string这里编译器根据构造函数pair(T1, T2)从实参42和“hello”推导出T1int,T2const char*。然而C17 CTAD有一个关键限制它只对正在构造的类模板本身生效。当这个类模板是另一个类模板的派生类时问题就来了。templatetypename T struct Base { T value; Base(T v) : value(v) {} }; templatetypename T struct Derived : BaseT { Derived(T v) : BaseT(v) {} // 必须显式写 BaseT }; int main() { // C17 错误无法推导 ‘T’ // Derived d1(42); // C17 必须显式指定 Derivedint d2(42); // 可行但冗长 }编译器看到Derived d1(42)时它只知道要推导Derived的模板参数T。虽然Derived的构造函数形参是T并且它调用了BaseT的构造函数但在C17的规则下编译器在推导Derived的T时不会去考虑或利用基类Base的构造函数或任何与之相关的推导指南。基类BaseT的实例化被看作是DerivedT内部的一个实现细节在推导初始阶段是不被纳入考量的。这就导致了推导失败。2.2 C23 继承CTAD的工作原理C23通过扩展推导规则解决了这个问题。新规则的核心思想是在推导派生类模板参数时将其基类的构造函数也纳入候选集进行考虑。具体来说当编译器尝试推导Derived的模板参数时过程变得更加复杂和智能收集候选构造函数编译器不仅收集Derived自身的所有构造函数及相关的推导指南还会递归地收集其所有直接或间接非虚基类的构造函数。匹配与推导编译器尝试用提供的实参去匹配这些来自派生类和基类的候选构造函数。这个过程可能涉及模板参数推导、重载决议等。确定最终类型一旦找到一个最佳匹配的构造函数可能来自派生类也可能来自基类编译器就利用这个匹配结果来确定派生类Derived的模板参数。如果匹配到的是基类的构造函数那么基类的模板参数被确定后派生类的模板参数也就随之确定因为派生类模板参数列表与基类模板参数列表之间存在关联通常通过继承声明: BaseT...体现。这个过程允许了“通过基类构造派生类”的推导。我们来看一个更复杂的例子它混合了多个模板参数和自定义推导指南templatetypename T, typename U struct Base { T first; U second; Base(T f, U s) : first(f), second(s) {} }; // 为Base定义一个推导指南非必须仅作演示 templatetypename T, typename U Base(T, U) - BaseT, U; templatetypename A, typename B struct Derived : BaseA, B { using BaseA, B::Base; // 继承Base的构造函数 // Derived没有自己额外的构造函数 }; int main() { // C23 可行 Derived d1(3.14, “world”); // 推导为 Deriveddouble, const char* // 推导过程 // 1. 编译器看到Derived d1(...)需要推导A, B。 // 2. 收集候选Derived自身无构造函数但通过using继承了Base的构造函数。 // 3. 实参(3.14, “world”)与Base(double, const char*)构造函数匹配。 // 4. 推导出Base的模板参数为double, const char*。 // 5. 由于Derived继承自BaseA, B因此Adouble, Bconst char*。 }注意using BaseA, B::Base;这条语句继承构造函数声明在C11就已存在它的作用是将基类的构造函数引入派生类的作用域使得在构造派生类对象时可以直接使用基类的构造函数。在C23继承CTAD的上下文中它变得尤为重要因为它明确地将基类的构造函数暴露为派生类接口的一部分从而让编译器在CTAD过程中能够“看到”并利用这些构造函数。2.3 对现有代码的影响与兼容性这是一个纯粹的语言特性扩展旨在增加便利性不会破坏现有符合标准的代码。所有在C17下需要显式指定模板参数的继承代码在C23下依然完全有效。新特性只是为那些原本因CTAD限制而无法省略模板参数的场景提供了新的、更简洁的写法。对于库开发者而言这意味着你无需修改现有库的实现用户就能在支持C23的编译器上享受到更简洁的调用方式。当然为了最大化这个特性的好处检查并确保你的类模板的基类构造函数是正确且公开的或者通过using声明引入是一个好的实践。3. 核心细节解析与实操要点3.1 触发继承CTAD的关键条件不是所有继承场景都能自动享受CTAD的便利。编译器需要明确的线索来建立派生类模板参数和基类构造函数参数之间的推导关系。以下是几个关键条件基类构造函数必须对派生类可见这是最基本的前提。通常通过以下两种方式实现在派生类中使用using Base::Base;这是最直接、最推荐的方式。它明确地将基类的所有构造函数引入派生类的作用域。派生类构造函数成员初始化列表中调用基类构造函数如果派生类有自己的构造函数并且在初始化列表中显式调用了基类的构造函数那么这个构造函数也可能参与推导但情况更复杂一些。派生类的模板参数必须能从其基类的模板参数中确定这通常意味着派生类模板参数列表是基类模板参数列表的超集或完全一致。例如templatetypename T struct D1 : BaseT { /*...*/ }; // 可行D1的T就是Base的T。 templatetypename T, typename U struct D2 : BaseT { /*...*/ }; // 可行Base的T是D2的TU是D2的额外参数需要从D2的构造函数或其他地方推导。 templatetypename T struct D3 : BaseT, int { /*...*/ }; // 可行Base的第二个参数固定为intD3的T对应Base的第一个T。如果关系过于复杂或模糊编译器可能无法推导。存在一条从实参到某个候选构造函数的可行转换路径提供的实参类型必须能够通过标准转换、用户定义转换等匹配到某个派生类或基类的构造函数的形参类型。3.2 处理多重继承与虚继承继承CTAD也适用于多重继承场景但规则会变得更加复杂因为编译器需要协调多个基类的推导结果。templatetypename T struct Base1 { Base1(T); }; templatetypename U struct Base2 { Base2(U); }; templatetypename T, typename U struct DerivedMulti : Base1T, Base2U { using Base1T::Base1; using Base2U::Base2; // 注意这里没有同时接受T和U的构造函数 }; int main() { // C23 下哪个构造函数被调用 DerivedMulti dm1(10); // 错误有歧义 // 编译器既可以将10匹配给Base1int的构造函数推导为DerivedMultiint, ? // 也可以通过某种转换尝试匹配给Base2?但Base2的构造函数也需要一个参数。 // 由于DerivedMulti自身没有构造函数两个基类的构造函数通过using引入地位平等导致歧义。 DerivedMulti dm2(10, 3.14); // 同样错误有歧义 // 两个参数应该分别给Base1和Base2吗顺序如何编译器无法决定。 }上例展示了多重继承下的歧义问题。为了解决这个问题通常需要在派生类中提供自己的构造函数来明确如何用实参构造各个基类从而消除歧义并指导模板参数推导。templatetypename T, typename U struct DerivedMultiResolved : Base1T, Base2U { // 派生类自定义构造函数明确初始化顺序和参数分配 DerivedMultiResolved(T t, U u) : Base1T(t), Base2U(u) {} }; int main() { // C23 可行推导过程 // 1. 实参(10, 3.14)匹配DerivedMultiResolved的构造函数(T, U)。 // 2. 推导出Tint, Udouble。 // 3. 用推导出的int, double去实例化Base1和Base2。 DerivedMultiResolved dmr(10, 3.14); }对于虚继承情况则更为特殊。由于虚基子对象在最终派生类中只存在一份其初始化责任落在最底层的派生类构造函数上。在CTAD场景中如果中间派生类涉及虚继承推导规则会确保虚基类的构造函数只被考虑一次并且其模板参数的推导需要与最终派生类的构造上下文协调一致。在实际编码中应尽量避免在高度复杂的虚继承层次结构上依赖CTAD手动指定模板参数往往是更清晰、更安全的选择。实操心得继承CTAD在单继承或清晰的链式继承中工作得最好。对于多重继承除非派生类提供了明确的、无歧义的构造函数来“统领”各个基类的初始化否则很容易遇到推导失败或歧义错误。在设计类模板继承体系时如果希望支持CTAD应优先考虑简单的继承关系或在派生类中精心设计构造函数来引导推导。3.3 与用户定义推导指南的协同用户定义推导指南在继承CTAD中依然扮演着重要角色并且可以用于解决一些复杂场景下的推导问题。场景一基类有推导指南派生类希望沿用或修改。基类的推导指南不会自动被派生类继承。如果派生类希望改变推导行为需要在派生类层面重新定义推导指南。templatetypename T struct Box { T contents; Box(T c) : contents(c) {} }; // 基类推导指南从值推导 templatetypename T Box(T) - BoxT; templatetypename T struct LabeledBox : BoxT { std::string label; using BoxT::Box; // 继承构造函数 // 派生类自定义构造函数增加label LabeledBox(T c, std::string l) : BoxT(c), label(std::move(l)) {} }; // 为派生类定义推导指南 // 指南1当用一个参数构造时沿用基类的推导行为推导T templatetypename T LabeledBox(T) - LabeledBoxT; // 指南2当用两个参数(T, std::string)构造时推导T templatetypename T LabeledBox(T, std::string) - LabeledBoxT; int main() { LabeledBox lb1(42); // 使用指南1推导为 LabeledBoxint LabeledBox lb2(3.14, “pi”); // 使用指南2推导为 LabeledBoxdouble }场景二处理派生类独有的默认值或类型转换。有时你希望派生类的CTAD能产生与基类不同的类型。例如一个数字包装器派生类希望传入整数时默认推导为double类型。templatetypename T struct Number { T val; Number(T v) : val(v) {} }; templatetypename T Number(T) - NumberT; templatetypename T struct DoubleNumber : NumberT { using NumberT::Number; }; // 关键的推导指南将任何算术类型都推导为 DoubleNumberdouble templatetypename T requires std::is_arithmetic_vT DoubleNumber(T) - DoubleNumberdouble; int main() { DoubleNumber dn1(5); // 推导为 DoubleNumberdouble, Tdouble DoubleNumber dn2(5.0f); // 推导为 DoubleNumberdouble, Tdouble // 注意基类Numberint或Numberfloat仍被实例化但对外类型是DoubleNumberdouble }这里requires子句C20概念用于约束指南只对算术类型生效。这个派生类的推导指南完全覆盖了从基类继承来的推导行为实现了自定义的类型映射。4. 实战应用构建一个支持继承CTAD的智能指针包装器让我们通过一个具体的例子将上述原理付诸实践。假设我们想创建一个LoggedPtr类模板它继承自std::unique_ptr并添加日志功能记录指针的创建和销毁。我们希望它支持CTAD让用户能像使用std::unique_ptr一样方便地使用它。4.1 基础版本实现首先我们实现一个基础版本展示如何设置继承和构造函数以使CTAD成为可能。#include memory #include iostream #include string // 一个简单的日志器类 class Logger { public: Logger(const std::string name) : name_(name) { std::cout “[Logger ” name_ “] Created.\n”; } ~Logger() { std::cout “[Logger ” name_ “] Destroyed.\n”; } void log(const std::string msg) { std::cout “[” name_ “] ” msg “\n”; } private: std::string name_; }; // LoggedPtr 类模板 templatetypename T, typename Deleter std::default_deleteT class LoggedPtr : public std::unique_ptrT, Deleter { private: Logger logger_; public: // 关键使用using声明继承unique_ptr的所有构造函数 using std::unique_ptrT, Deleter::unique_ptr; // 自定义构造函数接受一个日志器名称并默认构造unique_ptr explicit LoggedPtr(const std::string log_name) : std::unique_ptrT, Deleter(), logger_(log_name) { logger_.log(“Empty LoggedPtr constructed.”); } // 自定义构造函数接受指针和日志器名称最常用的构造函数 templatetypename U LoggedPtr(U* ptr, const std::string log_name) : std::unique_ptrT, Deleter(ptr), logger_(log_name) { logger_.log(“LoggedPtr constructed with raw pointer.”); } // 析构函数中添加日志 ~LoggedPtr() { if (this-get()) { logger_.log(“Destroying LoggedPtr, raw pointer will be deleted.”); } else { logger_.log(“Destroying empty LoggedPtr.”); } } // 删除拷贝构造和拷贝赋值遵循unique_ptr语义 LoggedPtr(const LoggedPtr) delete; LoggedPtr operator(const LoggedPtr) delete; // 允许移动语义 LoggedPtr(LoggedPtr other) noexcept : std::unique_ptrT, Deleter(std::move(other)), logger_(std::move(other.logger_)) { logger_.log(“LoggedPtr moved from another.”); } LoggedPtr operator(LoggedPtr other) noexcept { if (this ! other) { std::unique_ptrT, Deleter::operator(std::move(other)); logger_ std::move(other.logger_); logger_.log(“LoggedPtr move-assigned from another.”); } return *this; } };在这个版本中我们通过using std::unique_ptrT, Deleter::unique_ptr;继承了std::unique_ptr的所有构造函数。这意味着所有能构造std::unique_ptr的实参组合理论上也能用于推导LoggedPtr的模板参数T和Deleter。4.2 为继承CTAD添加推导指南然而仅仅继承构造函数还不够。std::unique_ptr有它自己复杂的构造函数集和推导指南C17起。我们的LoggedPtr增加了Logger成员和一个std::string参数这改变了构造函数的签名。我们需要提供新的推导指南来告诉编译器当使用(指针, 字符串)这样的参数构造LoggedPtr时应该如何推导。// 推导指南1从 (U*, std::string) 推导 // 匹配我们的自定义构造函数 templatetypename U LoggedPtr(U* ptr, const std::string log_name) templatetypename U LoggedPtr(U*, const std::string) - LoggedPtrU; // 推导为默认的std::default_delete // 推导指南2从 (std::unique_ptrU, D, std::string) 推导 // 用于支持从unique_ptr移动构造并添加日志名 templatetypename U, typename D LoggedPtr(std::unique_ptrU, D, const std::string) - LoggedPtrU, D; // 注意我们没有为只传递一个string的构造函数提供指南因为它构造的是空指针T无法推导。 // 使用它时必须显式指定模板参数LoggedPtrMyType lp(“mylog”);4.3 使用示例与测试现在让我们测试一下我们的LoggedPtr是否支持继承CTAD。struct Widget { Widget() { std::cout “Widget constructed.\n”; } ~Widget() { std::cout “Widget destroyed.\n”; } void use() { std::cout “Widget used.\n”; } }; int main() { std::cout “ Test 1: Raw pointer name \n”; { // C23 继承CTAD生效 // 匹配指南1: LoggedPtr(Widget*, const std::string) // 推导出 UWidget, 因此 TWidget, Deleterstd::default_deleteWidget LoggedPtr w1(new Widget, “TestWidgetLogger”); w1-use(); } // w1离开作用域自动析构并打印日志 std::cout “\n Test 2: Moving from unique_ptr \n”; { auto unique_widget std::make_uniqueWidget(); // 匹配指南2: LoggedPtr(std::unique_ptrWidget, const std::string) // 推导出 UWidget, Dstd::default_deleteWidget LoggedPtr w2(std::move(unique_widget), “MovedWidgetLogger”); // unique_widget 现在为空 } std::cout “\n Test 3: Using inherited constructors (CTAD) \n”; { // 这里利用了‘using’继承的基类构造函数。 // 假设std::unique_ptr有一个从派生类指针到基类指针的构造函数。 struct Base { virtual ~Base() default; }; struct Derived : Base {}; // 这个构造调用继承自std::unique_ptr的构造函数。 // 在C23下编译器会利用基类(std::unique_ptr)的推导指南来推导LoggedPtr的模板参数。 // std::unique_ptr的推导指南能从Derived*推导出std::unique_ptrBase。 // 因此这里推导出 LoggedPtrBase。 LoggedPtr w3(new Derived, “DerivedToBaseLogger”); // w3的类型是 LoggedPtrBase, std::default_deleteBase } std::cout “\n Test 4: Explicit template argument (when CTAD fails) \n”; { // 当仅提供日志名时T无法推导必须显式指定。 LoggedPtrWidget empty_widget(“EmptyLogger”); // 或者使用C17风格的make函数推荐避免new auto made_widget std::make_uniqueWidget(); LoggedPtr w4(std::move(made_widget), “MadeWidgetLogger”); // 这里CTAD可以工作因为参数是unique_ptr } return 0; }运行这个程序你会看到构造函数和析构函数中插入的日志信息清晰地展示了对象的生命周期。更重要的是在支持C23的编译器上LoggedPtr w1(new Widget, “...”);这样的语句不再需要写成LoggedPtrWidget w1(...);模板参数被成功推导了出来。4.4 实现中的注意事项与陷阱构造函数转发与explicit继承基类构造函数时它们的explicit属性也会被继承。如果你的派生类构造函数不希望是explicit的而基类的是这可能引发意料之外的编译错误。需要仔细考虑构造函数的显隐性。派生类新增成员的初始化在上面的LoggedPtr中我们新增了Logger logger_成员。在通过using继承的构造函数中这个logger_成员如何初始化答案是它会被默认初始化。这可能导致问题因为Logger类没有默认构造函数。这就是为什么我们还需要提供自定义的构造函数来正确初始化logger_。如果基类的某个构造函数被调用但派生类新增成员无法默认初始化代码将无法编译。移动语义的正确实现由于我们管理资源指针和状态日志器必须正确实现移动构造函数和移动赋值运算符确保资源所有权转移后源对象处于有效状态通常是空状态并且日志记录不会错乱。推导指南的精确匹配推导指南的签名必须与某个构造函数精确匹配忽略explicit和默认参数。设计指南时要仔细考虑所有希望支持的构造场景并为其提供对应的指南。不完整或冲突的指南会导致CTAD失败。与SFINAE和概念的结合在更高级的用法中你可能希望用std::enable_if或C20概念来约束某些构造函数或推导指南使其只在特定条件下生效。这能创建更安全、表达能力更强的接口但也增加了实现的复杂性。5. 常见问题与排查技巧实录在实际使用C23继承CTAD时你可能会遇到一些编译错误或意料之外的行为。下面是一些常见问题及其解决方法。5.1 编译错误“类模板参数推导失败”这是最常遇到的错误。编译器无法根据提供的实参推导出模板参数。可能原因及排查没有匹配的构造函数或推导指南检查你调用构造函数的方式。确保实参类型和数量与派生类或基类中某个可见的构造函数匹配。记住通过using引入的基类构造函数是候选。检查项派生类是否有自定义构造函数是否使用了using Base::Base是否为所有希望支持CTAD的构造场景提供了推导指南基类构造函数不可见或不可访问using Base::Base必须放在派生类的public区域。如果基类构造函数是private或protected的并且派生类没有友好关系那么它无法被用于CTAD。检查项确认using声明的访问权限。确认基类构造函数的访问权限。模板参数推导存在歧义多个构造函数或推导指南同样匹配实参编译器无法决定用哪一个。templatetypename T struct A { A(T); }; templatetypename T struct B { B(T*); }; templatetypename T struct C : AT, BT { using AT::A; using BT::B; }; C c(0); // 歧义0可以匹配Aint(int)也可以匹配Bint(int*)? 0到int*的转换解决在派生类中提供一个更匹配的构造函数或者使用SFINAE/概念约束某些继承来的构造函数消除歧义。派生类模板参数无法从基类确定即使基类模板参数推导成功如果无法映射到派生类的模板参数列表也会失败。templatetypename T, typename U struct Base { Base(T, U); }; templatetypename X struct Derived : BaseX, int { using BaseX, int::Base; }; Derived d(1, 2); // 可能失败。推导BaseX,int需要从(1,2)推导X和int。但第二个参数是int与Base的第二个模板参数int匹配第一个参数1推导Xint。看起来可行实际上这取决于推导指南。 // 需要为Base提供 (T, U) - BaseT, U 的指南。并且Derived的using要能正确关联。解决仔细检查继承声明: BaseArgs...确保派生类模板参数与基类模板参数表达式之间存在明确的、可推导的关系。5.2 行为异常推导出的类型与预期不符CTAD成功了但实例化的类型不是你想要的。可能原因及排查选择了错误的推导指南当存在多个推导指南时重载决议会选择一个“最佳匹配”。这可能不是你期望的那个。特别是当实参可以进行隐式转换时。排查检查所有相关的推导指南。使用static_assert或打印类型typeid(...).name()或使用编译器内置宏如__PRETTY_FUNCTION__来验证推导结果。解决调整推导指南的签名使其更特化或使用requires子句添加约束确保正确的指南被选中。继承的构造函数与派生类新增成员初始化冲突如前所述通过using继承的构造函数不会初始化派生类新增的成员。如果该成员没有默认构造函数会导致编译错误。如果它有默认构造函数但未正确初始化会导致运行时未定义行为。解决要么确保新增成员有合适的默认构造函数且默认初始化状态是可接受的要么避免依赖继承的构造函数来初始化这些成员转而提供派生类自己的构造函数并正确初始化所有成员。与自动生成的函数交互问题如果派生类没有显式定义拷贝/移动构造函数或赋值运算符编译器会自动生成它们。这些自动生成的函数会调用基类的对应函数。这在多数情况下是好的但如果你在派生类中添加了资源管理如Logger内部可能有文件句柄自动生成的移动操作可能只是移动了基类部分和成员对于像Logger这样的类其移动后源对象的状态需要留意例如移动后的Logger是否应该记录日志。这更多是类设计问题而非CTAD特有但在使用CTAD简化构造时容易忽略。5.3 调试与验证技巧使用编译器诊断GCC和Clang在CTAD失败时通常会给出详细的候选列表。仔细阅读这些信息看哪个候选被考虑了又为什么被拒绝。GCC/Clang使用-fdiagnostics-coloralways获得彩色输出更容易阅读。简化与隔离当遇到复杂的CTAD问题时尝试创建一个最小的、可复现的例子。移除无关的模板参数、基类或成员变量直到问题消失然后再逐步添加回来定位导致问题的具体元素。显式实例化对比先写出你期望的、显式指定模板参数的代码确保它能编译运行。然后再尝试去掉模板参数看CTAD是否成功。这能帮你确认是CTAD推导规则的问题还是代码本身就有问题。利用std::declval和decltype进行静态检查在编写推导指南或复杂构造函数时可以在编译时检查类型推导是否符合预期。// 在某个static_assert或requires子句中检查 static_assert(std::is_same_v decltype(LoggedPtr(std::declvalWidget*(), std::declvalstd::string())), LoggedPtrWidget );查阅编译器支持状态C23特性需要编译器支持。确保你使用的编译器版本支持继承CTAD例如GCC 13, Clang 17, MSVC 19.34 可能部分支持或完全支持。查阅编译器的标准支持页面或发布说明。继承CTAD是C迈向更简洁、更直观的泛型编程的重要一步。它减少了模板代码的冗余让接口更加干净。虽然其背后的推导规则略显复杂但一旦掌握就能显著提升编写和使用模板库的体验。正如所有强大的工具一样从简单的场景开始逐步理解其边界和陷阱是有效利用它的最佳途径。在重构旧代码或设计新库时不妨有意识地思考一下这里是否可以通过继承CTAD让用户的代码变得更优雅。
返回列表