CppGuide社区 CppGuide社区
首页
  • 最新谷歌C++风格指南(含C++17/20)
  • C++17详解
  • C++20完全指南
  • C++23快速入门
  • C++语言面试问题集锦
  • 🔥C/C++后端开发常见面试题解析 (opens new window)
  • 网络编程面试题 (opens new window)
  • 网络编程面试题 答案详解 (opens new window)
  • 聊聊WebServer作面试项目那些事儿 (opens new window)
  • 字节跳动面试官现身说 (opens new window)
  • 技术简历指南 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 从零用C语言写一个Redis
  • Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go开发实用指南
    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
  • Rust编程

    • Rust编程指南
  • 数据库

    • SQL零基础指南
    • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 最新谷歌C++风格指南(含C++17/20)
  • C++17详解
  • C++20完全指南
  • C++23快速入门
  • C++语言面试问题集锦
  • 🔥C/C++后端开发常见面试题解析 (opens new window)
  • 网络编程面试题 (opens new window)
  • 网络编程面试题 答案详解 (opens new window)
  • 聊聊WebServer作面试项目那些事儿 (opens new window)
  • 字节跳动面试官现身说 (opens new window)
  • 技术简历指南 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 从零用C语言写一个Redis
  • Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go开发实用指南
    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
  • Rust编程

    • Rust编程指南
  • 数据库

    • SQL零基础指南
    • MySQL开发与调试指南
GitHub (opens new window)
  • C++17 详解 说明
  • 第一部分——语言特性
  • 1. 快速入门
  • 2. 移除或修正的语言特性
  • 3. 语言澄清(Language Clarification)
  • 4. 通用语言特性
  • 5. 模板(Templates)
  • 6. 代码标注
  • 第二部分 - 标准库的变化
  • 7. std::optional
  • 8. std::variant
  • 9. std::any
  • 10. std::string_view
    • 10. std::string_view
      • 基础
      • 使用场景
      • std::basic_string_view类型
      • std::string_view的创建
      • 其他操作
      • 迭代器
      • 访问元素
      • 大小和容量
      • 修改器
      • 其他方法
      • 非成员函数
      • C++20中的更多特性
      • 使用string_view的风险
      • 处理非空终止字符串
      • 引用和临时对象
      • 初始示例的问题
      • 引用生命周期延长
      • 从string_view初始化字符串成员
      • 其他类型和自动化
      • 处理非空终止字符串
      • 性能和内存考量
      • 常量表达式中的字符串
      • 从boost::string_ref和boost::string_view迁移
      • 示例
      • 与不同的字符串API协同工作
      • 字符串分割
      • 总结
  • 11. 字符串转换
  • 12. 搜索器与字符串匹配
  • 13. 文件系统
  • 14. 并行STL算法
  • 15. 标准库中的其他变化
  • 16. 移除和弃用的库特性
  • 第三部分 - 更多示例和用例
  • 17. 使用std::optional和std::variant进行重构
  • 18. 使用[[nodiscard]]强制执行代码契约
  • 19. 用if constexpr替换enable_if——带可变参数的工厂函数
  • 20. 如何实现CSV读取器的并行化
目录

10. std::string_view

# 10. std::string_view

自C++11引入移动语义(move semantics)以来,传递字符串变得快多了。然而,你可能会遇到很多临时字符串副本的问题。在C++17中,出现了一种新类型string_view。它允许你创建一个指向连续字符序列的常量、非拥有型视图。你可以操作这个视图并传递它,而无需复制被引用的数据。不过,这个特性也有一些代价:你需要注意避免出现“悬空”视图,而且通常这样的视图可能不是以空字符结尾的。

在本章中,你将学到:

  • 什么是string_view?
  • 为什么它能加快你的代码速度?
  • 使用string_view对象会涉及哪些风险?
  • 什么是引用生存期延长,它对string_view意味着什么?
  • 如何使用string_view使你的应用程序编程接口(API)更通用?

# 基础

让我们做个小实验:在下面的示例中会创建多少个字符串副本?

// string function:
std::string StartFromWordStr(const std::string& strArg, const std::string& word) {
    return strArg.substr(strArg.find(word)); // substr函数创建一个新字符
}

// call:
std::string str {"Hello Amazing Programming Environment" };
auto subStr = StartFromWordStr(str, "Programming Environment");

std::cout << subStr << '\n';
1
2
3
4
5
6
7
8
9
10

你能数清楚吗?

