当前位置: 首页 > news >正文

C++20概念与约束:从SFINAE到现代模板编程的进化之路

1. 项目概述:从SFINAE到概念,C++模板约束的进化之路

如果你写过C++模板,尤其是需要约束模板参数类型的时候,大概率被SFINAE(Substitution Failure Is Not An Error)折磨过。那种在返回类型或者模板参数里塞满std::enable_if_tdecltype的代码,写的时候小心翼翼,读的时候一头雾水,编译器报错信息更是长得像天书。我自己在维护一个大型的数学库时,为了给不同的数值类型(标量、向量、矩阵)提供统一的接口,就曾深陷SFINAE的泥潭,一个简单的operator+重载,为了处理各种可能的类型组合,代码膨胀了好几倍,调试起来异常痛苦。

C++20引入的“概念”(Concepts)与“约束”(Constraints),就是为了终结这种混乱。它并不是一个凭空出现的新玩具,而是对SFINAE机制的一次官方“正名”和语法糖封装,将其从一种需要巧妙利用的“技巧”提升为语言的一等公民特性。简单来说,概念让你能用一种近乎自然语言的方式,告诉编译器:“我这个模板,只接受满足某某条件的类型。”比如,template <std::integral T>一眼就能看懂:T必须是个整数类型。

这篇文章,我会带你彻底搞懂C++20概念与约束背后的机制,尤其是它和SFINAE的血缘关系。无论你是正在学习现代C++的新手,还是被祖传模板代码困扰的老手,理解这套机制,都能让你写出更清晰、更健壮、错误信息更友好的模板代码。我们会从为什么需要约束讲起,拆解SFINAE的原理与局限,然后深入概念的定义、使用和各种高级玩法,最后对比新旧两种方式,让你明白概念不仅仅是语法糖,更是思维模式的升级。

2. 模板参数约束的核心需求与SFINAE的救赎

2.1 为什么模板需要约束?

C++模板的核心是“泛型”,即编写与类型无关的代码。但完全的“无约束”往往是不现实的。考虑一个经典的例子:一个求和的函数模板。

template<typename T> T add(T a, T b) { return a + b; }

这个模板对intdouble甚至std::string(连接)都工作得很好。但如果你不小心传入了两个std::vector<int>呢?编译器会尝试实例化vector::operator+,发现没有这个成员函数,于是报出一大堆晦涩的错误,核心信息可能淹没在模板实例化的层层堆栈中。用户看到的不是“类型T不支持+操作”,而是一连串关于std::vector内部实现的错误。

这就是无约束模板的问题:错误反馈滞后且糟糕。编译器只有在尝试实例化模板时才会发现类型不匹配,此时错误发生在模板内部,而非接口层面。我们需要一种机制,在模板被选择之前,就告诉编译器:“只有满足条件的类型才能进入候选名单。”

2.2 SFINAE:在失败中寻找出路的古老智慧

在C++20之前,社区主要依靠SFINAE机制来实现约束。它的核心思想是:在模板重载解析阶段,如果替换模板参数导致某个模板声明无效(如访问不存在的成员、无效的表达式),那么这个模板就从重载集中被默默地移除,而不是引发编译错误。只有当所有候选模板都因替换失败而被移除时,编译器才会报“无匹配函数”的错误。

这听起来有点绕,看个简单例子就明白了:

#include <iostream> #include <type_traits> // 重载1:针对有`inner_type`成员的类型 template<typename T> void foo(typename T::inner_type*) { std::cout << "Has inner_type\n"; } // 重载2:针对其他所有类型 template<typename T> void foo(T*) { std::cout << "No inner_type\n"; } struct MyType { using inner_type = int; }; struct PlainType {}; int main() { MyType* p1 = nullptr; PlainType* p2 = nullptr; foo<MyType>(p1); // 输出:Has inner_type foo<PlainType>(p2); // 输出:No inner_type return 0; }

当我们调用foo<PlainType>(p2)时,编译器首先尝试匹配重载1。它将T替换为PlainType,那么参数类型就变成了typename PlainType::inner_type*。由于PlainType没有inner_type这个成员类型,这个替换导致了无效的类型。根据SFINAE规则,这个“失败”不是错误,编译器只是默默地把重载1从候选列表中丢掉。然后它继续尝试重载2,T*就是PlainType*,匹配成功。

