1:开篇
上一篇我们学习了编译期类型范围确定的 std::optional 和 std::variant,它们在类型已知的场景下提供了完善的类型安全保障。本篇我们补充两个定位不同的标准库组件:
- std::any :运行时的通用类型容器,可以存储任意可拷贝类型,替代不安全的
void*,自带类型检查与生命周期管理 - std::string_view:零拷贝字符串视图,以非拥有的方式观察字符串,彻底消除传参、解析场景下的冗余字符串拷贝
两个特性都直击传统 C++ 开发的痛点:一个解决通用类型存储的安全性问题,一个解决字符串处理的性能问题,学习成本低,收益非常显著。
2:std::any运行时类型安全的通用容器
1:历史痛点
在需要存储 "任意类型" 的场景(如配置系统、消息队列、异构容器),传统 C++ 通常使用 void* 来实现,但它存在无法忽视的致命问题:
- 完全无类型安全:取出值时完全依赖开发者手动记忆原始类型,类型转换错误直接导致未定义行为,崩溃且难以排查
- 生命周期完全手动管理 :
void*只保存地址,不会调用构造和析构函数,存储非平凡类型(如 string、vector)时必须手动管理内存,极易泄漏 - 代码可读性差:业务逻辑中充斥着强制类型转换,维护成本极高
cpp
// 传统void*写法
void* data = new std::string("hello");
// ...
std::string* str = static_cast<std::string*>(data); // 类型全靠记,错了就崩
// 忘记delete就内存泄漏
2:核心接口与基本用法
std::any 是一个可以存储任意可拷贝构造类型的单值容器,自带类型信息,支持安全的类型转换,自动管理对象生命周期。
核心接口
| 接口 | 作用 |
|---|---|
| 默认构造 / 拷贝构造 / 赋值 | 支持直接用任意可拷贝类型赋值 |
emplace<T>(args...) |
原地构造 T 类型对象,避免额外拷贝 |
reset() |
销毁内部对象,变为空状态 |
has_value() |
判断是否包含有效值 |
type() |
返回当前存储值的类型信息(std::type_info) |
std::any_cast<T>() |
类型安全的取值转换,失败抛异常或返回空指针 |
基础实例
cpp
#include <any>
#include <string>
#include <iostream>
int main() {
// 1. 构造与赋值
std::any a1 = 42; // 存储int
std::any a2 = 3.14; // 存储double
std::any a3 = std::string("Hello"); // 存储std::string
std::any a4; // 空状态
a4 = std::make_pair(std::string("key"), std::string("value"));
// 2. 原地构造,避免临时对象拷贝
a3.emplace<std::string>("World");
// 3. 状态与类型检查
if (a3.has_value()) {
std::cout << "a3 has value" << std::endl;
const std::type_info& ti = a1.type();
std::cout << "a1 type: " << ti.name() << std::endl;
}
// 4. 重置为空
a1.reset();
return 0;
}
三种any_cast取值方式
std::any_cast 是访问 std::any 的唯一途径,有三种使用形式,对应不同场景:
cpp
#include <any>
#include <string>
#include <iostream>
int main() {
std::any a = std::string("Hello");
// 方式1:值拷贝,类型不匹配抛 std::bad_any_cast 异常
try {
std::string s = std::any_cast<std::string>(a); // 正确
std::cout << "Value: " << s << '\n';
// double d = std::any_cast<double>(a); // 错误,抛出异常
} catch (const std::bad_any_cast& e) {
std::cout << "Cast failed: " << e.what() << '\n';
}
// 方式2:引用形式,避免拷贝,可修改内部值
std::string& s_ref = std::any_cast<std::string&>(a);
s_ref[0] = 'h';
std::cout << std::any_cast<const std::string&>(a) << '\n'; // 输出 hello
// 方式3:指针形式,传入any的指针,不匹配返回nullptr,不抛异常
if (auto* p = std::any_cast<int>(&a)) {
std::cout << "int value: " << *p << '\n';
} else {
std::cout << "Not an int" << '\n';
}
return 0;
}
3:底层原理
std::any 的核心设计是带类型信息的类型擦除容器 ,主流实现都会加入**小缓冲区优化(Small Buffer Optimization, SBO)**来减少堆分配:
存储结构:内部包含三部分核心信息
- 类型信息指针:指向
std::type_info运行时类型信息,用于类型检查 - 存储缓冲区:小对象直接存在栈上的内部缓冲区(通常几个指针大小),大对象则分配堆内存,存储堆指针
- 操作函数表:保存拷贝、析构等操作的函数指针,实现类型擦除后的生命周期管理
cpp
// 简化版底层结构
class any {
private:
const std::type_info* type_; // 类型信息
union {
std::byte small_buffer_[sizeof(void*) * 3]; // 小对象缓冲区
void* heap_ptr_; // 大对象堆指针
} storage_;
// 操作函数指针:拷贝、析构等
void (*destroy_)(void*);
void* (*clone_)(const void*);
};
- 小缓冲区优化 (SBO) :
- 小对象(大小不超过内部缓冲区、对齐要求满足)直接在栈上构造,无堆分配开销
- 大对象在堆上分配,内部存储指针
- 类似
std::string的 SSO 短字符串优化,目的都是减少内存分配次数
- 类型擦除机制:通过函数指针封装不同类型的操作,对外只暴露统一接口,实现 "存储任意类型" 的能力;同时保留类型信息,保证转换时的安全检查
- 生命周期自动管理:赋值、析构时自动调用对应类型的构造 / 析构函数,无需手动干预
4:实战场景
1:异构配置容器
存储不同类型的配置项,替代 void* 或复杂的继承体系:
cpp
#include <any>
#include <string>
#include <vector>
#include <iostream>
#include <cassert>
void printConfig(const std::vector<std::any>& configs) {
for (const auto& item : configs) {
if (item.type() == typeid(int)) {
std::cout << "整数配置: " << std::any_cast<int>(item) << '\n';
} else if (item.type() == typeid(double)) {
std::cout << "浮点配置: " << std::any_cast<double>(item) << '\n';
} else if (item.type() == typeid(std::string)) {
std::cout << "字符串配置: " << std::any_cast<const std::string&>(item) << '\n';
} else {
assert(false && "未知配置类型");
}
}
}
2:哈希桶的any版本实现
cpp
#include <any>
#include <list>
#include <set>
#include <vector>
#include <algorithm>
#include <cassert>
template<class K, size_t Len = 8>
class HashTable {
public:
HashTable() : _tables(10, std::list<K>()) {}
void Insert(const K& key) {
size_t hashi = key % _tables.size();
auto listInsert = [this, &key, hashi](std::list<K>& lt) {
if (lt.size() < Len) {
lt.push_back(key);
} else {
std::set<K> s(lt.begin(), lt.end());
s.insert(key);
_tables[hashi] = std::move(s);
}
};
auto setInsert = [&key](std::set<K>& s) {
s.insert(key);
};
// 手动尝试类型转换,判断当前存储的类型
if (auto* ptr = std::any_cast<std::list<K>>(&_tables[hashi])) {
listInsert(*ptr);
} else if (auto* ptr = std::any_cast<std::set<K>>(&_tables[hashi])) {
setInsert(*ptr);
} else {
assert(false);
}
}
bool Find(const K& key) {
size_t hashi = key % _tables.size();
if (!_tables[hashi].has_value()) return false;
auto listFind = [&key](std::list<K>& lt) -> bool {
return std::find(lt.begin(), lt.end(), key) != lt.end();
};
auto setFind = [&key](std::set<K>& s) -> bool {
return s.count(key);
};
if (auto* ptr = std::any_cast<std::list<K>>(&_tables[hashi])) {
return listFind(*ptr);
} else if (auto* ptr = std::any_cast<std::set<K>>(&_tables[hashi])) {
return setFind(*ptr);
} else {
assert(false);
return false;
}
}
private:
std::vector<std::any> _tables;
};
5:std::any和std:variant的对比
| 维度 | std::variant | std::any |
|---|---|---|
| 类型范围 | 编译期固定所有可能的类型 | 运行时可存储任意可拷贝类型 |
| 类型检查 | 编译期检查,visit 强制覆盖所有分支 | 运行时检查,转换时才验证类型 |
| 存储位置 | 完全栈上,共享内存 | 小对象栈上,大对象堆分配 |
| 访问性能 | 极高,visit 通常优化为跳转表,接近 switch | 较低,需要运行时类型判断、指针转换 |
| 类型安全 | 极高,编译期保证类型合法 | 中等,运行时检查,比 void * 安全但弱于 variant |
| 适用场景 | 类型数量固定、已知的多类型场景 | 类型完全不确定、需要极致灵活性的场景 |
结论 :能确定类型范围时优先用 std::variant,性能和安全性都更优;只有当类型完全无法在编译期确定时,才使用 std::any。
3:std::string_view零拷贝字符串试图
1:字符串的冗余拷贝
C++的std::string是所有权的字符串类,每次传参,截取字串都会产生内存分配和数据拷贝,在高频调用的字符串处理场景下开销非常大。
两个最常见的性能浪费场景:
- 函数传参的临时对象 :形参为
const std::string&时,传入字符串字面量会构造临时std::string对象,触发堆分配 - 字符串解析的子串拷贝 :调用
substr()拆分字符串时,每个子串都会生成新的std::string,大量冗余拷贝
2:设计理念与具体用法
std::string_view 是非拥有(non-owning)的只读字符串视图,它本身不分配内存,只保存指向原字符串的指针和长度,相当于一个观察窗口,零拷贝地查看字符串内容。
核心设计原则
- 非所有权:不管理原字符串的生命周期,只负责观察
- 只读性:只能读取字符,不能修改原字符串内容
- 零开销 :仅包含
const char*和size_t两个成员,构造拷贝成本极低
构造方式
cpp
#include <iostream>
#include <string>
#include <string_view>
int main() {
// 1. 从C风格字符串构造
std::string_view sv1("Hello, world!");
// 2. 从std::string隐式转换构造
std::string str = "C++17 string_view";
std::string_view sv2(str);
// 3. 从部分字符串构造(指针+长度)
std::string_view sv3(str.c_str() + 6, 6); // 取从第6位开始的6个字符
// 4. 字面量后缀 sv(C++17)
using namespace std::literals;
std::string_view sv4 = "Literal"sv;
return 0;
}
常用接口
std::string_view 提供了和 std::string 几乎一致的只读接口,学习成本极低:
- 容量:
size()、empty()、length() - 访问:
operator[]、data()、front()、back() - 查找:
find()、rfind()、find_first_of()等 - 截取:
substr(pos, len)------ 注意返回的还是 string_view,零拷贝 - 比较:支持和 string、字符串字面量的比较运算
3:底层原理
std::string_view 的底层极其简单,就是两个成员变量,没有任何堆分配:
cpp
// 简化版底层结构
class string_view {
private:
const char* data_; // 指向原字符串的指针
size_t size_; // 视图的长度
};
- 大小固定为两个指针大小(64 位系统 16 字节),完全在栈上
- 所有操作都是指针和长度的计算,没有内存分配和数据拷贝
substr()只是调整指针和长度,不会生成新的字符串副本,性能极高
4:两大核心使用场景
1:函数参数代替const std::string&
这是 string_view 最常用的场景,作为只读字符串参数,避免临时对象拷贝:
cpp
#include <string_view>
// 替代 const std::string&,接受任意字符串类型,零拷贝
void process_string(std::string_view sv) {
// 只读处理sv,和使用string几乎一样
}
int main() {
process_string("C-string"); // 无临时对象,零拷贝
std::string s("std::string");
process_string(s); // 隐式转换,零开销
return 0;
}
2:零拷贝字符串解析
字符串拆分、提取子串等解析场景,全程使用 string_view,完全避免拷贝:
cpp
#include <string_view>
#include <vector>
// 提取分隔符前的子串,零拷贝返回
std::string_view extract_str(std::string_view input, char delimiter) {
size_t pos = input.find(delimiter);
return input.substr(0, pos);
}
// 按分隔符拆分字符串,返回视图数组,全程零拷贝
std::vector<std::string_view> split(std::string_view str, char delimiter) {
std::vector<std::string_view> result;
size_t start = 0;
size_t end = str.find(delimiter);
while (end != std::string_view::npos) {
result.push_back(str.substr(start, end - start));
start = end + 1;
end = str.find(delimiter, start);
}
result.push_back(str.substr(start));
return result;
}
整个拆分过程没有任何字符串拷贝,仅操作指针和长度,性能远高于基于 std::string 的实现。
5:性能对比
cpp
#include <iostream>
#include <chrono>
#include <string>
#include <string_view>
void process_string(const std::string& s) {}
void process_string_view(std::string_view sv) {}
int main() {
const int N = 100'0000;
std::string long_str = "This is a very long string...";
// 测试std::string参数
auto start1 = std::chrono::high_resolution_clock::now();
for (int i = 0; i < N; ++i) {
process_string(long_str); // 无额外开销
process_string("literal"); // 创建临时string,有分配
}
auto end1 = std::chrono::high_resolution_clock::now();
// 测试std::string_view参数
auto start2 = std::chrono::high_resolution_clock::now();
for (int i = 0; i < N; ++i) {
process_string_view(long_str); // 隐式转换,零开销
process_string_view("literal");// 无临时对象
}
auto end2 = std::chrono::high_resolution_clock::now();
auto duration1 = std::chrono::duration_cast<std::chrono::milliseconds>(end1 - start1);
auto duration2 = std::chrono::duration_cast<std::chrono::milliseconds>(end2 - start2);
std::cout << "std::string: " << duration1.count() << "ms\n";
std::cout << "std::string_view: " << duration2.count() << "ms\n";
return 0;
}
典型结果:传入字面量的场景下,string_view 的性能可以比 string 快数倍到数十倍,差异主要来自临时对象的堆分配开销。
6:致命陷阱与注意事项
std::string_view 虽然高效,但有三个极易踩的陷阱:
1:生命周期悬垂
string_view 不拥有字符串,它的生命周期不能超过原字符串的生命周期。返回局部字符串的 string_view 是典型错误:
cpp
std::string_view get_view() {
std::string temp = "Temporary string";
return temp; // 严重错误!temp函数结束时销毁,返回的视图指向已释放内存
}
void error_example1() {
std::string_view sv = get_view();
std::cout << sv << std::endl; // 未定义行为,悬垂引用
}
2:不一定以'\0'结尾
std::string::c_str() 保证返回以空字符结尾的 C 字符串,但 string_view::data() 不保证末尾有 '\0'。如果视图是原字符串的子串,data () 指向的位置后面不一定是空字符。
cpp
void error_example2() {
char buffer[] = {'T', 'e', 's', 't', '.', 't', 'x', 't'}; // 无\0结尾
std::string_view sv(buffer, 4); // 正确,指定长度
// 错误:传给期望\0结尾的C接口,会越界读取
FILE* fout = fopen(sv.data(), "w");
}
如果需要传给 C 风格接口,必须手动构造 std::string 副本。
3:原字符串修改导致视图失效
如果原字符串发生了修改(尤其是触发扩容的修改),string_view 会失效:
cpp
void error_example3() {
std::string str = "Hello";
std::string_view sv = str;
str[0] = 'h'; // 未扩容,视图仍然有效
std::cout << sv << std::endl;
// 触发扩容,str重新分配内存,原内存释放,sv变为悬垂
str = "New value 111111111111111111111111";
std::cout << sv << std::endl; // 未定义行为
}
4:常见陷阱与最佳实践
1:std::any最佳实践
- 优先用 variant,any 是最后选项:能在编译期确定类型范围的场景,一律用 variant,性能和安全性都更优
- 取值优先用指针形式 :
std::any_cast<T>(&any)不会抛异常,适合类型不确定的分支判断场景 - 避免频繁类型转换:运行时类型检查有开销,不要在高频循环中反复做 any_cast
- 不要存储不可拷贝类型:any 要求存储的类型必须可拷贝,不可拷贝类型无法存入
2:std::string_view最佳实践
- 只读函数参数首选 string_view :所有只读取不修改的字符串参数,优先用
std::string_view,替代const std::string&和const char* - 绝对不要用 string_view 存储字符串:它只是视图,不是容器,永远不要用它来持有字符串数据
- 注意生命周期边界 :
- 函数参数传入的 string_view,只在当前函数调用内使用,不要存下来
- 不要返回局部字符串的 string_view
- 调用 C 接口前检查结尾 :需要传 C 风格字符串时,先构造
std::string临时对象再调用
5:总结
- std::any :运行时类型安全的通用容器,替代不安全的
void*,自带生命周期管理和类型检查,小对象有 SBO 优化,适合类型完全不确定的灵活场景 - std::string_view:零拷贝只读字符串视图,仅包含指针和长度,彻底消除传参、解析场景的冗余字符串拷贝,性能极高,但必须注意生命周期问题
结合上一篇的 optional 和 variant,四个工具覆盖了从 "可空值"、"多类型固定值" 到 "任意类型值"、"字符串视图" 的全场景,是现代 C++ 提升代码健壮性与性能的核心武器。