答案取决于编译器,可能是3个或5个,但通常应该是3个。

  • 第一个是str。

  • 第二个是StartFromWordStr函数的第二个参数——该参数是const string&类型,由于我们传递的是const char*,所以会创建一个新字符串。

  • 第三个来自substr,它返回一个新字符串。

  • 然后,由于对象是从函数中返回的,可能还会有一两个额外的副本。但通常编译器可以进行优化并省略这些副本(特别是在C++17之后,在这种情况下拷贝省略已成为强制要求)。

  • 如果字符串很短,那么可能不会进行堆分配,因为有小字符串优化。

    小字符串优化在 C++ 标准中并未定义,但它是流行编译器中常见的优化方式。 目前,在微软 Visual Studio 2017 的编译器(MSVC)和 GCC 8.1 中,小字符串的长度限制是 15 个字符,而在 Clang 6.0 中是 22 个字符 。

上面的示例比较简单。然而,你可以想象在实际生产代码中,字符串操作经常发生。在那种情况下,甚至很难数清编译器创建的所有临时字符串。

解决额外临时副本问题的一个更好模式是使用std::string_view。顾名思义,你不再使用原始字符串,而是只获取它的一个非拥有型视图。大多数时候,它是一个指向连续字符序列的指针和长度。你可以传递它,并使用大多数常规的字符串操作。

视图在处理字符串操作(如子串操作substr)时表现出色。在典型情况下,每个子串操作都会创建一个更小的字符串副本。而使用string_view,substr只会映射原始缓冲区的不同部分,无需额外的内存使用或动态分配。

下面是使用string_view更新后的代码:

std::string_view StartFromWord(std::string_view str, std::string_view word) {
    return str.substr(str.find(word)); 
}

// call:
std::string str {"Hello Amazing Programming Environment"};
auto subView = StartFromWord(str, "Programming Environment");

std::cout << subView << '\n';
1
2
3
4
5
6
7
8
9

在上述代码中,我们只进行了一次分配——仅为主要字符串str分配。string_view的任何操作都不会为新字符串调用拷贝或进行额外的内存分配。当然,string_view会被拷贝——但由于它只是一个指针和一个长度,所以比拷贝整个字符串高效得多。

有一点需要注意:虽然这个示例展示了字符串视图的优化能力,但请继续阅读以了解该代码的风险和前提条件!或许你现在就能发现一些问题?那么,什么时候应该使用string_view呢?

# 使用场景

  • 优化:你可以仔细检查代码,用string_view替换各种字符串操作。在大多数情况下,你的代码会更快,内存分配也会更少。
  • 作为const std::string&参数的可能替代:特别是在那些不需要拥有字符串所有权且不存储字符串的函数中。
  • 处理来自其他API的字符串:如QString、CString、const char*等等,只要是存储在连续内存块中且基本字符类型相同的字符串。

你可以编写一个接受string_view的函数,这样就无需对其他实现进行转换。

在任何情况下,都要记住它只是一个非拥有型视图,所以如果原始对象不存在了,这个视图就会变得毫无意义,你可能会遇到问题。

此外,string_view可能不包含空字符终止符,所以你的代码也必须支持这一点。例如,将string_view传递给一个接受以空字符结尾的字符串的函数,从来都不是一个好主意。关于这一点,我们将在单独的“string_view的风险”部分详细讨论 。

# std::basic_string_view类型

虽然我们一直在说string_view,但需要知道它只是一个名为basic_string_view的模板类的特化版本:

template<
class CharT ,
class Traits = std::char_traits<CharT>
> 
class basic_string_view;
1
2
3
4
5

Traits类用于抽象字符类型上的操作,例如如何比较字符、如何在序列中查找某个字符。

这种层次结构与std::string类似,std::string是std::basic_string的特化版本。

我们有以下特化类型:

std::string_view std::basic_string_view<char>
std::wstring_view std::basic_string_view<wchar_t>
std::u16string_view std::basic_string_view<char16_t>
std::u32string_view std::basic_string_view<char32_t>

如你所见,这些特化类型使用了不同的底层字符类型。

为了方便起见,在本章的其余部分,我们仅讨论string_view。

# std::string_view的创建

你可以通过几种方式创建string_view:

  • 从const char*创建——提供一个指向以空字符结尾的字符串的指针;
  • 从const char*和长度创建;
  • 通过std::string转换创建;
  • 使用""sv字面量创建。

下面是各种创建方式的示例:

const char* cstr = "Hello World";

// the whole string:
std::string_view sv1 { cstr };
std::cout << sv1 << ", len: " << sv1.size() << '\n';

// slice
std::string_view sv2 { cstr, 5 };  // not null-terminated!
std::cout << sv2 << ", len: " << sv2.size() << '\n';

// from string:
std::string str = "Hello String";
std::string_view sv3 = str;
std::cout << sv3 << ", len: " << sv3.size() << '\n';

