What & How & Why

字符串, Vector, 数组

C++ Primer 笔记 第三章


对命名空间使用 using 声明

之前对命名空间的使用都是以 namespace name + scope operator + object name 的形式进行使用,比如:

std::cin //using cin in namespace std;
当然,每次使用之前都要加上命名空间的名字确实不太方便。C++ 提供了 using 关键字来优化该访问的过程。使用 using 可以提前声明要使用的对象和其对应的命名空间,从而避免重复输入命名空间名:
#include<iostream>
using std::cin; //using declaration for cin in namespace std
int i;
cin >> i;
通过 using 声明来使用指定资源的方式被设计为一种按需声明的方式。如果希望使用多个对象(Name),那么必须分开声明:
/*using declaration for 3 different names*/
using std::cin;
using std::cout;
using std::endl;
int i;
cin >>i;
cout << i << endl;

注意:不要把命名空间的声明放到 header 里。如果 header 里有命名空间的声明,那么所有包含了这个 header 的程序都会有同样命名空间的声明,这样很可能会导致未知的冲突。

标准库类型string

string是一个字符组成的,长度可变的序列。在C++中,使用string前需要:

#include <string> //string is a part of the library.
using std::string; //string is defined in std namespace.
标准库里的类型大多都经过精心设计和优化,对一般的运用来说是足够了的。

定义和初始化string

string用string关键字定义。string的初始化有好几种方法,可以大致分为直接初始化和复制初始化。这两者的区别主要是看在初始化的时候有没有用上 “=” 运算符。举个例子:

string s0; //default initialization, s0 is empty.
string s1("good"); //direct initialization;
string s2(10, 'c'); //direct initialization; string is ten copies of char 'c';
string s3 = s0; //s3 is a copy of s0;
string s4(s0); //equivalent to s3 = s0;
string s5 = "good"; // copy initialization;

Operations on strings

string的读 / 写

string 的读入操作有两种常用的方式:» 运算符和 getline 函数。
» 运算符遇到 whitespace (空格,换行,tabs等等)的时候会结束读取,因此读取只会保留 whitespace 以前的部分:比如读取”hello world“,打印出来就变成了”hello“。

因为 string 是左值,因此 stream 运算符在 string 中是可以连用的:

string s1, s2;
cin >> s1 >> s2;
cout << s1 << s2 << endl;

string 的连续读写

如果需要读取不确定数量的 string,可以使用之前的 while 循环加输入判定写法。判定为假的条件是 EOF / 或者输入不合法:

string word;
//false when hit the end of line or invalid input
while(cin >> word){
    cout << word << endl;
}

getline for entire line

如果我们希望同时也读入 whitespace 字符,那么我们需要用 getline(). getline() 需要一个 stream 对象和一个 string 对象作为输入,并将 stream 内容的第一行存储到 string 中。当换行(遇到 \n)以后,getline 就会结束读取并返回读取的 string 到 stream 中。如果 stream 的第一个字符是 \n,那么 string 为空。

getline 返回的是 istream 对象,因此可以用于条件判断。 所以我们也可以通过以下代码来读取一个 string:

string line;
while (getline(cin, strLine)){
    cout << strLine << endl;
}

empty() & size()

empy()size() 两者都是 string 的成员函数。

empty() 判断当前的 string 是否为空,如果为空返回 bool 类型 ture,否则返回 false。比如:

while (getline(cin,string)) {
	if(!string.empty())
		cout << string << endl;
}

同样的,size() 也是 string 类的一个成员函数,用于计算 string 的长度。我们可以通过和访问 empty() 同样的方法去访问它。

不过需要注意的是,size() 返回的值并不是int,而是 string::size_type 类型。这个是一个 unsigned 的类型,所以要避免与 signed 的类型比较。该类型可以被 “视作” unsigned integer 类型, 但需要注意的是 size_type() 是一个固定值,而 unsigned integer 的字长会根据计算机的而浮动。

size_type() 类型的出现,是因为size() 需要有一个大小固定且足够大的类型来描述 string 的长度。大多数情况下 size_typeunsigned integer 用法相同;使用的时候需要注意以下几点:

  • size_type 是无符号的,不能和带符号变量进行条件判断。
  • 不能把 size_type 当成 int 类型理解。在定义 size_tpye 类型变量的时候,可以使用 auto 来获取类型。
Comparing strings

String 之间的比较分为比较是否相等和比较大小两大类。比较是区分大小写的。

当比较相等的时候,使用 equality operator (==!=)。两个 string 相等的条件是长度相同并且所包含的字符相同

当比较大小的时候,使用 relational operator (<>)。比较的过程如下:

  • 如果两个 string 长度不相同,且长度较短的 string 内所有的字符与较长 string 对应位置上的字符都相同,那么较长的 string 比较大。
  • 如果两个 string 对应位置上的字符不完全相同,那么该比较将会转化为对两个 string 上第一位不相同的字符的比较(ASCII 值)。