实操心得:SFINAE的“替换失败”必须发生在直接上下文中,通常指的是模板声明本身(函数签名、返回类型、模板参数列表)。如果失败发生在函数体内部,那就是硬错误了。这是SFINAE使用中的一个关键陷阱。

2.3 SFINAE的经典工具:std::enable_ifstd::void_t

手动编写上面那种依赖成员类型的SFINAE代码很繁琐。标准库提供了两个强大的工具来简化。

std::enable_if:这是一个在编译期进行条件编译的开关。它的定义很简单:

template<bool B, class T = void> struct enable_if {}; template<class T> struct enable_if<true, T> { using type = T; }; // 仅在B为true时有`type`成员

std::enable_if_t<B, T>是它的别名模板,等价于typename enable_if<B, T>::type。当Btrue时,它有成员type(即T);当Bfalse时,它没有type成员。利用这一点,我们可以把它放在会导致替换失败的地方。

最常见的用法是作为函数返回类型或额外的默认模板参数:

#include <type_traits> #include <iostream> // 方法1:用于返回类型,约束T必须是整数 template<typename T> typename std::enable_if_t<std::is_integral_v<T>, T> // 如果T是整数,返回类型就是T add_int(T a, T b) { return a + b; } // 方法2:用于额外的函数参数(通常给个默认值0) template<typename T> T add_int(T a, T b, typename std::enable_if_t<std::is_integral_v<T>, int> = 0) { return a + b; } // 方法3:用于模板的默认参数 template<typename T, typename = std::enable_if_t<std::is_integral_v<T>>> T add_int(T a, T b) { return a + b; } int main() { auto r1 = add_int(3, 5); // 正确,匹配任意一个重载 // auto r2 = add_int(3.14, 2.71); // 编译错误!没有匹配的重载,因为std::is_integral_v<double>为false std::cout << r1 << std::endl; return 0; }

Tdouble时,std::is_integral_v<T>falsestd::enable_if_t<false, ...>没有type成员,导致函数签名无效,触发SFINAE,该重载被移除。

std::void_t:这是一个C++17引入的、看似简单却威力巨大的工具:

template<class...> using void_t = void;

它就是一个总是返回void的别名模板。它的妙用在于探测类型成员是否存在。结合模板特化,可以优雅地实现类型特征检查。

#include <type_traits> #include <iostream> // 主模板:默认继承std::false_type template<class, class = void> struct has_type_member : std::false_type {}; // 特化模板:当第二个模板参数(经过void_t处理)有效时,继承std::true_type template<class T> struct has_type_member<T, std::void_t<typename T::type>> : std::true_type {}; struct A { using type = int; }; struct B {}; int main() { std::cout << std::boolalpha; std::cout << has_type_member<A>::value << std::endl; // 输出:true std::cout << has_type_member<B>::value << std::endl; // 输出:false return 0; }

原理是:当检查has_type_member<A>时,编译器尝试匹配特化版本。它将T替换为A,然后计算std::void_t<typename A::type>。因为A::type存在,所以std::void_t<...>是合法的void类型,特化版本匹配成功。对于Btypename B::type无效,std::void_t<...>替换失败,根据SFINAE,这个特化被忽略,编译器回退到主模板,其valuefalse

注意事项:使用std::enable_if时,要特别注意它的放置位置。如果放在类模板的成员函数中,并且条件依赖于类模板参数,很容易踩坑。因为成员函数在类实例化时并不会立即实例化,但它的声明(包括返回类型)需要被检查。如果条件为假导致声明无效,会直接引发编译错误,而不是SFINAE。通常的解决方法是给成员函数自己也添加一个默认的模板参数,将SFINAE的依赖转移到这个新参数上。

3. C++20概念与约束:化繁为简的新范式

SFINAE虽然强大,但代码可读性差,像是一种“黑魔法”。C++20的概念(Concepts)特性,本质上是对“约束”这个思想的直接语言支持,它提供了清晰、直观的语法来定义和使用约束。

3.1 核心定义:什么是概念与约束?

一个概念(Concept)是一个命名的、可以在编译期求值的布尔谓词。它是对一组要求的抽象描述。例如,“可递增的”、“可比较相等的”、“可哈希的”都可以是概念。

一个约束(Constraint)是施加在模板参数上的一组要求。概念是约束的一种,但约束也可以是更复杂的逻辑组合。

定义概念的语法非常直观:

template <typename T> concept Integral = std::is_integral_v<T>; // 使用类型特征 template <typename T> concept SignedIntegral = Integral<T> && std::is_signed_v<T>; // 概念的组合 template <typename T> concept Addable = requires(T a, T b) { // 使用requires表达式 a + b; // 要求表达式 a+b 是合法的 };

这里定义了三个概念。Integral直接包装了现有的类型特征。SignedIntegral展示了概念的逻辑组合(合取&&)。Addable则使用了requires表达式,它声明:对于类型T的两个对象ab,表达式a + b必须语法正确(不要求实际求值)。

3.2 约束的用法:四种方式让模板更清晰

定义了概念之后,有四种主要方式来约束模板:

1. Requires子句(Requires Clause)这是最灵活的方式,requires关键字后跟一个常量布尔表达式。

template <typename T> requires Addable<T> && Copyable<T> // 约束:T必须可加且可拷贝 T sum(const std::vector<T>& vec) { T total{}; for (const auto& elem : vec) total = total + elem; return total; }

2. 简写函数模板(Abbreviated Function Template)在函数参数列表中使用概念,可以极大地简化语法。

// 传统写法 template <typename T> void process(const T& obj) { ... } // 使用概念的简写写法 (C++20) void process(const std::integral auto& obj) { ... } // 等价于: // template <std::integral T> // void process(const T& obj) { ... }

这行代码声明了一个函数模板,其参数obj的类型被std::integral概念约束。auto关键字在这里表示一个被推导的、受约束的类型。

3. 约束的模板参数(Constrained Template Parameter)在模板参数列表中直接使用概念。

template <std::input_iterator Iter> // Iter必须满足std::input_iterator概念 void advance(Iter& it, int n) { while (n-- > 0) ++it; } template <std::random_access_iterator Iter> // 另一个重载,约束更强 void advance(Iter& it, int n) { it += n; }

这是最推荐的用法之一,清晰地将约束与参数声明结合在一起。编译器会根据传入的迭代器类型,选择最匹配(约束最满足)的重载。

4. 约束的auto占位符(Constrained auto Placeholder)在任何可以使用auto的地方,都可以用概念来约束它。

std::integral auto x = 42; // 正确,42是整数 // std::integral auto y = 3.14; // 错误!3.14不是整数类型 template <typename Container> void print(const Container& c) { for (std::input_iterator auto it = c.begin(); it != c.end(); ++it) { std::cout << *it << ' '; } }

3.3 Requires表达式:定义约束的瑞士军刀

requires表达式是定义概念和复杂约束的核心工具。它用于在编译期检查某些属性或操作是否有效。

基本语法是:requires (参数列表) { 要求序列; }参数列表提供一些用于检查的“假变量”,要求序列则由一系列“要求”组成。

简单要求:检查一个表达式是否合法。