// ""sv literal
using namespace std::literals;
std::string_view sv4 = "Hello\0 Super World"sv;
std::cout << sv4 << ", len: " << sv4.size() << '\n';
std::cout << sv4.data() << " - till zero\n ";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

代码输出如下:

Hello World, len: 11
Hello, len: 5
Hello String, len: 12
Hello Super World, len: 18
Hello - till zero
1
2
3
4
5

请注意最后两行:sv4中间包含'\0',但std::cout仍然可以打印整个序列。在最后一行,我们尝试使用.data()打印,得到一个字符串指针,因此打印在空字符终止符处中断。

# 其他操作

string_view的设计与std::string非常相似。然而,string_view并不拥有数据,因此任何修改数据的操作都不能出现在其接口中。以下是可以用于这个新类型的方法简要列表:

# 迭代器

方法 描述
cbegin()、begin() 返回指向第一个字符的迭代器
crbegin()、rbegin() 返回指向反向视图中第一个字符的反向迭代器,它对应序列中的最后一个字符
cend()、end() 返回指向序列最后一个字符之后位置的迭代器
crend()、rend() 返回指向反向序列末尾的迭代器,它对应于反向序列第一个字符之前的位置

请注意,上述所有方法都是constexpr且const的,所以你得到的总是一个常量迭代器(即使是begin()或end())。

# 访问元素

方法 描述
operator[] 返回指定位置字符的常量引用,不检查边界
at() 返回指定位置字符的常量引用,并进行边界检查(可能会抛出std::out_of_range异常)
front() 返回序列中第一个字符的常量引用
back() 返回序列中最后一个字符的常量引用
data() 返回指向底层数据的指针

如果视图为空,对operator[]、front()、back()和data()的调用会导致未定义行为。

# 大小和容量

方法 描述
size()/length() 返回序列中的字符数量
max_size() 返回basic_string_view可以引用的最大字符类对象数量
empty() 返回size == 0

# 修改器

方法 描述
remove_prefix(size_type n) 相当于:data_ += n; size_ -= n;
remove_suffix(size_type n) 相当于:size_ -= n;
swap(basic_string_view& s) 交换*this和s的值

# 其他方法

方法 描述
copy(charT* s, size_type n, size_type pos) 从位置pos开始将n个字符复制到s中,不是constexpr,复杂度为O(1),不像std::string中是O(n)
substr(size_type pos, size_type n) 从位置pos开始,返回长度为n的子视图
compare(...) 比较字符串,与std::basic_string::compare类似
find(...) 返回输入字符串或basic_string_view::npos第一次出现的位置
rfind(...) 返回输入字符串或basic_string_view::npos最后一次出现的位置
find_first_of(...) 返回与输入模式中任何字符相等的第一个字符的位置,若未找到则返回basic_string_view::npos
find_last_of(...) 返回与输入模式中任何字符相等的最后一个字符的位置,若未找到则返回basic_string_view::npos
find_first_not_of(...) 返回与输入模式中任何字符都不相等的第一个字符的位置,若未找到则返回basic_string_view::npos
find_last_not_of(...) 返回与输入模式中任何字符都不相等的最后一个字符的位置,若未找到则返回basic_string_view::npos

上表中...表示该方法有多个重载。

# 非成员函数

函数 描述
比较运算符:==、!=、<=、>=、<、> 按字典序比较两个字符串视图
operator << 用于ostream输出

关于上述方法、函数和类型的关键要点:

  • 上述所有方法(除了copy、operator <<和std::hash特化)也都是constexpr的!有了这个特性,现在你可以在常量表达式中处理连续的字符序列。
  • 上述列表几乎与所有不可变字符串操作相同。不过,有两个新方法:remove_prefix和remove_suffix - 它们不是const的,会修改string_view对象。请注意,它们仍然不能修改所引用的数据。
  • operator[]、at、front、back、data - 也是const的 - 因此你不能更改底层字符序列(只有“读访问”权限)。在std::string中,这些方法有返回引用的重载,所以你有“写访问”权限。但string_view不支持。
  • string_view也有针对std::hash的特化。
  • string_view有字符串字面量""sv,你可以像这样定义变量:auto sv = "hello"sv;

# C++20中的更多特性

在C++20中,我们将获得两个新方法:

  • starts_with()
  • ends_with()

它们同时在std::basic_string_view和std::basic_string中实现。截至2019年8月,Clang 6.0、GCC 9.0和VS 2019 16.2已支持这两个方法。

# 使用string_view的风险

std::string_view被添加到标准库中主要是为了实现性能优化。然而,它并不能完全替代字符串!因此,在使用视图时,你必须留意一些潜在风险。

# 处理非空终止字符串

