C++学习之路(二):字符串、向量和数组

2-字符串向量和数组-大纲.png

2.1 命名空间的using声明

std::cin表示从标准输入中读取内容。此处的作用域操作符::的含义是:编译器应从操作符左侧名字所示的作用域中寻找右侧那个名字。

上面写法太繁琐,可以通过using声明简化。using声明格式:

using namespace::name;

每个名字都需要独立的using声明

按照规定,每个using声明引入命名空间中的一个成员。

头文件不应包含using声明

因为头文件会被很多文件包含,对于某些程序来说,由于不经意间包含了一些名字,反而可能产生始料未及的名字冲突。

2.2 标准库类型string

标准库类型string表示可变长的字符序列。头文件及名字空间:

#include <string>
using std::string;

2.2.1 定义和初始化string对象

如何初始化类的对象是由类本身决定的。一个类可以定义很多种初始化对象的方式,只不过这些方式之间必须有所区别:或者是初始值的数量不同,或者是初始值的类型不同。

string s1;              // 默认初始化,s1是一个空字符串
string s2 = s1;         // s2是s1的副本
string s3 = "hiya";     // s3是该字符串字面值的副本
string s4(10, 'c');     // s4的内容是cccccccccc

初始化string对象的方式

代码
含义
string s1 默认初始化,s1是一个空串
string s2(s1) s2是s1的副本
string s2 = s1 等价于s2(s1),s2是s1的副本
string s3("value") s3是字面值“value”的副本
string s3 = "value" 等价于s3("value")
string s4(n, 'c') 把s4初始化为由连续n个字符c组成的串
直接初始化和拷贝初始化
  • 使用等号(=)初始化一个变量,是拷贝初始化,编译器把等号右侧的初始值拷贝到新创建的对象中去。
  • 不使用等号,则执行的是直接初始化

当初始值只有一个时,使用直接初始化和拷贝初始化都可以。

string s5 = "hiya"; // 拷贝初始化
string s6("hiya");  // 直接初始化
string s7(10, 'c'); // 直接初始化

对于多个值进行初始化的情况,非要用拷贝初始化也可以,不过先要显式的创建一个临时对象用于拷贝:

string s8 = string(10, 'c');    // 拷贝初始化

上面这种写法,相关编译器可以对其进行优化,使等号右边的临时变量直接变为s8,这和编译器有关系,并不一定等同于下面的写法。

string temp(10, 'c');
string s8 = temp;           // 将temp拷贝给s8

2.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中所含的字符完全一样,则他们相等
s1 != s2 如果s1和s2中所含的字符不完全一样
<,<=,>,>= 利用字符在字典中的顺序进行比较,且对字母的大小写敏感
读写string对象
string s;           // 空字符串
cin >> s;           // 将string对象读入s,遇到空格停止,读入时会去掉开头和末尾的空格
cout << s << endl;  // 输出s

如果程序输入" hello world! "则将输出 "hello",结果中没有任何空格。

读取未知数量的string对象
int main()
{
    string word;
    while (cin >> word) {       // 反复读取,直至到达文件结尾
        cout << word << endl;   // 逐个输出单次,每个单次后面紧跟一个换行
    }
    return 0;
}

遇到文件结束标记或者非法输入,循环就会结束了。

使用getline读取一整行

getline可以保留空格字符,getline会读取输入流中的换行符,但是不会把换行符保存到string中,如果一开始就是一个换行符,那么将会等到一个空string。

和输入运算符一样,getline也会返回它的流参数。

int main()
{
    string line;
    // 每次读入一整行,直至到达文件末尾
    while (getline(cin, line)) {
        cout << line << endl;   // line是不包含换行符的
    }
    return 0;
}
string的empty和size操作

empty函数判断string对象的字符串是否为空。

// 每次读入一整行,遇到空行直接跳过
while (getline(cin, line)) {
    if (!line.empty())
        cout << line << endl;
}

size函数返回string对象的字符串长度。

// 每次读入一整行,输出其中超过80个字符的行
while (getline(cin, line)) {
    if (line.size() > 80)
        cout << line << endl;
}
string::size_type类型
  1. size函数返回的是string::size_type类型的值。

  2. 这中配套类型体现了标准库与机器无关的特性。

  3. string::size_type是无符号类型的值,而且能够存下任何string对象的大小。

  4. 在c++11之后,可以使用auto或者decltype来推断变量的类型。

    auto len = line.size(); // len的类型是string::size_type
    
  5. size()函数返回值不要和int类型的变量参与运算,可以避免混用int和unsigned可能带来的问题。

比较string对象
  1. 按照字母表顺序进行比较。
  2. 大小写字母敏感。
  3. 小写字母比大写字母大。
string str = "Hello";
string phrase = "Hello World";
string slang = "Hiya";
string a = "a";
string b = "A";
cout << (a > b) << endl;        // 1
cout << (str < phrase) << endl; // 1
cout << (str < slang) << endl;  // 1
为string对象赋值

一般来说,在设计标准库类型时都力求在易用性上向内置类型看起,因此大多数库类型都支持赋值操作。

string s1(10, 'c'), s2; // s1:cccccccccc, s2是空字符串
s1 = s2;                // s1和s2都是空字符串
两个string对象相加
  • 使用加法运算符(+)将左右两侧的运算对象串接在一起。
  • 使用复合赋值运算符(+=)将右侧的string对象的内容追加到左侧的string对象后面。
