C++模板编程:从函数模板到类模板的实战指南
1. 项目概述为什么C模板是“懒人”的终极武器干了这么多年C我见过太多程序员在重复造轮子。写一个swap函数要写int版本、double版本、string版本代码长得像俄罗斯套娃。直到他们真正理解了模板才恍然大悟原来C早就准备好了“一劳永逸”的解决方案。模板本质上就是一种“代码生成器”它允许你编写与类型无关的通用代码编译器在编译时根据你实际使用的类型自动为你生成一份特化后的代码。这听起来有点像宏但比宏强大、安全得多。宏只是简单的文本替换而模板是C类型系统的一部分会进行完整的类型检查。对于初学者来说模板常常是C学习路上的第一个“劝退点”语法看起来古怪错误信息长得像天书。但一旦你跨过这个门槛你会发现你的代码抽象能力、复用能力会得到质的飞跃。标准库里的vector、map、sort这些你天天用的工具其核心都是模板。这篇文章我会带你从零开始掰开揉碎地讲清楚函数模板和类模板不仅告诉你“怎么写”更重点解释“为什么这么写”以及那些官方手册里不会写的“坑”在哪里。无论你是想摆脱重复代码的困扰还是想读懂并贡献开源库的模板代码这里的内容都值得你花时间细读。2. 函数模板告别重复一次编写处处通用函数模板是模板中最直观、最常用的形式。它的核心思想是定义一个函数蓝图其中的参数类型和返回类型是“待定”的用所谓的“模板参数”来代替。2.1 基础语法与类型推导一个最简单的交换函数模板长这样template typename T // 模板声明T是一个类型参数 void mySwap(T a, T b) { T temp a; a b; b temp; }这里的template typename T是模板引入的关键字。typename也可以用class替代两者在绝大多数情况下完全等价我个人习惯用typename因为它语义更清晰表示一个类型名而class容易让人误解为只能是类类型。使用起来和普通函数几乎没区别int x 1, y 2; mySwap(x, y); // 编译器推导出T是int生成void mySwap(int, int) std::string s1 hello, s2 world; mySwap(s1, s2); // 编译器推导出T是std::string生成void mySwap(std::string, std::string)编译器在这里做了件聪明事模板实参推导。它根据你调用函数时传入的实参类型自动推导出模板参数T的具体类型。你不需要显式地写成mySwapint(x, y)。注意模板的编译过程是“两阶段查找”。第一阶段在模板定义时检查所有不依赖于模板参数的语法比如漏了分号。第二阶段在模板实例化时检查所有依赖于模板参数的代码比如T temp a;要求类型T必须可拷贝构造。这也是为什么模板错误信息又长又难懂的原因——错误发生在实例化那一刻编译器会把整个模板实例化的“堆栈”都吐出来。2.2 多参数与非类型模板参数模板参数不限于类型。你可以有多个类型参数甚至可以传入非类型参数值。// 多个类型参数 template typename T1, typename T2 auto add(const T1 a, const T2 b) - decltype(a b) { return a b; } // 使用C14的自动返回类型推断更简洁templatetypename T1, typename T2 auto add(const T1 a, const T2 b) { return a b; } // 非类型模板参数一个整型常量 template typename T, int N class FixedArray { T data[N]; // 数组大小在编译期就确定了 public: int size() const { return N; } };非类型模板参数必须是编译期常量可以是整型、枚举、指针或引用。这带来了一个强大的特性编译期计算。数组大小N在编译时就固定了不需要动态内存分配性能更高并且编译器可以进行更多的优化。实操心得当你发现一个函数或类的行为依赖于某个在编译期就能确定的常量值时考虑使用非类型模板参数。但要注意不同的模板参数值会产生不同的实例可能导致代码膨胀。例如FixedArrayint, 10和FixedArrayint, 20在编译器看来是两个完全不同的类。2.3 函数模板的重载与特化函数模板也可以被重载。编译器在选择时优先级通常是非模板函数 特化的模板函数 泛化的模板函数。// 泛化版本 template typename T void print(const T val) { std::cout Generic: val std::endl; } // 为const char* 提供的重载非模板函数优先级最高 void print(const char* val) { std::cout C-string: val std::endl; } // 函数模板全特化注意语法 template void printint(const int val) { std::cout Specialized for int: val std::endl; }这里有一个关键点函数模板通常不建议全特化而是使用重载。因为函数模板的全特化不参与重载决议可能会产生令人意外的行为。上面的printint全特化只是一个语法演示在实际工程中如果你需要对int类型特殊处理直接写一个void print(int val)的重载函数更安全、更直观。对于函数模板我们更多使用的是通过重载实现“偏特化”效果。C标准不允许函数模板的偏特化即template typename T void printT*但你可以通过重载一个接收指针类型的模板来实现类似目的template typename T void print(T* ptr) { if (ptr) std::cout Pointer points to: *ptr std::endl; else std::cout Null pointer std::endl; }3. 类模板构建通用容器的基石如果说函数模板让算法通用那么类模板就让数据结构通用。std::vectorstd::liststd::map这些耳熟能详的容器都是类模板的杰作。3.1 类模板的定义与成员函数类模板的声明和定义通常都放在头文件里。因为模板不是真正的代码它是一份“蓝图”编译器需要在每一个使用它的编译单元中看到完整的定义才能实例化出具体的类。// MyStack.h template typename T class MyStack { private: T* data; // 存储元素的数组 int capacity; // 栈容量 int topIndex; // 栈顶索引 public: // 构造函数 MyStack(int size 10); // 析构函数 ~MyStack(); // 成员函数声明 void push(const T item); T pop(); bool isEmpty() const; };类模板的成员函数在类外定义时语法需要特别注意// 每一个成员函数定义前都需要重复模板声明 template typename T MyStackT::MyStack(int size) : capacity(size), topIndex(-1) { data new T[capacity]; } template typename T void MyStackT::push(const T item) { if (topIndex capacity - 1) { // 扩容逻辑...实际项目会更复杂 } data[topIndex] item; } template typename T T MyStackT::pop() { if (isEmpty()) { throw std::out_of_range(Stack is empty!); } return data[topIndex--]; }注意MyStackT::这个作用域标识符。它告诉编译器这个push函数是属于MyStackT这个类的而不是普通的MyStack类实际上没有模板参数的MyStack类并不存在。3.2 嵌套类模板与成员模板这是模板中比较高级但也非常强大的特性。你可以在一个类模板内部再定义一个模板可以是类模板也可以是函数模板。template typename OuterT class OuterClass { public: // 嵌套类模板 template typename InnerT class InnerClass { InnerT innerData; OuterT* outerPtr; // 可以访问外部类的类型 public: InnerClass(OuterT* ptr) : outerPtr(ptr) {} }; // 成员函数模板 template typename U void memberFunc(const U param) { // 可以同时使用OuterT和U两种类型 std::cout Outer type: typeid(OuterT).name() , Param type: typeid(U).name() std::endl; } // 使用嵌套类 InnerClassint createInner() { return InnerClassint(static_castOuterT*(this)); } };为什么需要成员模板最经典的例子是容器的构造函数。std::vector除了可以用vectorint v(10, 100)初始化还可以用迭代器范围初始化vectorint v2(v.begin(), v.end())。这个迭代器构造函数就是一个成员函数模板它接受任意类型的迭代器只要该迭代器指向的元素能转换为int这样vector就可以从list、deque甚至原生数组进行构造极大地增强了通用性。3.3 类模板的友元让外部函数或类能够访问类模板的私有成员就需要用到友元。模板的友元声明比普通类要复杂一些。template typename T class MyArray { private: T* arr; int size; public: MyArray(int n) : size(n), arr(new T[n]{}) {} // 声明一个函数模板为该类模板的友元 template typename U friend bool arraysEqual(const MyArrayU a, const MyArrayU b); // 声明一个特定的类为该类模板所有实例的友元 friend class ArrayPrinter; }; // 友元函数模板的定义 template typename U bool arraysEqual(const MyArrayU a, const MyArrayU b) { if (a.size ! b.size) return false; for (int i 0; i a.size; i) { // 这里能直接访问私有成员a.size, a.arr[i] if (a.arr[i] ! b.arr[i]) return false; } return true; } // ArrayPrinter类的定义 class ArrayPrinter { public: template typename T static void print(const MyArrayT arr) { for (int i 0; i arr.size; i) { // 能访问私有成员 std::cout arr.arr[i] ; } std::cout std::endl; } };这里的关键是理解友元关系。template typename U friend bool arraysEqual(const MyArrayU a, const MyArrayU b);意味着对于每一种类型T实例化的MyArrayT与之对应的arraysEqualU函数当U等于T时是其友元。MyArrayint的友元是arraysEqualint而不是arraysEqualdouble。4. 模板特化与偏特化当通用方案遇到特殊情况模板提供了通用方案但总有些类型或情况需要特殊对待。这就是特化Specialization的用武之地。4.1 类模板的全特化全特化就是为模板参数指定全部的具体类型或值提供一个完全定制化的版本。// 泛化版本 template typename T class MyContainer { T data; public: void describe() { std::cout Generic container holding some type. std::endl; } }; // 全特化版本当T为bool时 template class MyContainerbool { bool data; // 可以用完全不同的内部实现 std::bitset8 bitData; public: void describe() { std::cout Specialized container optimized for bool (could use bit-level storage). std::endl; } };全特化时template 表示这是一个特化后面紧跟的MyContainerbool指明了特化的具体条件。全特化的类可以和泛化版本完全不同包括数据成员、成员函数、继承关系等。标准库中std::vectorbool就是一个著名的全特化尽管其设计存在争议它尝试对bool类型进行位存储优化。4.2 类模板的偏特化偏特化Partial Specialization也叫部分特化它只特化一部分模板参数或者对模板参数加上一些约束比如它必须是指针类型。// 泛化版本两个类型参数 template typename T1, typename T2 class MyPair { T1 first; T2 second; public: void describe() { std::cout Generic pair. std::endl; } }; // 偏特化版本当两个类型相同时 template typename T class MyPairT, T { T first; T second; public: void describe() { std::cout Pair with both elements of the same type. std::endl; } }; // 偏特化版本当第二个类型是int时 template typename T class MyPairT, int { T first; int second; public: void describe() { std::cout Pair with second element fixed as int. std::endl; } }; // 偏特化版本针对指针类型 template typename T class MyPointerWrapper { T* ptr; public: void describe() { std::cout Wrapper for a generic pointer. std::endl; } }; template typename T class MyPointerWrapperT* { T** ptr; // 注意这里T是原始类型例如int*那么T*就是int** public: void describe() { std::cout Specialized wrapper for a pointer to type T. std::endl; } };偏特化极大地增强了模板的灵活性。它允许你为一类类型而不仅仅是一个具体类型提供优化或不同的实现。编译器在实例化模板时会选择最匹配的特化版本。避坑指南偏特化的匹配规则可能很复杂。当有多个偏特化版本都匹配时编译器会选择“最特化”most specialized的那个。一个简单的判断原则是如果特化A能接受的参数集合是特化B的子集那么A就比B更特化。例如MyPairT, T比MyPairT1, T2更特化因为前者要求两个类型相同。5. 模板元编程基础与SFINAE模板不仅仅用于生成运行时代码借助C编译器的图灵完备性我们可以在编译期进行计算和类型操纵这就是模板元编程Template Metaprogramming, TMP。虽然现代CC11/14/17引入了constexpr、if constexpr等更友好的编译期计算工具但理解TMP的基础对于读懂高级库代码至关重要。5.1 编译期类型计算与值计算最简单的例子是编译期计算阶乘template int N struct Factorial { static const int value N * FactorialN - 1::value; }; // 特化作为递归终止条件 template struct Factorial0 { static const int value 1; }; int main() { int x Factorial5::value; // 在编译期就计算出120 // 等价于 int x 120; static_assert(Factorial5::value 120, Compile-time factorial failed.); }这里Factorial不是一个用来创建对象的类而是一个“类型函数”。我们通过::value来获取编译期计算的结果。编译器会递归地实例化Factorial5、Factorial4...直到Factorial0最终在编译期完成计算。5.2 SFINAE与类型萃取SFINAESubstitution Failure Is Not An Error替换失败并非错误是C模板的一个核心规则。它指的是在模板重载决议过程中如果某个候选模板在替换模板参数时产生了无效的代码比如访问不存在的成员类型那么这个候选模板会被直接从重载集中丢弃而不会导致编译错误。利用这个规则我们可以实现编译期的类型检查和条件选择。标准库中的std::enable_if就是SFINAE的经典应用。// enable_if的实现原理 templatebool B, class T void struct enable_if {}; templateclass T // 偏特化当B为true时 struct enable_iftrue, T { using type T; }; // 使用enable_if进行条件编译 template typename T typename std::enable_ifstd::is_integralT::value, T::type // 只有整型才启用这个函数 foo(T t) { std::cout Called integral version. std::endl; return t; } template typename T typename std::enable_ifstd::is_floating_pointT::value, T::type // 只有浮点型才启用 foo(T t) { std::cout Called floating point version. std::endl; return t; } // foo(10); // OK调用整型版本 // foo(3.14); // OK调用浮点版本 // foo(hello); // 编译错误没有匹配的foo版本因为两个enable_if条件都不满足std::is_integralT::value和std::is_floating_pointT::value是类型萃取Type Traits它们也是通过模板特化实现的用于在编译期查询类型的属性。// is_integral的简化实现示意 templatetypename T struct is_integral { static const bool value false; }; template struct is_integralint { static const bool value true; }; template struct is_integralshort { static const bool value true; }; template struct is_integrallong { static const bool value true; }; // ... 其他整型特化实操心得虽然SFINAE功能强大但代码可读性很差。C17引入了if constexprC20引入了concepts它们都能更清晰、更直观地实现条件编译和类型约束。在新项目中应优先考虑使用这些新特性。但理解SFINAE对于维护遗留代码和深入理解模板机制仍然必不可少。6. 可变参数模板处理任意数量参数的优雅方式C11引入了可变参数模板Variadic Templates允许模板接受任意数量、任意类型的参数。这是实现像std::tuple、std::function、完美转发等高级特性的基础。6.1 基础语法与递归展开可变参数模板使用...语法。// 递归终止函数 void print() { std::cout std::endl; } // 可变参数函数模板 template typename T, typename... Args // Args是一个模板参数包 void print(T first, Args... rest) { // rest是一个函数参数包 std::cout first ; print(rest...); // 递归调用参数包展开 } int main() { print(1, 2.5, hello, a); // 输出: 1 2.5 hello a }这个过程是递归的print(1, 2.5, hello, a)- 打印1调用print(2.5, hello, a)- 打印2.5调用print(hello, a)- ... - 调用print()触发终止函数。6.2 折叠表达式C17递归展开虽然经典但写起来麻烦。C17的折叠表达式让可变参数模板的处理简洁了许多。// 使用折叠表达式计算所有参数的和 template typename... Args auto sum(Args... args) { return (args ...); // 一元右折叠(arg1 (arg2 (arg3 ...))) // 也可以写成左折叠return (... args); } // 使用折叠表达式和逗号运算符调用函数 template typename... Args void callEach(Args... args) { (std::cout ... args) std::endl; // 输出所有参数 // 或者对每个参数执行某个操作 (someFunc(args), ...); // 依次调用someFunc }折叠表达式大大简化了可变参数包的操作代码意图更清晰编译器也可能生成更高效的代码。6.3 实现一个简单的元组Tuple理解了可变参数模板我们就能窥见std::tuple的实现原理。其核心思想是递归继承。// 空的特化作为递归基类 template typename... Types class MyTuple; // 递归定义一个元素 剩余元素的元组 template typename Head, typename... Tail class MyTupleHead, Tail... : private MyTupleTail... { private: Head value; public: MyTuple() default; MyTuple(const Head h, const Tail... t) : MyTupleTail...(t...), value(h) {} // 获取第N个元素简化版实际std::tuple用更复杂的技术 template std::size_t N auto get() { if constexpr (N 0) { return value; } else { // 向上转型到基类递归获取 return MyTupleTail...::template getN-1(); } } }; // 终止条件空元组 template class MyTuple { // 空基类不存储任何数据 };这个简化的MyTuple通过递归继承来存储所有元素。MyTupleint, double, char继承自MyTupledouble, char后者继承自MyTuplechar最后继承自MyTuple。每个派生类存储自己对应的那个元素Head。get函数通过递归和if constexpr在编译期确定要返回哪个基类中的成员。7. 模板实战从零实现一个简单的智能指针让我们综合运用所学实现一个简化版的std::unique_ptr这是理解类模板、特化、移动语义的绝佳练习。7.1 基础版本template typename T class UniquePtr { private: T* ptr_ nullptr; public: // 显式构造函数接管原始指针 explicit UniquePtr(T* ptr nullptr) noexcept : ptr_(ptr) {} // 禁止拷贝 UniquePtr(const UniquePtr) delete; UniquePtr operator(const UniquePtr) delete; // 移动构造函数 UniquePtr(UniquePtr other) noexcept : ptr_(other.ptr_) { other.ptr_ nullptr; // 源对象放弃所有权 } // 移动赋值运算符 UniquePtr operator(UniquePtr other) noexcept { if (this ! other) { delete ptr_; // 释放当前资源 ptr_ other.ptr_; other.ptr_ nullptr; } return *this; } // 析构函数 ~UniquePtr() { delete ptr_; } // 解引用运算符 T operator*() const noexcept { return *ptr_; } // 箭头运算符 T* operator-() const noexcept { return ptr_; } // 获取原始指针 T* get() const noexcept { return ptr_; } // 释放所有权 T* release() noexcept { T* temp ptr_; ptr_ nullptr; return temp; } // 重置指针 void reset(T* ptr nullptr) noexcept { delete ptr_; ptr_ ptr; } // 布尔转换用于if检查 explicit operator bool() const noexcept { return ptr_ ! nullptr; } };这个基础版本已经具备了unique_ptr的核心功能独占所有权、禁止拷贝、支持移动。但还有一个重大问题它只能处理通过new分配的对象无法处理数组new[]。7.2 针对数组的特化版本我们需要为T[]提供一个偏特化版本使用delete[]进行释放。// 针对数组的偏特化 template typename T class UniquePtrT[] { private: T* ptr_ nullptr; public: explicit UniquePtr(T* ptr nullptr) noexcept : ptr_(ptr) {} UniquePtr(const UniquePtr) delete; UniquePtr operator(const UniquePtr) delete; UniquePtr(UniquePtr other) noexcept : ptr_(other.ptr_) { other.ptr_ nullptr; } UniquePtr operator(UniquePtr other) noexcept { if (this ! other) { delete[] ptr_; // 注意这里是 delete[] ptr_ other.ptr_; other.ptr_ nullptr; } return *this; } ~UniquePtr() { delete[] ptr_; // 注意这里是 delete[] } // 对于数组operator[] 比 operator* 更合适 T operator[](std::size_t index) const { return ptr_[index]; } // 没有 operator* 和 operator-因为对数组解引用不明确 T* get() const noexcept { return ptr_; } T* release() noexcept { T* temp ptr_; ptr_ nullptr; return temp; } void reset(T* ptr nullptr) noexcept { delete[] ptr_; ptr_ ptr; } explicit operator bool() const noexcept { return ptr_ ! nullptr; } };现在我们可以这样使用UniquePtrint ptr1(new int(42)); // 调用基础版本 UniquePtrint[] arr(new int[10]{1,2,3}); // 调用数组特化版本 arr[0] 100; // 使用 operator[] // *arr; // 错误数组特化版本没有定义 operator*7.3 添加自定义删除器真正的std::unique_ptr更强大之处在于支持自定义删除器。我们可以通过模板的第二个参数来实现。// 默认删除器用于delete template typename T struct DefaultDelete { void operator()(T* ptr) const noexcept { delete ptr; } }; // 数组的默认删除器 template typename T struct DefaultDeleteT[] { void operator()(T* ptr) const noexcept { delete[] ptr; } }; // 带删除器的UniquePtr template typename T, typename Deleter DefaultDeleteT class UniquePtrWithDeleter { private: T* ptr_ nullptr; Deleter deleter_; // 删除器是对象可以是有状态的 public: // ... 构造函数、移动语义等与之前类似但析构函数改为 ~UniquePtrWithDeleter() { if (ptr_) { deleter_(ptr_); } } // reset函数也需要使用deleter_ void reset(T* ptr nullptr) noexcept { if (ptr_) { deleter_(ptr_); } ptr_ ptr; } // ... 其他成员函数 }; // 使用示例用于关闭文件的删除器 struct FileCloser { void operator()(std::FILE* fp) const noexcept { if (fp) std::fclose(fp); } }; void example() { // 管理动态内存 UniquePtrWithDeleterint p1(new int(10)); // 管理文件句柄 UniquePtrWithDeleterstd::FILE, FileCloser p2(std::fopen(test.txt, r)); // 当p2离开作用域时FileCloser()(fp)会被调用即fclose(fp) }通过模板参数Deleter我们将资源释放的策略从UniquePtr类中解耦出来使其能够管理任意类型的资源内存、文件句柄、互斥锁等这体现了策略模式的思想也是模板强大灵活性的体现。8. 模板的常见陷阱与最佳实践模板功能强大但坑也不少。下面是我在实际项目中总结的一些经验和教训。8.1 代码膨胀问题模板是在编译期根据不同类型生成多份代码。如果你用std::vectorint、std::vectorlong、std::vectordouble编译器就会生成三份几乎完全相同的vector代码。这会导致最终二进制文件体积增大即“代码膨胀”。缓解策略将非类型相关的代码抽离到非模板基类中。如果vector的容量管理逻辑与元素类型T完全无关可以将这部分逻辑放到一个普通的基类中让所有特化的vector共享这份代码。使用外部模板Explicit Instantiation。在大型项目中你可以在一个.cpp文件中显式实例化你需要的所有模板类型然后在其他编译单元中使用extern template声明来阻止重复实例化。// mytemplate.cpp #include mytemplate.h template class MyContainerint; // 显式实例化 template class MyContainerdouble; // otherfile.cpp #include mytemplate.h extern template class MyContainerint; // 声明已在别处实例化 extern template class MyContainerdouble; MyContainerint c1; // 链接时使用mytemplate.cpp中的实例8.2 编译时间过长模板特别是复杂的模板元编程和大量模板实例化会显著增加编译时间。因为每次实例化编译器都需要解析整个模板定义。优化建议使用前置声明和typename关键字在模板类内部如果有一个嵌套类型依赖于模板参数必须使用typename前缀来告诉编译器这是一个类型而不是静态成员。template typename T class MyClass { typename T::SubType* ptr; // 正确告诉编译器SubType是类型 // T::SubType* ptr; // 错误编译器会认为SubType是静态成员*是乘法 };避免在头文件中包含不必要的头文件。使用前置声明替代包含可以大幅减少预处理和编译时间。使用if constexprC17替代SFINAE。if constexpr在编译期判断条件丢弃不满足条件的分支不会产生无效的模板实例化能简化代码并提升编译速度。8.3 晦涩的错误信息模板编译错误可能是C程序员最大的噩梦之一。错误信息往往长达几十甚至上百行充斥着各种内部类型名。调试技巧从第一行和最后一行看起编译器错误信息通常像栈展开最后一行才是根源第一行是直接触发错误的位置。使用static_assert进行编译期检查在模板代码中加入static_assert可以提前给出清晰的错误信息。template typename T void process(T val) { static_assert(std::is_arithmeticT::value, process() requires an arithmetic type.); // ... 处理逻辑 }概念ConceptsC20是终极解决方案概念允许你对模板参数施加语义约束当约束不满足时错误信息会清晰得多。template std::integral T // 要求T必须是整型 T add(T a, T b) { return a b; } // add(3.14, 2.0); // 清晰的错误找不到匹配的重载函数因为double不满足std::integral概念8.4 分离编译问题这是模板的老大难问题。模板的定义必须对使用它的每一个编译单元可见因此模板通常都定义在头文件中。这违反了传统的“.h声明.cpp定义”的分离编译模式。解决方案接受现实将模板定义全部放在头文件。这是最常见、最简单的做法。使用显式实例化如上文8.1所述将模板的实例化限制在少数几个.cpp文件中。C11的extern template可以帮助声明外部实例化但治标不治本。我个人在实际项目中的体会是对于应用层代码坦然接受模板定义放在头文件中。对于库代码如果模板的实例化类型集合是已知且有限的比如你的库只支持int,float,double那么使用显式实例化并隐藏实现细节是更好的选择这能保持清晰的接口分离。模板是C最强大也最复杂的特性之一它就像一把双刃剑。用好了代码简洁、高效、类型安全用不好编译慢、错误难懂、代码膨胀。我的建议是先从简单的函数模板和类模板用起解决实际的代码重复问题。当你能熟练使用标准库中的模板容器和算法时再逐步探索更高级的特性如特化、SFINAE和可变参数模板。最后时刻关注新标准C17/20/23像if constexpr、concepts、auto模板参数这些新特性正在让模板编程变得越来越简单、安全。