一、数组
数组是一种存储若干元素的数据类型,在诸多编程语言中存在,其显著的特点是元素通常是在物理层面上连续存储的(逻辑上的数组,比如链表,可能不是),并且具有极快的元素访问速度。
数组通常是同构的(homogenous ),即数组中的元素都是同一类型的,或者是兼容的。 在诸如等脚本语言中,数组通常是异构(heterogenous)的,这允许存储不同的类型。
在C++中,只支持前者,另一方面,数组,广泛来讲是一种对象的容器(container)。
a container is a data type that provides storage for a collection of unnamed objects (called elements).
容器是一种数据类型,它为一组未命名对象(称为元素)提供存储。
存储的方式可以多种多样,它们通常基于某些数据结构。数组和其它存储方式的容器构成了C++的容器库,它们被称之为容器类,所以这时候的数组不再是C语言中的数组,而是C++的数组。
在C++中有三种基本常用的数组:C-style数组、std::vector容器类、std::array容器类
- C-style 数组:传统的 C 语言风格数组。
- std::vector:动态数组,在运行时可以根据需要调整大小。
- std::array:固定大小的容器,提供了一些安全和方便的操作方法。
(一)C-style 数组回顾
在C语言中,通常可以将数组分为两类,整数数组和字符数组,这是最常用的两种数组,字符数组我们在后半部分讨论。
数组一般而言,有三个部分的内容:
- 创建,初始化问题
- 访问(读操作),引用或遍历
- 改变(写操作),修改、移动或删除
1、创建
C-style的数组初始化有下面三种情况
在C++的语法下,也可以
2、定长还是动态
C 语言数组的大小在声明时确定,是固定长度的。例如int arr[5],这个数组的长度就是 5,在程序运行过程中不能改变。
然而,可以通过一些方法实现类似动态增长的效果。
在堆空间中
3、访问元素与安全检查
在 C 语言中,可以通过 for 语句或下标运算符访问元素。例如:
int arr[5] = {1,2, 3, 4, 5};
// 使用下标运算符访问
printf("%d\n", arr[0]);
// 使用 for 语句访问
for(int i = 0; i < 5; i++){
printf("%d ", arr[i]);
}
但是,程序员需要负责检查下标的合理范围,防止下标越界。因为 C 语言不自动检查下标是否越界,一旦越界可能会导致错误的结果甚至程序崩溃。例如访问int arr[5]中的arr[5]就是越界访问。
基于此,结合内存的分布,整个C程序都可以看作一个大的数组
int boundary_right = 400;
int array[3] = {1,2,3};
int boundary_left = 100;
int* dynamic_array = &boundary_left;
for(int i = 0; i < 6; i++){
std::cout << "Address of dynamic_array[" << i << "]: " << &dynamic_array[i] << " is " << dynamic_array[i] << std::endl;
}
4、数组操作
C-style数组除了类型,没有提供过多的操作,比如,排序,所以如果要使用C-style数组去实现一些问题可能需要前期设计一些函数。
(二)std::vector
如上所见,C-style的数组缺乏安全性和可操作性,为了与C++面向对象的编程特性相适应,我们需要面向对象的数组。这时候就需要C++提供的容器库了
容器库是一组通用的类模板和算法的集合,允许程序员轻松实现常见的数据结构,如队列、列表和栈。
容器分为三类——序列容器、关联容器和无序关联容器——每一类都设计为支持不同的操作集。容器管理为其元素分配存储空间,并提供成员函数直接或通过迭代器(具有类似于指针的属性的对象)访问它们。大多数容器至少有几个共同的成员函数,并共享功能。对于特定应用程序而言,哪个容器最适合不仅取决于所提供的功能,还取决于其对不同工作负载的效率。
类模板是C++的高级特性,后面详细讨论。不过在这里,可以先体验它的便利之处。
是一种序列容器类,定义在库中
在《从数学到泛型编程》一书中,亚历山大·斯特潘诺夫写道:“STL 中的名称‘vector(向量)’取自早期编程语言 Scheme 和 Common Lisp。不幸的是,这与该术语在数学中更古老的含义不一致……这个数据结构本应被称为‘array(数组)’。可悲的是,如果你犯了一个错误并违反了这些原则,结果可能会持续很长时间。”
也即,vector与其字面意义并不相符,但是已经很难改变了,并且已经存在了一个std::array,从某种程度上,vector应该叫做dynamic_array而std::array应该叫做static_array
它的部分函数如下
分类 | 具体内容 |
构造函数 | 1. vector():创建一个空 vector。 2. vector(int nSize):创建一个 vector,元素个数为 nSize。 3. vector(int nSize, const T& t):创建一个 vector,元素个数为 nSize,且值均为 t。 4. vector(const vector&):复制构造函数。 5. vector(begin, end):复制 [begin, end) 区间内另一个数组的元素到 vector 中。 |
析构函数 | 当 vector 对象生命周期结束时,自动释放其占用的内存资源。 |
成员函数 | 1. 增加函数:push_back、insert等。 2. 删除函数:erase、pop_back、clear等。 3. 遍历函数:at、front、back、begin、end、rbegin、rend等。 4. 判断函数:empty等。 5. 大小函数:size、capacity、max_size等。 6. 其他函数:swap、assign等。 |
1、初始化问题
如上表所示,提供了很多构造函数以满足不同的需求,可以简单的创建一个空的
上述代码会报错
这是因为缺少了类模板参数 ,可以理解为缺少了对于容器元素类型的指定,在C++17开始,增加了,这允许我们在提供初始化值时省略对于容器元素类型的指定
“CTAD” 全称为 “Class Template Argument Deduction”,即类模板参数推导。在 C++17 中,它允许编译器根据初始化值推导出类模板的参数类型。例如,在初始化一个容器时,如果没有显式指定元素类型,编译器可以根据初始值来推断容器中元素的类型。
所以,如下的操作是不允许的,这没有提供推断元素类型的依据
正确的做法是,创建一个空的容器,但是必须得指定容器元素类型,使用尖括号包括一个具体的类型(模板参数,template argument)。
或者,提供初始化值,让编译器自动推断
所以,我们可以得到以下初始化
// 值初始化(调用默认构造函数),一个空的容器
std::vector<int> v{};
std::cout << v.size() << std::endl;
std::cout << v.capacity() << std::endl;
// 直接初始化,列表初始化
std::vector v2{1,2,3,4};
std::cout << v2.size() << std::endl;
std::cout << v2.capacity() << std::endl;
有一个神奇的地方在于,是一个类,但是在构造时可以接受任意多的元素,就像初始化一个无限大的数组一样,事实上,这调用到了一个特殊的构造函数,列表构造器(list constructor),这是一个特殊的机制,这里不多讨论。(不过确实隐藏了一个数组,可以通过函数获得)
这将会调用N次元素类型的拷贝构造器 ,总结来说会做以下三件事
Ensures the container has enough storage to hold all the initialization values (if needed).
1. 确保容器有足够的存储空间来容纳所有的初始化值(如果需要的话)。
Sets the length of the container to the number of elements in the initializer list (if needed).2. 将容器的长度设置为初始化列表中的元素数量(如果需要的话)。
Initializes the elements to the values in the initializer list (in sequential order).3. 按顺序将元素初始化为初始化列表中的值。
注意:不属于容器尽管具有大部分容器的功能,可称之为“伪容器”
The standard library provides a specialization of std::vector for the type bool, which may be optimized for space efficiency.
标准库为bool类型提供了std::vector的特化,这可能会优化空间效率。
换句话说,当你使用`std::vector<bool>`时,标准库会提供专门针对`bool`类型的优化版本,这个版本在空间使用上可能更加高效。这是因为`bool`类型通常只占用一个字节,而一个`std::vector<bool>`元素可能只需要一个位来存储,从而大大节省了空间。
需要注意的是,`std::vector<bool>`虽然看起来像一个容器,但在一些方面它的行为可能与预期的容器行为不同,因为它是为了位操作进行优化的。例如,它不提供对单个元素的指针,因为单个元素可能没有独立的地址。
2、数组大小
在上面的初始化代码中,使用到了两个成员函数
size()
——Returns the number of elements in the container.
返回容器里面的元素数量
capacity()
——Returns the number of elements that the container has currently allocated space for.
返回当前分配给容器元素空间的数量
这样看,或许有些疑惑,但是我们从源码入手,就一清二楚了
返回在vector里面的元素数量
返回在需要重新分配内存空间之前,容器可以容纳的元素数量
所以,因为可以动态增长,所以需要预先分配额外的空间
函数可能会导致数组内存空间的增长,以上在额外增加一个元素时,空间增长到了原空间的两倍,事实上,数组一开始的数组容量会影响再扩容时的大小
std::vector<int> v(4) ;
std::cout << "Size of vector: " << v.size() << std::endl;
std::cout << "Capacity of vector: " << v.capacity() << std::endl;
v.push_back(6);
std::cout << "Size of vector: " << v.size() << std::endl;
std::cout << "Capacity of vector: " << v.capacity() << std::endl;
在这里,我们调用的是显式构造函数,这将值初始化nSize个元素作为容器的初始元素。
预先多申请空间,可以避免频繁的内存空间申请,但是可能也会浪费内存空间,在不需要多余的空间的情况下,可以将数组空间收缩(shrink)
std::vector<int> v(4) ;
std::cout << "Size of vector: " << v.size() << std::endl;
std::cout << "Capacity of vector: " << v.capacity() << std::endl;
v.push_back(6);
std::cout << "After append one elements : " << std::endl;
std::cout << "Size of vector: " << v.size() << std::endl;
std::cout << "Capacity of vector: " << v.capacity() << std::endl;
v.shrink_to_fit();
std::cout << "After shrink: " << std::endl;
std::cout << "Size of vector: " << v.size() << std::endl;
std::cout << "Capacity of vector: " << v.capacity() << std::endl;
3、访问与限制
既然是数组,那么作为一个容器类,是否能够使用下标呢,是可以的,原因在于std::vector实现了以下成员函数,我们称之为运算符重载(对运算符操作行为的重定义),后面我们将讨论这种函数。
此运算符允许轻松进行数组样式的数据访问。注意,使用此运算符进行数据访问是未检查的,并且对于数组越界访问是未定义行为。
为了安全访问数组,推荐使用成员函数,这算是对于上述访问的一种封装,源码如下
“check”函数会检查索引是否大于了实际的元素个数,但是没有检查下限,因为我们的索引是类型,正常情况下,这是一个的8字节数据类型
在C或C++中,我们通常只会遇到索引过大而导致的数组越界,几乎不会遇到负数索引 (在python等脚本语言中,这通常为反向索引),但是另一方面,我们也常常使用有符号数作为索引,当数值在正数范围,一切都很好,但是,但是当数值过大,有符号数就会变成负数,负数索引就出现了,这会被隐式转换为一个更大的正数。
由于下标不进行边界检查,所以,这通常会造成内存的不安全,使用就尤为重要。另一个值得思考的点,是索引类型的合理性,因为通常我们不会需要如此多的元素,可能内存空间都容不下。
4、foreach 与迭代器
遍历是数组一种极其常见的操作,但是总有些麻烦,我们必须写上经典的“三段式”。所以,C++提供了升级版的for循环(for-each loop)
Range-based for loop,即“for loop over a range”(针对范围的for循环)。这种循环结构通常用于遍历一系列值,比如容器中的所有元素,是一种比传统for循环更易读的替代方式。
语法如下
for (element_declaration : array_object)
statement;
这相比原来的写法,显然简洁得多
std::vector<int> v{2,4,5,6,7,8};
for(int e: v){
std::cout << e << std::endl;
}
当然,有好处,也有坏处,比如,不能反向遍历,无法直接得到索引 。不过这也在一点一点改善,在C++20中,官方提供有foreach循环的反向遍历,在库中
std::vector<int> v{2,4,5,6,7,8};
for(int e: std::views::reverse(v)){
std::cout << e << std::endl;
}
除此之外,C++还提供了另一种遍历的方式,称之为迭代器
迭代器是一个对象,可以循环访问 C++ 标准库容器中的元素,并提供对各个元素的访问。 C++ 标准库容器全都提供迭代器,以便算法可以采用标准方式访问其元素,而不必考虑用于存储元素的容器类型。
拿一个简单的例子来说明一下
迭代器类似于指针,使用的时候是按照*iterator的格式,而不能采用下标的形式。
可通过使用成员和全局函数(如
begin()
和end()
)以及运算符(如++
和--
)向前或向后移动,来显式使用迭代器。 还可通过范围 for 循环或(对于某些迭代器类型)下标运算符[]
,来隐式使用迭代器。
vector<int> vec{ 0,1,2,3,4 };
for (auto it = begin(vec); it != end(vec); it++)
{
// Access element using dereference operator
cout << *it << " ";
}
在使用迭代器遍历的过程中,不能向循环中添加元素,否则会导致迭代器失效。
5、copy / move semantics:复制/移动语义
一直以来,数组是不能整体作为返回值的,最多只能返回其首元素的指针,但是元素个数就得另外传递。现在有了容器类,这一切都不是问题。
#include <iostream>
#include <vector>
std::vector<int> createVec(int size) {
// 调用构造vector( size_type count,
// const T& value,
// const Allocator& alloc = Allocator() );
return std::vector<int>(size, 0); // 返回一个临时vector对象
}
int main() {
std::vector<int> vec1 {createVec(10)};
std::cout << "vec1 size: " << vec1.size() << std::endl;
for(int i : vec1){
std::cout << i << " ";
}
std::cout << std::endl;
}
一般而言,这里 将通过vec1的拷贝构造函数将函数返回的临时vector对象赋值给自身,当表达式结束以后,临时vector对象将不复存在。可当vector的元素很多时,这种方式将会变得很昂贵,这并不同于以下情况:
std::vector<int> v1{1,2,3,4};
std::vector<int> v2(v1);
是一个左值,在表达式结束后仍然存在,在这种情况下,如果可以,我们可以选择引用,这将会变得很高效。所以对于上一种情况,复制语义并不高效。
复制语义是指通过拷贝构造函数(Copy Constructor)和拷贝赋值运算符(Copy Assignment Operator)来创建对象副本的规则。在
std::vector
的上下文中,当一个vector
对象被用于初始化另一个同类型的对象时,或者当vector
对象被赋值给另一个同类型的对象时,就会触发复制语义。
- 拷贝构造函数:在创建新对象时使用现有对象来初始化。
- 拷贝赋值运算符:将一个对象的状态复制给另一个已经存在的对象。
复制语义通常会导致资源的复制,对于大型vector
对象(包含大量元素或复杂元素)来说,这可能会非常耗时和占用大量内存。考虑到源对象的将亡性(xvalue),能否复用而不是复制,这便引出了移动语义
When ownership of data is transferred from one object to another, we say that data has been moved.
当数据的所有权从一个对象转移到另一个对象时,我们说数据已移动。
移动语义是C++11引入的一项新特性,用于有效地管理对象的资源。移动语义的目的就是通过转移对象的资源,而不是复制它们,来提高性能。
This is the essence of move semantics, which refers to the rules that determine how the data from one object is moved to another object. When move semantics is invoked, any data member that can be moved is moved, and any data member that can’t be moved is copied. The ability to move data instead of copying it can make move semantics more efficient than copy semantics, especially when we can replace an expensive copy with an inexpensive move.
这就是移动语义的本质,它指的是确定如何将一个对象中的数据移动到另一个对象的规则。调用移动语义时,将移动任何可移动的数据成员,并复制任何无法移动的数据成员。移动数据而不是复制数据的能力可以使移动语义比复制语义更有效,尤其是当我们可以用廉价的移动替换昂贵的副本时。
复制和移动的重要区别在于资源的利用上
复制语义 | 移动语义 | |
---|---|---|
目的 | 创建对象的副本 | 转移对象的资源所有权 |
涉及的操作 | 拷贝构造函数、拷贝赋值运算符 | 移动构造函数、移动赋值运算符 |
性能影响 | 可能昂贵,特别是对象较大时 | 通常更高效,因为避免了资源复制 |
源对象状态 | 保持不变 | 变为有效但未定义(通常是空或未定义) |
应用场景 | 需要对象副本时 | 对象不再需要其资源,或资源即将被销毁时 |
但是,通常它们在形式上难以区分,在正常情况下,当一个对象正在使用(或赋值)相同类型的对象进行初始化时,将使用复制语义(假设复制没有被省略),当以下所有条件都满足时,将调用移动语义:
- 对象的类型支持移动语义。 只有std::vector 和 std::string 为数不多的类型支持移动语义
- 对象正在使用相同类型的右值(临时)对象进行初始化(或赋值)。
- 移动没有被省略。
在C++的复制语义中,复制没有被省略(Copy Not Elided)通常指的是在编译器优化过程中,原本可以通过复制省略(Copy Elision)优化的复制操作没有被省略,而是按照标准的复制/移动语义进行了执行。复制省略是C++语言标准中定义的一种编译优化技术,旨在减少不必要的对象复制或移动,提高程序性能。
复制省略主要发生在以下几种情况:
返回值优化(RVO, Return Value Optimization):当函数返回一个对象时,如果返回的是局部对象的拷贝或移动,编译器可以优化为直接在调用点构造该对象,而不需要先构造局部对象再进行复制或移动。如果这种优化没有发生,即复制没有被省略,那么将按照正常的复制或移动语义进行对象的构造和赋值。
命名返回值优化(NRVO, Named Return Value Optimization):这是RVO的一种特殊情况,当函数返回的是一个具名局部对象的拷贝或移动时,如果满足一定条件(如该对象在返回前没有其他引用),编译器也可以进行优化,直接在调用点构造对象。同样,如果NRVO没有发生,则复制或移动操作将不会被省略。
参数传递:虽然参数传递通常不涉及返回值的复制省略,但在某些情况下(如函数参数为值传递且传入的是右值时),也可能发生类似的优化。然而,这更多是与移动语义相关,而非直接的复制省略。
在C++17及以后的版本中,复制省略被进一步强化为“强制消除”(Mandatory Elision),即在一些特定情况下(如返回语句中的纯右值),编译器必须省略复制/移动操作。但在某些情况下,如果编译器由于某种原因未能执行这种优化,或者代码显式禁止了这种优化(例如,通过关闭编译器的优化选项或使用了某些特定技术阻止优化),那么复制或移动操作就不会被省略。
总之,复制没有被省略意味着在原本可以通过优化减少复制/移动开销的情况下,编译器没有执行这种优化,而是按照标准的复制/移动语义进行了对象的构造和赋值。这可能会导致程序性能的下降,特别是当处理大型对象或频繁进行对象复制/移动时。
6、constexpr与std::vector
可以 标记为,确保了数据一旦被初始化,不会被修改。
const std::vector<int> primeNumbers = {2, 3, 5, 7, 11, 13, 17, 19};
不过不存在
A containers const-ness comes from const-ing the container itself, not the elements.
容器 const 来自容器本身的 const-ing,而不是元素。
还可以标记为
constexpr int square(int x) { return x * x; }
constexpr std::vector<int> perfectSquares = {square(1), square(2), square(3), square(4)};
在上述示例中,perfectSquares
向量在编译时期就已被完全填充,无需运行时的额外开销。这种能力不仅减少了程序的执行时间,还使得编译器能够进一步优化生成的代码,提升整体性能。
然而, 使用也面临一定的限制:
- 动态性受限:由于 的要求, 的大小必须在编译时期确定,这限制了其在需要动态调整大小的场景下的应用。
- 成员函数限制:并非所有 的成员函数都支持。特别是那些涉及动态内存分配和修改的函数,如,无法用于 。
7、栈与std::vector
是一个极其灵活且强大的序列容器,它不仅可以作为动态数组使用,还能够在一定程度上模拟栈的行为。
的栈行为主要体现在以下几个方面:
-
动态数组特性: 内部使用动态分配的数组来存储元素。当元素数量超出当前数组的容量时,会自动重新分配一个更大的数组,并复制(或移动)现有元素到新数组中。这种机制允许 动态地增长和缩小。
-
push_back()
和pop_back()
:push_back()
:在vector
的末尾添加一个元素。如果当前容量不足,则进行扩容操作。pop_back()
:移除vector
末尾的元素,减少其大小但不减少其容量。
-
后进先出(LIFO)原则:通过
push_back()
和pop_back()
的组合使用, 可以模拟栈的后进先出行为。
尽管 std::vector
的栈行为在功能上是足够的,但在性能上,特别是在频繁进行 push_back()
操作时,可能会遇到性能瓶颈。以下是一些优化策略:
-
预留空间:
使用reserve()
成员函数可以提前为vector
分配足够的空间,以避免在添加元素时频繁进行扩容操作。这对于已知或可预测元素数量的场景特别有用。std::vector<int> stack; stack.reserve(100); // 预留100个元素的空间
-
使用
emplace_back()
代替push_back()
:
当需要向vector
中添加新元素时,如果元素类型支持就地构造(in-place construction),则应优先考虑使用emplace_back()
。emplace_back()
直接在vector
的内存位置构造对象,避免了临时对象的创建和可能的移动或复制操作。std::vector<MyClass> vec; vec.emplace_back("example", 42); // 直接在 vector 中构造 MyClass 对象
(三)std::array
1、我们需要静态数组
是一个很好的容器类,正如上面的讨论,这给我们提供了强大的编程能力
元素是连续存储的,这意味着不仅可以通过迭代器访问元素,还可以使用常规元素指针的偏移量访问元素。这意味着指向元素的指针可以传递给任何需要指向数组元素指针的函数。
的存储是自动处理的,可以根据需要进行扩展。通常比静态数组占用更多的空间,因为分配了更多的内存来处理未来的增长。这样,就不需要在每次插入元素时重新分配,而只需要在额外的内存耗尽时重新分配。可使用函数查询已分配的内存总量。额外的内存可以通过调用返回给系统。
就性能而言,重新分配通常是代价高昂的操作。如果预先知道元素的数量,可以使用函数来消除重新分配。
对 vector 进行常见运算的复杂度 (效率) 如下:
- 随机访问 - 常数 O(1)。
- 在末尾插入或移除元素 - 摊销常数 O(1)。
- 元素的插入或删除 - 与向量 O(n) 末尾的距离呈线性关系。
但仍要注意动态数组的缺陷不可避免
Dynamic arrays are powerful and convenient, but like everything in life, they make some tradeoffs for the benefits they offer.
动态数组功能强大且方便,但就像生活中的一切一样,它们会为它们提供的好处做出一些权衡。
std::vector
is slightly less performant than the fixed-size arrays. In most cases you probably won’t notice the difference (unless you’re writing sloppy code that causes lots of inadvertent reallocations).
'std::vector' 的性能略低于固定大小的数组。在大多数情况下,您可能不会注意到差异(除非您编写的草率代码会导致大量无意的重新分配)。std::vector
only supportsconstexpr
in very limited contexts.
'std::vector' 仅在非常有限的上下文中支持 'constexpr'。In modern C++, it is really this latter point that’s significant. Constexpr arrays offer the ability to write code that is more robust, and can also be optimized more highly by the compiler. Whenever we can use a constexpr array, we should -- and if we need a constexpr array,
std::array
is the container class we should be using.
在现代 C++ 中,后一点确实很重要。Constexpr 数组提供了编写更健壮的代码的能力,并且还可以通过编译器进行更高程度的优化。只要我们可以使用 constexpr 数组,我们就应该 -- 如果我们需要一个 constexpr 数组,'std::array' 就是我们应该使用的容器类。
2、极近C-Style
是一个封装了固定大小数组的容器。这个容器是一个聚合类型,具有与以 C 风格数组 作为其唯一非静态数据成员的相同的语义。与 C 风格数组不同,它不会自动衰减为 。作为一个聚合类型,它可以使用最多 个可转换为 的初始化器进行聚合初始化。这个结构体结合了 C 风格数组的性能和可访问性,以及标准容器的优点,例如知道自己的大小、支持赋值、随机访问迭代器等。
所以可以类似聚合类型一样初始化,构造一个
std::array a = {1, 2, 3, 4, 5};
但是,如果C++版本过低,会触发一个错误,像前面一样
不过不同于,这里我们需要两个模板参数
std::array<int, 4> a1 = {1,2,3,4};
for (auto i : a1)
{
std::cout << i << std::endl;
}
第一个是定义数组元素类型的类型模板参数。第二个是定义数组长度的整数非类型模板参数。从C++17开始,在有了后,可以省略类型参数和长度参数(不支持只提供一个)。
对于长度,是和C类似的限定,d但是可以这样
std::array<int, 0> emptyArray{}; // empty array
emptyArray[0] = 5; // error: array index 0 is past the end of the array
是静态数组,所以不存在有扩容的函数。同时因为是聚合类型,也没有其它的构造函数。
3、为constexpr而生
在需求中十分受限,但是在,情况却天差地别
Even though the elements of a
const std::array
are not explicitly marked as const, they are still treated as const (because the whole array is const).
即使 'const std::array' 的元素没有显式标记为 const,它们仍然被视为 const(因为整个数组都是 const)。
因为是一个固定大小的容器,其所有元素的大小和数量都是在编译时确定的。这使得 成为 的理想候选对象。
为什么适合
- 固定大小:的大小在编译时是固定的,这意味着其内存布局和所需的空间都是已知的。这使得编译器可以在编译时优化 的使用。
- 元素访问:提供了多种访问元素的方式, 这些操作在编译时可以完全确定,因为它们依赖于已知的大小和索引。
- 初始化:可以使用列表初始化,编译器可以轻松地在编译时处理这些初始化列表,并将它们转换为实际的数组元素。
4、大括号省略
类模板的特性使得我们可以创建几乎所有我们想要的对象类型的数组,比如对于一个自定义类型,如下
struct Person{
std::string name;
int age;
char sex;
};
std::array<Person, 3> people = {
{"Alice", 30, 'F'},
{"Bob", 25, 'M'},
{"Charlie", 35, 'M'}
};
但是,这会得到一个意想不到的结果
这是因为,一直以来,我们忽视了括号对于列表初始化的作用,现在从编译器的角度看看如何解释这段代码,为了方便,这里选择另一个简单一点的版本
粗略的分析如下
当括号补充后
这样看起来很复杂,但其实有更简单的方式,我们可以更显式的初始化
或省略彻底
事实上,我们一直在修改,或者迎合的是一种机制 ,叫做大括号省略
Brace elision is a syntactic mechanism to simplify (in some cases) nested structures.
大括号省略是一种语法机制,用于简化(在某些情况下)嵌套结构。
但是令人疑惑的地方在于, 如何确定是否需要大括号 ,让我们拿原来的例子分析
其实一个关键点在于最外部的双大括号,以及必要时显式初始化
However, aggregates in C++ support a concept called brace elision, which lays out some rules for when multiple braces may be omitted. Generally, you can omit braces when initializing a
std::array
with scalar (single) values, or when initializing with class types or arrays where the type is explicitly named with each element.
但是,C++ 中的聚合支持一个称为大括号省略的概念,该概念为何时可以省略多个大括号列出了一些规则。通常,当使用标量(单个)值初始化 'std::array' 时,或者使用类类型或数组初始化时,如果类型与每个元素一起显式命名,则可以省略大括号。There is no harm in always initializing
std::array
with double braces, as it avoids having to think about whether brace-elision is applicable in a specific case or not. Alternatively, you can try to single-brace init, and the compiler will generally complain if it can’t figure it out. In that case, you can quickly add an extra set of braces.
总是用双括号初始化 'std::array' 并没有什么坏处,因为它避免了考虑大括号省略是否适用于特定情况。或者,你可以尝试单大括号 init,如果编译器无法弄清楚,它通常会抱怨。在这种情况下,您可以快速添加一组额外的大括号。
二、字符串
C字符串是一个以空字符(null,'\0')终止的字符数组; C++字符串则是一个与面向对象的程序设计编程思想相匹配的类。
(一)C-style 字符串
C字符串不是类类型。它是一个字符数组,但这并不意味着任何字符数组都是C 字符串。要成为C字符串,数组中的最后一个字符必须是空字符((‘\0')。换言之,C 字符串是以空字符结尾的字符数组。
上图显示了一个C字符串。因为 字符串名称是指向第一个字符的常量指针。,所以C字符串的名称是指向字符串中第一个字符的指针。但是,我们必须牢记,C字符串的名称没有定义变量,它定义了指针值(就像数组的名称)。换言之,C字符串名称不是左值,而是右值。它是一个常量指针,这意味着该指针不能指向任何其他元素。
1、初始化
前面提到C字符串不是一个类类型,这意味着在库中没有定义构造函数。要构造一个C字符串,必须创建一个字符数组,并把最后一个元素设置为空字符'\0'。
可以创建两种类型的C字符串:非常量字符串和常量字符串。在一个非常量C字符串中,可以在创建字符串之后改变其值; 在一个常量C字符串中,字符串的值不能改变。 以下代码片段演示了如何创建和初始化C字符串。
char str[] = {'A', 'B', 'C', 'D', '\0'}; // 非常量字符串
char str[]= "ABCD"; // 紧凑型非常量字符串
const char str[] = {'A', 'B', 'C', 'D', '\0'};// 常量字符串
const char str[]= "ABCD"; // 紧凑型常量字符串
第一种初始化形式是前文讨论过的字符数组的初始化形式。第二种形式有时被称为紧凑形式,使用紧凑型初始化时字符用双引号括起来,不包含空字符。编译器逐个提取字符,并将它们存储在相应的数组单元格中,并自动添加空字符。
如前所述,创建的字符串的名称是一个右值指针,而不是一个变量。如果我们想要创建一个变量,我们必须声明一个char *或者 const char *类型的变量,并将字符串的名称赋值给该变量。
我们提到过不能从函数返回数组,因为数组需要一个指向第一个元素的指针以及数组的大小。C字符串的设计消除了第二个需求。C字符串不需要被告知字符串的大小,因为最后一个字符是空字符,隐式地定义了字符串的大小。这意味着我们可以从函数返回指向C字符串的指针变量。
在堆内存中构造。 由于C字符串是一个数组,所以我们可以在堆内存中创建C字符串。但是,由于在这种情况下字符串的名称是指向字符的指针,因此不能使用紧凑型初始化。如果是一个非常量字符串,我们必须逐字符进行初始化; 如果是常量字符串,我们必须使用字符串字面量来进行初始化。
char* str = new char [3]; // 包含2个字符的非常量字符串
const char* str = new char [3]; // 包含2个字符的常量字符串
2、字符串字面量
字符串字面量是以空字符结尾的字符数组,其名称是由两个引号括起来的数组中的字符序列。
字符串字面量是一个常量字符串,它是C++语言的一部分,C字符串和C++字符串都使用字符串字面量。一旦创建了一个字符串字面量,就可以在任何可以使用字符串字面量的地方使用它。我们已经使用了字符串字面量来打印消息。但是,我们必须牢记,字符串字面量是一个常量实体,在创建之后不能被更改。
字符串字面量使得创建C字符串变得容易。首先创建所需的字符串字面量,然后将其赋值给指向常量字符的指针。
C++禁止把一个字符串字面量赋值给一个非常量字符指针,如下所示:
char * str = "Hell"; // 编译错误。字符串字面量是一个常量
const char * str = "Hello"; //正确
把一个字符串字面量赋值给一个非常量字符指针将导致编译错误。
紧凑型初始值设定项和字符串字面量。尽管两者看起来相同,但我们必须把紧凑型初始值设定项和字符串字面量区分开来。其区别在于使用的位置。
紧凑型初始值设定项是我们讨论的常规初始值设定项的简单形式。使用紧凑型初始值设定项时,编译器会逐个取出字符,并将它们存储在字符数组中。
字符串字面量已经是一个常量字符串,可以在任何可以使用字符串的地方使用。字符串字面量是指向在内存中创建的字符串的指针,可以赋值给指向常量字符的指针。以下显示了使用方法的差异:
char str1[] = "Hello"; // "Hello" 是紧凑型初始值设定项
const char str2[]= "Hello"; // "Hello" 是紧凑型初始值设定项
const char* str3 = "Hello"; // "Hello" 是字符串字面量
2、输入和输出
除了使用紧凑型初始化或者使用字面量字符串赋值之外,我们还可以将字符读入到声明为字符数组的 C 字符串中。当字符串被声明为一个类型(char *或 const char *) 时,则不支持这种操作,因为编译器必须在读取字符之前分配内存。
重载的提取运算符和插入运算符。请回顾一下库重载了提取运算符(>>) 和插入运算符(<<)以用于字符串输入和输出的情况。提取运算符从输入对象(键盘或者文件)中逐个提取字符,并将它们存储在数组中,直到遇到空白字符为止,然后在末尾添加空字符。问题是存储输入的字符数组必须分配足够的内存位置来存储所有输入的字符(在空白之前)以及一个空字符。如果分配的内存不够,则结果无法预测。插入运算符将数组中的字符写入输出设备,直到遇到空字符为止。
std::cin >> str; //输入
std::cout << str; //输出
getline函数。 要读取包含空格的字符行,必须使用为此目的定义的函数:getline函数。getline函数是istream类的成员,这意味着我们必须有一个cin类型的对象。我想我们已经有了,通过源码,我们可以得知,换行符将结束getline的读取
不过有时候,我们希望遇到自己指定的字符结束,这时候就可以使用的成员函数,它有一个三参的重载函数
如果省略delim参数,则使用''字符。
std::cin.getline(str, n); // 使用''作为分隔符
std::cin.get(str, n, 'delimeter') // 使用特定的分隔符
(二)std::string
1、现代化的字符串
C字符串很古老,并且具有很大的内存隐患,尽管在某些时候它很简单,但也不是太得心应手,为了能与现代化,成员函数的函数原型
2、构造
C++字符串定义了一个默认构造函数和三个参数构造函数。
默认构造函数。
默认构造函数。构建一个空字符串(长度为零且容量未指定)。
以下代码片段显示了如何创建空字符串。
string strg; // 创建一个空字符串对象
参数构造函数。除了默认的构造函数之外,string类还允许我们以三种不同的方式创建string对象,如上表 所示。我们可以使用一组相同值的字符、字符串字面量、字符串字面量的一部分来创建字符串对象。以下代码片段显示了如何创建这些对象:
string strg1(5, 'a'); //字符串"aaaaa"
string strg2("hello"); //字符串 "hello"
string strg3 ("hello", 2); //字符串"he"
字符串 strg1 由五个相同的字符组成(此处的 size_type定义长度)。
字符串 strg2 由字符串字面量组成。在这种情况下,函数将字面量中的所有字符(末尾的空字符除外)复制到字符串对象。
字符串 strg3 是字符串字面量的一部分。如果我们想使用C字符串对象的一部分(这里和后面其他成员函数中),我们必须从字符串的开头开始,因为指向字符串文本的指针是常量指针,不能移动。但是,我们可以定义应该复制的字符数。在这种情况下,我们只需要两个字符,这意味着只使用“he”来创建C++字符串对象。
string类允许我们使用两个不同的拷贝构造函数:一个拷贝构造函数拷贝完整的现有对象,一个拷贝构造函数拷贝现有对象的一部分。
string strg(oldStrg); // 使用全部oldStrg
string strg(oldstrg1, index, length); // 使用部分 oldStrg
无特殊说明,一般的字符串字面量都是C-style字符串,但是可以使用特殊的后缀表示std::string字符串字面量
using namespace std::string_literals; // 允许使用 "s" 作为字符串字面值 std::string str = "Hello, World!"s;
3、大小和容量
std::string提供了一系列成员函数用于大小和容量的操作,你会看到一些和前面数组相似的东西
size_type string::size() const
size_type string::max_size( ) const
void string::resize (size_type n, char c)
size_type string::capacity( ) const
void string::reserve(size_type n = 0)
bool string::empty() const
C++字符串对象使用堆中的字符数组。如果在操作期间必须减小数组的大小,则会使用成员函数resize更改数组大小。然而,如果在操作期间必须增大字符串的大小,则需要重新分配内存。必须在堆中创建更大的数组,复制现有元素的值,填充新元素,并回收原始内存。这些操作由后台的私有成员函数完成。但是,如果需要对大小进行多次小增量的更改,则此过程可能会为系统带来巨大的开销。为了避免这种开销,系统允许用户预留空间,这将导致创建的数组比实际所需的数组要大。
这从以下三个函数可以看出
大小和最大字符数。存在两个函数用于返回字符串大小的值。size函数返回字符串对象中当前的字符数。max_size 函数返回一个字符串对象可以拥有的最大字符数,它通常是一个与系统相关的非常大的数字。
size_type n = strg.size(); // 获取大小
size_type n = strg.max_size(); // 获取最大字符数
调整大小。 函数resize更改字符串的大小。如果n < size,则从字符串末尾删除字符,使字符串大小等于n; 如果n> size,则将字符c 的副本添加到字符串末尾,使字符串大小为n。
strg.resize(n, 'c'); //调整大小,使用'c'填充剩余的字符串
容量和预留量。函数 capacity返回字符数组的当前容量。如果我们没有预留量,则容量和大小是一样的。我们可以调用reserve 函数使容量(capacity)大于大小(size)。
size_type n = strg.capacity(); // 获取容量
strg.reserve(n); // 预留一个较大的数组
但是,也存在一些限制。如果函数的参数小于大小(size),则不会发生任何事情(容量(capacity)不能小于大小(size))。如果参数定义了一个小的增量,则系统可能会增加其值。
4、输入和输出
到目前为止,我们在基本数据类型上使用的输入和输出运算符是输入对象(istream) 和输出对象(ostream) 的成员函数。要输入或者输出的对象是参数。如果要输入或者输出字符串,则参数必须是 string类的实例。
使用 >>
操作符
#include <iostream>
#include <string>
int main() {
std::string word;
std::cout << "Enter a word: ";
std::cin >> word; // 读取直到遇到空格、制表符或换行符
std::cout << "You entered: " << word << std::endl;
return 0;
}
使用 std::getline()
#include <iostream>
#include <string>
int main() {
std::string line;
std::cout << "Enter a line: ";
std::getline(std::cin, line); // 读取整行,包括空格
std::cout << "You entered: " << line << std::endl;
return 0;
}
std::getline
:这是一个标准库函数,定义在<string>
头文件中。它用于从输入流(如std::cin
、文件流等)中读取一行数据,直到遇到指定的分隔符(默认为换行符\n
),并将读取的数据存储在std::string
类型的变量中。std::cin.getline
:该函数用于从输入流中读取一行数据,但通常将数据存储在字符数组(如char[]
)中,而不是std::string
对象中。
5、访问与修改字符
是 C++ 标准库中的一个非常强大的类,用于表示和操作字符串。它提供了多种方式来访问和操作其中的字符。
使用下标操作符 []
支持使用下标操作符 []
来访问字符串中的单个字符。这种方式非常直观,但需要注意的是,如果下标越界,程序不会进行边界检查,这可能会导致未定义行为(如访问违规内存)。
std::string str = "Hello, World!";
char c = str[0]; // 访问第一个字符,c 的值为 'H'
使用 at()
成员函数
与下标操作符 []
不同,at()
成员函数会进行边界检查。如果下标越界,at()
会抛出一个 std::out_of_range
异常。这使得 at()
在需要确保字符串访问安全时更加可靠。
使用迭代器
支持迭代器,允许你遍历字符串中的每个字符。迭代器提供了对容器(如字符串)中元素的通用访问方式。
std::string str = "Hello, World!";
for (std::string::iterator it = str.begin(); it != str.end(); ++it) {
char c = *it; // 解引用迭代器以访问字符
// 处理字符 c
}
// 或者使用范围基于的 for 循环(C++11 及更高版本)
for (char c : str) {
// 处理字符 c
}
使用 front
和 back
成员函数
front()
和 back()
成员函数分别用于访问字符串的第一个字符和最后一个字符。如果字符串为空,则这些函数的行为是未定义的。
std::string str = "Hello, World!";
char firstChar = str.front(); // 访问第一个字符,firstChar 的值为 'H'
char lastChar = str.back(); // 访问最后一个字符,lastChar 的值为 '!'
6、字符串拼接
在C语言里,字符串是以空字符`\0`结尾的字符数组来表示的。对于字符串的操作通常依赖于标准库函数,比如`strcat()`用于连接两个字符串。
#include <stdio.h>
#include <string.h>
int main() {
char str1[50] = "Hello, ";
char str2[] = "World!";
// 使用 strcat 连接字符串
strcat(str1, str2);
printf("%s\n", str1); // 输出: Hello, World!
return 0;
}
可以自动管理内存,并提供多种方法进行字符串操作, 它提供了更加丰富且安全的方法来处理字符串,包括`+`运算符、`append()`等。
C++字符串重载了赋值运算符(=)、复合赋值运算符(+=) 和加法运算符(+)
#include <iostream>
#include <string>
int main() {
std::string str1 = "Hello, ";
std::string str2 = "World!";
// 使用 + 运算符连接字符串
std::string result = str1 + str2;
std::cout << result << std::endl; // 输出: Hello, World!
// 或者使用 append 方法
str1.append(str2);
std::cout << str1 << std::endl; // 同样输出: Hello, World!
return 0;
}
拼接也兼容C风格的字符串
char str1[] = "Hello"; // C-风格字符串
std::string str2 = ", World!"; // C++ 字符串
str = str1 + str2; // 连接字符串
C风格字符串 | C++字符串 | |
---|---|---|
优点 |
|
|
缺点 |
|
|
性能考量
- 内存分配:C++的`std::string`虽然提供了更灵活的内存管理,但这也意味着可能涉及更多的内存分配/释放过程,特别是在大量重复地创建临时对象的情况下。
- 拷贝成本:在C++中使用`+`操作符或者`append`方法时,根据具体实现,有时会触发额外的拷贝操作。现代编译器通常对此进行了优化,但在极端情况下仍需注意。
- 线程安全性:标准C库函数不是线程安全的,而`std::string`成员函数通常是线程安全的(针对单个实例)。这意味着在多线程环境中使用C++的`std::string`更为安全可靠。
7、C与C++字符串转换
在C和C++中,字符串的表示方式有所不同:C使用以空字符`\0`结尾的字符数组(即C风格字符串),而C++则提供了类来封装更高级的功能。因此,在实际应用中有时需要在这两种字符串类型之间进行转换。
从std::string到C-style
使用c_str()成员函数。
使用data()成员函数(C++11及以上)。
#include <iostream>
#include <string>
int main() {
std::string cppStr = "Hello, World!";
// 使用 c_str()
const char* cStr1 = cppStr.c_str();
std::cout << cStr1 << std::endl; // 输出: Hello, World!
// 使用 data()
const char* cStr2 = cppStr.data();
std::cout << cStr2 << std::endl; // 同样输出: Hello, World!
return 0;
}
性能与机制
- c_str返回一个指向内部存储的常量指针,并保证该内存以`\0`结尾。这个过程几乎不消耗额外时间或空间。
- data同样返回一个指向内部数据的指针,但并不保证总是以`\0`结尾。不过,对于大多数实现来说,`data()`通常也以`\0`结尾,特别是在字符串非空时。从C++17开始,`data()`始终确保返回以`\0`结尾的字符串。
从C-style到std::string
直接构造std::string对象。
使用std::string的assign方法。
#include <iostream>
#include <string>
int main() {
const char* cStr = "Hello, World!";
// 构造 std::string 对象
std::string cppStr1(cStr);
std::cout << cppStr1 << std::endl; // 输出: Hello, World!
// 使用 assign 方法
std::string cppStr2;
cppStr2.assign(cStr);
std::cout << cppStr2 << std::endl; // 同样输出: Hello, World!
return 0;
}
性能与机制
- 当通过构造函数或assign方法将C风格字符串转换为std::string时,std::string会复制输入字符串的内容到其内部缓冲区。这意味着会有一次内存分配及拷贝操作,这相对于简单的指针赋值来说开销更大。
- 如果源字符串非常大,这种转换可能会导致较高的时间和空间成本。但是,现代编译器通常会对小字符串进行优化,例如采用短字符串优化(SSO),从而减少不必要的内存分配。
总结
- 从std::string到C风格字符串:这是相对廉价的操作,因为只需要获取现有数据的一个指针,不会涉及额外的数据拷贝。但需要注意的是,得到的C风格字符串可能是只读的,且不能超出std::string对象的生命期。
- 从C风格字符串到std::string:这涉及到内存分配和数据拷贝,可能带来一定的性能开销。但对于小型字符串,现代编译器的优化可以减轻这一影响。
8、传递std::string
传递参数
值传递
void processString(std::string str) {
// 使用 str
}
std::string s = "Hello, World!";
processString(s);
- 机制:每次调用函数时,都会创建一个 std::string 的副本。这个过程涉及内存分配和数据复制。
- 性能影响:如果字符串较大,频繁的值传递会导致大量的内存分配和复制开销,从而降低性能。对于小型字符串,现代 C++ 标准库通常会使用短字符串优化(SSO),在这种情况下,值传递的开销相对较小。
引用传递
void processString(const std::string& str) {
// 使用 str
}
std::string s = "Hello, World!";
processString(s);
- 机制:传递的是原 std::string 对象的引用,不会创建新的副本。常量引用可以防止修改原始对象。
- 性能影响:引用传递非常高效,因为没有额外的内存分配和复制。适用于大型字符串或频繁调用的情况。
指针传递
void processString(std::string* pStr) {
if (pStr) {
// 使用 *pStr
}
}
std::string s = "Hello, World!";
processString(&s);
- 机制:传递的是指向 std::string对象的指针,需要进行空指针检查。
- 性能影响:与引用传递类似,指针传递也很高效,但增加了空指针检查的复杂性,并且容易出错
按值返回
std::string createString() {
return "Hello, World!";
}
int main() {
std::string s = createString();
// 使用 s
return 0;
}
潜在问题:如果每次返回都涉及完整的字符串复制,这可能会导致较高的性能开销,特别是对于大型字符串。
根据经验,以下情况下按值返回 std::string 是合理的:
1. 局部变量:
如果返回的是函数内部的局部变量,编译器可以应用 NRVO 来避免复制。
std::string createString() { std::string localStr = "Hello, World!"; return localStr; // NRVO 可能生效 }
2.由另一个函数调用或运算符返回的 std::string:
如果返回的是另一个函数或运算符返回的 std::string,编译器可以应用 RVO 或移动语义来避免复制。
std::string concatenate(const std::string& a, const std::string& b) { return a + b; // 临时对象,RVO 可能生效 }
3. 作为返回语句的一部分创建的 std::string 临时变量:
如果返回的是在返回语句中创建的临时 std::string,编译器可以应用 RVO 或移动语义来避免复制。std::string createString() { return "Hello, World!"; // 临时对象,RVO 可能生效 }
(三)std::string_view
1、std::string 有时是昂贵的
很强大,但是从上面我们可以看到,往往伴随许多内存的操作,在某些情况下,昂贵的内存操作,比如说拷贝,能否进行优化,这就是为什么我们要有。
是 C++17 引入的一个轻量级、非拥有(non-owning)的字符串视图,它提供对现有字符串的只读访问,而不需要进行复制。这使得在许多场景下比 更高效。
不拥有底层数据,因此它不会分配内存或管理资源。这使得它的创建和使用非常廉价。
提供了对现有字符串的只读访问,可以用于以下类型的字符串:
- C 风格字符串(以空字符结尾的字符数组)
- 对象
- 其他对象
为什么不是cosnt std::string&
1. 灵活性
多种字符串类型:std::string_view可以接受更多种类型的字符串,包括 C 风格字符串(const char*)、std::string对象以及其他std::string_view对象。2. 安全性
生命周期管理:std::string_view不拥有数据,因此它不会延长其引用的数据的生命周期。这意味着你需要确保std::string_view所引用的数据在其生命周期内有效。虽然这需要开发者小心处理,但也提供了更多的灵活性。const std::string&引用一个std::string对象会延长该对象的生命周期,因此你不需要担心生命周期问题。但如果传递的是临时对象,可能会导致悬挂引用。3. 性能
轻量级:std::string_view是一个轻量级的对象,只包含一个指向字符串的指针和一个长度。相比之下,const std::string&引用了一个完整的std::string对象,即使它是只读的,也可能涉及额外的开销(例如,内部指针、大小等)。4. 字面量支持
字面量后缀:C++17 引入了sv后缀,可以直接创建 std::string_view字面量,使得代码更加简洁和直观。5.constexpr支持
编译时计算:std::string_view完全支持 constexpr,可以在编译时进行各种操作。这对于编写高效的模板元编程或编译时常量表达式非常有用。6. 一致性
API 一致性:如果你的 API 设计中需要接受多种类型的字符串,使用 std::string_view可以保持接口的一致性,而不必为每种类型提供重载。
2、创建视图
#include <string_view>
#include <iostream>
using namespace std::string_view_literals; // 允许使用 "sv" 作为字符串视图字面值
int main() {
// 从 C 风格字符串创建
const char* cstr = "Hello, world!";
std::string_view sv1(cstr);
// 从 std::string 创建
std::string str = "Hello, world!";
std::string_view sv2(str);
// 从另一个 std::string_view 创建
std::string_view sv3(sv2);
// 从双引号字符串字面量创建
std::string_view sv4 = "Hello, world!";
// 使用 sv 后缀创建 std::string_view 字面量
std::string_view sv5 = "Hello, world!"sv;
std::cout << sv1 << '\n';
std::cout << sv2 << '\n';
std::cout << sv3 << '\n';
std::cout << sv4 << '\n';
std::cout << sv5 << '\n';
return 0;
}
std::string_view的机制
std::string_view内部通常包含两个指针:
- 一个指向字符串的起始位置
- 一个表示字符串长度的值
这种设计使得std::string_view非常轻量级,因为它不涉及任何内存分配或拷贝。
3、视图裁剪
提供了一种高效的方式来处理字符串的子串或视图裁剪,这通常通过其成员函数 substr
来实现。
裁剪实际上是通过创建一个新的 w
实例来实现的,这个新实例引用了原 的一个子集。这通过调用 substr
成员函数来完成,该函数可以指定子串的起始位置和长度(可选)。
#include <iostream>
#include <string_view>
int main() {
std::string_view sv = "Hello, world!";
// 裁剪从索引 7 开始的剩余所有字符
std::string_view sv_substr = sv.substr(7);
std::cout << sv_substr << std::endl; // 输出: world!
// 裁剪从索引 7 开始的 5 个字符
std::string_view sv_substr_limited = sv.substr(7, 5);
std::cout << sv_substr_limited << std::endl; // 输出: world
return 0;
}
4、数据改变与视图更新
本身不管理底层数据的生命周期,也不负责数据的存储。因此,当底层数据发生变化时,的行为取决于数据是如何变化的。
底层数据改变的情况
1.数据内容改变:
- 如果 引用的数据内容发生了改变(例如,通过修改对象的内容),仍然会指向原来的位置,但其内容可能不再有效。
- 这种情况下,不会自动更新,你必须手动创建一个新的 来反映最新的数据。
2. 数据被销毁:
- 如果 引用的数据被销毁(例如,临时对象超出作用域),那么 将变成悬挂引用,使用它将导致未定义行为。
- 因此,在使用 时,必须确保在其生命周期内,底层数据是有效的。
3.数据重新分配:
- 如果引用的是一个对象,并且该对象进行了重新分配(例如,调用了 `resize` 或 `reserve`),那么可能会指向无效的内存。
- 重新分配会导致 的内部缓冲区发生变化,原来的指针可能不再有效。
#include <iostream>
#include <string>
#include <string_view>
void printStringView(std::string_view sv) {
std::cout << "String view: " << sv << '\n';
}
int main() {
// 创建一个 std::string 对象
std::string str = "Hello, world!";
// 创建一个 std::string_view,引用 str
std::string_view sv = str;
// 打印初始状态
std::cout << "Initial state:\n";
printStringView(sv);
// 修改 str 的内容
str[7] = 'W';
std::cout << "After modifying str:\n";
printStringView(sv); // 输出 "Hello, World!"
// 重新分配 str 的空间
str.resize(5);
std::cout << "After resizing str:\n";
printStringView(sv); // 输出 "Hello",但可能是未定义行为
std::string_view svTemp;
// 使用临时对象
{
std::string temp = "Temporary string";
svTemp = temp;
printStringView(svTemp); // 输出 "Temporary string"
}
// temp 超出作用域,svTemp 成为悬挂引用
// 使用 svTemp 将导致未定义行为
printStringView(svTemp); // 危险!
return 0;
}
5、与 constexpr的嵌合
完全支持constexpr,这意味着可以在编译时初始化 并进行各种操作。
- 编译时初始化: 可以在编译时被初始化,但这通常意味着它指向的字符串数据也必须是编译时常量。例如,你可以使用字符串字面量来初始化 ,因为字符串字面量在编译时就已经存在。
- 编译时操作:虽然 的某些成员函数(如
size()
、substr()
等)可以被声明为constexpr
,但它们的操作仍然受限于它们所指向的字符串数据的性质。如果 指向的是编译时常量字符串,那么这些操作可以在编译时完成。但如果它指向的是运行时才能确定的字符串(如动态分配的字符数组或 的内容),则这些操作将在运行时进行。