template <typename T> concept Streamable = requires(T obj, std::ostream& os) { os << obj; // 检查是否支持流输出操作 };

类型要求:检查一个嵌套类型是否存在。

template <typename T> concept HasValueType = requires { typename T::value_type; // 检查T是否有名为value_type的成员类型 };

复合要求:检查表达式是否合法,并且其返回类型满足某个约束。

template <typename F, typename... Args> concept Invocable = requires(F f, Args... args) { { f(args...) } -> std::same_as<void>; // 调用f(args...)必须合法,且返回void // 注意:-> 后面跟的是一个概念,用于约束返回类型 };

这里std::same_as<void>是一个标准概念,要求类型完全与void相同。

嵌套要求:在requires表达式内部再使用requires来引入额外的约束。

template <typename T> concept Arithmetic = requires(T a, T b) { a + b; a - b; a * b; a / b; requires std::is_arithmetic_v<T>; // 嵌套要求:同时必须是算术类型 };

实操心得requires表达式中的语句不会被执行,编译器只进行语法检查。这意味着即使你写requires { 1/0; },只要1/0这个表达式对类型T是合法的(比如Tint),这个要求就是满足的,不会引发除零错误。它检查的是“合法性”,而非“合理性”。

4. 概念与SFINAE的深度融合与实战解析

理解了概念的基本用法,我们来看看它如何与现有的SFINAE机制协同工作,并解决一些实际问题。

4.1 概念如何改进错误信息?

这是概念最直观的优点。对比下面两段代码:

使用SFINAE (C++17及之前)

template <typename T> auto print(const T& val) -> decltype(std::cout << val, void()) { std::cout << val << std::endl; } void print(...) { std::cout << "[Object not printable]" << std::endl; } struct MyData { int x; }; int main() { print(42); // OK print(MyData{}); // 调用第二个重载 }

当调用print(MyData{})时,编译器首先尝试第一个重载。在推导返回类型decltype(std::cout << val, void())时,std::cout << MyData{}失败。根据SFINAE,这个重载被移除。然后匹配第二个重载(catch-all的省略号版本)。错误信息可能很简洁,但如果你有多个SFINAE重载,错误链会很长。

使用概念 (C++20)

template <typename T> concept Printable = requires(const T& val, std::ostream& os) { os << val; }; template <Printable T> // 约束清晰明了 void print(const T& val) { std::cout << val << std::endl; } void print(...) { std::cout << "[Object not printable]" << std::endl; }

当调用print(MyData{})时,编译器检查MyData是否满足Printable概念。不满足,因此第一个模板被从候选集中排除。错误信息会是类似:“print(MyData)候选函数不可行:constraints not satisfied”,并且会紧接着列出Printable概念有哪些要求没有满足(例如:no operator<< matches these operands)。信息直接指向问题的核心:为什么不匹配

4.2 概念重载与约束排序

概念使得基于约束的重载解析变得非常强大和直观。编译器会选择最受约束(即要求最严格)的可行模板。

#include <concepts> #include <iostream> // 最泛化的版本 template <typename T> void process(T val) { std::cout << "Generic: " << val << std::endl; } // 更受约束的版本:要求是整数 template <std::integral T> void process(T val) { std::cout << "Integral: " << val << std::endl; } // 最受约束的版本:要求是有符号整数 template <std::signed_integral T> void process(T val) { std::cout << "Signed Integral: " << val << std::endl; } int main() { process("hello"); // 调用 Generic 版本 (const char*) process(42u); // 调用 Integral 版本 (unsigned int) process(-100); // 调用 Signed Integral 版本 (int) }

对于process(-100)int同时满足三个模板的约束。但std::signed_integralstd::integral更严格(多了一个有符号的要求),而std::integral又比无约束的模板更严格。因此编译器选择最受约束的Signed Integral版本。这种“约束偏序”规则让基于类型特性的重载设计变得异常清晰。

4.3 用概念重构经典SFINAE场景

让我们用概念来重构之前提到的SmartCache例子,它需要为有close()方法的资源类型提供一个close_all()成员函数。

SFINAE版本(繁琐且易错)

template<class T, class = void> struct has_close : std::false_type {}; template<class T> struct has_close<T, std::void_t<decltype(std::declval<T>().close())>> : std::true_type {}; template<class ResourceType> class SmartCache { std::vector<ResourceType> resources_; public: // 需要额外模板参数R来触发SFINAE在正确时机 template<class R = ResourceType> typename std::enable_if_t<has_close<R>::value> close_all() { for (auto& res : resources_) res.close(); } // 对于没有close的类型,这个函数根本不存在 };

概念版本(清晰直观)

template<class T> concept Closeable = requires(T t) { t.close(); // 简单要求:t.close()必须合法 }; template<class ResourceType> class SmartCache { std::vector<ResourceType> resources_; public: void close_all() requires Closeable<ResourceType> { // requires子句约束成员函数 for (auto& res : resources_) res.close(); } // 对于没有close的类型,这个函数声明存在但被约束排除在候选集外 };

或者,如果你希望对于不支持close的类型,close_all()是一个空操作,可以结合if constexpr

void close_all() { if constexpr (Closeable<ResourceType>) { for (auto& res : resources_) res.close(); } // 否则什么都不做 }

概念的代码意图一目了然:Closeable描述了一个类型能做什么,而requires Closeable<ResourceType>则清晰地声明了这个成员函数在什么条件下可用。

4.4 标准库中的概念应用

C++20标准库头文件<concepts><iterator>等定义了大量现成的概念,你应该优先使用它们。

  • 核心语言概念std::same_as,std::derived_from,std::convertible_to,std::integral,std::floating_point等。
  • 比较概念std::equality_comparable,std::totally_ordered等。
  • 对象概念std::movable,std::copyable,std::semiregular,std::regular等。
  • 可调用概念std::invocable,std::predicate等。
  • 迭代器概念std::input_iterator,std::forward_iterator,std::random_access_iterator等。
  • 范围概念std::ranges::range,std::ranges::input_range等(在<ranges>头文件中)。

例如,标准库算法现在普遍使用概念来约束迭代器:

namespace std::ranges { template<std::input_iterator I, std::sentinel_for<I> S, typename T> requires std::indirect_binary_predicate<std::ranges::equal_to, std::projected<I, Proj>, const T*> constexpr I find(I first, S last, const T& value); }

虽然看起来复杂,但每个约束都精确描述了算法对参数的要求,使得接口契约无比清晰。

5. 迁移策略、常见陷阱与性能考量

5.1 从SFINAE迁移到概念

如果你有一个现有的、使用SFINAE的代码库,迁移到概念可以循序渐进:

  1. 识别并定义概念:找出代码中重复出现的SFINAE条件(例如,检查begin()/end(),检查operator<<),将它们提取为命名概念。这本身就是一种代码重构和文档化。

    // 以前:分散在各处的SFINAE template<typename C> auto func(C& c) -> decltype(c.begin(), c.end(), void()) { ... } // 现在:定义统一的概念 template<typename C> concept Container = requires(C c) { c.begin(); c.end(); typename C::value_type; // ... 其他容器要求 }; template<Container C> void func(C& c) { ... } // 清晰多了
  2. 替换std::enable_if:将函数签名或返回类型中的std::enable_if_t<...>直接替换为requires子句或约束的模板参数。

  3. 替换特征检查:将std::void_t和特化实现的类型特征(如has_type_member),用requires表达式定义的概念替代。概念更易于组合和阅读。

  4. 注意重载解析变化:概念带来的约束排序可能微妙地改变重载决议的结果。在迁移后需要进行充分的测试。

5.2 使用概念时的常见陷阱

  1. 过度约束:定义的概念过于严格,排除了本应有效的类型。例如,一个“可排序”的概念如果要求同时提供operator<std::sort能工作,可能就太严格了,因为std::sort还需要随机访问迭代器。概念应该描述最小化的一组要求
  2. 约束非原子性:在requires表达式中,多个要求是“与”的关系,必须全部满足。但有时你需要“或”的关系。这时应该定义多个小概念,然后用||组合。
    template<typename T> concept Number = std::integral<T> || std::floating_point<T>;
  3. 忽略ADL(Argument-Dependent Lookup):在requires表达式中检查自由函数时,要注意ADL。requires { swap(a, b); }检查的是在ab类型所在命名空间中能找到的swap,这通常是正确的。但如果你错误地写成了requires { std::swap(a, b); },就要求必须存在std::swap的特化,这可能过于严格。
  4. 概念与auto的混淆template <ConceptName T>ConceptName auto在函数参数中是等价的。但在变量声明中,只有后者是合法的。std::integral auto x = 10;是声明一个受约束的变量,而template<std::integral T> T x = 10;是声明一个变量模板,语法和含义都不同。

5.3 概念对编译性能的影响

这是一个很多人关心的问题。概念是在编译期处理的,它会不会显著增加编译时间?

短期来看,可能略有增加:编译器需要解析和检查requires表达式,并进行约束满足性验证。这比简单的模板实例化要多一些工作。

长期来看,通常能提升性能

  1. 更早的错误诊断:SFINAE的错误发生在重载解析和替换阶段,编译器可能会尝试实例化多个模板分支才失败。概念约束在模板被加入重载集之前就进行验证,无效的模板直接被排除,避免了不必要的实例化尝试,这可以节省大量时间,尤其是对于复杂的、深度嵌套的模板。
  2. 更清晰的错误信息:虽然生成错误信息本身有开销,但精准的错误能让你更快定位问题,减少“编译-看错误-猜原因-修改-再编译”的循环,从开发效率上看是巨大的提升。
  3. 更好的编译器优化:明确的约束给了编译器更多关于类型的信息,理论上在某些情况下可以辅助生成更好的代码(尽管这点优化通常很微小)。

我的经验是,对于一个中型项目,全面采用概念后,整体的增量编译时间变化不大,甚至可能因为减少了不必要的头文件展开和模板实例化而略有减少。而开发调试效率的提升是实实在在的。

5.4 调试与测试概念

如何测试你定义的概念是否正确?

  1. 使用static_assert:这是最基本也是最重要的测试手段。

    static_assert(Addable<int>); // 应该通过 static_assert(!Addable<std::vector<int>>); // 应该通过 static_assert(SignedIntegral<int>); // 通过 static_assert(SignedIntegral<unsigned int>); // 应该失败

    在编译期就能验证概念的行为是否符合预期。

  2. 编写概念检查单元测试:对于复杂的、由多个子要求组成的概念,可以编写专门的测试套件,用各种边界类型(如const类型、引用类型、包含特定成员的类等)来验证。

  3. 利用编译器诊断:如果某个模板因为约束不满足而被排除,现代编译器(如GCC >=10, Clang >=13, MSVC >=19.28)能给出非常详细的诊断信息,列出是概念的哪个子要求失败了。仔细阅读这些信息是调试概念定义的最佳途径。

C++20的概念与约束,将C++模板元编程从“技巧性艺术”拉回到了“工程性设计”的轨道。它用清晰的语法表达了程序员的意图,让编译器能成为你更强的盟友,而不是一个吐出晦涩错误的黑盒。虽然学习它需要一点投入,但这份投入在代码的可读性、可维护性和健壮性上带来的回报是绝对超值的。从我个人的项目经验来看,一旦习惯了概念的思维方式,就再也不想回头写那些充满std::enable_if的“魔法”代码了。

http://www.jsqmd.com/news/1378066/

相关文章:

  • BetterNCM插件管理器完整指南:3分钟快速打造个性化音乐播放体验
  • IPTVnator:免费跨平台IPTV播放器的终极解决方案
  • 定制一件传家级和田玉是什么体验?我在合玉文化的全流程经历 - 优质品牌中立测评推荐
  • 昆泰芯 KTM5900|3.0~5.5V/-40~125℃24bit TMR 绝对磁性编码器 HFBP5×5-32L 伺服直线电机分享
  • 上虞汽车维修店实测好评,实力工艺推荐 - 产品推荐官
  • Node.js Web服务器搭建指南:从原生HTTP模块到Express框架实践
  • LookScanned.io实战指南:如何将PDF电子文档转换为专业扫描件
  • 打印机只打半张照片?全页照片打印不全的排查与修复
  • PyCharm与pytest深度配置指南:提升Python测试效率与代码质量
  • 2026安徽省民办高中学费高昂且质量参差不齐?合肥理工学校怎么报名?在哪报名?联系方式多少? - 最新资讯
  • 深度解析中温伴热带:核心原理与工艺维温应用 - 全域品牌推荐
  • Maven资源打包问题排查:从原理到实战解决FileNotFoundException
  • Hotkey Detective终极指南:三分钟定位Windows热键冲突的完美解决方案
  • 3分钟掌握iOS虚拟定位:无需越狱的安全解决方案
  • Markdown字体渲染问题:解决花体字母与等宽字体冲突
  • GPU上Transformer模型优化实战:从AMP到梯度检查点的完整指南
  • 如何快速掌握AMD Ryzen专业调试工具:面向初学者的完整使用指南
  • 蓝桥杯B组初赛:算法与数据结构备战全攻略
  • Python列表切片思维在AI提示工程中的应用:从数据结构到精准引导
  • 三角洲机器码解码软件_三角洲解码工具_三角洲机器码被封解除
  • Win10系统安装3Ds Max 2020报错1603的完整排查与修复指南
  • WarcraftHelper终极指南:快速掌握魔兽争霸III增强插件的5个核心技巧
  • 年终总结PPT哪个AI工具自动排版效果最好|5款主流工具实测横评
  • H3C MSR830路由器BootWare恢复实战:从系统丢失到业务重生
  • 昆明资质齐全的沃尔沃专修门店云南沃之捷|在昆明修车,为什么我总劝你:能修就别换?实话实说有点得罪人 - 专业优选推荐榜
  • 5分钟快速安装:群晖NAS Realtek USB网卡驱动终极指南
  • 计算机毕业设计之基于SpingBoot框架的公共房屋租赁管理系统设计与实现
  • 江苏专业食品销毁公司!厦亦环保作为优质服务商 以标准化流程筑牢全域食安防线 - 产品推荐官
  • Vue3源码学习环境搭建与调试指南:从响应式系统到运行时核心
  • LTSC-Add-MicrosoftStore:为Windows 11 LTSC系统添加Microsoft Store的技术方案