字面值和string对象相加

当把string对象和字符字面值及字符串字面值混在一条语句中使用时,必须确保加法运算符(+)的两侧的运算对象至少有一个是string。

string s1 = "hello", s2 = "world";
string s3 = s1 + ", " + s2 + '\n';  // 正确:s3 = "hello, world\n"
string s4 = "hello" + ", ";         // 错误:两个运算对象都不是string

2.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是大写字母,输出对应的小写字母;否则原样输出
toupper(c) 如果c是小写字母,输出对应的大写字母;否则原样输出

建议:使用c++版本的c标准库头文件

处理每个字符?使用基于范围的for语句

范围for语句

string s("Hello World!!!");
// 转换成大写形式
for (auto &c : s) {     // 注意是引用
    c = toupper(c);
}
cout << s << endl;  // "HELLO WORLD!!!"
只处理一部分字符?

使用下标运算符([ ]),接收的参数是string::size_type类型的值

string对象的下标必须大于等于0,而小于s.size()。使用超出此范围的下标将引起不可预知的结果。

if (!s.empty()) {           // 确保确实有字符需要输出
    cout << s[0] << endl;   // 输出s的第一个字符
}
使用下标执行迭代
string s("Hello World!!!");
// 依次处理s中的字符直至我们处理完全部字符或者遇到一个空白
for (decltype(s.size()) index = 0;
     index != s.size() && !isspace(s[index]); ++index) {
    s[index] = toupper(s[index]);
}
cout << s << endl;  // "HELLO World!!!"

2.3 标准库类型vector

标准库类型vector表示对象的集合,其中所有对象的类型都相同。也被称为容器。头文件及名字空间:

#include <vector>
using std::vector;

c++语言既有类模板,也有函数模板,其中vector是一个类模板。

模板本身不是类或函数。编译器根据模板创建类或者函数的过程称为实例化

vector<int> ivec;               // ivec保存int类型的对象
vector<Sales_item> Sales_vec;   // 保存Sales_item类型的对象
vector<vector<string>> file;    // 该向量的元素时vector对象

vector能容纳绝大多数类型的对象作为其元素,但是因为引用不是对象,所以不存在包含引用的vector。

2.3.1 定义和初始化vector对象

和任何类类型一样,vector模板控制这定义和初始化向量的方法。

代码
含义
vector<T> v1 v1是一个空vector,它潜在的元素是T类型的,执行默认初始化
vector<T> v2(v1) v2中包含v1所有元素的副本
vector<T> v2 = v1 等价于v2(v1)
vector<T> v3(n, val) v3包含了n个重复的元素,每个元素的值都是val
vector<T> v4(n) v4包含了n个重复地执行了值初始化的对象
vector<T> v5 v5包含了初始值个数的元素,每个元素被赋予相应的初始值
vector<T> v5 = 等价于 v5

可以默认初始化vector对象:

vector<string> svec;    // 默认初始化,svec不含任何元素

可以拷贝vector对象给另一个vector对象赋值。注意两个vector对象的类型必须相同:

vector<int> ivec;           // 初始状态为空
vector<int> ivec2(ivec);    // 把ivec的元素拷贝给ivec2
vector<int> ivec3 = ivec;   // 把ivec的元素拷贝给ivec3
vector<string> svec(ivec2); // 错误:svec的元素是string对象,不是int
列表初始化vector对象

c++11标准提供了一种为vector对象的元素赋初值的方法,即列表初始化。用花括号括起来的0个或多个初始元素值被赋给vector对象:

vector<string> articles = {"a", "an", "the"};

c++语言提供了几种不同的初始化方式:

  1. 使用拷贝初始化时(即使用=时),只能提供一个初始值。
  2. 如果提供的是一个类内初始值,则只能使用拷贝初始化或使用花括号的形式初始化。
  3. 如果提供的是初始元素值的列表,则只能把初始值都放在花括号里进行列表初始化,而不能放在圆括号里。
vector<string> v1{"a", "an", "the"};    // 列表初始化
vector<string> v2("a", "an", "the");    // 错误
值初始化

当只指定vector的大小,而不提供初始值时,此时库会创建一个值初始化的元素初值,并把它赋给容器中的所有元素。这个初值由vector对象中元素的类型决定。

vector<int> vi(10);         // 10个元素,每个都初始化为0
vector<string> svec(10);    // 10个元素,每个都是空string对象

对于这种初始化的方式有两个特殊限制:

  1. 如果vector对象中的元素类型不支持默认初始化,就没法完成初始化工作。

  2. 如果只提供了元素的数量而没有设定初始值,只能使用直接初始化:

    vector<int> vi = 10;    // 错误:是想创建含有10个元素的容器,还是想把10拷贝到容器中?
    
列表初始化还是元素数量?

在某些情况下,初始化的真实含义依赖于传递初始值时用的是花括号还是圆括号。例如:

vector<int> v1(10);     // v1有10个元素,每个的值都是0
vector<int> v2{10};     // v2有1个元素,该元素的值是10
vector<int> v3(10, 1);  // v3有10个元素,每个的值都是1
vector<int> v4{10, 1};  // v4有2个元素,值分别是10和1
  • 如果用的是圆括号,可以说提供的值是用来构造vector对象的。
  • 如果是用的花括号,可以表述成我们想列表初始化该vector对象。也就是说,初始化过程会尽可能的把花括号内的值当成是元素初始值的列表来处理,只有在无法执行列表初始化时才会考虑其他初始化方式。
  • 如果使用花括号,但是提供的值又不能用来列表初始化,就要考虑用这样的值来构造vector对象了。