"Hello" vs "Hello World": "Hello" is shorter
"Hello" vs "Hiya" : "Hiya" is greater since i > e

string 的其他操作
  • string 的赋值:将一个 string 拷贝给另外一个 string
  • string 的相加:将两个 string 连接起来,将加号右边的 string 加到左边 string 的末尾
  • string 与 literal 的混合相加:将 string 与 literal 连起来

string s1 = "hello", s2, s3, s4;
s2 = s1; //assignment;
s3 = s1 + s2; //adding two strings
s1 += s2; //adding s2 to the end of s1
s4 = s1 + ", " + s2; //adding literals and strings
对于 string 和 literal 的直接相加,我们要注意的是 + 号两边的 operand 必须至少有一个是 string 的对象。注意在有多个 operand 的时候,在遵循当前运算优先级的情况下,如果 + 号两边没有 string 对象,也是非法的,比如:
string s2;
string s = "hello" + ", " + s2; //error, "hello" and ", " can't be added

string中的字符操作

C++ 通过头文件 cctype 中定义的下列函数来对单个字符进行操作:

在对 string 中的字符操作过程中,C++ 继承了 C 的字符操作库 ctype.h。不过在 C++ 中,有属于 C++ 自己的版本来表达这个库,那就是去掉扩展名(.h suffix), 然后在原有的库名前加一个 c (c 开头表示这个 header 来源于 c 标准库的一部分),这样 ctype.h 就变成了 cctype。书上推荐这么写的原因是:这样可以让程序员更好的辨别哪些 header 继承于 C,而哪些 header 是 C++ 特有的。

string的字符遍历

C++11中提出了一种新的方法遍历string: range for。形式如下:

for (declaration : expression) 
    statement
expression 表示某种可以代表【一系列元素组成的序列】的对象,declaration 定义了用于访问字符串中元素的变量。以字符串来为例来说:
string str ("hello world.");
for (auto c : str)
    cout <<  c << endl;
这里的 str 是字符类型的对象组成的序列,c 部分则定义了我们用于访问这个序列的变量。这里我们一般用 auto 来决定 c 部分是什么类型。下面是一个计算标点符号个数的例子:
string s("Hello World!!!");
decltpye(s.size()) punct_cnt = 0;
for (auto c : s) {
	if (ispunct(c)) {
		++punct_cnt;
	}
	cout << punct_cnt << endl;
}
这个例子中,因为 size() 函数返回的类型不好确定,因此使用 decltype 得到其类型。

当然操作必不可少的要牵涉到修改原string的内容。需要注意的是:C++ range for 使用引用来作为循环中访问元素的变量,比如:
string str ("hello world.");
for (auto &c : str)// c is reference here
    c = toupper(c);

Processing Only Some Characters

如果只想处理字符串中的某些字符,有两种方法:

第一种方法是使用下标(Subscript)来访问指定的元素。[] 下标操作符(Subscript operator)获取 string::size_type 类型的值来拿到我们想访问的字符,该操作返回对应位置字符的引用。下标的范围是[0, s.size() -1]

通过下标访问某个元素前,必须保证下标所在位置在字符串的范围内,否则会导致 undefined. 也就是说,通过下标访问 empty string 的行为是 undefined 的行为!

因此,使用下标访问字符串之前,最好检查字符串是否是空字符串:

string s("xyz");
if (!s.empty()) {
	s[0] = toupper(s[0]);
}
如果需要按 word 为单位来进行修改操作,可以通过判断当前的字符是不是空格来得知之前的内容是否是为一个完整的 word。这种情况下使用标准的 for 循环遍历即可:
string str ("hello world.");
for(decltype(s.size()) index = 0; 
	 index != s.size() && !isspace(s[index]; ++index) //if the index in range and not a space
	s[index] = toupper(s[index]); //then capitalize

标准库类型Vector

Vector 是 C++ 标准库中提供的一种容器(Container),用于存储一系列的对象。每个对象都被给与了指定的目录用于访问。声明 vector 的方法如下:

#Include <vector> // must include the appropriate header in order to use vector
using std::vector;
Vector 本身是类模板,而类模板本身是一系列指令,用于指导编译器生成类和方法。我们把这个生成的过程称为实例化( Instantiation )。所以,在 vector 中,我们需要给 vector 传递的信息就是我们需要存储对象的类型。比如:
vector<int> ivec; //ivec holds elements of type int.
vector<Sale_item> S_vec; // vector also can hold class;
vector<vector<string>> file; // and can hold vectors too;
由于 vector 是模板,不是类型。所以vector进行实例化必须指定对象的类型,所以才有了vector<int> 这种写法。同时vector是用于存放对象的,因为引用不是对象,所以我们不能用vector存放引用

需要注意的是,早期的 C++,定义存放vector的vector跟C++11有一点不同:
vector<vector<string> > file; // old format, must have a space before the closing outter angle bracket.
vector<vector<string>> file; // ok in C++11.

Vector的定义和初始化

  • 由于我们能够很有效的在 runtime 期间对 vector 进行操作,所以最普遍的 vector 初始化方式就是定义一个的 vector。
  • 如果希望使用其他 vector 进行初始化,那么 initializer vector 必须与目标 vector 的类型相同

vector<T> v1; //Default initialization, vector that holds objects of type T. v1 is empty.
vector<T> v2(v1); // v2 has copy of each element in v1.
vector<T> v2 = v1; // equivalent to v2(v1);
vector<T> v3(n, val); // v3 has n elements with value val.
vector<T> v4(n); //v4 has n elements with default value.
vector<T> v5{1,2,3,....} //List initialization, must comes with curly brace.
vector<T> v6 = { 1,2,3,....} // equivalent to v5.

List Initializing a vector

vector listing initialization 是 C++ 11的新特性,允许我们用 list (大括号) 的形式对 vector 进行初始化:

vector<string> articles = {"a", "an", "the"};
需要注意的是,initializer 的数量是受初始化的形式限制的。如果初始化使用的 copy 的形式,比如(赋值 =,括号 ()),这样的形式只能接受单个的 initializer:
vector<string> v1 ("a", "b", "c"); //error, too many initializers

创建指定数量的元素

如果需要创建指定元素个数的 vector,我们使用括号进行初始化:

vector<int> v1(10, 1); //vector has 10 elements with value 1

Value Initialization

使用值对 vector 初始化的时候,可以只指定 vector 的大小。vector 会自动创建 value-initialized 元素来进行初始化。通常情况下,如果 vector 元素的类型是 Build-in type,那么初始值为 0;如果是,那么初始值为类的默认初始化值

值初始化有两种形式上的限制:

  • 某些类在初始化的时候要求显式的给与 initializer;对于这种不能默认初始化的类,我们必须同时给与大小与初始值。
  • 如果使用单个元素的值初始化,那么必须使用直接初始化形式(copy 形式的初始化必须提供元素个数)
List Initializer or Element Count?

需要注意的是,使用大括号的初始化意味着完全不同的意义:

vector<int> v1(10, 1); //vector has 10 elements with value 1.
vector<int> v2{10, 1}; //vector has 2 elements, 10 and 1.
在使用了 list initialization 后,编译器会尽可能将 curly brace 内的内容视作 list initialization 的初始值;只有在无法使用这些内容进行 list initialization 的时候,编译器才会考虑其他的办法来对 vector 进行初始化。比如如下的例子,当 vector 的类型与初始化值不匹配的时候:
vector<string> v7{10}; //10 elements with default value
vector<string> v8{10, "hi"}; //10 elements with value "hi"
这种行为被称为 list initialization 的 construct 功能。需要注意的是,普通的括号初始化则没有这种construct功能

Vector的操作

Vector 由于其可以在 runtime 下修改的特性,往往用在比较 flexble 的场景中。C++ 中提供了非常多的函数用于对 vector 的数据进行操作。

为 vector 添加元素

C++ 使用 push_back() 成员函数对 vector 进行元素添加操作。该函数接收一个元素,并将该元素“推”到 vector 当前的最后一个位置上:

vector<int> v2; // empty vector
for (int i = 0; i != 100; ++i)
    v2.push_back(i); // append sequential integers to v2
// at end of loop v2 has 100 elements, values 0 . . . 99
可以看到数字是一个一个被添加到 vector 的末尾的。另外一个应用则是将每次输入的 word 添加到 vector 中:
string word;
vector<string> v;
while(cin >> word) {
    v.push_back(word); //append word to tex
}

由于 STL 对 vector 添加元素有性能上的要求,因此在定义的时候指定 vector 的大小往往会导致更差的性能表现(除非是所有的 vector 元素都一样)。这点与 array 的用法是完全不同的。

Vector 是大小可变的。如果在循环体内包含了 vector,那么循环本身有可能会改变 vector 的大小 - 这点必须特别注意,尤其是在使用 for range 迭代的时候,for range 的范围大小是不应该被改变的

其他操作

除开 push_back(),C++ 还为 vector 提供了一些常用的操作:



值得提醒的是,size() 函数的返回值也是 size_type。如果需要定义该类型数据的 vector,必须指定该类型数据对应的类型:

vector<int>::size_type
除此之外,vector 也可以使用 equality op 与 relational op,策略与 string 是类似的:

  • vector 在长度以及每个对应元素都相等的情况下相等
  • 如果 vector 一场一短,短 vector 中元素与长 vector 中元素一一对应并相等,那么 长的 vector 比较大
  • 否则比较第一个不同的元素
  • 比较元素的前提是元素之间存在着大小关系。 vector 无法比较不存在 equality 和 relational 关系的元素。

当然,像 string 一样,也可以使用 range for 对 vector 进行遍历:

vector<int> v{1,2,3,4,5,6,7,8,9};
for (auto &i : v)
    i*=i;//square the element value
for (auto i : v)
    cout << i << endl;

Computing a vector Index

与 string 类似,vector 也可以通过类型为 size_type 的下标对指定元素进行访问和修改。下面是一个例子:

/*
*给定一串 100 以内的数,将 100 分成 10 个相等区间,统计每个数出现在自己区域的出现次数。
*/
vector<unsigned> scores(11, 0); //11 containers for counters 0-9..90-99, 100
unsigned grade;
while (cin >> grade) {
    if (grade <= 100) {
    ++scores[grade / 10];
    }
}
这里通过 grade 除以 10 得到该数所处的区间编号(使用下标访问),再对对应位置的 counter 做自加处理即可。

需要注意的是,在用下标访问 vector 的时候,有时候我们会想当然的使用下标给 vector 的添加元素:
vector<int> v; //empty vector;
for(decltype(v.size() ix = 0; ix != 10; ++ix)
    v[ix] = ix; //error!!
这样写是非常危险的。因为 vector 中根本没有元素,所以用下标访问 vector 只能拿到一个undefined 的值。更要命的是,编译器是察觉不到这个错误的,所以会导致bug非常难查。这种错误称之为:buffer overflow error。预防这种错误的出现最好是尽可能的用 range for 代替下标访问

vector 的元素不能通过下标来进行添加!如果 vector 本身为空,那么使用下标对其访问是非法的。如果想添加元素,正确的方法是使用 push_back()。

迭代器

迭代器(Iterators)是 C++ 提供的一种与下标功能类似的,但更加通用的访问元素的方式。这种方式使用更加广泛,基本上 STL 中的所有容器都可以使用迭代器(string 不是容器,但支持很多容器的算法)。

迭代器类似于指针,代表了当前元素的位置与内容;同时迭代器通过自身的运算可以改变指向的元素,方便遍历。 与指针不同的是,迭代器并不直接使用地址来指向元素,而是使用成员函数 begin()end() 来返回迭代器。begin() 返回指向容器第一个元素的迭代器,而 end() 返回的是容器最后一个元素下一个位置one past the end)的迭代器。end.() 的返回的迭代器并不指向任何的有效元素,只是作为容器的边界使用(off-the-end-iterator)。因此如果容器是空的,那么 begin()end.() 返回的迭代器都是 off-the-end-iterator

迭代器的操作

迭代器支持一系列的操作:



利用迭代器性质判断容器是否为空:

string s("a string");
if(s.begin() != s.end()) {//if s is not empty
    auto it  = s.begin(); // we don't care about what kind of type that iterator has.
    *it = toupper(*it);//access the element like pointer
}
对于 iterator 的类型,我们一般用 auto 去推断。iterator 对元素的访问也跟 pointer 类似,都是用 * (解引用)的方式访问。

Moving Iterators

迭代器通过 ++ 运算来实现改变指向的元素。++ 的意思是 “将当前迭代器的位置推进到下个元素的位置”。我们可以利用该运算符来对整个容器进行迭代:

//alternative iterator approch for Q.3.2.3
string s = "Hello World";
for (auto it = s.begin(); it != s.end() && isspace(*it); ++it) {
	*it = toupper(*it);
}
需要注意的是,这里使用了 equality op 而不是用 relational op 做条件运算。这样使用是因为基本上所有的容器都定义了 ==!= 运算符,而大多数迭代器并没有定义 < 运算。并且,迭代器之间的比较需要遵循比较繁琐的定义,尤其是必须知道比较元素的类型。使用 equality op 可以避免这些。

Iterator Types

迭代器的类型与之前提到过的 size_type 类似,我们往往不会知道其具体的类型。相比之下更重要的是我们需要知道 iterator 与 const_iterator 的区别:

vector<int>::iterator it; // can read and wirte int elements
vector<int>::const_iterator cit; //can read but not write int elements
string::iterator it2; //string ver
string::const_iterator cit2;
与指针的类似,迭代器也可以通过指定 const_iterator 定义指向常量的迭代器,其行为与 pointer to const 类似。

迭代器类型有好几种不同的意义:

  • 迭代器本身的概念
  • 容器定义的迭代器类型
  • 迭代器对象时的类型

但无论意义若何,这一套解释都是为了说明迭代器支持一系列的,可以让我们访问或者移动容器中的元素的操作。换句话说,如果某个类型支持迭代器概念定义的操作,那这个类型就是迭代器类型。

begin / end 与 const

begin()end() 返回的类型取决于被操作的对象是否是常量。如果是,那么他们返回 const_iterator,反之返回 iterator。

vector<int> v;
const vector<int> cv;
auto it1 = v.begin(); //iterator
auto it2 = cv.begin(); //const_iterator
C++11 提供了 cbegin()cend() 来处理我们希望返回值是常量的情况。当使用这两个函数的时候,无论被操作的对象是否为常量,返回的迭代器一定是常量迭代器
auto it3 = v.cbegin(); //vector<int>::const_iterator

Good practice: 不修改容器元素的情况下使用常量迭代器。

解应用与成员函数的连用

有时候我们会使用装载类的容器。这种情况下一个常见的操作是首先是先访问类(解应用 *),再访问类的成员函数。通常这种复合操作需要指定优先级,也就是括号:

(*it).empty(); //The brace ensures dereference happens first
*it.empty(); //empty() only applys to it, not *it
为了避免上述的错误,C++ 提供了另外一种写法:
it->empty();
这种写法等同于解应用再调用成员函数的写法。下面是使用该写法做判定容器中某个元素是否为空的一个例子:
for (auto it = text.cbegin(); it != text.cend() && !it->empty(); ++it) {....;}

Vector 导致迭代器无效的情况

需要注意的是 vector 有导致迭代器无效的潜在危险。比如像已经确定范围的 vector 遍历过程中再对该 vector 添加新的元素,那么迭代器会有很大的概率会失效。因此,需要确保在使用迭代器循环的时候不能往其操作的容器中添加新的元素。

迭代器的运算

迭代器通常支持位移(++)运算,比较运算和关系运算。String 和 Vector 额外支持一次移动迭代器好几位的运算: 当迭代器进行算术运算的时候,参与运算的迭代器必须指向同一个容器内的元素,或者是指向容器最后一个元素的下一个位置(off-the-end-iterator)。下面一个例子使用了头部迭代器和 size 信息计算了容器中部的迭代器:

auto mid = vi.begin() + vi.size() / 2;
如果容器的类型是 String 和 vector,那么这两者的迭代器还可以进行关系运算。进行关系运算的迭代器还需要满足:

  • 参与运算的迭代器有效
  • 参与运算的迭代器处于同个容器的有效范围内

除此之外,两个满足上述要求的迭代器还能进行相减的操作。得到的结果是两个迭代器之间的距离;也就是一个迭代器要到达另外一个迭代器所在位置需要移动的单位数。相减得到的结果是一个有符号的、类型名为 difference_type 的类型。

使用迭代器运算

一个比较经典的,使用迭代器的算法是二分查找法(Binary search)。

auto beg = text.begin(), end = text.end();
auto mid = text.begin() + (end - beg) / 2;

while (mid != end && *mid != sought) {
	if (sought < *mid){
		end = mid;
	}
	else {
		beg = mid + 1;
	}
	mid = beg + (end - beg) / 2;
}
通过反复比较目标与 Mid 的值,来改变 mid 的位置,从而实现求解的目的。

数组

数组(Arrays)是一种与 vector 相似的数据结构。与 vector 不同的是,数组的大小是固定的。在 run-time 期 数组的性能往往好于 vector,但这是以付出灵活性为代价的。

定义和初始化数组

定义数组的形式是 arrayName[Dimentions]数组必须在编译的时候确定大小,而数组的 Dimension 必须是个常量表达式。

int i = 10;
constexpr ci  = 10; 
int arr[10]; // ok
int *parr[ci]; //ok, 42 pointers
int bad[i]; // error, dimension of array must be a constant expression
string strs[get_size()];// ok only if get_size is const expression
几个需要注意的事情:

  1. Build-in type 类型的数组在函数内部的默认初始化结果与 build-in type 一致,都是 undefined.
  2. 定义数组的时候必须指定类型,不能用 auto 去推断。
  3. 数组装载的元素均为对象,因此不存在元素为引用的数组。
显式初始化数组

当采用 List 的方式初始化数组时,如果不设置 dimension, 编译器会根据初始化元素的个数推断dimension;如果初始化元素个数小于 dimension,那么编译器会自动对余下的位置进行值初始化,初始化的值根据元素的类型来决定:

const unsigned sz = 3;
int a1[sz] = {0,1,3}; //dimension = 3
int a2[] = {0,1,2}; //dimenstion = 3
int a3[5] = {0,1,2}; // two elements are value initialized, {0,1,2,0,0}

字符串数组的初始化

字符串是比较特别的一种数组。当使用字符串 literal 对字符串数组进行初始化的时候,字符串需要额外的一位空间来装载字符串的终止符 \0

char a1[] ={'c', '+', '+' }; //no null
char a2[] ={'c', '+', '+', '\0'}; //mannally add null
char a3[] = "c++"; // has null
const char a4[3] = "Hey"; //error, can't add null

无法直接拷贝 / 赋值

数组是无法通过另外一个数组来初始化的,也不能直接将一个数组赋值给另外一个数组:

int a[] = {1,2};
int b[] = a; //error
b = a; //error

某些编译器允许数组的赋值,但并不是标准,应该尽量避免。

复杂的数组声明

数组也可以与复合类型一起使用,组成更复杂的类型。除了引用以外,数组可以与绝大部分类型一起使用,但是也更复杂。相较于之前从右往左看的识别方法,识别数组声明的类型是从内往外看的,比如下面例子的第三个定义:

int *ptrs[10]; //array of pointers to int
int &ptrs[10] = ??; //error, reference is not object
int (*Parray)[10] = &arr; //points to an array of ten ints
int (&arrRef)[10] = arr; //reference to an array of ten ints

  1. 首先找到 变量名 Parray,发现 Parray 是一个指针类型的变量 *Parray
  2. 然后跳出括号看,左边是这个指针指向数组的大小,右边是这个数组的类型。
  3. 因此 int (*Parray)[10] 是一个指向 int[10] 数组的指针。

再来看一个更复杂的例子 int *(&arry) [10] = ptrs,看看这个表达式是如何适配从里往外看的规律的:

  1. 首先看到了括号,那么对括号里的内容进行分析,括号里从右往左看发现 arry 是一个引用
  2. 接着往外看,*(&arry) 意味这该引用是 refer to 指针的引用
  3. 再往外看,左边是 [10],右边是 int,因此确定数组装载了 10 个指向 int 的指针
  4. 那么这个例子定义的是一个引用,这个引用 refer to 一个数组,数组里装载了 10个指向 int 类型数据的指针。

访问数组

我们可以用下标或者 Range for 来访问数组;下标的范围是[0, size-1]。数组的下标类型是 size_t,是一种机器指定的,保证有足够空间存储任何对象的类型。该类型被定义于 cstddef (c 语言中是 stddef.h 中。

使用下标访问数组与 vector 基本上类似,唯一的区别在于数组与 vector 声明的方式不一样。除此之外,[] 下标运算符虽然看上去一样,但在 vector 和数组中是不同的两种运算符。

除此之外,数组还能用 range for 遍历:

for (auto i : arry)
    cout << i <<endl;

下标的检查

不管是 string、vector 还是数组,保证下标的值在 [0 , size() -1] 的范围内都是极其重要的一件事。这种因为下标越界而带来的安全问题称为 Buff Overflow Bug

数组和指针

在 C++ 中,指针与数组的联系非常紧密。总的来说,指针通过 address-of operator 来访问对象;由于数组的元素都是对象,因此指针也可以用来访问数组的元素:

string num[] ={"1", "2", "3"};
string *p = &num[0];
默认情况下,如果不指定访问的数组元素位置,那么编译器会将指针指向数组的第一个元素:
string *p2 = num; //equal string *p2 = &num[0], 注意这里 num 没有取地址
实际上,C++ 中很多地方都在暗示对数组的操作实际上就是对指针的操作。比如我们使用 auto 来初始化一个数组,而得到的类型结果实际上是一个指针:
int arry[] = {0,1,2};
auto t(arry); // equivalent to auto t = arry; auto deduces that t is a int* type
t = 42; //error, can't assign a int to a pointer
在这里,编译器实际上是这么进行初始化类型判定的:
auto t(&arry[0]); // t is pointer that points to the first element of arry
需要注意的是,decltype 对数组的返回类型是数组,因此上述的数组 arry 使用 decltype 推断类型得到的返回值是拥有10个 int 元素的数组:
int ia[] ={1,1,1,1}
decltype(ia) ia2 = {1,2,3,4};
ia2[2] = 4; // ok, assgin int to an element in ia2

指针是迭代器

在数组中,可以将指针像在 vector 中使用迭代器一样使用:

int arr [] = {1,2,3,4,5,6,7,8,9,0};
int *p = arr;
++p; // p will points from arr[0] to arr[1]
同样,指针也可以像迭代器一样遍历数组。需要的信息也相同,只需要找到数组的第一个元素的位置和 off-the-end 位置就可以。我们可以通过定义指向数组最后一位的下一位的指针来获取该数组的 off-the-end pointer,之后就可以使用迭代器的方式对数组进行遍历:
int  arry[] = {0,1,2,3,4}
int *e = & arry[5];  //The max index is 4, e is the off-the-end ponter of arry
for (int *b = arry; b != e; ++b)
     cout << *b << endl;

标准库 begin() / end()

尽管能通过下标得到 off-the-end pointer, 但这样做真的很容易犯错。幸好 C++ 11 为指针也提供了类似迭代器的 begin()end()。这两个函数分别返回指向数组的第一个指针和 off-the-end 指针,于是上一小节的例子就可以写成:

int  arry[] = {0,1,2,3,4}
int *pBeg = begin(arry);
int *pEnd = end(arry);
for (p_beg; pBeg != pEnd; ++pBeg)
      statements;
使用的时候需要申明其所在的 scope,包括定义这俩函数的头文件 iterator
#include <iterator>
using std::begin;
using std::end;

begin() / end()range for 都是以数组的大小作为循环条件来遍历数组的。由于数组的大小是数组类型的一部分,在数组类型未知的情况下,不能使用这些功能对数组进行遍历(比如堆上分配的“动态数组”)

指针的算术操作

在数组中,指针可以进行一切迭代器可以进行的运算,意义也是一样的;使用迭代器的限制也同样应用于指针:

constexpr size_t sz = 5;
int arr[sz] = {1,2,3,4,5,6};
int *ip = arr;
int *ip2 = ip + 5; // a new pointer points the 5th element behind the first element
int *ip3 = ip + 6; //error, arr has 5 elements, ip3 has an undefined value
int *ip4 = arr + sz; //ok but do not dereference
同样,两个指针也可以进行减法。得到的值类型为 ptrdiff_t,该类型也是由机器指定的,定义于 cstddef 中的类型。该类型可以为负数

关系运算也可以用于指针中,前提是指针指向的位置必须是数组元素的位置,或者是 off-the-end pointer 的位置。利用该运算可以写出如下的遍历写法:
int *b = arr;
int *e = arr + sz;
while (b < e) { 
    do sth....
    ++b;
}
除此之外,指针的运算也适用于空指针和指向不是数组中对象的指针。

解应用与指针运算

指针与整型的加减会返回一个指针。如果想获取该指针对应的元素内容,需要使用括号确定指针的加减运算:

int a[] = {0,2,4,6,8};
int last = *(a + 4); // last = a[4];
last= *a + 4 // last = a[0] + 4;

数组下标与指针

使用下标访问数组实际上就是在对当前的指针进行计算,比如:

int ia = {0,2,4,6,8};
int i = ia[2];
实际上是做了如下的操作:
int *p = ia; // p points to the first element of ia
ia[2] = *(p + 2);
可以看出来下标运算符实际上等同于指针的算术运算;因此指针的加减法可以用下标替代:
int *p = &ia[2]; // p points to 3rd element of ia
int j = p[1]; // p[1] equal *(p + 1), a[3]
int k =p[-2] // p[-2] equal *(p - 2), a[0]
注意这里的下标可以是负数,这是数组下标(原生下标)独有的写法。相比之下,Vector / spring 的下标都是 unsigned 类型。

C style string

本章作者介绍 c style string 的意图应该是想介绍一些兼容老代码的背景知识。C++ 程序中应该避免使用 c style string。

字符串 literal 并不是类型,而是一种继承自 c style string 的实例。这种结构实际上是一个以 /0 (null terminated) 为结尾的字符数组。通常我们使用指针来操作字符串 literal。

C Library string functions

C 中有操作该类字符串的函数,定义于 string.h 内(C++ 中是 cstring): 以上的函数均不会校验其收到的参数,因此需要特别注意参数的合法性。一个比较常见的错误就是将没有 null terminated 的字符串作为以上函数的参数:

char ca[] = {'C', '+', '+'}; // not null terminated
cout << strlen(ca) << endl; // disaster,undefined

Comparing Strings

不像 C++ string, C style string 并不能直接使用关系运算符比较大小:

char ca[] = "teststringa";
char cb[] = "teststringb";
if (ca > cb) // undefined
这是因为 C style string 的本质是一个字符数组,因此上述的比较操作会被转化成两个指向数组头元素指针的大小比较。因为这两个指针分别指向了不同对象(数组),因此他们之间的比较是未定义的。正确的方法是使用函数 strcmp 函数来进行比较。如果两个 c string 相等,该函数会返回 0,否则会返回正数或者负数(取决于c string 参数的大小):
if (strcmp(ca, cb) < 0)

调用者决定结果的大小

如果想将两个字符串连接起来,C++ 中使用 + 运算就可以。但在 C style string 中需要将 strcatstrcpy 配合使用才能达到同样的效果(直接相加会导致不同对象的指针相加)。在使用上述两个函数连接字符串时,用于存储的字符数组必须足够大到装下结果字符串以及 /0

这是另外一个不使用 c style string 的重要原因:每次字符串的连接都要求用户确保目标字符串的大小。

Interfacing Old code

很多在 STL 出来之前的 C++ 程序,或是为 C 程序做的接口,都没有用上 vector 和 string。C++ 提供了一些功能用于衔接这些老程序和现代 C++。

mixing string & c-string

首先,C style string,也就是 null-terminated char array,可以用于任何 string literal 适用的场景,比如:

  • 使用 c-string 初始化 & 赋值 string
  • 与 string 混用时,c-string 可以充当算子:比如 string 与 c-string 的相加。

需要注意的是,上述规则反过来用是不行的。如果在某些特定的情况下需要使用 string 作为 c-string 的初始值,那么可以用到 string 的成员函数 c_str 来实现:

const char *str = s.c_str();
该函数会返回一个 c-string,也就是一个 const char* 类型的指针。

c_str() 并不能保证返回值的有效性。改变 string 很可能导致 c_str() 之前返回的数组无效。使用的时候最好将得到的数组复制一份使用。

使用数组初始化 vector

C++ 中,vector 不能初始化数组,但反过来可以。vector 的初始化可以接收两个数组指针作为参数,来确定初始值:

int int_arr[] {0,1,2,3,4,5};
vector<int> ivec(begin(int_arr), end(int_arr));
还可以使用指针的算数运算来指定数组的子集作为初始值:
vector<int> ivec2(int_arr + 1, int_arr + 4); //int_arr[1] to int_arr[3]

多维数组

在多维数组的定义中,定义的实际是包含数组的数组。我们可以看到,多维数组的定义实际上是:

type array[sub_array1][sub_array2]...[elements];

二维数组的定义中,一般定义第一行为数组的个数(行),第二行为数组包含元素的个数(列),层级从右到左依次往上。

多维数组的初始化

我们再用list初始化的方法来看一看二维数组:

int ia[3][4] = {
     {1,2,3,4}, //ia[0][x]
     {5,6,7,8}, //ia[1][x]
     {9,10,11,12} //ia[2][x]
};
从这里可以发现,这根先前我们总结的定义是符合的,3是指的行数(数组个数),4指的是列数(元素个数)。以上的定义中,定义中的每一行的大括号,都代表这些大括号里的元素属于一个数组。因此,我们可以用大括号来控制每个数组的初始化:
int ia[3][4] = { {0}, {4}, {8} };
//{0,0,0,0,4,0,0,0,8,0,0,0}
如果不加大括号,初始化就是按 List 中的元素顺序进行初始化:
int ix [3][4] = {1,2,3,4}; 
//{1,2,3,4,0,0,0,0,0,0,0,0}

访问多维数组

使用下标访问

对于多维数组的遍历,外层循坏对应数组左边的 subscript, 内层循环对应数组右边的 subscript。所以对于二维数组的遍历,我们是在外层循环遍历行(以数组为单位的元素),内层循环则遍历该数组的内部元素。比如:

constexpr size_t rowCnt = 3, colCnt = 4;
int ia[rowCont][colCnt];
//for each row
for (size_t i = 0; i < rowCnt; ++i) {
	//for each elements (column)
	for (size_t j = 0; j < colCnt; ++j) {
		ia[i][j] = i * colCnt + j;
	}
}

使用 range for 访问

C++ 11 允许使用 range for 访问多维数组:

size_t cnt = 0;
for (auto &row : ia) {
	for (auto &col : row) {
		col = cnt;
		++cnt;
	}
}
该循环里使用了引用,因为需要修改元素的值。然而使用引用还有另外一层原因。下面的例子:
for (const auto &row : ia) 
	for (auto col : row)
		cout << col << endl;
这个循环只有读操作,可是外层的循环变量还是加上了引用。之所以这么做是为了防止数组到指针的转化。row 是外层的数组,如果不使用引用定义的话,编译器会自动将 row 的类型从数组转化为指向 row 第一个元素的指针。很显然,内层循环无法去遍历一个指针。

因此可以得出结论:使用 range for 访问多维数组,除开最内层的循环,所有外层循环的控制变量都必须是引用。

指针与多维数组

首先需要再次明确的是,当使用数组名的时候,数组会自动的转换成指向第一个元素的指针。因为多维数组是真正的包含数组的数组,因此该指针指向的对象实际上是第一个内层数组

int ia[3][4];
int (*p)[4] = ia;// p is pointer points to an array with 4 ints
p = &ia[2]; //p points to the 3rd sub array in ia
需要注意的是,上面代码中定义指针的括号是不能省的:
int *p[4]; // array with 4 pointers that point to int
int (*p)[4]; // a pointer points to an array with 4 ints
C++11 中可以通过使用 auto 或者 decltype 来避免在数组前加上指针的类型:
for (auto p = ia; p != i  * a + 3; ++p) {
	for (auto q = *p; q != *p + 4; ++q)
		cout << *q << " ";
	cout << endl;
}
这个循环里使用了 auto,利用编译器会将数组名转换成指向该数组第一位指针的特性来循环。外层直接使用 p = ia 来获取外层数组的指针,通过位移该指针完成外层循环。内层需要多做一步:

  1. 首先需要获取内层数组。p 作为外层指针,指向的元素就是内层数组。因此,对 p 解引用则可获取内层数组。
  2. 再次进行数组名到指针的转换。此处定义了 qauto 判断 q 是指向内层数组第一个元素的指针类型
  3. 再通过位移 q 完成对内层的循环。

当然,使用标准库函数 begin()end() 会更加简单:

for (auto p = begin(ia); p != end(ia); ++p) {
	for (auto q = begin(*p); q != end(*p); q++){
		cout << *q << " ";
	}
	cout << endl;
}

使用 type alias 简化上述过程

另一种方式是使用 type alias 简化数组的类型:

typedef int(*p)[4] int_arr;
using int_arr = int[4];
for (int_arr p = ia; p != ia +3; ++p) {
	for (int_arr q = *p; q != *p + 4; ++q) {
		cout << *q << endl;
	}
}