string_view的字符串末尾可能没有\0。所以你必须对此有所准备。

  • string_view在与所有接受传统C字符串的函数配合使用时可能会有问题,因为string_view不符合C字符串的终止假设。如果一个函数只接受const char*参数,将string_view传递给它可能不是个好主意。另一方面,如果一个函数同时接受const char*和长度参数,那么传递string_view可能是安全的。
  • 转换为字符串时 - 你不仅需要指定指向连续字符序列的指针,还需要指定长度。

# 引用和临时对象

string_view并不拥有内存,所以在处理临时对象时必须格外小心。

一般来说,string_view的生命周期绝不能超过拥有该字符串的对象的生命周期。

在以下情况中,这一点可能至关重要:

  • 从函数返回string_view - 该视图必须指向函数结束后仍然存在的数据。
  • 在对象或容器中存储string_view - 这类似于在容器中存储指针。当你访问容器中的元素时,被引用的数据必须仍然存在。

为了探究所有这些问题,让我们从本章最初的示例开始。

# 初始示例的问题

引言部分展示了这样一个示例:

std::string_view StartFromWord(std::string_view str, std::string_view word) {
    return str.substr(str.find(word)); 
}
1
2
3

这段代码在处理非空终止字符串时没有任何问题,因为所有函数都来自string_view的接口。

然而,临时对象呢?如果调用以下代码会发生什么?

auto str = "My Super"s;
auto sv = StartFromWord(str + " String", "Super");
// 后续代码中使用`sv`...
1
2
3

这样的代码可能会出错!

"Super"是一个临时的const char*字面量,并作为string_view类型的word参数传递给函数。这没问题,因为临时对象的生命周期会持续到整个函数调用结束。

然而,字符串拼接str + " String"的结果是一个临时对象,并且函数返回了这个临时对象的string_view,超出了调用范围!

所以在这种情况下,一般的建议是,虽然可以从函数返回string_view,但你必须小心,确保底层字符串的状态是正确的。

为了理解临时值的问题,了解引用生命周期延长(reference lifetime extension)是很有帮助的。

# 引用生命周期延长

在以下情况中会发生什么?

std::vector<int> GenerateVec() {
    return std::vector<int>(5, 1);
}
const std::vector<int>& refv = GenerateVec();
1
2
3
4

上述代码安全吗?

是的 - C++规则规定,绑定到常量引用的临时对象的生命周期会延长到该引用本身的生命周期。

以下是从标准(C++17草案 - N4687)中引用的完整示例,15.2临时对象[class.temporary]:

[示例:

struct S {
    S();
    S(int);
    friend S operator+(const S&, const S&);
    ~S();
};
S obj1;
const S& cr = S(16)+S(23);
S obj2;

表达式S(16) + S(23)创建了三个临时对象:第一个临时对象T1用于保存表达式S(16)的结果,第二个临时对象T2用于保存表达式S(23)的结果,第三个临时对象T3用于保存这两个表达式相加的结果。然后,临时对象T3被绑定到引用cr。T1和T2哪个先创建是未指定的。在T1先于T2创建的实现中,T2应在T1之前销毁。临时对象T1和T2被绑定到operator+的引用参数;这些临时对象在包含对operator+调用的完整表达式结束时被销毁。绑定到引用cr的临时对象T3在cr的生命周期结束时被销毁,即程序结束时。此外,T3的销毁顺序考虑了其他具有静态存储期的对象的销毁顺序。也就是说,因为obj1在T3之前构造,T3在obj2之前构造,所以obj2应在T3之前销毁,T3应在obj1之前销毁。 - 示例结束]
1
2
3
4
5
6
7
8
9
10
11
12
13

虽然最好不要对所有变量都编写这样的代码,但在类似以下的情况中,这可能是一个有用的特性:

for (auto &elem : GenerateVec()) {
    // ...
}
1
2
3

在上述示例中,GenerateVec在基于范围的for循环内被绑定到一个引用(向量起始位置的右值引用)。如果没有延长生命周期的支持,这段代码会出错。

这与string_view有什么关系呢?

对于string_view,以下代码通常容易出错:

std::string func() {
    std::string s;
    // 构建s...
    return s; 
}

std::string_view sv = func();
// 没有临时对象生命周期延长!
1
2
3
4
5
6
7
8

这可能不太容易理解 - string_view也是一个常量视图,所以它的行为应该与常量引用类似。但根据现有的C++规则,情况并非如此 - 编译器会在整个表达式结束后立即销毁临时对象。在这种情况下,生命周期无法延长。

string_view只是一个代理对象,类似于以下代码:

std::vector<int> CreateVector() { ... }
std::string GetString() { return "Hello"; }

auto &x = CreateVector()[10]; 
auto pStr = GetString().c_str();
1
2
3
4
5

