参考资料:C++ Primer 中文版(第5版)——[美] Stanley B. Lippman [美] Josée Lajoie [美] Barbara E. Moo 著 王刚 杨巨峰 译
代码编辑器:VS 2019
除了内置类型之外,C++ 语言还定义了一个内容丰富的抽象数据类型库。其中,string 和vector 是两种最重要的标准库类型,前者支持可变长字符串,后者则表示可变长的集合。还有一种标准库类型是迭代器,它是string 和vector 的配套类型,常被用于访问string 中的字符或vector 中的元素。
内置数组是一种更基础的类型,string 和vector 都是对它的某种抽象。
和其他内置类型一样,数组的实现与硬件密切相关。因此相较于标准库类型string 和vector ,数组在灵活性上稍显不足。
1. 命名空间的 using 声明
这里介绍访问库中名字的简单方法。
前面用到的比如std::cin 就表示从标准输入中读取内容。库函数cin 就属于命名空间std 。我们用到的是作用域操作符(:: ),它的含义是:编译器应从操作符左侧名字所示的作用域中寻找右侧那个名字。因此,std::cin 的意思就是要使用命名空间std 中的名字cin 。
还有一种简单的途径使用到命名空间中的成员,就是使用using 声明,同时这也是最安全的方法。
有了using 声明就无需专门的前缀(形如命名空间:: )也能使用所需的名字了。using 声明具有如下的形式:
using namespace::name;
一旦声明了上述语句,就可以直接访问命名空间中的名字。
注意:每个名字都需要独立的 using 声明。因为按照规定,每个using 声明引入命名空间中的一个成员。例如,可以把要用到的标准库中的形式表示出来:
#include<iostream>
using std::cin; using std::cout;
int main() {
int a;
cin >> a;
cout << a;
return 0;
}
C++ 语言比较自由,因此既可以一行只放一条using 声明语句,也可以一行放上多条。不过要注意,用到的每个名字都必须有自己的声明语句,而且每句话都得以分号结束。
还要注意:头文件中不应包含using 声明。这是因为头文件中的内容会拷贝到所有引用它的文件中去,如果头文件中里有某个using 声明,那么每个使用了该头文件的文件就都会有这个声明。对于某些程序来说,由于不经意间包含了一些名字,反而可能产生始料未及的名字冲突。
2. 标准库类型 string
标准库类型string 表示可变长的字符串序列,使用string 类型必须首先包含string 头文件。作为标准库的一部分,string 定义在命名空间std 中。
2.1 定义和初始化 string 对象
初始化string 对象的方式如下表所示:
序号 | 格式 | 说明 |
---|
1 | string s1 | 默认初始化,s1 是个空串 | 2 | string s2(s1) | s2 是s1 的副本 | 3 | string s2 = s1 | 等价于方式3 | 4 | string s3("value") | s3 是字面值"value" 的副本(但不包括末尾的空字符) | 5 | string s3 = "value" | 等价于方式4 | 6 | string s4(n, 'c') | 把s4 初始化为由连续n 个字符c 组成的串 |
注意上面的方式4和方式5,我们提供了一个字符串字面值来初始化string 对象,字面值中除了最后那个空字符外其他所有的字符都被拷贝到新创建的string 对象中去。
下面区分一下直接初始化和拷贝初始化。
如果使用等号= 初始化一个变量,实际上执行的是拷贝初始化(copy initialization),编译器把等号右侧的初始值拷贝到新创建的对象中去。
与之相反,如果不使用等号,则执行的是直接初始化(direct initialization)。
当初始值只有一个时,使用直接初始化或拷贝初始化都行。如果初始化要用到的值有多个,一般来说只能使用直接初始化的方式。
2.2 string 对象上的操作
一个类除了要规定初始化其对象的方式外,还要定义对象上所能执行的操作。类既能定义通过函数名调用的操作,也能定义<< 、+ 等各种运算符在该类对象上的新含义。
一些常用的操作如下表:
操作 | 说明 |
---|
os << s | 将s 写到输出流os 当中,返回os | is >> s | 从is 中读取一行赋给s ,返回is | getline(is, s) | 从is 中读取一行赋给s ,返回is | s.empty() | s 为空返回true ,否则返回false | s.size() | 返回s 中字符的个数 | s[n] | 返回s 中第n 个字符的引用,位置n 从0 记起 | s1 + s2 | 返回s1 和s2 连接后的结果 | s1 = s2 | 用s2 的副本代替s1 中原来的字符 | s1 == s2 | 判断所含字符是否完全一样(对大小写敏感) | s1 != s2 | 判断所含字符是否不一样 | <, <=, >, >= | 利用字符在字典中的顺序进行比较(对大小写敏感) |
注意,使用cin 读取内容时,会自动忽略开头的空白,从第一个真正的字符开始读起,直到遇见下一个空白符为止。
所以想要保留输入时的空白符时,应该用getline 函数代替原来的>> 运算符。getline 函数的参数是一个输入流和一个string 对象,函数从给定的输出流中读入内容,直到遇到换行符为止(注意换行符也被读进来了),然后把所读的内容存入到那个string 对象中去(注意不存换行符)。getline 只要一遇到换行符就结束读取操作并返回结果,哪怕输入的一开始就是换行符也是如此。如果输入真的一开始就是换行符,那么所得的结果是个空string 。
关于上表中的"返回is "这一点,指的是返回流参数,可以参考这两篇文章:C++ cin>>n 的返回值、C++ IO库条件状态。这一点可以用在while 循环当中,当读到EOF 或^Z 时会退出循环,比如下面的例子:
#include<iostream>
#include<string>
#include<fstream>
int main() {
std::ofstream outFile("test.txt");
std::string line;
while (getline(std::cin, line)) {
outFile << line << std::endl;
}
return 0;
}
比如我们输入:
Hello, world! 2021/8/26
Hello, C++! Aug.26 2021
^Z
那么在源文件所在文件夹中便会出现一个名为test.txt 的文本文件,里面便存放着我们刚刚输入的内容。
再说明一下size 函数的返回值类型。这个函数的返回值类型并非我们预料的那样是int 或unsigned ,而是一个string::size_type 类型的值。很清楚的一点是它是个无符号类型的值而且足够存放下任何string 对象的大小。所有用于存放string 类的size 函数返回值的变量,都应该是string::size_type 类型的。所以当我们需要遍历string 对象时,假如这里我们使用for 循环,那么建议自增的索引的类型声明为decltype(s.size()) ,如下例:
std::string s = "hello, world!";
for(decltype(s.size()) i = 0; i < s.size(); i++){
std::cout << s[i] << ' ';
}
最后再说明一下字面值和string 对象相加。
当把string 对象和字符字面值混在一条语句中使用时,必须确保每个加法运算符(+ )的两侧的运算对象至少有一个是string 。下面举一些正确和错误的例子:
std::string s1 = "hello";
std::string s2 = s1 + ",";
std::string s3 = "hello" + ",";
std::string s4 = s1 + "," + "world";
std::string s5 = "hello" + "," + s2;
切记:字符串字面值与string 是不同的类型。
2.3 处理 string 对象中的字符
cctype 头文件中定义了一组标准库函数处理字符,如下表:
函数 | 说明 |
---|
isalnum(c) | 当c 是字母或数字时为真 | isalpha(c) | 当c 是字母时为真 | iscntrl(c) | 当c 是控制字符时为真 | isdigit(c) | 当c 是数字时为真 | isgraph(c) | 当c 不是空格但可打印时为真 | islower(c) | 当c 是小写字母时为真 | isprint(c) | 当c 是可打印字符(空格或具有可视形式)时为真 | ispunct(c) | 当c 是标点符号(不是控制字符、数字、字母、可打印空白中的一种)时为真 | isspace(c) | 当c 是空白(空格、横向制表符、纵向制表符、回车符、换行符、进纸符)时为真 | isupper(c) | 当c 是大写字母时为真 | isxdigit(c) | 当c 是十六进制数时为真 | tolower(c) | 如果c 是大写字母,输出对应的小写字母;否则原样输出c | toupper(c) | 如果c 是小写字母,输出对应的大写字母;否则原样输出c |
下面介绍一下范围 for 语句。
范围for 语句可以遍历给定序列中的每个元素并对序列中的每个值执行每个操作,其语法形式是:
for(declaration : expression)
statement
其中,expression 部分是一个对象,用于表示一个序列。declaration 部分负责定义一个变量,该变量将用于访问序列中的基础元素。每次迭代,declaration 部分的变量会被初始化为expression 部分的下一个元素值。
一个string 对象表示一个字符的序列,因此string 对象可以作为范围for 语句中的expression 部分。遍历方法如下:
std::string s = "hello, world";
for(auto c : s){
}
比如我们写一个判断一个string 对象中标点符号的个数的程序:
#include<iostream>
#include<string>
#include<cctype>
int main() {
std::string s = "<Jan.><Mar.><May.><Jul.><Sep.><Nov.>";
int n = 0;
for (auto c : s) {
if (ispunct(c))
n++;
}
std::cout << n << std::endl;
return 0;
}
再比如,我们修改上面的程序,将字符串中的尖括号<> 换成[] :
#include<iostream>
#include<string>
int main() {
std::string s = "<Jan.><Mar.><May.><Jul.><Sep.><Nov.>";
for (auto &c : s) {
if (c == '<')
c = '[';
if (c == '>')
c = ']';
}
std::cout << s << std::endl;
return 0;
}
注意,上面用的是引用类型auto &c : s ,因此对c 的修改即为对string 对象修改。
3. 标准库类型 vector
标准库类型vector 表示对象的集合,其中所有对象的类型都相同。集合中的每个对象都有一个与之对应的索引,索引用于访问对象。因为vector “容纳” 着其他对象,所以它也常被称作容器(container)。
要想使用vector ,必须包含vector 头文件。
C++ 既有类模板(class template)也有函数模板,其中vector 是一个类模板。
模板本身不是类或函数,相反可以将模板看作为编译器生成类或函数编写的一份说明。编译器根据模板创建类或函数的过程称为实例化(instantiation),当使用模板时,需要指出编译器应把类或函数实例化成何种类型。
对于类模板来说,我们通过提供一些额外信息来指定模板到底实例化成什么样的类,需要提供哪些信息由模板决定。提供信息的方式总是这样:即在模板名字后面跟一对尖括号,在括号内放上信息。
以vector 为例,提供的额外信息是vector 内所存放对象的类型:
vector<int> iv;
vector<std::string> strv;
vector<vector<std::string>> file;
尖括号中可以放各种数据类型,比如自己定义的类、结构体等等。
vector 能容纳绝大多数类型的对象作为其元素。但是因为引用不是对象,所以不存在包含引用的vector 。除此之外,其他大多数内质类型和类类型都可以构成vector 对象,甚至组成vector 元素也可以是vector 。
3.1 定义和初始化vector对象
初始化vector 对象的常用方法如下表所示:
序号 | 格式 | 说明 |
---|
1 | vector<T> v1 | v1 是一个空vector ,它潜在的元素是T 类型,执行默认初始化 | 2 | vector<T> v2(v1) | v2 中包含有v1 所有元素的副本 | 3 | vector<T> v2 = v1 | 等价于方法2 | 4 | vector<T> v3(n, val) | v3 包含了n 个重复的元素,每个元素的值都是val | 5 | vector<T> v4(n) | v4 包含了n 个重复地执行了值初始化的对象 | 6 | vector<T> v5{a,b,c...} | v5 包含了初始值个数的元素,每个元素被赋予相应的初始值 | 7 | vector<T> v5={a,b,c...} | 等价于方法6 |
这里再强调一下初始化方式的特殊要求:
- (1)使用拷贝初始化时(即使用
= 时),只能提供一个初始值; - (2)如果提供的是一个类内初始值,则只能使用拷贝初始化或使用花括号的形式初始化;
- (3)如果提供的是初始元素的列表,则只能把初始值都放在花括号里进行列表初始化,而不能放在圆括号里。
注意下面容易混淆的初始化例子:
vector<int> v1(10);
vector<int> v2{10};
vector<int> v3(10, 1);
vector<int> v4{10, 1};
请记住,如果用的是圆括号,则表示提供的值是用来构造(construct)vector 对象的。如果用的还花括号,则表示我们想列表初始化(list initialize)该vector 对象。
但是,请注意,如果初始化时使用了花括号的形式但提供的值又不能用来列表初始化,就要考虑用这样的值来构造vector 对象了。如下例所示:
vector<string> v1{"hi"};
vector<string> v2("hi");
vector<string> v3{10};
vector<string> v4{10, "hi"};
即在编译器确认无法执行列表初始化后,编译器会尝试使用默认值初始化vector 对象。
3.2 vector 对象上的操作
对vector 对象来说,直接初始化的方式适用于三种情况:初始值已知且数量较少、初始值是另一个vector 对象的副本、所有元素的初始值都一样。然而更常见的情况是,创建一个vector对象时并不清楚实际所需的元素个数,元素的值也经常无法确定。所以向vector 对象中添加元素是一种很重要的操作。除了添加操作,还有一些其他很常用的操作,一并列入下表:
格式 | 说明 |
---|
v.push_back(t) | 向v 的尾端添加一个值为t 的元素 | v.pop_back() | 弹出(即删除)v 尾端的一个元素 | v.size() | 返回v 中元素的个数 | v.empty() | 判断v 是否为空——是则返回true ,否则返回false | v[n] | 返回v 中第n 个位置上元素的引用 | v1 = v2 | 用v2 中元素的拷贝替换v1 中的元素 | v1 = {a,b,c...} | 用列表中元素的拷贝替换v1 中的元素 | v1 == v2 | 判断v1 和v2 是否相等 | v1 != v2 | 判断v1 和v2 是否不相等 | <, <=, >, >= | 以字典顺序进行比较 |
首先需要注意的是不能通过下标形式向vector 中添加元素,要么在初始化时添加,要么通过push_back() 函数添加。
其次需要说明的是,v.size() 的返回值和前面的string 类型的大同小异,它的返回值类型是vector<T>::size_type 。
最后再解释一下上面v[n] 的说明中"返回引用"。返回引用即表示对v[n] 进行修改即是对vector 中的元素进行修改。
4. 迭代器介绍
我们已经知道可以使用下标运算符来访问string 对象的字符或vector 对象的元素,还有另外一种更通用的机制也可以实现同样的目的,这就是迭代器(iterator)。C++ 除了vector 之外,标准库还定义了其他几种标准器。所有标准库容器都可以使用迭代器,但是其中只有少数几种才同时支持下标运算符。
严格来说,string 对象不属于容器类型,但是string 支持很多与容器类似的操作。vector 支持下标运算符,这点和string 一样;string 支持迭代器,这也和vector 是一样的。
类似于指针类型,迭代器也提供了对对象的间接访问。就迭代器而言,其对象是容器中的元素或者string 对象中的字符。使用迭代器可以访问某个元素,迭代器也能从一个元素移动到另外一个元素。迭代器有有效和无效之分,这一点和指针差不多。有效的迭代器或者指向某个元素,或者指向容器中尾元素的下一位置;其他所有情况都属于无效。
4.1 使用迭代器
和指针不一样的是,获取迭代器不是使用取地址符,有迭代器的类型同时拥有返回迭代器的成员。比如,这些类型都拥有名为begin 和end 的成员,其中begin 成员负责返回指向第一个元素(或第一个字符)的迭代器。end 成员则负责返回指向容器(或string 对象)“尾元素的下一个位置(one past the end)” 的迭代器,也就是说,该迭代器指示的是容器的一个本不存在的尾后元素(off the end)。这样的迭代器没什么实际含义,仅是个标记而已,表示我们已经处理完了容器中的所有元素。end 成员返回的迭代器常被称作尾后迭代器(off-the-end iterator)或者简称为尾迭代器(end itertor)。特殊情况下如果容器为空,则begin 和end 返回的是同一个迭代器。
请看下例:
#include<iostream>
#include<string>
#include<typeinfo>
int main() {
std::string str = "hello";
auto x = str.begin();
std::cout << typeid(x).name() << std::endl;
return 0;
}
运行之后,输出结果:
class std::_String_iterator<class std::_String_val<struct std::_Simple_types<char> > >
这便是str.begin() 这个迭代器的数据类型。
4.1.1 迭代器运算符
下表列出迭代器支持的运算:
格式 | 说明 |
---|
*iter | 返回迭代器iter 所指元素的引用 | iter->mem | 解引用iter 并获取该元素的名为mem 的成员,等价于(*iter).mem | ++iter | 令iter 指示容器中的下一个元素 | --iter | 令iter 指示容器的上一个元素 | iter1 == iter2 | 判断两个迭代器是否相等 | iter1 != iter2 | 判断两个迭代器是否不相等 |
关于解引用操作的说明:执行解引用的迭代器必须合法并确实指示着某个元素。试图解引用一个非法迭代器或者尾后迭代器都是未被定义的行为。
关于判断两迭代器是否相等的说明:如果两个迭代器指示的是同一个元素或者它们是同一个容器的尾后迭代器则相等;反之不相等。
再看一个上面出现过的把尖括号改成中括号的例子,我们用迭代器来实现:
int main() {
std::string s = "<Jan.><Mar.><May.><Jul.><Sep.><Nov.>";
for (auto iter = s.begin(); iter != s.end(); iter++) {
if (*iter == '<')
*iter = '[';
if (*iter == '>')
*iter = ']';
}
std::cout << s << std::endl;
return 0;
}
输出结果和之前一样:
[Jan.][Mar.][May.][Jul.][Sep.][Nov.]
4.1.2 迭代器类型
就像不知道string 和vector 的size_type 成员到底是什么类型一样,一般来说我们也不知道(其实是无须知道)迭代器的精确类型。而实际上,那些拥有迭代器的标准库类型使用iterator 和const_iterator 来表示迭代器的类型:
vector<int>::iterator it;
string::iterator it2;
vector<int>::const_iterator it3;
string::const_iterator it4;
const_iterator 和常量指针差不多,能读取但不能修改它所指的元素值。相反,iterator 的对象可读可写。
4.1.3 begin 和 end 运算符
begin 和end 返回的具体类型由对象是否是常量决定,如果对象是常量,begin 和end 返回const_iterator ;如果对象不是常量,返回iterator 。
如果对象只需读操作而无须写操作的话最好使用常量类型。为了专门得到const_iterator 类型的返回值,C++11 新标准引入了两个新函数,分别是cbegin 和cend :
auto it = v.cbegin();
4.1.4 结合解引用和成员访问操作
解引用迭代器可获得迭代器所指的对象,如果该对象的类型恰好是类,就有可能希望进一步访问它的成员。
如下例:
(*it).f();
it -> f();
箭头运算符即把解引用和成员访问两个操作结合在一起——it -> mem 和(*it).mem 表达的意思相同
4.1.5 某些对 vector 对象的操作会使迭代器失效
虽然vector 对象可以动态地增长,但是也会有一些副作用。已知的一个限制是不能在范围for 循环中向vector 对象添加元素。另外一个限制是任何一种可能改变vector 对象容量的操作(比如push_back )都会使该vector 对象的迭代器失效。
谨记,但凡是使用了迭代器的循环体,都不要向迭代器所属的容器添加元素。
4.2 迭代器运算
迭代器的递增运算令迭代器每次移动一个元素,所有的标准库容器都有支持递增运算的迭代器。类似的,也能用== 和!= 对任意标准库类型的两个有效迭代器进行比较。
srting 和vector 的迭代器提供了更多额外的运算符,一方面可使得迭代器的每次移动跨过多个元素,另外也支持迭代器进行关系运算。srting 和vector 迭代器支持的运算如下表所示:
运算 | 说明 |
---|
iter + n | 迭代器加上一个整数值仍得到一个迭代器,迭代器指示的新位置与原来相比向前移动了若干个元素。结果迭代器或者指示容器内的一个元素,或者指示容器尾元素的下一位置 | iter - n | 与+ 大同小异,不再赘述 | iter += n | 复合赋值语句 | iter -= n | 复合赋值语句 | iter1 - iter2 | 两个迭代器相减的结果是它们之间的距离,也就是说,将运算符右侧的迭代器向前移动差值个元素后将得到左侧的迭代器。参与运算的两个迭代器必须指向的是同一个容器中的元素或者尾元素的下一位置 | >、>=、<、<= | 迭代器的关系运算符,如果某迭代器指向的容器位置在另一个迭代器所指位置之前,则说前者小于后者。参与运算的两个迭代器必须指向的是同一容器中的元素或者尾元素的下一位置 |
下面给出使用迭代器运算的经典算法——二分搜索。
首先搜索的对象必须是有序的,这里我们假设对象是递增的,给出算法如下:
auto beg = v.begin(), end = v.end();
auto mid = v.begin() + (end - beg) / 2;
while(mid != end && *mid != sought){
if(sought < *mid)
end = mid;
else
beg = mid + 1;
mid = beg + (end - beg) / 2;
}
再给出一个具体的例子如下:
#include<iostream>
#include<vector>
int main() {
std::vector<int> numArr{ 1,3,5,7,9,11,13,14,15,17,19,21,
23,24,25,27,29,31,33,35,37,39,40 };
int sought;
std::cin >> sought;
auto beg = numArr.begin(), end = numArr.end();
auto mid = numArr.begin() + (end - beg) / 2;
while (mid != end && *mid != sought) {
if (sought < *mid)
end = mid;
else
beg = mid + 1;
mid = beg + (end - beg) / 2;
}
if (*mid == sought)
std::cout << mid - numArr.begin() << std::endl;
else
std::cout << "Not found!" << std::endl;
return 0;
}
5. 数组
由于 C语言中已经出现了数组这个类型,所以这里仅列出 C++ 中数组方面的新知识点。
-
引用数组: int arr[2] = {0, 1};
int (&arrRef)[2] = arr;
int &arrRef2[2] = arr;
要想理解数组声明的含义,最好的办法是从数组的名字开始按照由内向外的顺序阅读。 -
在使用数组下标的时候,通常将其定义为size_t 类型。size_t 是一种机器相关的无符号类型,它被设计的足够大以便能表示内存中任意对象的大小。 -
遍历数组的所有元素也可以采用范围for 语句。 -
C++11 新标准引入的跟指针相关的两个标准库函数begin 和end 。这两个函数与容器中的两个同名成员功能类似,不过数组不是类类型,因此这两个函数不是成员函数,所以使用形式也不太相同: int ia[] = {0,1,2,3,4,5,6};
int *beg = begin(ia);
int *last = end(ia);
begin 函数返回指向ia 首元素的指针,end 函数返回指向ia 尾元素下一位置的指针,这两个函数定义在iterator 头文件中。 -
两个指向数组的指针相减的结果的类型是一种名为ptrdiff_t 的标准库类型,和size_t 一样,ptrdiff_t 也是一种定义在cstddef 头文件中的机器相关的类型。因为差值可能为负值,所以ptrdiff_t 是一种带符号类型。 -
关于混用string 对象和 C 风格字符串。 有以下性质:
- 允许使用以空字符结束的字符数组来初始化
string 对象或为string 对象赋值 - 在
string 对象的加法运算中允许使用以空字符结束的字符数组作为其中一个运算对象(不能两个对象都是) 但上述性质反过来就不成立了,如果程序的某处需要一个 C 风格字符串,无法直接用string 对象来代替它。为了完成该功能,string 专门提供了一个名为c_str 的成员函数: string s = "hello, world";
char *str = s;
const char *str = s.c_str();
顾名思义,c_str 函数的返回值是一个 C 风格的字符串。也就是说,函数的返回结果是一个指针,该指针指向一个以空字符结束的字符数组,而这个数组所存数据恰好与那个string 对象的一样。 -
使用数组初始化vector 对象。要实现这一目的,只需指明要拷贝区域的首元素地址和尾后地址就可以了: int int_arr[] = {0,1,2,3,4};
vector<int> ivec(begin(int_arr), end(int_arr));
当然,使用数组的一部分初始化vector 对象也是可以的,提供起始地址和中指地址后一个地址即可。 -
要使用范围for 语句处理多维数组,除了最内层的循环外,其他所有循环的控制变量都应该是引用类型。 -
可以使用类型别名简化多维数组的指针。如下例: #include<iostream>
int main() {
using int_array = int[3];
int a[3][3] = { {1,2,3}, {4,5,6}, {7,8,9} };
for (int_array *p = a; p != a + 3; ++p) {
for (int *q = *p; q != *p + 3; ++q)
std::cout << *q << ' ';
std::cout << std::endl;
}
return 0;
}
C++ 基础——字符串、向量和数组的内容到这里就结束了。
下一篇预告:【C++】C++ 基础——表达式和语句
链接指路:
上一篇:C++ 基础——变量和基本类型
|