vector<string> v5{"hi"};        // 列表初始化:v5有一个元素
vector<string> v6("hi");        // 错误:不能使用字符串字面值构建vector对象
vector<string> v7{10};          // v7有10个默认初始化的元素
vector<string> v8{10, "hi"};    // v8有10个值为“hi”的元素
  • 只有v5是列表初始化。v7和v8不是。
  • 要想列表初始化vector对象,花括号里的值必须与元素类型相同。

2.3.2 向vector对象中添加元素

直接初始化的方式适用三种情况:

  1. 初始值已知且数量较少;
  2. 初始值是另一个vector对象的副本;
  3. 所有元素的初始值一样。

但是更常见的情况是:不清楚元素的个数,不清楚元素的值。更好的方法就是创建一个空vector对象,再利用vector的成员函数push_back添加元素。

vector<int> v2;                 // 空vector对象
for (int i = 0; i != 100; ++i)
    v2.push_back(i);            // 依次把整数值放到v2尾端

关键概念:vector对象能高效增长

C++标准要求vector应该能在运行时高效快读地添加元素。因此既然vector对象能高效地增长,那么在定义vector对象的时候设定其大小也就没有什么必要了,事实上如果这么做性能可能更差。只有一种例外情况,就是所有元素的值都一样。

开始的时候创建空的vector对象,在运行时再动态添加元素,这一做法与C语言及其他大多数语言中内置数组类型的用法不同。特别是如果用惯了C或者Java,可以预计在创建vector对象时顺便指定其容量是最好的。然后事实上,通常的情况是恰恰相反。

向vector对象添加元素蕴含的编程假设
  • 必须要确保所写的循环正确无误,特别是在循环有可能改变vector对象容量的时候。
  • 如果循环体内部包含有向vector对象添加元素的语句,则不能使用范围for循环。

2.3.3 其他vector操作

表达式
含义
v.empty() 如果v不含任何元素,返回真;否则返回假
v.size() 返回v中元素的个数
v.push_back(t) 向v的尾端添加一个值为t的元素
v[n] 返回v中第n个位置上元素的引用
v1 = v2 用v2中元素的拷贝替换v1中的元素
V1 = 用列表中元素的拷贝替换v1中的元素
v1 == v2 v1和v2相等当且仅当它们的元素数量相同且对应位置的元素都相同
V1 != v2
<,<=,>,>= 以字典顺序进行比较

求元素的平方:

vector<int> v{1, 2, 3, 4, 5, 6, 7, 8, 9};
for (auto &i : v)   // 注意I是引用
    i *= I;
for (auto i: v)
    cout << i << " ";
cout << endl;

要使用size_type,需首先指定它是由哪种类型定义的。vector对象的类型总是包含着元素的类型:

vector<int>::size_type // 正确

vector::size_type // 错误

  • 只有当元素的值可比较时,vector对象才能被比较。

  • 计算vector内对象的索引:使用下标运算符能获取到指定的元素。

  • 不能用下标形式添加元素:vector对象(以及string对象)的下标运算符可用于访问已存在的元素。而不能用于添加元素。

2.4 迭代器介绍

可以使用下标运算符来访问string对象的字符或者vector对象的元素,还有另外一种更通用的机制也可以实现同样的目的,这就是迭代器(iterator)。

  • 类似于指针类型,迭代器也提供了对对象的间接访问。

  • 迭代器有有效和无效之分。有效的迭代器或者指向某个元素,或者指向容器尾元素的下一个位置;其他所有情况都属于无效。

2.4.1 使用迭代器

和指针不一样的是,获取迭代器不是使用取地址符,有迭代器的类型同时拥有返回迭代器的成员。比如,这些类型都拥有名为beginend的成员,其中begin成员负责返回指向第一个元素的迭代器。end成员则负责指向容器“尾元素的下一个位置”的迭代器,常被称为尾后迭代器或者简称尾迭代器。

auto b = v.begin(), e = v.end();  // b和e的类型相同

一般来说,不用关心迭代器准确的类型到底是什么。

迭代器运算符

标准容器迭代器的运算符

表达式
含义
*iter 返回迭代器iter所指元素的引用
iter->mem 解引用iter并获取该元素的名为mem的成员,等价于(*iter).mem
++iter 令iter指示容器中的下一个元素
--iter 令iter指示容器中的上一个元素
iter1 == iter2 判断两个迭代器是否相等(不相等),如果两个迭代器指示的是同一个元
iter1 != iter2 素或者它们是同一个容器的尾后迭代器,则相等;反之,不相等
  • 试图解引用一个非法迭代器或者尾后迭代器都是未被定义的行为。

  • 使用递增(++)运算符来从一个元素移动到下一个元素。因为end返回的迭代器并不实际指示某个元素,所以不能对其进行递增或解引用的操作。

    string s = "Hello world!!!";
    // 依次处理s的字符直至我们处理完全部字符或者遇到空格
    for (auto it = s.begin(); it != s.end() && !isspace(*it); ++it) {
        *it = toupper(*it);  // 将当前字符改成大写形式
    }
    cout << s << endl;
    
迭代器类型

那些拥有迭代器的标准库类型使用iterator和const_iterator来表示迭代器的类型:

vector<int>::iterator it;        // it能读写vector<int>的元素
string::iterator it2;            // it2能读写string对象中的元素
vector<int>::const_iterator it3; // it3只能读元素,不能写元素
string::const_iterator it4;      // it4只能读字符,不能写字符
  • const_iterator和常量指针差不多,能读取但不能修改它所指的元素值。相反,iterator的对象可读可写。
  • 如果vector对象或string对象是一个常量,只能使用const_iterator。
  • 如果vector对象或string对象不是常量,那么既能使用iterator也能使用const_iterator。
begin和end运算符

begin和end返回的具体类型由对象是否是常量决定,如果对象是常量,begin和end返回const_iterator,否则返回iterator:

vector<int> v;
const vector<int> cv;
auto it1 = v.begin();   // it1的类型是vector<int>::iterator
auto it2 = cv.begin();  // it2的类型是vector<int>::const_iterator

有时候这种默认行为并非我们所要。为了专门得到const_iterator类型的返回值,c++11新标准引入了两个新函数,分别是cbegin和cend:

auto it3 = v.cbegin();  // it3的类型是vector<int>::const_iterator
结合解引用和成员访问操作

解引用迭代器可获得迭代器所指的对象,如果该对象的类型恰好是类,就有可能希望进一步访问它的成员。

(*it).empty();

为了简化上述表达式,C++语言定义了箭头运算符(->)。箭头运算符把解引用和成员访问两个操作结合在一起,也就是说,it->mem(*it).mem表达的意思相同。

某些对vector对象的操作会使迭代器失效

虽然vector对象可以动态地增长,但是也会有一些副作用:

  1. 不能在范围for循环中向vector对象添加元素。
  2. 任何一种可能改变vector对象容量的操作,如push_back,都会使该vector对象的迭代器失效。

2.4.2 迭代器运算

所有容器的迭代器都支持递增运算符,每次移动一个元素,以及能用==和!=对任意标准库类型的两个迭代器进行比较。

string和vector的迭代器提供了更多额外的运算符:

表达式
含义
iter + n 迭代器向前移动n个元素。结果或者指示容器内的一个元素,或者指示容器尾元素的下一个位置。
iter - n 迭代器向后移动n个元素。结果或者指示容器内的一个元素,或者指示容器尾元素的下一个位置。
iter1 += n 迭代器加法的复合赋值语句,将iter1加n的结果赋给iter1
iter1 -= n 迭代器减法的复合赋值语句,将iter1减n的结果赋给iter1
iter1 - iter2 结果是它们之间的距离,也就是说,将运算符右侧的迭代器向前移动差值个元素后将得到左侧的迭代器。参与运算的两个迭代器必须指向的是同一个容器的元素或者尾元素的下一个位置
>、>=、<、<= 迭代器的关系运算符,如果某迭代器指向的容器位置在零个
迭代器的算术运算

可以令迭代器和一个整数相加(或相减),其返回值是向前(或向后)移动了若干个位置的迭代器。执行这样的操作时,结果迭代器或者指示原vector对象内的一个元素,或者指示原vector对象尾元素的下一个位置。

// 计算得到最接近vi中间元素的一个迭代器
auto mid = vi.begin() + vi.size() / 2;

假设it和mid是同一个vector对象的两个迭代器,可以使用下面的代码来比较它们所指的位置谁前谁后:

if (it < mid)
    // 处理vi前半部分的元素

两个迭代器的差值表示两个迭代器的距离。所谓距离指的是右侧的迭代器向前移动多少位置就能追上左侧的迭代器,其类型是名为difference_type的带符号整形数。

使用迭代器运算

使用迭代器运算的一个经典算法是二分搜索。一个完整的例子如下:

vector<string>::const_iterator
BinarySearch(const vector<string> &text, const string &sought)
{
    auto begin = text.begin(), end = text.end();
    auto mid = begin + (end - begin) / 2;
    while (mid != end && *mid != sought) {
        if (sought < *mid) {
            end = mid;          // 搜索前半部分
        } else {
            begin = mid + 1;    // 搜索后半部分, 搜索范围是 [begin, end)
        }
        mid = begin + (end - begin) / 2;
    }
    return *mid == sought ? mid : text.cend();
}

void test(const vector<string> &text, const string &sought)
{
    auto it = BinarySearch(text, sought);
    if (it != text.cend()) {
        cout << "找到了" << sought << endl;
    } else {
        cout << "没有找到" << sought << endl;
    }
}

int main()
{
    vector<string> text = {"hello", "world", "c", "c++", "java"};
    // 必须保证text是有序的
    sort(text.begin(), text.end()); // "c" "c++" "hello" "java" "world"

    test(text, "c");
    test(text, "c++");
    test(text, "python");
    return 0;
}

运行结果是:

找到了c
找到了c++
没有找到python

2.5 数组

  • 数组也是存放类型相同的对象的容器。
  • 数组的大小确定不变,不能随意向数组中增加元素。
  • 对某些特殊的应用来说程序的运行时性能较好,但相应的损失了一些灵活性。

如果不清楚元素的确切个数,请使用vector。

2.5.1 定义和初始化内置数组

  • 数组是一种复合类型。
  • 数组的声明形如a[d],其中a是数组的名字,d是数组的维度,也就是元素个数,因此必须大于0.
  • 数组元素的个数也属于数组类型的一部分,编译时确定。因此必须是一个常量表达式。(实际上数组的大小在运行时确定都是可以的,这属于编译器的扩展功能?)