在这两种情况下,x和pStr都不会延长在CreateVector()或GetString()中创建的临时对象的生命周期。

你可以通过以下方式修复这个问题:

std::string func() {
    std::string s;
    // 构建s...
    return s;
}

auto temp = func();
std::string_view sv { temp };
// 通过`temp`延长了临时对象的生命周期,没问题
1
2
3
4
5
6
7
8
9

每次你赋值某个函数的返回值时,都必须确保对象的生命周期是正确的。

有一个提案旨在修复string_view和其他应该具有延长引用生命周期语义的类型的问题:查看P0936 (opens new window) 。

# 从string_view初始化字符串成员

由于string_view在函数传参时可以作为const string&的潜在替代品,我们可以考虑用它来初始化字符串成员的情况。string_view在这种场景下是最佳选择吗?看下面这个例子:

class UserName {
    std::string mName;
public:
    UserName(const std::string& str) : mName(str) { }
};
1
2
3
4
5

可以看到,构造函数接收const std::string& str。另一种选择是使用string_view:

UserName(std::string_view sv) : mName(sv) { }
1

让我们在三种情况下比较这两种实现方式:从字符串字面量创建对象、从左值(lvalue)创建对象以及从右值引用(rvalue reference)创建对象:

// 从字符串字面量创建对象
UserName u1{"John With Very Long Name"};

// 从左值创建对象
std::string s2 {"Marc With Very Long Name"};
UserName u2 { s2 };

// 后续使用s2...
// 从右值引用创建对象
std::string s3 {"Marc With Very Long Name"};
UserName u3 { std::move(s3) };

// 第三种情况也类似于接收返回值
std::string GetString() { return "some string..."; }
UserName u4 { GetString() };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

现在我们来分析UserName构造函数的两个版本,一个使用字符串引用,另一个使用string_view。

请注意,这里不考虑s2和s3的内存分配及创建过程,我们只关注构造函数调用时发生的情况。对于s2,我们还假设它在代码中后续会被使用。

  • 使用const std::string&的情况:
    • u1:两次内存分配。第一次创建一个临时字符串并将其绑定到输入参数,然后再将其复制到mName中。
    • u2:一次内存分配。我们可以无开销地将引用绑定到s2,然后将其复制到成员变量mName中。
    • u3:一次内存分配。同样是无开销地将引用绑定到输入参数,然后再复制到成员变量mName中。
    • 对于u1的情况,你可以编写一个接收右值引用的构造函数来减少一次内存分配;对于u3的情况,也可以通过从右值引用移动数据来减少一次复制操作。
  • 使用std::string_view的情况:
    • u1:一次内存分配。输入参数无需复制或分配内存,仅在创建mName时进行一次内存分配。
    • u2:一次内存分配。为参数创建string_view的开销很小,然后将其复制到成员变量mName中。
    • u3:一次内存分配。同样是为参数创建string_view的开销很小,接着复制到成员变量mName中。
    • 对于u3的情况,如果你想减少一次内存分配,也需要编写一个接收右值引用的构造函数,因为这样可以从右值引用移动数据。

虽然在传递字符串字面量时string_view表现更好,但在使用现有字符串或从现有字符串移动数据时,它并没有优势。

然而,自C++11引入移动语义(move semantics)以来,通常将字符串按值传递然后再移动它,这种方式更好且更安全。

例如:

class UserName {
    std::string mName;
public:
    UserName(std::string str) : mName(std::move(str)) { }
};
1
2
3
4
5

现在来看使用std::string按值传递的结果: - u1:一次内存分配。为输入参数分配内存,然后将其移动到mName中。这比使用const std::string&时的两次内存分配要好,和使用string_view的方式类似。 - u2:一次内存分配。我们需要将值复制到参数中,然后再将其移动到mName中。 - u3:无需内存分配,仅进行两次移动操作。这比使用string_view和const string&的方式都要好!

按值传递std::string不仅代码更简洁,而且也无需为右值引用编写单独的重载函数。

完整代码示例见string_view/initializing_from_string_view.cpp文件。

这种按值传递的方式与斯科特·迈耶斯(Scott Meyers)所著《Effective Modern C++》中第41条建议 “对于可拷贝、移动开销小且总是会被拷贝的参数,考虑按值传递” 一致。

然而,std::string的移动操作开销真的小吗?

虽然C++标准没有明确规定,但通常情况下,字符串是通过小字符串优化(Small String Optimisation,SSO)来实现的,即字符串对象包含额外的空间,可以在不进行额外内存分配的情况下存储字符 。这意味着移动一个字符串和复制它的开销是一样的。而且由于字符串较短,复制操作也很快。

