前言
类模板让「数据结构的形状」与「元素类型」解耦:写一次Record<T>,就能用Record<int>、Record<double>、Record<std::string>分别保存不同类型的数据。把这类对象持久化到文件,则需要另一套机制——<fstream>提供的文件流。两件事单独看都不难,合在一起时问题集中出现在三个地方:模板的定义该放哪里、流操作失败如何判断、以及「按字节 dump」到底能不能用。
常见的误解是「模板类像普通类一样,声明放头文件、实现放 .cpp」。对普通类这是标准做法,对模板类却会导致链接错误:编译器在实例化Record<int>时需要看到成员函数的定义,而 .cpp 里的定义对调用方所在的翻译单元不可见。另一个误解是把std::string、std::vector成员的对象用write()原样写入文件,再read()回来——这类类型不是可平凡复制的(trivially copyable),按字节复制不满足对象生命周期规则,读回的对象内部指针指向的是旧地址,属于未定义行为。
本文先讲类模板的定义与实例化规则,再讲文件流的打开模式与错误检查,最后给出一个可以保存和加载的模板记录类,并说明文本格式与二进制格式各自的边界。代码基准为 C++17。
一、类模板的定义、实例化与成员
类模板以template开头,模板参数可以是类型参数,也可以是非类型参数:
// 适用于 C++17
#include <cstddef>
#include <stdexcept>
#include <vector>
template <class T, std::size_t Capacity = 16>
class Ring {
public:
void push(const T& value) {
if (size_ == Capacity) {
throw std::overflow_error("ring is full");
}
data_[size_++] = value;
}
std::size_t size() const { return size_; }
static constexpr std::size_t capacity() { return Capacity; }
private:
T data_[Capacity]{};
std::size_t size_ = 0;
};这里T是类型参数,Capacity是非类型参数,默认值 16。Capacity在编译期已知,所以T data_[Capacity]是合法的数组成员;如果Capacity是运行期变量,这个写法就不成立,必须改用std::vector<T>。
类模板的成员函数在类外定义时,要重复模板参数列表,并且用ClassName<T>::限定:
// 适用于 C++17
template <class T>
class Box {
public:
void set(const T& value);
const T& get() const;
private:
T value_{};
};
template <class T>
void Box<T>::set(const T& value) {
value_ = value;
}
template <class T>
const T& Box<T>::get() const {
return value_;
}在模板内部引用依赖名称(dependent name)时,如果它是个类型,必须加typename。例如typename Box<T>::value_type。不加typename时编译器默认把它当成静态成员,解析失败。C++20 起在某些上下文里可以省略,但为了可移植性,C++17 代码里还是显式写出更稳妥。
实例化与显式实例化
当你写Box<int> b;时,编译器用int替换T,生成一份具体的类,这叫隐式实例化。同一个类型在同一翻译单元里只生成一次,不同翻译单元之间由链接器合并(模板实例化通常带弱符号)。
也可以在某个 .cpp 里显式实例化:
// 适用于 C++17
#include "box.h"
template class Box<int>;
template class Box<double>;这叫显式实例化定义,它让编译器在这里生成全部成员函数的代码。配合头文件里的显式实例化声明extern template class Box<int>;,可以避免每个使用Box<int>的翻译单元都重复实例化,从而缩短编译时间、减小目标文件。代价是:使用方只能链接到这些预先生成的类型,新增类型必须回到那个 .cpp 里补一行。
二、模板定义为什么通常必须放在头文件
C++ 的编译模型是「分离编译」:每个 .cpp 独立编译成目标文件,再链接。编译器在处理调用record.save(path)的翻译单元时,只看到声明bool save(const std::string&) const;,看不到函数体。对普通函数,这没问题——链接器会去别的目标文件里找符号。对模板成员函数,问题在于「符号根本不存在」:模板本身不是代码,只有被实例化后才产生代码,而实例化需要看到定义。
因此有三种常见组织方式:
| 方式 | 做法 | 优点 | 缺点 |
|---|
| 全部放头文件 | 声明与定义都写在.h | 简单,任意类型可用 | 头文件变大,编译变慢 |
定义放.tpp/.ipp | 头文件末尾#include "x.tpp" | 逻辑分离,仍可任意实例化 | 需要额外文件约定 |
| 显式实例化 | 定义放.cpp,列出支持的类型 | 编译快,实现隐藏 | 只支持列出的类型 |
第三种方式在库开发中很常见,因为库作者通常希望隐藏实现、控制 ABI。但它要求使用者只能用预先实例化的类型,否则会在链接阶段报「undefined reference」。这也是很多模板库报错难读的根源:错误发生在链接期,信息里只有一堆修饰过的符号名。
还有一个细节:类模板的静态数据成员也必须在头文件里定义(C++17 起可以用inline static在类内定义),否则同样会缺符号。
三、文件流:打开模式与状态检查
<fstream>提供三个主要类型:std::ifstream读文件、std::ofstream写文件、std::fstream读写。它们的构造函数可以接受文件名和打开模式:
| 模式 | 含义 |
|---|
std::ios::in | 读 |
std::ios::out | 写 |
std::ios::app | 追加,每次写前定位到末尾 |
std::ios::trunc | 打开时清空文件(out的默认行为之一) |
std::ios::ate | 打开后立即定位到末尾,但后续可移动 |
std::ios::binary | 二进制模式,不做换行转换 |
std::ofstream默认隐含out,如果文件已存在会截断;想追加必须显式写std::ios::app。在 Windows 上文本模式会把\n转换成\r\n,二进制模式不会;这也是「同一份文件在不同平台字节数不同」的常见原因。
流对象可以直接用在布尔上下文里判断状态:if (!out) { ... }。更细的检查用is_open()、good()、fail()、bad()、eof()。写入后要确认数据真的落盘,应该flush()再检查状态,或者依赖析构时的自动 flush——但析构不会告诉你失败,所以需要可靠性的场景必须显式检查。
// 适用于 C++17
#include <fstream>
#include <string>
bool write_text(const std::string& path, const std::string& content) {
std::ofstream out(path, std::ios::out | std::ios::trunc);
if (!out.is_open()) {
return false;
}
out << content;
out.flush();
return static_cast<bool>(out);
}static_cast<bool>(out)检查的是流是否仍然处于良好状态。如果磁盘满、权限不足或路径不可写,failbit或badbit会被置位,返回false。仅判断is_open()不够——它只说明文件成功打开,不说明写入成功。
四、完整示例:可存取的模板记录类
下面这个类把一组同类型值连同名字保存为文本。文本格式的第一行是带引号的名字和元素个数,随后是各元素,以空白分隔。用std::quoted(C++14 起,声明在<iomanip>)处理带空格的名字。
// 适用于 C++17
#include <fstream>
#include <iomanip>
#include <istream>
#include <ostream>
#include <string>
#include <utility>
#include <vector>
template <class T>
class Record {
public:
explicit Record(std::string name) : name_(std::move(name)) {}
void add(const T& value) { values_.push_back(value); }
std::size_t size() const { return values_.size(); }
const std::string& name() const { return name_; }
friend std::ostream& operator<<(std::ostream& os, const Record<T>& record) {
os << std::quoted(record.name_) << ' ' << record.values_.size();
for (const T& value : record.values_) {
os << ' ' << value;
}
return os;
}
friend std::istream& operator>>(std::istream& is, Record<T>& record) {
std::string name;
std::size_t count = 0;
if (!(is >> std::quoted(name) >> count)) {
return is;
}
std::vector<T> values;
values.reserve(count);
for (std::size_t i = 0; i < count; ++i) {
T value{};
if (!(is >> value)) {
is.setstate(std::ios::failbit);
return is;
}
values.push_back(std::move(value));
}
record.name_ = std::move(name);
record.values_ = std::move(values);
return is;
}
bool save(const std::string& path) const {
std::ofstream out(path, std::ios::out | std::ios::trunc);
if (!out.is_open()) {
return false;
}
out << *this << '\n';
out.flush();
return static_cast<bool>(out);
}
bool load(const std::string& path) {
std::ifstream in(path, std::ios::in);
if (!in.is_open()) {
return false;
}
Record<T> temp(std::string{});
in >> temp;
if (!in) {
return false;
}
*this = std::move(temp);
return true;
}
private:
std::string name_;
std::vector<T> values_;
};使用时:
// 适用于 C++17
#include <iostream>
int main() {
Record<double> temperatures("room 101");
temperatures.add(21.5);
temperatures.add(22.0);
temperatures.add(20.75);
if (!temperatures.save("temperatures.txt")) {
std::cerr << "save failed\n";
return 1;
}
Record<double> loaded("empty");
if (!loaded.load("temperatures.txt")) {
std::cerr << "load failed\n";
return 1;
}
std::cout << loaded << '\n';
}几个设计取舍值得说明。第一,operator<<和operator>>定义成友元函数模板的内联友元,每个Record<T>实例化都会生成对应的非模板函数,ADL 能找到它们,不需要额外声明。第二,load()先读进临时对象,全部成功后才用移动赋值替换*this,避免「读到一半失败,把原对象也改坏」。第三,record.values_在循环中逐个push_back,中途失败时临时对象被丢弃,原对象不受影响。
std::quoted对字符串加双引号并转义内部引号,读回时自动去掉引号。它对std::string和const char*都可用,要求元素是char;宽字符版本需要std::quoted的wchar_t重载。名字里含空格、制表符时,用空白分隔的格式会解析错位,加引号正是为了解决这个问题。
五、文本格式与二进制格式的边界
文本格式的优点是跨平台、可读、可用文本工具检查;缺点是体积大、浮点数精度受格式化影响、解析速度较慢。默认的operator<<对double只输出约 6 位有效数字,需要更多精度时用std::setprecision(std::numeric_limits<double>::max_digits10),这个常量保证往返转换不丢精度。
二进制格式常被误用成「把对象内存直接写出去」:
// 适用于 C++17 —— 仅对可平凡复制类型成立
#include <fstream>
#include <type_traits>
template <class T>
bool dump_raw(const T& value, const std::string& path) {
static_assert(std::is_trivially_copyable_v<T>,
"raw dump requires trivially copyable type");
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out.write(reinterpret_cast<const char*>(&value), sizeof(T));
return static_cast<bool>(out);
}这个写法只对可平凡复制的类型成立,例如int、double、只含这些成员的struct。即使满足条件,仍有三个问题:字节序在不同架构间可能不同;填充字节的内容未指定;sizeof和布局是实现相关的。所以二进制文件通常只适合「同一程序、同一平台、同一版本」内部使用,跨平台交换应选文本或明确定义的序列化格式。
至于std::string、std::vector、Record<T>这类含指针成员的类型,is_trivially_copyable_v为假,上面的static_assert会直接拒绝。如果绕过检查强行写,读回的对象里指针指向的是另一个进程的旧地址,解引用是未定义行为。
常见坑点
- ❌ 把模板成员函数的定义放在
.cpp里,只在头文件留声明。
✅ 定义跟着头文件走,或用.tpp包含,或显式实例化;否则使用方链接时报未定义符号。
- ❌
std::ofstream out("data.txt");以为会追加到已有文件末尾。
✅ 默认是截断;追加要写std::ios::out | std::ios::app。
- ❌ 只判断
is_open()就认为写入成功。
✅ 写入后flush()并检查流状态,磁盘满、权限不足都会让写入失败。
- ❌ 用
write()把含std::string成员的对象原样写入文件。
✅ 非平凡复制类型不能按字节序列化;逐字段写文本或使用明确的序列化格式。
- ❌
load()直接读进*this,中途失败留下半新半旧的数据。
✅ 先读进临时对象,全部成功后再移动赋值。
- ❌ 模板里写
Box<T>::value_type而不加typename。
✅ 依赖名称是类型时必须写typename Box<T>::value_type,否则编译期解析失败。
- ❌ 以为
template class Box<int>;是声明。
✅ 这是显式实例化定义,会生成代码;声明要写extern template class Box<int>;。
- ❌ 用默认精度保存
double,读回后数值发生细微变化。
✅ 用std::setprecision(std::numeric_limits<double>::max_digits10),保证往返不丢精度。
总结
| 主题 | 结论 |
|---|
| 类模板定义位置 | 通常放头文件,或.tpp被头文件包含 |
| 显式实例化 | template class X<int>;生成代码,extern template抑制重复生成 |
| 依赖类型名 | 模板内引用需加typename |
| 文件写模式 | out默认截断,追加用app |
| 错误检查 | is_open()只管打开,写入成败看流状态 |
| 文本格式 | 可读、跨平台,浮点注意精度 |
| 二进制格式 | 仅限可平凡复制类型,仍有字节序与填充问题 |
| 加载安全 | 先读临时对象,成功后再替换原对象 |
类模板与文件持久化的组合,难点不在语法,而在两处纪律:模板定义必须让实例化点看得见;序列化必须尊重类型的内存语义。把「哪些类型可以按字节复制」交给static_assert在编译期拦截,把「读失败会不会污染原对象」交给临时对象加移动赋值,这两个习惯能消掉大部分难以复现的 bug。至于格式选择,凡是需要跨平台、跨版本交换的数据,优先文本;只在性能敏感且生命周期可控的内部缓存里考虑二进制。