unsigned cnt = 42;          // 不是常量表达式
constexpr unsigned sz = 42; // 是常量表达式
int arr[10];                // 含义10个整数的数组
int *parr[sz];              // 含义42个整形指针的数组
string bad[cnt];            // 错误:cnt不是常量表达式
string strc[get_size()];    // 当get_size是constexpr时正确;否则错误

实际使用时,bad和strc并没有出现编译错误,这可能是编译器的扩展功能?

默认情况下,数组的元素被默认初始化。

和内置类型的变量一样,如果在函数内部定义了某种内置类型的数组,那么默认初始化会令数组含有未定义的值。

定义数组的时候必须指定数组的类型,不允许使用auto关键字由初始值的列表推断类型。另外和vector一样,数组的元素应为对象,因此不存在引用的数组

显式初始化数组元素
  • 可以对数组的元素进行列表初始化,此时允许忽略数组的维度。
  • 如果指定了维度,那么初始值的总数量不应该超出指定的大小。
  • 如果维度比提供的初始值数量大,则用提供的初始值初始化靠前的元素,剩下的元素被初始化成默认值。
const unsigned sz = 3;
int ial[sz] = {0, 1, 2};      // 含义3个元素的数组,元素值分别是0, 1, 2
int a2[] = {0, 1, 2};         // 维度是3的数组
int a3[5] = {0, 1, 2};        // 等价于 a3[] = {0, 1, 2, 0, 0}
string a4[3] = {"hi", "bye"}; // 等价于 a4[] = {"hi", "bye", ""}
int a5[2] = {0, 1, 2};        // 错误:初始值过多
字符数组的特殊性

可以使用字符串字面值对此数组初始化。字符串字面值的结尾处的空白字符也会被拷贝到数组中去:

char a1[] = {'C', '+', '+'};       // 列表初始化,没有空字符。维度是3
char a2[] = {'C', '+', '+', '\0'}; // 列表初始化,含义显式的空字符。维度是4
char a3[] = "C++";                 // 自动添加表示字符串结束的空字符。维度是4
const char a4[6] = "Daniel";       // 错误:没有空间可存放空字符!维度至少是7才可以
不允许拷贝和赋值

不能将数组的内容拷贝给其他数组作为其初始值,也不能用数组为其他数组赋值:

int a[] = {0, 1, 2}; // 含义3个整数的数组
int a2[] = a;        // 错误:不允许使用一个数组初始化另一个数组
a2 = a;              // 错误:不能把一个数组直接赋值给另一个数组
理解复杂的数组声明

和vector一样,数组能存放大多数类型的对象。又因为数组本身就是对象,所以允许定义数组的指针及数组的引用。其中,定义存放指针的数组比较简单,但是定义数组的指针或数组的引用就稍微复杂一点了:

int *ptrs[10];            // ptrs是含义10整形指针的数组
int &refs[10];            // 错误:不存在引用的数组
int (*Parray)[10] = &arr; // Parray指向一个含义10个整数的数组
int (&arrRef)[10] = arr;  // arrRef引用一个含义10个整数的数组

默认情况下,类型修饰符从右向左依次绑定,对于ptrs来说,从右向左:首先定义一个大小为10的数组,它的名字是ptrs,然后数组中存放的是指向int的指针。

由内向外阅读Parray的含义:首先Parray是一个指针,接下来右边,知道Parray是个指向大小为10的数组指针,最后观察左边,知道数组中的元素是int。合起来就是Parray是一个指针,它指向一个int数组,数组中包含10个元素。

当然,对修饰符的数量并没有特殊限制:

int *(&array)[10] = ptrs; // array是数组的引用,该数组含有10个int型指针

要想理解数组声明的含义,最好的办法是从数组的名字开始按照由内向外的顺序阅读。

2.5.2 访问数组元素

与标准类型vector和string一样,数组的元素也能使用范围for语句或下标运算符来访问。

在使用数组下标时,通常将其定义为size_t类型。这是一种机器相关的无符号类型,它被设计得足够大以便能表示内存中任意对象的大小。

// 以10分为一个分数段统计成绩的数量:0~9,10~19,...,90~99,100
unsigned scores[11] = {};   // 11个分数段,全部初始化为0
unsigned grade;
while (cin >> grade) {
    if (grade <= 100) {
        ++scores[grade/10]; // 将当前分数段的计数值加1
    }
}

与vector和string一样,当需要遍历数组的所有元素时,最好的办法也是使用范围for语句:

for (auto i : scores) {
    cout << i << " ";
}
cout << endl;
检测下标的值

与vector和string一样,数组的下标是否在合理范围之内由程序员负责为检查,所谓合理就是说下标应该大于等于0而且小于数组的大小。