小字符串优化(SSO)并未标准化,且可能会发生变化。目前,在 MSVC(VS 2017)/GCC(8.1)中,小字符串优化的字符数限制是 15 个字符,而在 Clang(6.0)中是 22 个字符。对于跨平台代码而言,基于小字符串优化来进行假设性的优化并不是一个好主意。

让我们重新考虑在字符串较短时按值传递的示例:

UserName u1{"John"}; 
std::string s2 {"Marc"}; 
UserName u2 { s2 };
std::string s3 {"Marc"}; 
UserName u3 { std::move(s3) };
1
2
3
4
5

记住,每次移动操作和复制操作的开销相同。

  • 使用const std::string&的情况:
    • u1:两次复制。一次是将输入的字符串字面量复制到临时字符串参数中,另一次是将临时字符串复制到成员变量中。
    • u2:一次复制。将现有字符串绑定到引用参数,然后再复制到成员变量中。
    • u3:一次复制。将右值引用无开销地绑定到输入参数,之后再复制到成员变量中。
  • 使用std::string_view的情况:
    • u1:一次复制。输入参数无需复制,仅在初始化mName时进行一次复制。
    • u2:一次复制。输入参数无需复制,因为创建string_view很快,然后再复制到成员变量中。
    • u3:一次复制。创建string_view的开销很小,然后将参数复制到mName中。另外,string_view还存在指向已删除字符串的风险。
  • 使用std::string按值传递的情况:
    • u1:两次复制。输入参数由字符串字面量创建,然后再复制到mName中。
    • u2:两次复制。一次复制到参数中,然后再复制到成员变量中。
    • u3:两次复制。一次复制到参数中(移动操作等同于复制操作),然后再复制到成员变量中。

可以看到,对于短字符串,当传递现有字符串时,按值传递可能会 “更慢”,因为会进行两次复制而不是一次。另一方面,当编译器看到对象而不是引用时,可能会对代码进行更好的优化。而且,短字符串的复制开销很小,所以这种潜在的 “速度减慢” 甚至可能察觉不到。

总之,按值传递字符串参数然后再移动它是更优的解决方案。对于较长的字符串,这种方式代码简单且性能更好。

一如既往,如果你的代码对性能要求极高,那么就必须对所有可能的情况进行测量。

# 其他类型和自动化

本节讨论的问题也适用于其他可拷贝和可移动的类型。如果移动操作的开销很小,那么按值传递可能比按引用传递更好。你还可以使用自动化工具,比如Clang-Tidy,它可以检测出潜在的优化点。Clang-Tidy针对这种情况有单独的规则,详见clang-tidy - modernize-pass-by-value。

下面是传递字符串和初始化字符串成员的总结:

输入参数 const string& string_view string并移动
const char* 2次分配 1次分配 1次分配 + 1次移动
const char*(适用SSO) 2次复制 1次复制 2次复制
左值 1次分配 1次分配 1次分配 + 1次移动
左值(适用SSO) 1次复制 1次复制 2次复制
右值 1次分配 1次分配 2次移动
右值(适用SSO) 1次复制 1次复制 2次复制

# 处理非空终止字符串

如果你从一个字符串创建string_view,它将指向一段以空字符终止的内存:

std::string s = "Hello World"; 
std::cout << s.size() << '\n '; 
std::string_view sv = s;
std::cout << sv.size() << '\n ';
1
2
3
4

这两条cout语句都会输出11。

但是,如果只取字符串的一部分呢:

std::string s = "Hello World";
std::cout << s.size() << '\n ';
std::string_view sv = s;
auto sv2 = sv.substr(0, 5);
std::cout << sv2.data() << '\n '; 
1
2
3
4
5

sv2应该只包含"Hello",但是当你访问底层内存的指针时,得到的却是指向整个字符串的指针。表达式cout << sv2.data()会输出整个字符串,而不只是它的一部分!sv2.data()返回的是指向字符串s对象中"Hello World"字符数组的指针。

当然,当你输出sv2时会得到正确的结果:

std::cout << sv2 << '\n ';
// 输出 "Hello"
1
2

这是因为std::cout可以处理string_view类型。

这个例子展示了所有假定字符串以空字符终止的第三方API可能存在的问题。例如:

  • 使用printf()打印:
std::string s = "Hello World";
std::string_view sv = s;
std::string_view sv2 = sv.substr(0, 5);
printf("My String %s", sv2.data()); 
1
2
3
4

你应该使用:

printf("%.*s\n", static_cast<int>(sv2.size()), sv2.data()); 
1

.*用于指定精度,详见printf的规范:精度不是在格式字符串中指定的,而是作为一个额外的整数值参数,放在需要格式化的参数之前。

  • 类似atoi()/atof()的转换函数:
std::string number = "123.456";
std::string_view svNum { number.data(), 3 };
auto f = atof(svNum.data()); 
std::cout << f << '\n';
1
2
3
4

atof只接受指向以空字符终止的字符串的指针,所以string_view与之不兼容。

要解决这个问题,可以考虑使用from_chars函数(C++17中也添加了该函数):

// 使用from_chars(C++17)
std::string number = "123.456";
std::string_view svNum { number.data(), 3 }; 
int res = 0;
std::from_chars(svNum.data(), svNum.data()+svNum.size(), res);
std::cout << res << '\n';
1
2
3
4
5
6
  • 通用解决方案:如果你的API只支持以空字符终止的字符串,且无法切换到带有额外计数或大小参数的函数,那么就需要将string_view转换为字符串。

例如,在string_view/string_view_null.cpp文件中:

void ConvertAndShow(const char* str) {
    auto f = atof(str);
    std::cout << f << '\n ';
}

std::string number = "123.456";
std::string_view svNum { number.data(), 3 };
// ... 一些代码
std::string tempStr { svNum.data(), svNum.size() };
ConvertAndShow(tempStr.c_str());
1
2
3
4
5
6
7
8
9
10

ConvertAndShow函数只处理以空字符终止的字符串,所以我们唯一的办法是创建一个临时字符串tempStr,然后将其传递给该函数。

如果你想从string_view创建一个字符串对象,记住要使用.data()和.size(),这样才能正确引用底层字符序列的切片。

# 性能和内存考量

将string_view添加到标准库背后的核心思想是提高性能和减少内存消耗。利用string_view,可以有效地避免创建许多临时字符串,从而提升性能。

在内存方面,string_view通常实现为[ptr, len],即一个指针和一个通常为size_t类型的变量来表示可能的大小。

这就是为什么它的大小通常是8字节或16字节(取决于架构是x86还是x64)。

对于std::string类型,由于常见的小字符串优化,std::string的大小通常是24字节或32字节,是string_view的两倍。如果字符串长度超过了小字符串优化(SSO)的缓冲区大小,那么std::string会在堆上分配内存。如果不支持小字符串优化(这种情况很少见),那么std::string将由一个指向已分配内存的指针和大小组成。

在字符串操作的性能方面,string_view仅支持字符串操作的一个子集,即那些不会修改所引用字符序列的操作。像find()这样的函数,其性能应该与std::string中的对应函数相同。

另一方面,string_view中的substr操作只是复制两个元素,而std::string的substr操作会复制一段内存范围。它们的时间复杂度分别是O(1)和O(n)。所以,如果你需要分割一个较长的字符串并处理分割后的子串,string_view的实现方式应该会更快。

# 常量表达式中的字符串

string_view的一个有趣特性是,除了复制操作、operator<<以及为string_view专门化的std::hash函数外,它的所有方法都被标记为constexpr。有了这个特性,你可以在编译时对字符串进行操作。

例如,在string_view/string_view_constexpr.cpp文件中:

#include <string_view>

int main() {
    using namespace std::literals;

    constexpr auto strv = "Hello Programming World"sv;
    constexpr auto strvCut = strv.substr("Hello "sv.size());
    static_assert(strvCut == "Programming World"sv);
    return strvCut.size();
}
1
2
3
4
5
6
7
8
9
10

如果你使用像GCC 8.1这样的现代编译器,并加上-std=c++1z -Wall -pedantic -O2这些选项,那么编译后的汇编代码应该如下所示:

main:
movl    $17, %eax
ret
1
2
3

类似的代码,如果使用std::string,会生成更多代码。由于这个例子使用了长字符串,无法进行小字符串优化(Small String Optimisation),所以编译器必须生成用于new/delete的代码来管理字符串的内存。

# 从boost::string_ref和boost::string_view迁移

和C++17中的大多数新类型一样,string_view也受到了Boost库的启发。马歇尔·克洛(Marshall Clow)在1.53版本(2012年2月)中实现了boost::string_ref,之后它演进为boost::string_view(在1.61版本中添加 - 2016年5月)。

boost::string_ref和boost::string_view的主要区别在于对constexpr的支持。

boost::string_view实现了与std::string_view相同的功能,还添加了一些新函数:

  • starts_with
  • ends_with

完整的头文件可查看boost/doc/libs/1_67_0/boost/utility/string_view.hpp ,也可查看Boost实用库 。

关于string_ref弃用的讨论链接:“string_view与string_ref的对比” 。

# 示例

下面是两个使用string_view的示例。

# 与不同的字符串API协同工作

string_view一个有趣的用例是在处理不同字符串实现的代码中使用它。

