ARTICLE DETAIL

资讯详情

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

c++模板自定义数组

c++模板自定义数组 前言手写一个数组类是学完 C 类之后最常被布置的作业。很多人写完的第一版长这样成员是int data[10];然后发现想存double就得再抄一遍于是再写一个DoubleArray。模板要解决的正是这个问题——把元素的类型和数组的长度都变成参数让编译器替你生成所有版本。真正上手时两个误解最容易出现。第一个是把模板当成宏的语法升级版觉得它就是文本替换实际上模板参数参与完整的类型检查和重载决议std::vectorint和std::vectordouble是两个毫不相干的类型这是宏做不到的。第二个是以为类模板的成员函数必须也能放在.cpp里——模板的实例化发生在使用点编译.cpp的那个翻译单元根本不知道你会用哪组参数把定义藏在.cpp里得到的通常是链接错误 undefined reference而不是编译错误。本文以 C17 为基准GCC 13 / Clang 17 / MSVC 19.3x 均可编译从零写一个静态长度的Array类模板逐步补上变参构造、推导指引、越界检查、迭代器支持和流输出最后给一份完整的可编译示例。一、类模板与非类型模板参数C 的模板参数分两类类型模板参数typename T和非类型模板参数std::size_t N。数组长度天然适合做后者——它在编译期就是常量。#include cstddef template typename T, std::size_t N class Array { public: constexpr std::size_t size() const noexcept { return N; } T operator[](std::size_t i) noexcept { return data_[i]; } const T operator[](std::size_t i) const noexcept { return data_[i]; } private: T data_[N]{}; };用法是Arrayint, 5 a;、Arraydouble, 3 b;。几点说明std::size_t的整数类型和长度必须由调用方显式给出编译器没有办法从别处推出来——除非你另外提供推导指引见第二节。成员初始化写成T data_[N]{};这样默认构造出来的数组是值初始化的内置类型全部置零而不是不确定的值。把{}去掉Arrayint, 5 a;里的五个int就是未初始化的读它们得到的是不确定值。size()声明成constexpr之后Arrayint, 5().size()这类表达式可以在常量表达式里使用。operator[]沿用标准容器的约定不做越界检查。越界访问是未定义行为UB标准不保证任何结果。要检查就用at()。二、构造与推导指引变参构造函数一次把若干值装进去用变参模板最自然#include utility #include type_traits template typename... U Array(U... values) : data_{ static_castT(std::forwardU(values))... } { static_assert(sizeof...(U) N, 元素个数必须等于数组长度); }这里sizeof...(U)是参数包里的实参个数static_assert把它和N比一下数目不对时给出一条人能看懂的报错。即使不加static_assertdata_{...}那行也会因为个数不匹配而失败但报错会藏在初始化列表的细节里远不如一句中文提示好用。static_castT(...)是必需的它把每个实参显式转成元素类型T同时保证不触发花括号初始化里的窄化转换检查。比如Arrayint, 2 a{1.0, 2.0}会把两个double截断成1和2。这不是 bug而是static_cast明确表达了我知道我在做转换。推导指引deduction guideC17写Arrayint, 5 a{...}时长度要手数一遍很容易数错。C17 的类模板实参推导CTADclass template argument deduction允许这么写Array a{1, 2, 3}; // 自动推导成 Arrayint, 3让它成立的是紧跟在类模板后面的一条推导指引template typename T, typename... U Array(T, U...) - Arraystd::common_type_tT, U..., sizeof...(U) 1;箭头左边是用来匹配的伪函数签名右边是推导出的类模板特化。std::common_type_t取所有实参的公共类型sizeof...(U) 1就是元素总数第一个参数单独占一个。这是 C17 特性C17 之前没有 CTAD只能老老实实写全Arrayint, 3。标准的std::array也提供了同样的推导指引所以std::array a{1, 2, 3}从 C17 起也是合法的。三、让它用起来像标准容器越界检查的at()#include stdexcept T at(std::size_t i) { if (i N) throw std::out_of_range(Array::at: 下标越界); return data_[i]; }std::out_of_range定义在stdexcept里构造函数接受一个const char*描述信息。operator[]和at()的分工与std::vector一致前者零开销、越界是 UB后者抛异常、有分支开销。不要把operator[]改成抛异常版本那会让下标一定合法的热点循环凭空多一次比较。迭代器让范围 for 能用范围forfor (auto x : a)只要求类型提供begin()和end()。对连续存储的数组来说返回指针就够了T* begin() noexcept { return data_; } T* end() noexcept { return data_ N; } const T* begin() const noexcept { return data_; } const T* end() const noexcept { return data_ N; }指针是合法迭代器因为它满足随机访问迭代器的全部要求。加上这四个函数之后std::sort(a.begin(), a.end())、std::accumulate之类的标准算法也能直接作用在Array上。流输出与相等比较#include ostream friend std::ostream operator(std::ostream os, const Array a) { os {; for (std::size_t i 0; i N; i) { if (i ! 0) os , ; os a.data_[i]; } return os }; } friend bool operator(const Array lhs, const Array rhs) { for (std::size_t i 0; i N; i) { if (!(lhs.data_[i] rhs.data_[i])) return false; } return true; }在类模板内部定义的友元函数叫隐藏友元hidden friend它每个实例化生成一个独立的非模板函数只能通过参数依赖查找ADL找到。好处是Arrayint,3的operator不会和Arraydouble,3的混在一起参与重载决议能避免一大堆意外的隐式转换。四、完整可编译示例// my_array.cpp —— 模板自定义数组C17 #include algorithm #include cstddef #include iostream #include ostream #include stdexcept #include string #include type_traits #include utility template typename T, std::size_t N class Array { public: Array() default; template typename... U Array(U... values) : data_{ static_castT(std::forwardU(values))... } { static_assert(sizeof...(U) N, 元素个数必须等于数组长度); } constexpr std::size_t size() const noexcept { return N; } T operator[](std::size_t i) noexcept { return data_[i]; } const T operator[](std::size_t i) const noexcept { return data_[i]; } T at(std::size_t i) { if (i N) throw std::out_of_range(Array::at: 下标越界); return data_[i]; } const T at(std::size_t i) const { if (i N) throw std::out_of_range(Array::at: 下标越界); return data_[i]; } T* data() noexcept { return data_; } const T* data() const noexcept { return data_; } T* begin() noexcept { return data_; } T* end() noexcept { return data_ N; } const T* begin() const noexcept { return data_; } const T* end() const noexcept { return data_ N; } void fill(const T value) { for (std::size_t i 0; i N; i) data_[i] value; } friend std::ostream operator(std::ostream os, const Array a) { os {; for (std::size_t i 0; i N; i) { if (i ! 0) os , ; os a.data_[i]; } return os }; } friend bool operator(const Array lhs, const Array rhs) { for (std::size_t i 0; i N; i) { if (!(lhs.data_[i] rhs.data_[i])) return false; } return true; } friend bool operator!(const Array lhs, const Array rhs) { return !(lhs rhs); } private: T data_[N]{}; }; // C17 的推导指引Array a{1, 2, 3} 推导成 Arrayint, 3 template typename T, typename... U Array(T, U...) - Arraystd::common_type_tT, U..., sizeof...(U) 1; int main() { // 1. 基本用法显式给出类型和长度 Arrayint, 5 a; a.fill(7); std::cout a a size a.size() \n; // 2. 变参构造 CTAD Array b{3, 1, 4, 1, 5}; std::cout b b \n; // 3. 迭代器兼容标准算法 std::sort(b.begin(), b.end()); std::cout 排序后 b b \n; // 4. 范围 for int sum 0; for (int v : b) sum v; std::cout sum sum \n; // 5. at() 越界抛异常 try { std::cout b.at(100) \n; } catch (const std::out_of_range e) { std::cout 捕获异常: e.what() \n; } // 6. 换成别的元素类型同一个模板 Arraystd::string, 2 names{张三, 李四}; std::cout names names \n; // 7. 相等比较 Arrayint, 3 x{1, 2, 3}; Arrayint, 3 y{1, 2, 3}; std::cout x y ? (x y) \n; return 0; }编译命令g -stdc17 -Wall -Wextra -o my_array my_array.cpp输出std::cout对bool打印的是1而不是truea {7, 7, 7, 7, 7} size 5 b {3, 1, 4, 1, 5} 排序后 b {1, 1, 3, 4, 5} sum 14 捕获异常: Array::at: 下标越界 names {张三, 李四} x y ? 1关于把模板写在哪上面所有内容必须放在头文件里.h或.hpp或者像这里一样放在同一个.cpp里。如果非要把成员函数定义拆到array.cpp唯一的补救办法是在array.cpp里做显式实例化template class Arrayint, 5; // 显式实例化整个类 template class Arraystd::string, 2;但这只能覆盖你写出来的那几种组合Arraydouble, 100一样会链接失败。所以模板库的常规做法就是定义全放头文件。常见坑点1. 把模板成员函数的定义放进.cpp导致链接错误// array.h template typename T, std::size_t N class Array { public: std::size_t size() const noexcept; // ❌ 只声明 }; // array.cpp template typename T, std::size_t N std::size_t ArrayT, N::size() const noexcept { return N; } // 编译过了但没人会用到 // ✅ 定义直接写在头文件里 template typename T, std::size_t N class Array { public: std::size_t size() const noexcept { return N; } };2. 长度用int做非类型模板参数template typename T, int N class Array; // ❌ 负数字面量会被静默接受 // Arrayint, -1 a; // 展开成负数长度的数组报错信息极难读 template typename T, std::size_t N class Array; // ✅ 用无符号类型-1 会被转成巨大值从而报错3.N为 0 时写T data_[N]template typename T, std::size_t N class Array { T data_[N]; }; // ❌ N 0 时是零长度数组标准 C 不允许GCC 会警告并当作扩展 template typename T, std::size_t N class Array { static_assert(N 0, Array 的长度必须大于 0); // ✅ 用 static_assert 挡住 T data_[N]{}; };4. 忘记{}初始化读到不确定值template typename T, std::size_t N class Array { T data_[N]; }; Arrayint, 5 a; std::cout a[0]; // ❌ int 未初始化读取的是不确定值 template typename T, std::size_t N class Array { T data_[N]{}; }; Arrayint, 5 a; std::cout a[0]; // ✅ 输出 05. 以为operator[]会做越界检查Arrayint, 3 a{1, 2, 3}; std::cout a[5]; // ❌ UB标准不保证任何结果不要指望它抛异常 std::cout a.at(5); // ✅ 抛 std::out_of_range6. 把operator写成类外模板却忘了类内的 friend 声明template typename T, std::size_t N class Array { /* ... */ T data_[N]{}; }; // ❌ 类外定义的模板不是友元访问不到私有的 data_ template typename T, std::size_t N std::ostream operator(std::ostream os, const ArrayT, N a) { return os a.data_[0]; // 编译错误data_ 是 private } // ✅ 方案一在类内先声明这个模板是友元模板本身要提前声明 template typename T, std::size_t N class Array { template typename U, std::size_t M friend std::ostream operator(std::ostream, const ArrayU, M); }; // ✅ 方案二推荐在类内定义隐藏友元只走 ADL 查找 template typename T, std::size_t N class Array { friend std::ostream operator(std::ostream os, const Array a) { return os a.data_[0]; } };7. 以为Arrayint, 5和Arraydouble, 5能互相转换Arrayint, 5 a{1, 2, 3, 4, 5}; Arraydouble, 5 b a; // ❌ 不同类型的模板特化之间没有隐式转换编译错误8. 用非类型模板参数存浮点数template typename T, double N class Array; // ❌ C17 不允许只有整型、枚举、指针、引用、 // 以及部分字面量类类型可以作为非类型参数 template typename T, std::size_t N class Array; // ✅ C17 的常规做法C20 放宽了这一限制允许浮点数和字面量类类型作为非类型模板参数如果确实需要要注明需要 C20 及以上。总结需求做法备注元素类型可替换template typename T, ...类型模板参数长度在编译期确定std::size_t N非类型模板参数用无符号类型避免负数一次装入多个初值变参构造 static_assert(sizeof...(U) N)个数不符时报错清晰免写长度推导指引Array(T, U...) - Array...C17 的 CTAD默认元素值确定成员写成T data_[N]{};值初始化内置类型置零越界可控at()抛std::out_of_rangeoperator[]保持零开销兼容标准算法提供begin()/end()指针就是合法迭代器可打印、可比较类内定义隐藏友元operator/只走 ADL不污染重载集定义放哪儿全放头文件或显式实例化别拆到.cpp里写自定义模板容器的价值不在于造了个轮子而在于它把 C 里最容易出错的几件事——模板实例化时机、非类型模板参数的类型选择、隐藏友元的查找规则——都摆到了台面上。写完之后再回头看std::array的接口设计会发现自己少踩的那些坑标准委员会早就踩过了。
返回列表