
1. 先看一个故事从 const char* 说起在 C 语言时代字符串的标准长相是这样的const char* s hello;底层发生了什么编译器在静态存储区放了一串字节内存地址: 0x1000 0x1001 0x1002 0x1003 0x1004 0x1005 内容: h e l l o \0关键点const char* 只保存了起始地址0x1000至于字符串有多长它不知道。想知道长度必须从头数到 \0 为止这就是 strlen() 做的事size_t strlen(const char* s) { size_t n 0; while (*s ! \0) { // 一个个字符找结束符 s; n; } return n; }⚠️问题来了每次想知道长度都要 O(n) 扫描字符串越长越慢字符串中间不能有 \0否则会被当成结尾二进制数据直接不能用传入的指针可能根本没有 \0 结尾strlen 会一路读下去直到踩到非法内存——这是经典的未定义行为UB。通俗类比const char* 就像只告诉你书从第 1 页开始读的书签却不告诉你书有多少页。每次想知道页数你得翻到最后一页数一遍。C 的 std::string 解决了部分问题自带长度但传参时又引入了新烦恼传 const std::string 意味着调用方必须构造一个完整的 std::string如果手里只有 const char*还得隐式构造临时 string白白拷贝一次数据。于是C17 和 C20 带来了两个只借不买的视图类型类型标准看什么数据能修改数据吗std::string_viewC17字符序列字符串/字节❌ 只读std::spanTC20任意连续内存的 T 数组✅ 可以spanT / ❌spanconst T2. 视图类型的核心思想只看不拥有视图View类型的哲学可以浓缩成一句话视图不拥有数据它只是数据的窗户。拥有Owningstd::string、std::vectorT 自己分配内存、管理生命周期析构时释放内存。它们是房东。视图Viewingstd::string_view、std::spanT 只保存指向哪里和有多长不分配、不释放、不复制数据。它们是租客只是站在窗前看风景。通俗类比std::string 是你买下的房子拥有产权、负责维护std::string_view 是房产中介带你看房时手里的户型图只是告诉你房子长什么样房子并不是你的。底层上视图几乎就是一个指针 长度的结构体// 概念上的实现真实实现有细节差异见下节 template typename CharT class basic_string_view { const CharT* data_; // 指向数据开头 size_t size_; // 数据长度不数到 \0直接记数 }; template typename T class span { T* data_; // 指向数据开头 size_t size_; // 元素个数 };就这么简单——两个指针宽度的数据64 位机器上通常是 16 字节却承载了零拷贝传参、零拷贝切片、统一接口三大能力。3. std::string_view 底层实现指针 长度3.1 内存布局在主流 64 位实现libstdc / libc / MSVC STL中std::string_view 的布局都是偏移 0 (8 字节): const char* data_ → 指向字符序列第一个字符 偏移 8 (8 字节): size_t size_ → 字符个数 总大小: 16 字节#include string_view #include cstdio int main() { const char* raw Hello, C Views!; // 静态存储区里的字符串 std::string_view sv{raw, 5}; // 只看前 5 个字符 Hello std::printf(size %zu\n, sv.size()); // 输出 5O(1) 直接拿到不扫描 std::printf(%.*s\n, (int)sv.size(), sv.data()); // 打印 Hello return 0; }注意sv.size() 是 O(1) 的——它读的是结构体里存好的数字而不是像 strlen 那样逐字符扫描。这就是指针 长度比指针 结束符的第一个胜利。3.2 构造时不拷贝数据std::string s abcdef; // s 自己拥有一份数据 std::string_view sv s; // sv 只是看着s 的数据零拷贝sv 内部data_ 指向 s 的内部缓冲区首地址size_ 6。没有任何一个字符被复制。验证零拷贝的方法看地址。#include iostream #include string #include string_view int main() { std::string s zero copy check; std::string_view sv s; std::cout string 数据地址: (const void*)s.data() \n; std::cout view 数据地址: (const void*)sv.data() \n; // 两个地址完全相同 → sv 没有复制数据只是借用了 s 的缓冲区 return 0; }3.3 为什么还要保存长度而不是用 \0三个理由O(1) 取长度前面已经演示过支持嵌入 \0 的二进制数据长度已知中间有 \0 也无所谓不会提前截断可以安全地取子串sv.substr(2, 3) 只需要把 data_ 2、size_ 3还是零拷贝。std::string_view sv 0123456789; std::string_view sub sv.substr(3, 4); // 3456 // 底层sub.data_ sv.data_ 3; sub.size_ 4; —— 没有复制任何字符4. std::span 底层实现指针 长度但可以写std::span 与 string_view 是表亲同样是指针 长度但有两个重大区别可以指向任意类型 T不限于字符std::spanT 允许修改元素std::spanconst T 只读。#include span #include vector #include iostream int main() { std::vectorint v{1, 2, 3, 4, 5}; std::spanint sp{v}; // 从 vector 构造零拷贝 for (int x : sp) x * 10; // 通过 span 直接修改原数组 for (int x : v) std::cout x ; // 输出 10 20 30 40 50 return 0; }上面的例子说明span 不是副本它是原数组的窗口从窗口里伸手就能改到屋里的东西。4.1 内存布局两种形态std::spanint sp_dynamic; // 动态长度data_ size_共 16 字节 std::spanint, 5 sp_static; // 静态长度只有 data_共 8 字节当你在模板参数里写了固定大小std::spanint, 5size_ 在编译期就确定了运行时结构体里不再存长度所以 spanint, 5 只有 8 字节一个指针。这是 std::span 独有的优化静态 extent长度。#include span #include cstdio int main() { int arr[5] {1, 2, 3, 4, 5}; std::spanint, 5 fixed{arr}; // 编译期知道长度 5 std::spanint dyn{arr}; // 运行期才知道长度 std::printf(fixed: %zu bytes\n, sizeof(fixed)); // 通常是 8只有指针 std::printf(dyn: %zu bytes\n, sizeof(dyn)); // 通常是 16指针长度 return 0; }通俗类比动态 span 像带页码的相机取景框告诉你看多宽静态 span 像固定尺寸的相框尺寸刻在框上不需要额外说明。5. 零拷贝的真相拷贝的是视图不是数据很多初学者困惑string_view 和 span 不是也有拷贝吗值传递时不也复制了 16 字节关键区分拷贝对象拷贝的是什么代价std::string s2 s1堆上的所有字符O(n)可能触发内存分配std::string_view sv2 sv1指针 长度16 字节O(1)栈上复制两个数字所以零拷贝指的是零数据拷贝而不是零字节拷贝。视图的拷贝只是把窗户的位置和大小复制了一份房子本身没有任何变化。为什么这很重要看一个真实的传参场景// 旧写法要么拷贝传 string 值要么被迫接受 string 类型 size_t count_letter_a(const std::string s); // 只接受 std::string size_t count_letter_a(const char* s); // 不知道长度还得扫描 // 新写法任何字符序列都能直接传且零拷贝 size_t count_letter_a(std::string_view sv); // string/char*/字面量都能转 int main() { std::string a apple; const char* b banana; auto c count_letter_a(a); // 隐式构造 view零拷贝 auto d count_letter_a(b); // 零拷贝不用先转成 string auto e count_letter_a(cherry); // 字符串字面量直接可用 return 0; }在旧写法里count_letter_a(b) 如果函数签名是 const std::string会隐式构造一个临时 string 并复制整段字符而 string_view 直接把指针和长度递给函数省掉了这次拷贝。6. SSO / SBO 对比string 的小对象优化为什么和视图不同6.1 什么是 SSO / SBOSSOSmall String Optimization小字符串优化std::string 对短字符串通常 ≤ 15 或 ≤ 22 字符取决于实现不分配堆内存而是把字符直接塞进对象内部的固定缓冲区。SBOSmall Buffer Optimization小缓冲区优化同类思想在 std::function、std::any 等类型中的泛化名称。#include string #include iostream int main() { std::string short_s hi; // 短放进对象内部缓冲区不分配堆 std::string long_s this is a much longer string that exceeds the SSO threshold; std::cout sizeof(string) sizeof(short_s) bytes\n; // libstdc 上通常是 32短字符串用其中 16 字节存字符其余存指针等 return 0; }6.2 为什么 string_view 不做 SSOstring_view 的使命是看别人家的数据。如果它做 SSO就得自己存一份字符——那就变成拥有数据了违背了零拷贝视图的根本哲学。更直白的理由它没有堆、没有所有权根本没有存字符的需求。string_view 永远是 16 字节指针 长度无论数据有多短或有多长std::string_view sv_short a; // 16 字节 std::string_view sv_long a very very very long string view...; // 还是 16 字节通俗类比string 是搬家工人东西小就揣兜里东西大才叫货车——SSOstring_view 是房产中介手里永远只拿一张写着地址和面积的卡片不管房子多大。6.3 对比表string / string_view / span特性std::stringstd::string_viewstd::spanT是否拥有数据✅ 拥有❌ 不拥有❌ 不拥有是否可修改数据✅❌✅spanT内存分配可能分配堆从不分配从不分配典型大小32 字节含 SSO 缓冲16 字节8~16 字节SSO / SBO✅ SSO❌ 不需要❌ 不需要生命周期管理自动析构释放无由数据源负责无由数据源负责长度获取O(1)O(1)O(1)支持二进制数据✅✅✅7. 悬垂指针风险视图最大的敌人视图不拥有数据意味着数据被销毁时视图还在看着一片已释放的内存——这就是悬垂dangling。这是使用视图类型时最危险、最容易踩的坑。7.1 风险一视图指向局部临时对象#include string_view #include string // ⚠️ 错误示范返回指向局部字符串的 view std::string_view bad() { std::string local temporary data; return local; // local 离开作用域被销毁返回的 view 悬垂 } int main() { std::string_view sv bad(); // sv.data() 指向已释放的内存 // 下面这行是未定义行为读已释放内存可能崩溃、可能打印乱码 // std::cout sv \n; return 0; }通俗类比你记下了3 号桌客人的手机号结果客人结账走了string 析构你手里的纸条view还写着那个号码打过去就是空号甚至打到别人头上。✅正确做法视图的生命周期必须短于或等于数据的生命周期。数据活着视图才有意义。#include string_view #include string // 正确把数据也传出去或者让 view 只存在于数据存活期间 void good() { std::string data safe data; std::string_view sv data; // 在 data 存活期间使用 sv // ... 用完 sv 再让 data 析构 }7.2 风险二string 重新分配导致 view 失效std::string 扩容时可能重新分配堆内存旧缓冲区的地址就失效了。之前创建的 string_view 还指着旧地址。#include string #include string_view #include iostream int main() { std::string s small; std::string_view sv s; // 指向 s 当前的缓冲区 s and now this string grows much much longer...; // 触发扩容 // ⚠️ 此时 sv 可能已经悬垂指向被释放的旧缓冲区 // std::cout sv \n; // 未定义行为 std::cout s \n; // s 本身没问题它自己管理内存 return 0; }✅规则修改可能引发重新分配/移动的容器之后之前拿到的 view 一律作废必须重新获取。7.3 风险三vector 扩容后 span 失效和 string 一样std::vector 扩容push_back 超过容量时元素会搬到新内存旧的 std::span 立刻失效#include span #include vector int main() { std::vectorint v{1, 2, 3}; std::spanint sp{v}; // 指向 v 的内部缓冲区 for (int i 0; i 100; i) v.push_back(i); // 多次扩容旧缓冲区被释放 // ⚠️ sp 现在指向已释放的内存 // for (int x : sp) ... // 未定义行为 return 0; }✅规则对 vector/string 执行任何可能扩容、移动、清空的操作后旧 span/view 一律废弃。需要时基于新状态重新构造。7.4 风险四字面量/静态数据才最安全视图最安全的搭配是静态存储期数据字符串字面量、静态数组、全局变量因为它们在整个程序运行期间都不会消失#include span int global_array[] {1, 2, 3, 4, 5}; // 静态存储期 std::spanint get_global() { return global_array; // ✅ 安全global_array 生命周期 整个程序 } int main() { auto sp get_global(); // 永远安全 return 0; }7.5 悬垂风险速查表场景是否危险原因view 指向字符串字面量✅ 安全静态存储期程序结束才销毁view 指向全局/静态数组✅ 安全同上view 指向局部 string在函数内使用✅ 安全数据还活着view 指向局部 string带出函数返回⚠️ 危险局部对象已析构string 扩容后继续用旧 view⚠️ 危险缓冲区可能被重新分配vector push_back 扩容后继续用旧 span⚠️ 危险元素可能被搬到新内存用 span 修改元素✅ 安全只要 span 本身有效只是写入原内存8. std::span 的动态扩展与 bounds safety8.1 切片first / last / subspan零拷贝动态扩展span 最常见的操作是从一个大数组中切出一段——这仍然是零拷贝只是调整指针和长度#include span #include vector #include iostream int main() { std::vectorint v{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; std::spanint full{v}; auto head full.first(3); // {0, 1, 2} auto tail full.last(2); // {8, 9} auto mid full.subspan(3, 4); // {3, 4, 5, 6} for (int x : mid) std::cout x ; std::cout \n; return 0; }底层subspan(offset, count) 做两件事——data_ offset、size_ count或检查后设置不复制任何元素。这就是动态扩展你可以在不拷贝数据的前提下把视图扩展/收缩到任意连续子区间。8.2 bounds safetyoperator[] vs at() vs subspan()span 对越界访问的处理有严格分工访问方式越界时行为有无开销sp[i]operator[]未定义行为不检查零开销最快sp.at(i)抛出 std::out_of_range有运行时检查开销sp.first(n) / sp.last(n) / sp.subspan(o, c)抛出 std::out_of_range或编译期错误有边界检查#include span #include vector #include iostream #include stdexcept int main() { std::vectorint v{10, 20, 30}; std::spanint sp{v}; std::cout sp[1] \n; // 20 —— 快但不检查越界 try { std::cout sp.at(5) \n; // 抛出 std::out_of_range } catch (const std::out_of_range e) { std::cout at() caught: e.what() \n; } try { auto bad sp.first(10); // 10 3抛出异常 } catch (const std::out_of_range e) { std::cout first() caught: e.what() \n; } return 0; }工程建议性能敏感、已经自行保证不越界的代码用 operator[]处理外部输入、不确定长度时用 at() 或先 sp.size() 判断永远不要用越界下标 侥幸心理。8.3 静态 extent 的编译期检查当使用 std::spanT, N静态长度时编译器可以在编译期拦截部分错误#include span int main() { int arr[5] {1, 2, 3, 4, 5}; std::spanint, 5 sp5{arr}; // ✅ 正好 // std::spanint, 7 sp7{arr}; // ❌ 编译错误5 个元素装不进 7 的静态长度 // std::spanint, 3 sp3{arr}; // ❌ 编译错误对数组构造而言不匹配 return 0; }这属于编译期 bounds safety把长度错误提前到编译阶段暴露而不是运行时崩溃。8.4 空 span 与零长度处理std::spanint empty1{}; // 空 spandata_ 为空指针size_ 0 std::spanint empty2{nullptr, 0}; // 明确构造空 span if (empty1.empty()) { // 用 empty() 判断而不是比较 data() 是否为 nullptr }⚠️注意sp.data() 对空 span 可能是 nullptr也可能不是取决于构造方式所以判断空请用 sp.empty()不要用 sp.data() ! nullptr。9. 演进对比const char* → string_view → span用一张大表总结三代数组/字符串访问方式的演进维度const char*C 时代std::string_viewC17std::spanTC20存储内容仅指针指针 长度指针 长度静态 extent 时仅指针取长度O(n) 扫描到 \0O(1) 直接读字段O(1) 直接读字段支持嵌入 \0❌ 会被截断✅✅对任意 T传参时是否拷贝数据不拷贝但不知长度不拷贝不拷贝能否修改数据❌const 版本❌ 永远只读✅ 可变 span 可以越界保护无at() 可选at() / subspan 检查适用类型仅 char仅字符类型任意类型 T悬垂风险有指针悬垂有且更容易产生有vector 扩容等典型场景C 接口、遗留代码字符串解析、函数入参数组算法、跨库传参演进主线从只有地址const char*→ 地址 长度string_view / span核心收益是O(1) 长度、二进制安全、零拷贝切片。10. 常见问题速查表FAQQ1string_view 和 const std::string 到底该用哪个只需要读取字符串内容优先 std::string_view能接受 string / char* / 字面量且零拷贝。需要调用接受 const std::string 的旧接口、或需要保证字符串以 \0 结尾时用 const std::string。需要修改字符串用 std::stringview 是只读的。Q2span 和 vector 有什么区别vector 拥有元素、管理内存、可扩容span 不拥有、不管理、不可扩容只能看一段现成内存。需要增删元素 → vector只做只读/就地遍历与算法 → span。Q3把 view 作为函数返回值安全吗若 view 指向参数传入的数据如 std::string_view f(std::string_view s) { return s.substr(...); }✅ 安全数据由调用方持有。若 view 指向函数内部的局部 string/vector⚠️ 危险返回即悬垂。要么改返回数据本身要么让数据是参数传入的。Q4sp.first(n) 越界会怎样运行时抛出 std::out_of_range 异常n size() 时。operator[] 则不检查越界是未定义行为。Q5span 能像 vector 一样 push_back 吗不能。span 没有 push_back它只表示已有的连续区间。扩容是容器vector/string的职责span 的职责是看。Q6string_view 能转回 string 吗可以std::string s{sv};——但这会拷贝数据需要拥有时不可避免。如果需要修改/拥有数据就必须拷贝一次。Q7为什么 sizeof(string_view) 是 16 而不是 8因为要同时存指针和长度两个字段。只存指针的是 const char*但那就回到 O(n) 取长度的老问题了。Q8C20 里我应该用 span 还是 string_view 处理字符数组处理 char 序列且不需要修改两者都可以string_view 更贴合字符串语义有 find、substr 等字符串方法。处理任意类型int、double、自定义结构体用 std::spanT。Q9视图类型会引入额外开销吗几乎为零栈上多 8~16 字节构造、拷贝都是平凡操作编译器通常直接优化掉。比拷贝数据或扫描取长度快得多。Q10std::spanconst T 和 std::spanT 有什么关系前者是只读视图后者是可变视图。spanT 可以隐式转换为 spanconst T读的能力可以放宽反向不行不能把只读变成可写。11. 总结视图类型的本质一个指针 长度的结构体通过不拥有数据实现零拷贝。std::string_viewC17 提供只读字符视图取代const char* 手动数长度的落后写法O(1) 取长、二进制安全、支持嵌入 \0。std::spanC20 提供任意类型连续内存的视图支持可变访问、first/last/subspan 零拷贝切片、at() 边界检查、静态 extent 编译期长度校验。SSO/SBO 是拥有型容器的优化string 用 SSO 减少短字符串的堆分配视图类型因为不拥有数据天然不需要 SSO永远 16 字节或 8 字节静态 span。最大敌人是悬垂数据生命周期必须长于视图。string/vector 扩容、局部对象销毁后旧视图一律作废。选型口诀只读字符串 → string_view只读/就地处理任意数组 → span需要拥有和修改 → 老老实实用 string / vector。记住这句总纲视图让你看得更快但绝不替你保管房子——房子的存亡永远归房东管。