2.5.3 指针和数组

  • 数组名称是指向该数组首元素的指针。

    string nums[] = {"one", "two", "three"}; // 数组的元素是string对象
    string *p = &nums[0];                    // p指向nums的第一个元素
    string *p2 = nums;                       // 等价于p2 = &nums[0]
    
  • 由上可知,在一些情况下数组的操作实际上是指针的操作。

  • 当使用数组作为auto变量的初始值时,推断得到的类型是指针而非数组。

    int ia[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; // ia是一个含有10个整数的数组
    auto ia2(ia);                              // ia2是一个整形指针,指向ia的第一个元素
    ia2 = 42;                                  // 错误:ia2是一个指针,不能用int值给指针赋值
    
  • 当使用decltype关键字时上述转换不会发生,返回的是由10个整数构成的数组:

    // ia3是一个含义10个整数的数组
    decltype(ia) ia3 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
    ia3 = p;         // 错误:不能用整形指针给数组赋值
    ia3[4] = i;      // 正确:把i的值赋给ia3的一个元素
    
指针也是迭代器

与vector和string的迭代器相比,指向数组的指针不仅全部支持它们的运算,而且还有更多的功能。

int arr[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
int *p = arr;  // p指向arr的第一个元素
++p;           // p指向arr[1]
  • 通过数组名称可以获取指向首元素的指针。

  • 尾后指针指向最后一个元素的下一个位置。尾指针不指向具体的元素,因此不能对其执行解引用或递增操作。

    int *e = arr[10]; // 指向arr尾元素的下一位置的指针
    
标准库函数begin和end

尽管能计算得到尾后指针,但这种方法极易出错。C++11标准引入了两个名为begin和end的函数,和容器中的两个同名成员功能类似。

int ia[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
int *beg = begin(ia);   // 指向ia首元素的指针
int *last = end(ia);    // 指向ia尾元素的下一位置的指针

begin和end这两个函数定义在iterator头文件中。

使用begin和end可以很容易地写出一个循环并处理数组中的元素。

int *pbeg = begin(arr), *pend = end(arr);
while (pbeg != pend && *pbeg >= 0) // 寻找第一个负值元素
    ++pbeg;
指针运算

指向数组元素的指针可以运算包括解引用、递增、比较、与整数相加、两个指针相减等,用在指针和用在迭代器上意义完全一致。

  • 一个指针加上(减去)某整数值,结果仍是指针。新指针指向的元素与原来的指针相比前进了(后退了)该整数值个位置:

    constexpr size_t sz = 5;
    int arr[sz] = {1, 2, 3, 4, 5};
    int *ip = arr;     // 等价于 int *ip = &arr[0]
    int *ip2 = ip + 4; // ip2指向arr的元素的尾元素 arr[4]
    
  • 给指针加上一个整数,得到的新指针仍需指向同一个数组的其他元素,或者指向同一个数组的尾元素的下一个位置:

    // 正确:arr转换成指向它首元素的指针;p指向arr尾元素的下一位置
    int *p = arr + sz;  // 使用警告:不要解引用!
    int *p2 = arr + 10; // 错误:arr只有5个元素,p2的值未定义(这种错误编译器一般发现不了)
    
  • 和迭代器一样,两个指针相减的结果是它们之间的距离。参与运算的两个指针必须指向同一个数组当中的元素:

    auto n = end(arr) - begin(arr); // n的值是5,也就是arr中元素的数量
    
    • 两个指针相减的结果的类型是一种名为ptrdiff_t的标准库类型,和size_t一样。定义在cstddef头文件中的机器相关的类型。是一种带符号的类型。
  • 只要两个指针指向同一个数组的元素,或者指向该数组的尾元素的下一位置,就能利用关系运算符对其进行比较。

解引用和指针运算的交互

指针加上一个整数所得的结果还是一个指针。假设结果指针指向了一个元素,则允许解引用该结果指针:

int ia[] = {0, 2, 4, 6, 8}; // 含有5个整数的数组
int last = *(ia + 4);       // 正确:把last初始化成8,也就是ia[4]的值

表达式*(ia+4)计算ia前进4个元素后的新地址,解引用该结果指针的效果等价于表达式ia[4]

下标和指针

如前所述,在多数情况下使用数组的名字其实用的是一个指向数组元素的指针。一个典型的例子是当对数组使用下标运算符时,编译器会自动执行上述转换操作。给定

int ia[] = {0, 2, 4, 6, 8}; // 含有5个整数的数组

此时,ia[0]是一个使用了数组名字的表达式,对数组执行下标运算其实是对指向数组元素的指针执行下标运算:

int i = ia[2]; // ia转换成指向数组首元素的指针,ia[2]得到(ia+2)所指的元素
int *p = ia;   // p指向ia的首元素
i = *(p + 2);  // 等价于 i = ia[2]

只要指针指向的是数组中的元素(或者数组中尾元素的下一位置),都可以执行下标运算:

int *p = &ia[2];  // p指向索引为2的元素
int j = p[1];     // p[1]等价于 *(p+1),就是ia[3]表示的那个元素
int k = p[-2];    // p[-2]是ia[0]表示的那个元素

虽然标准库类型string和vector也能执行下标运算,但是数组与它们相比还是有所不同。

  • 标准库类型限定使用的下标必须是无符号类型
  • 而内置的下标运算可以是负值。

内置的下标运算符所用的索引值不是无符号类型,这一点与vector和string不一样。

2.5.4 C风格字符串

尽管C++支持C风格字符串,但是C++程序中最好还是不要使用它们。这是因为C风格字符串不仅使用起来不方便,而且极易引发程序漏洞,是诸多安全问题的根本原因。

C风格字符串不是一种类型,而是为了表达和使用字符串而形成的一种约定俗成的写法。字符串在数组中以空字符结束。

C标准库String函数
函数
含义
strlen(p) 返回p的长度,空字符不计算在内
strcmp(p1, p2) 比较p1和p2的相等性。如果p1==p2,返回0;如果p1>p2,返回一个正值;如果p1<p2,返回一个负值
strcat(p1, p2) 将p2附加到p1之后,返回p1
strcpy(p1, p2) 将p2拷贝给p1,返回p1

上表中所列函数不负责验证其字符串参数。

传入此类函数的指针必须指向以空字符作为结束的数组:

char ca[] = {'C', '+', '+'}; // 不以空字符结束
cout << strlen(ca) << endl;  // 严重错误:ca没有以空字符结束
比较字符串

标准库string对象的比较:

string s1 = "A string example";
string s2 = "A different string";
if (s1 < s2) // false: s2小于s1

如果把这些运算符用在两个C风格字符串上,实际比较的将是指针而非字符串本身:

const char ca1[] = "A string example";
const char ca2[] = "A different string";
if (ca1 < ca2)  // 未定义:试图比较两个无关地址

要想比较两个C风格字符串需要调用strcmp函数,此时比较的就不在是指针了。

if (strcmp(ca1, ca2) < 0)  // 和两个string对象的比较 s1 < s2效果一样
目标字符串的大小由调用者指定

连接或拷贝C风格字符串也与标准库string对象的同类操作差别很大。例如:

// 将largeStr初始化成s1、一个空格和s2的连接
string largeStr = s1 + " " + s2;

同样的操作如果放到ca1和ca2这两个数组身上就会产生错误。

正确的方法是使用strcat和strcpy函数。

// 如果我们计算错了largeStr的大小将引发严重错误
strcpy(largeStr, ca1);  // 把ca1拷贝给largeStr
strcat(largeStr, " ");  // 在largeStr的末尾加上一个空格
strcat(largeStr, ca2);  // 把ca2连接到largeStr后面

对大多数应用来说,使用标准库string要比使用C风格字符串更安全、更高效。

2.5.5 与旧代码的接口

混用string对象和C风格字符串

使用字符串字面值来初始化string对象:

string s("Hello World"); // s的内容是Hello World

更一般的情况是,任何出现字符串字面值的地方都可以用以空字符结束的字符数组来替代。

反过来就不成立,如果程序的需要C风格字符串,无法直接使用string对象来代替它。为此,string专门提供了一个名为c_str的成员函数:

char *str = s; // 错误:不能用string对象初始化char*
const char *str = s.c_str(); // 正确
  • 结果指针的类型是const char *,从而确保我们不会改变字符数组的内容。
  • 我们无法保证c_str函数返回的数组一直有效,事实上,如果后续的操作改变了s的值就可能让之前返回的数组失去效用。

如果执行完c_str()函数后程序想一直都能使用其返回的数组,最好将该数组重新拷贝一份。

使用数组初始化vector对象

之前介绍过,不予许使用一个数组为另一个内置类型的数组赋初值,也不允许使用vector对象初始化数组。相反的,允许使用数组来初始化vector对象。要实现这一目的,只需指明要拷贝区域的首元素地址和尾后地址就可以了:

int int_arr[] = {0, 1, 2, 3, 4, 5};
// ivec有6个元素,分别是int_arr中对应元素的副本
vector<int> ivec(begin(int_arr), end(int_arr));

用于初始化vector对象的值也可能仅是数组的一部分:

// 拷贝三个元素:int_arr[1], int_arr[2], int_arr[3]
vector<int> subVec(int_arr+1, int_arr+4);

尽量使用标准库类型而非数组

2.6 多维数组

严格来说,C++语言中没有多维数组,通常所说的多维数组其实是数组的数组。

当一个数组的元素仍然是数组时,通常使用两个维度来定义它:一个维度表示数组本身大小,另外一个维度表示其元素(也是数组)大小:

int ia[3][4]; // 大小为3的数组,每个元素是含有4个整数的数组
// 大小为10的元素,它的每个元素都是大小为20的数组,
// 这些数组大元素是含有30个整数的数组
int arr[10][20][30] = {0}; // 将所有元素初始化为0

按照由内而外的顺序阅读此类定义有助于更好的理解其真实含义:

  1. 我们定义的名字是ia,显然ia是一个含有3个元素的数组。
  2. 接着观察右边发现,ia的元素也有自己的维度,所以元素本身又都是含有4个元素的数组。
  3. 再观察左边,真正存储的元素是整数。
  4. 最后:它定义了一个大小为3的数组,该数组的每个元素都是含有4个整数的数组。

对于二维数组来说,常把第一个维度称作行,第二个维度称作列。

多维数组的初始化

允许使用花括号括起来的一组值初始化多维数组,这点和普通的数组一样。

int ia[3][4] = {        // 三个元素,每个元素都是大小为4的数组
        {0, 1, 2,  3},  // 第1行的初始值
        {4, 5, 6,  7},  // 第2行的初始值
        {8, 9, 10, 11}  // 第3行的初始值
};

其中内层嵌套着的花括号并非必需的,例如下面的初始化语句,形式上更为简洁,完成的功能和上面这段代码完全一样:

// 没有标识每行的花括号,与之前的初始化语句是等价的
int ia[3][4] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};

类似于一维数组,在初始化多维数组时也并非所有元素的值都必须包含在初始化列表之内。如果仅仅想初始化每一行的第一个元素,通过如下的语句即可:

// 显式地初始化每行的首元素
int ia[3][4] = {{0}, {4}, {8}};

其他未列出的元素执行默认值初始化,和一维数组一样。这种情况下如果再省略掉内存的花括号,结果就大不一样了。下面的代码:

// 显式地初始化第1行,其他元素执行值初始化
int ia[3][4] = {0, 3, 6, 9};

含义发生了变化,它初始化的是第一行的4个元素,其他元素被初始化为0。

多维数组的下标引用

可以使用下标运算符来访问多维数组的元素,此时数组的每个维度对应一个下标运算符。

// 用arr的首元素为ia最后一行的最后一个元素赋值
ia[2][3] = arr[0][0][0];
int (&row)[4] = ia[1];   // 把row绑定到ia的第二个4元素数组上,也就是第2行

程序中经常会用到两层嵌套的for循环来处理多维数组的元素:

constexpr size_t rowCnt = 3, colCnt = 4;
int ia[rowCnt][colCnt]; // 12个未初始化的元素
// 对于每一行
for (size_t i = 0; i != rowCnt; ++i) {
    // 对于行内的每一列
    for (size_t j = 0; j != colCnt; ++j) {
        // 将元素的位置索引作为它的值
        ia[i][j] = i * colCnt + j;
    }
}
使用范围for语句处理多维数组

由于C++11标准中新增了范围for语句,所以前一个程序可以简化为:

constexpr size_t rowCnt = 3, colCnt = 4;
int ia[rowCnt][colCnt]; // 12个未初始化的元素
size_t cnt = 0;
for (auto &row:ia) {
    for (auto &col:row) {
        col = cnt;
        ++cnt;
    }
}

上面的例子中,因为要改变数组元素的值,所以我们选用引用类型作为循环控制变量,但其实还有一个深层次的原因促使我们这么做。如:

for (auto &row:ia) {
    for (auto col:row) {
        cout << col << endl;
    }
}

这个循环中并没有任何写操作,可是我们还是将外层循环的控制变量声明成了引用类型,这是为了避免数组被自动转换成指针。假设不用引用类型,则循环如下形式:

for (auto row:ia)
    for (auto col:row)

程序将无法编译通过。这是因为,像之前一样第一个循环遍历ia的所有元素,注意这些元素实际上是大小为4的数组。因为row不是引用类型,所以编译器初始化row时会自动将这些数组形式的元素(和其他类型的数组一样)转换成指向该数组内首元素的指针。这样得到的row的类型就是int*,显然内层的循环就不合法了,编译器将试图在一个int*内遍历,这显然和程序的初衷相去甚远。

要使用范围for语句处理多维数组,除了最内层的循环外,其他所有循环的控制变量都应该是引用类型。

指针和多维数组

当程序使用多维数组的名字时,也会自动将其转换成指向数组首元素的指针。

定义指向多维数组的指针时,千万别忘了这个多维数组实际上是数组的数组。

因为多维数组实际上是数组的数组,所以由多维数组名转换得来的指针实际上是指向第一内层数组的指针:

int ia[3][4];     // 大小为3的数组,每个元素是含有4个整数的数组
int (*p)[4] = ia; // p指向含有4个整数的数组,圆括号必不可少。
p = &ia[2];       // p指向ia的尾元素

在C++11之后,通过使用auto或者decltype就能尽可能地避免在数组前面加上一个指针类型了:

// 输出ia中每个元素的值,每个内层数组各占一行
// p指向含有4个整数的数组
for (auto p = ia; p != ia + 3; ++p) {
    // q指向4个整数数组的首元素,也就是说,q指向一个整数
    for (auto q = *p; q != *p + 4; ++q) {
        cout << *q << ' ';
    }
    cout << endl;
}

外层的for循环首先声明一个指针p并令其指向ia的第一个内层数组,然后依次迭代直到ia的全部3行都处理完为止。其中递增运算++p负责将指针p移动到ia的下一行。

内层的for循环负责输出内层数组所包含的值。它首先令指针q指向p当前所在行的第一个元素。*p是一个含有4个整数的数组,想往常一样,数组名被自动地转换成所指向该数组首元素的指针。内层for循环不断迭代直到我们处理完了当前内层数组的所有元素为止。为了获取内层for循环的终止条件,再一次解引用p得到指向内层数组首元素的指针,给它加上4就得到了终止条件。

当然,使用标准库函数begin和end,也能实现同样的功能,而且看起来更为简洁:

// p指向ia的第一个数组
for (auto p = begin(ia); p != end(ia); ++p) {
    // q指向内层数组的首元素
    for (auto q = begin(*p); q != end(*p); ++q) {
        cout << *q << ' ';
    }
    cout << endl;
}

使用begin和end可以隐藏掉数组的维度,使之成为一个通用的二维数组遍历代码。

类型别名简化多维数组的指针

读、写和理解一个指向多维数组的指针是一个让人不胜其烦的工作,使用类型别名能让这项工作变得简单一点。例如:

using int_array = int[4];  // 新标准下类型别名的声明
typedef int int_array[4];  // 等价的typedef声明

// 输出ia中每个元素的值,每个内层数组各占一行
for (int_array *p = ia; p != ia + 3; ++p) {
    for (int *q = *p; q != *p + 4; ++q) {
        cout << *q << ' ';
    }
    cout << endl;
}

程序将类型“4个整数组成的数组”命名为int_array,用类型名int_array定义外层循环的控制变量让程序显得简洁明了。

最新修改时间:2021-02-09 10:40

posted @ 2021-02-09 10:39  知_了  阅读(154)  评论(0编辑  收藏  举报