例如,你可能会用到MFC中的CString、C语言API中的const char*、QT中的QString,当然还有std::string。

与其为不同的字符串类型创建重载函数,不如利用string_view!例如:

void Process(std::string_view sv) { }
1

如果你想在不同的字符串实现中使用Process函数,你所要做的就是从你的字符串类型创建一个string_view。大多数字符串类型都应该很容易做到这一点。

例如:

// MFC字符串:
CString cstr;
Process(std::string_view{cstr.GetString(), cstr.GetLength()});

// QT字符串:
QString qstr;
Process(std::string_view{qstr.toLatin1().constData()});

// 你自己的实现:
MySuperString myStr;
// MySuperString::GetData() - 返回char*
// MySuperString::Length() - 返回字符串长度
Process(std::string_view{myStr.GetData(), myStr.Length()});
1
2
3
4
5
6
7
8
9
10
11
12
13

假设Process()函数可以实现为Process(const char*, int len),但使用string_view会使代码更清晰、更简单。此外,你还可以使用string_view的所有可用方法,这样的代码比C风格的代码更方便。

# 字符串分割

string_view可能是字符串分割的一种潜在优化方式。如果你有一个大的持久化字符串,你可能希望创建一个string_view对象列表,来映射这个大字符串中的单词。

请注意,这段代码的灵感来自马可·阿雷纳(Marco Arena)的文章 “string_view:爱恨交加 (opens new window)” 。

在string_view/string_view_split.cpp文件中:

#include <vector>
#include <string_view>
#include <algorithm>

std::vector<std::string_view> splitSV(std::string_view strv, std::string_view delims = " ") {
    std::vector<std::string_view> output;
    auto first = strv.begin();

    while (first != strv.end()) {
        const auto second = std::find_first_of(
            first, std::cend(strv),
            std::cbegin(delims), std::cend(delims)
        );

        if (first != second) {
            output.emplace_back(strv.substr(
                std::distance(strv.begin(), first),
                std::distance(first, second)
            ));
        }

        if (second == strv.end()) {
            break;
        }

        first = std::next(second);
    }

    return output;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

示例用例:

const std::string str { "Hello Extra,,, Super, Amazing World" };

for (const auto& word : splitSV(str, " ,"))
	std::cout << word << '\n';
1
2
3
4

这将输出:

Hello
Extra
Super
Amazing World
1
2
3
4

该算法遍历输入的string_view,查找分隔符(即与分隔符列表匹配的字符)。然后,代码提取上一个分隔符和新分隔符之间的字符序列片段,并将这个子视图存储在输出向量中。

关于这个实现的一些说明:

  • 该算法的string_view版本假设输入字符串是持久化的,而不是临时对象。要注意返回的string_view向量,因为它也指向输入字符串。
  • if (first != second)这条指令可以防止在相邻出现多个分隔符(如连续空格)的情况下添加空 “单词”。
  • 该算法使用了std::find_first_of,也可以使用string_view::find_first_of。成员方法返回的不是迭代器,而是字符串中的位置。
  • 在一些测试中,当分隔符数量较少时,string_view的成员方法似乎比std::find_first_of版本慢。

如果你想查看关于本节代码的一些实验,可以看看 “C++17中std::string_view与std::string的性能对比 (opens new window)” 以及 “加速string_view字符串分割的实现 (opens new window)” 。这两篇博客文章描述了基准测试结果,并对代码提出了更多可能的改进方法。

# 总结

关于std::string_view,需要记住以下几点:

  • 它是std::basic_string_view<charType, traits<charType>>的特化版本,其中charType等于char。
  • 它是对连续字符序列的非拥有式视图。
  • 它末尾可能不包含空终止符。
  • 它可用于优化代码,减少对字符串临时副本的需求。
  • 它包含了大多数不会改变底层字符的std::string操作。
  • 它的操作也被标记为constexpr。

但是:

  • 要确保底层字符序列仍然存在!
  • 虽然std::string_view看起来像是对字符串的常量引用,但语言并不会延长绑定到std::string_view的返回临时对象的生命周期。
  • 当从string_view构建字符串时,始终记得使用stringView.size()。size()方法可以正确标记string_view的末尾。
  • 当将string_view传递给接受以空字符终止的字符串的函数时要小心,除非你确定你的string_view包含空终止符。

编译器支持情况:

特性 GCC Clang MSVC
std::string_view 7.1 4.0 VS 2017 15.0
上次更新: 2025/05/12, 17:14:47
9. std::any
11. 字符串转换

← 9. std::any 11. 字符串转换→

最近更新
01
第二章 关键字static及其不同用法
03-27
02
第一章 auto与类型推导
03-27
03
第四章 Lambda函数
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式