C++ Primer CH3 字符串、向量和数组

C++ 除了定义了各种内置类型,还定义了很多实用抽象数据类型以及与之相关的类型如:迭代器(iterator)和大小(size_type)。本章将描述 string 和 vector 类型。string 支持可变长度字符串,vector 支持可变长度的集合。两者都定义了迭代器类型来遍历其中的元素,这个功能类似于 C++ 内置的指针类型。string 本身则是对 C 风格字符串的简化和模拟,vector 则是对数组的简化和抽象。

内置类型是对硬件的较低抽象,当需要操作底层时很方便使用。而对于大型项目来说就显得抽象程度太低了,因而,C++ 的标准库定义了很多实用的更高抽象的数据类型来提高大型项目的开发效率。

C++ 在设计语言的时候就致力于让类类型与内置类型一样易于使用。因而,定义了其它语言中没有的操作符重载、拷贝构造函数,以及在栈上初始化对象(事实上,C++ 是主流语言中唯一可以在栈上进行初始化对象的语言)。

本章还将介绍数组,特别是数组与指针之间的关系,这一部分将使用的《C 语言编程》中的内容。数组亦是对硬件的较低抽象,就其灵活性而言将无法媲美 vector 类型。

3.1 名称空间的 using 声明

using 声明的格式 using namespace::name; 使得在程序中不再需要名称空间前缀就可以直接访问名字。不要在程序中滥用此特性,会引起名字冲突。即便是使用了 using 声明,也可以用全限定的方式引用名字,确保使用是我们想要的名字。必须为每个希望直接使用的名字加上 using 声明,并且用分号结尾。并且不能在头文件中加入 using 声明,这将会导致所有包含的源文件中都有此 using 声明,冲突的危险将加大。

3.2 string 类型

string 表示可变长度的字符串。string 类型包含在 <string> 头文件中。std::string 类型被标准库的实现者实现的性能足够好,因而可以适用于绝大多数场景。我们应该记住很重要的一点:相对于内置类型并不总是初始化,所有的类类型都会调用类自己构造函数进行初始化。一个类会定义多个构造函数来执行初始化。以下是最基本的 string 的构造函数:

  • string s1 默认初始化,s1 是空字符串;
  • string s2(s1) 将 s2 初始化为 s1 的副本;
  • string s2 = s1s2(s1) 一样;
  • string s3("value") s3 是字符串字面量,不包括末尾的 \0 字符;
  • string s3 = "value"s3("value") 相同;
  • string s4(n, 'c') 将 s4 初始化为 n 个字符 c ;

在上面的构造函数中有两种不同的形式:(copy initialize)是以等号形式将右边的初始值复制到左边的对象中去。这里 "value" 并不是 string 类型,这种方式其实是构造了一个 string 临时对象并以此临时对象代替了 s3,如果考察 string str = string("value") 就会更清楚,因为这个语句跟 s3 = "value" 的调用过程是一模一样的。其实这里边的复制过程被优化掉了,而 s2 = s1 则执行了复制构造函数,其原因在于 s1 的内存已经被分配个了另一个对象,不能直接替换,相反临时对象可以替换。还有一种初始化形式即直接初始化(direct initialization)。

当只有一个初始值时使用直接初始化和拷贝初始化的效果是一样的。当有多个参数时只能使用直接初始化,或者用 string s8 = string(10, 'c'); 的形式先创建一个临时对象再执行拷贝初始化,这里同样是直接将临时对象替换过去的,因为临时对象在声明语句后消失,因而可以安全替换。

3.2.2 string 可执行的操作

类除了定义了如何创建和初始化之外还定义了可以执行哪些操作。C++ 中的类既可以定义按名字调用的成员函数,也可以对操作符进行重载,事实上在 C++ 中操作符是一种特殊的函数。Lua 中提供了元表以及元方法跟 C++ 的操作符重载非常类似。如以下列出最常用的几个 string 类方法:

  • os << s 将字符串 s 写入到输出流 os 中,返回 os ;
  • is >> a 从输入流 is 中读取以空白符分割字符串到 s 中,前导空白也将略过,返回 is ;
  • getline(is, s) 从输入流 is 中读取一行到 s 中,读取的行将排除掉换行符,返回 is ;
  • s.empty() 判断 s 是否为空串;
  • s.size() 返回字符串 s 的长度;
  • s[n] [] 操作符重载,返回位置 n 的字符引用,索引从 0 开始;
  • s1 + s2 + 操作符重载,返回 s1 和 s2 拼接后的字符串,这重载不改变 s1 和 s2 而是生成一个新的字符串;
  • s1 = s2 按照字面意思将 s2 拷贝到 s1 中,将调用 string 的拷贝构造函数;
  • s1 == s2 == 操作符重载,比较字符串 s1 和 s2 是否完全一致;
  • s1 != s2 != 操作符重载,当字符串 s1 与 s2 不一致时返回 true ;
  • <,<=,>,>= 都是操作符重载,按照字典顺序对字符串进行比较;

<< >> getline 方法进行读取时,可以检查返回的流状态来判断是否成功,当输入流遇到 eof 或者非法输入时将使条件判断失败。

需要注意的是 size 函数返回的类型是 string::size_type 而不是 int,此类型定义在 string 内部作为其补充类型,补充类型使得标准库在不同机器上可以进行不同的实现,从而满足可移植的目的。string::size_type 被定义为一种无符号类型并且足够长用以容纳任何字符串的长度。在 C++ 中应该尽可能使用此类型,为了避免繁杂的拼写,可以用 auto 或者 decltype 关键字。

注意不要将 string::size_type 与 int 混用,因为负数会被转化为非常大的无符号值。

string 的比较策略是依次比较每一个字符,直到遇到不一样的字符,或者其中一个字符串结束,比较结果是不一致字符在字母表中位置较后的字符串较大。长短比较可以实现为 \0 与字符比较,而 \0 小于任何字符,因而,短字符串肯定更小。

将字面量与 string 字符串相加

注意在 + 操作符重载中,函数定义希望接收的参数是 string 类型,但是可以传递字符串字面量。当 C++ 需要一种类型,而另外一种类型可以转换为此类型时,可以将那一种类型的值传入。string 类允许将字符字面量和字符串字面量转为 string 对象。因而,我们可以混用 string 和字面量于 + 操作符重载函数中。但是不能完全使用字面量,原因在于完全使用字面量将会调用字符数组的加法运算,而数组不支持此运算,将无法通过编译。

string 类的这种转换事实上是由对应接收单个参数的构造函数定义的,C++ 语言会隐式调用此构造函数来执行转换。

必须说明的是由于 C++ 必须兼容 C 的原因,字符串字面量并不是 string 类型,而是以空字符 \0 结尾的字符数组。但进行混用时应当有所理解。

3.2.3 处理 string 中的字符

cctype 头文件中定义与字符相关的函数如:isalnum、isalpha、iscntrl、isdigit、ispunct 以及 tolower、toupper 。

C++ 除了使用其自己定义的标准库外,还会使用 C 的标准库,C++ 版本的 C 标准库头文件名字被替换为 c 开头,然后去掉 .h 后缀,如:ctype.h 在 C++ 中即为 cctype 。这样做的好处在于新的头文件中所有函数都在 std 名称空间中(虽然不使用 std:: 限定也是可以的),推荐的做法是使用新的头文件。

为了处理 string 中的字符有三种方法:范围 for(range for)语句、迭代器以及下标运算符。

范围 for 语句是 C++11 标准中引入的,形如:

for (declaration : expression)
    statement

其中 declaration 跟普通的声明一样,可以使用 auto 或者 decltype ,如果希望改变 expression 所表示对象中值或者避免拷贝,将声明中的变量指定为引用。范围 for 会顺序遍历中的 expression 中的元素,并值拷贝到控制变量中。

下标运算 [] 返回的是元素的引用,C++ 中的所有下标都是从 0 开始。如果下标超出了范围得到的结果是未定义的,因而对空字符串进行下标操作是未定义的。这种数组越界的错误在 C++ 中是不检查的,程序员必须自己保证下标不会越界。值得注意的是 string 的下标操作要求索引必须是 string::size_type 类型的,也就是无符号类型的,所以并不支持负数的索引,这与数组的索引允许负数是不一致的。string 的下标运算允许随机访问。

3.3 vector 类型

vector 是一种类似与数组的容器,容器中的对象都有相同的类型,并且有唯一的索引与之对应。所谓容器就是用来包含其它对象的对象。同时,vector 是一个类模板(class template),C++ 中同时有类和函数模板。模板本身不是函数或者类,但是当提供实例化参数时,编译器会帮助生成新的函数或者类,这个过程叫做实例化(instantiation)。实例化参数包含在模板名称后的尖括号中。如:vector<int>vector<vector<string>> ,可以为 vector 实例化为包含绝大多数类型,甚至元素可以是 vector ,但不能实例化引用的 vector 。

值得说明的是早期的模板实例化语法不允许两个相邻的 >,因为会被解释为右移操作,如:vector<vector<string> > 而不是上面的写法。

3.3.1 定义和初始化 vector

  • vector<T> v1 默认初始化 v1 为包含 T 类型变量的空向量;
  • vector<T> v2(v1) v2 拥有 v1 中每个元素的拷贝;
  • vector<T> v2 = v1 同上,拷贝初始化形式;
  • vector<T> v3(n, val) 将 v3 初始化包含 n 个 val 值;
  • vector<T> v4(n) v4 初始化为包含 n 个值初始化 T 类型对象;
  • vector<T> v5{a,b,c,d} 使用列初始化将 v5 初始化为包含全部初始列表中的值;
  • vector<T> v5={a,b,c,d} 与上面的初始化形式一致;
  • vector<T> v6(begin(T_arr), end(T_arr)) 将 v6 初始化为由两个迭代器指定的范围,范围内的元素拷贝到 v6 中;

一一解释下,空的 vector 看起来没什么用,可其实是最常用的方式。惯用的方式是先定义一个空的 vector,然后在运行时不断添加元素。添加元素本身是一个高效的操作。

当只提供个数而不提供初始值,vector 会对元素进行值初始化(value-initialized),值初始化对于类类型是调用类的默认构造函数,对于内置类型则初始化为 0 。值初始化保证一定有值,而默认初始化(default initialized)对于类类型将调用类的默认构造函数,对于内置类型则在函数外初始化为 0 ,在函数内作为自动变量则是未定义值。

值初始化和默认初始化的一个有意思的例子是,在函数内定义数组,如果不进行初始化则为默认初始化,其中的所有元素都是未定义值,而如果提供少量的初始值,其它元素则执行值初始化为 0 。如:

int arr1[10];
int arr2[10] = {1};

打印以上数组就会发现不同。

如果类类型的元素没有默认构造函数,则无法定义只包含个数的 vector 。

对于无法执行列初始化时,会转而直接调用构造函数。如:

vector<string> v7{10}; //v7 有 10 个元素,每个元素执行值初始化
vector<string> v8{10, "hi"}; //v8 有 10 个元素,每个元素都是 hi 字符串

为了达到列初始化,必须使得大括号中的值的类型与元素的类型匹配。

3.3.2 添加元素到 vector 中

预先给 vector 分配个数是低效的,而且 vector 的应用场景就是在不确定有多少元素时使用。事实上,对 vector 进行添加元素是非常高效的。vector 使用 push_back 函数进行添加元素。通常,在 C++ 中不会预先给 vector 分配个数。

将一个元素添加到 vector 中,事实上是将其拷贝一份然后添加到 vector 中。值得注意是在范围 for 中不能改变 vector 的元素个数。

3.3.3 vector 的其它操作

vector 中包含另外一些类似于 string 类的成员函数。如:

  • v.empty() 判断向量是否为空向量;
  • v.size() 返回向量的个数;
  • v.push_back(t) 在向量的尾部增加元素 t 的副本;
  • v[n] 下标操作将得到向量中的索引为 n 的元素引用,索引从 0 开始;
  • v1 = v2 将 v2 中的元素拷贝到 v1 中并替换掉 v1 中的旧值;
  • v1 = {a,b,c,d} 将大括号中的值替换掉 v1 中的旧值;
  • v1 == v2 判断 v1 与 v2 中的元素个数和值完全一致;
  • v1 != v2 判断 v1 与 v2 中的元素不完全一致;
  • <,<=,>,>= 重载后的关系操作符,执行字典比较,下面会解释;

size 函数返回的类型需要包含元素类型,如:vector<int>::size_type 。字典比较的意思是依次比较向量中的每个元素,当遇到第一个不一致的元素时,较大的元素的向量较大,如果所有元素都是一样,则根据长度判断,较长的向量较大。只有当可以比较向量的元素值时可以比较向量。

vector 和 string 的下标运算一样,索引是 size_type 类型是一个无符号整数,当索引超出范围时,其行为是未定义的。这种越界是非常常见而且难以定位的问题,称之为缓冲溢出(buffer overflow),但是 C++ 并不试图阻止程序员这样做,甚至在标准库容器中也不阻止,string 和 vector 的下标操作同样会产生未定义行为而不是抛出异常。

3.4 介绍迭代器

在 C++ 中更常用的访问容器中的元素方式是通过迭代器(iterator),由于有些容器事实上是根本无法通过下标来访问的,这种容器是不可随机访问的。所有容器都可以通过迭代器来访问元素。虽然 string 不是容器,但是 string 支持很多容器的操作,包括迭代器和下标访问。迭代器是对语言中的指针的一种抽象和模拟,用来间接访问元素。与指针一样,通过迭代器可以从一个元素移动到另一个元素,可以通过解引用返回元素的引用,可以通过箭头符调用其成员函数。迭代器也有非法迭代器(invalid iterator),只有确实指向元素或者指向尾元素的下一个位置才是合法迭代器,其它所有迭代器都是非法的。

3.4.1 使用迭代器

使用成员函数 begin 和 end 分别返回指向头元素的迭代器和指向尾元素的下一个位置(a position one past the end)的迭代器。尾元素的下一个位置是一个不存在的元素称为尾后(off-the-end)作为表示已经处理了所有元素的标记。当迭代器一直递增直到与尾后迭代器相等时应该停止迭代。空容器的 begin 和 end 返回相等的迭代器。

迭代器只支持比较少的操作,只有支持随机访问的容器支持其迭代器与整数的加减法来用快速步进迭代器。以下是所有迭代器都支持的操作:

  • *iter 解引用迭代器返回所指向的元素的引用,不能对尾后迭代器进行解引用;
  • iter->mem 直接调用迭代器所指向元素的成员函数 mem,与 (*iter).mem 完全一致;
  • ++iter 自增 iter 使其指向下一个元素,不应该对尾后迭代器进行自增操作;
  • --iter 自减 iter 使其指向前一个元素;
  • iter1 == iter2 判断两个迭代器是否相等,只有指向同一个容器中的同一个元素(尾后元素也是一个元素)的迭代器才会相等;
  • iter1 != iter2 判断两个迭代器不相等;

与指针一样解引用一个非法迭代器和尾后迭代器的行为是未定义的。在 C++ 中常用的比较迭代器的方式是 != 而不是 < ,其原因在于很多迭代器根本没有 < 操作,为了支持所有迭代器,通用的做法是使用 != 来比较。如:

for (auto it = s.begin(); it != s.end() && !isspace(*it); ++it)
    *it = toupper(*it);

迭代器类型

通常我们不需要知道迭代器的确切类型。标准库为每个容器类定义两个迭代器类型:iteratorconst_iterator 。所有的 const 对象返回的的迭代器都是 const_iterator,const 迭代器和 const 指针一样不能用于改变所指向的元素值。非 const 对象可以通过 cbegin 和 cend 成员函数返回 const 迭代器。

值得一提的是迭代器是一组概念相关的类型,它们都支持一类操作,并且行为是类似的。同样容器也是一组相关的类型。

记住:任何改变 vector 长度的操作(如 push_back)会使得之前返回的迭代器失效。所以,不要在使用迭代器的循环中改变 vector 的长度。

迭代器算术运算

只有少部分容器支持迭代器的算术运算,支持算术运算的容器通常是可随机访问的容器,意思是可以在任何时刻访问容器中的任意元素,而其它容器则必须一个一个向前移动访问。这种区别就跟数组与链表的区别。迭代器算术运算(iterator arithmetic)指的是对迭代器加减整数返回的是一个跨越若干元素的另外一个迭代器,加则向后移动,减则向前移动。两个迭代器相减返回两者之间的距离。这样的迭代器通常还支持关系运算,可以比较大小,而其它的迭代器则不可以。以下表格:

  • iter + n iter - n 加减运算将迭代器向前或向后移动 n 个位置,得到的迭代器需要程序员自己确保是合法的;
  • iter += n iter -= n 支持复合运算;
  • iter1 - iter2 产生两个迭代器间的距离,两个迭代器必须指向同一个容器,否则结果是未定义的。返回结果加上右操作数返回左操作数;
  • >,>=,<,<= 关系运算,只有当两个迭代器都指向同一个容器时才有意义,否则结果是未定义的。当一个迭代器在另一个之前时,我们说此迭代器较小。

iter1 - iter2 的结果类型是各自容器的 difference_type,如:vector<int> difference_type,这是一个有符号整数。

3.5 数组

数组(array)由语言定义,用于容纳一系列紧靠的无名对象,数组的大小是不可变的。通常使用数组是由于其优于 vector 的运行性能。建议除非有充分的理由使用数组,尽可能是在任何场景使用 vector 。

数组是第三种复合类型,而且数组的维度是其类型的一部分,意味着 int[3]int[10] 不是同一种类型。因而,C++ 要求数组的维度在编译时就得确定,因而,数组的维度必须是常量表达式。但需要了解的是某些编译器是允许定义可变长度数组(VLA),这是编译器自己的扩展行为并不属于语言标准。如:

constexpr unsigned sz = 42;
int *parr[sz];

在不给定初始值时,数组是默认初始化(default initialized)的,上面讲过与值初始化的区别。内置类型数组如果定义在函数中,其默认初始化是未定义值。数组的定义是不允许使用 auto 关键字对其元素类型进行推断,也没有元素为引用的数组。

显式初始化数组元素

数组可以用列初始化,这跟所有的容器类的列初始化是一样的。如果进行列初始化时不提供维度,则编译器从初始值列表中推断。如果指定了维度,则初始列表的个数一定不能超过维度,否则将是编译错误。如果初始值列表长度不足维度数,则剩余的元素将执行值初始化,对于内置类型来说就是都初始化为 0 。如:

int a3[5] = {0,1,2}; //相当于{0,1,2,0,0}
string a4[3] = {"hi","bye"};//相当于{"hi","bye",""}

需要特别指出的是 C++ 继承了 C 的字符串字面量,它们是以空字符 \0 结尾的字符数组。因而,当用字符串字面量初始化数组时,实际的维度比看到的字符数多 1,这个字符串包括结尾空字符都复制到字符数组中去。如:

char a1[] = "C++"; //相当于{'C','+','+','\0'}

不能将数组初始化为另外一个数组,也不能将数组赋值给另外一个数组。

理解复杂数组声明

需要理解以下一些复杂的数组声明:

int *ptrs[10]; //ptrs is an array of ten pointers to int
int (*Parray)[10]; //Parray points to an array of ten ints
int (&arrRef)[10]; //arrRef refers to an array of ten ints

3.5.2 访问数组元素

通过范围 for 和下标操作可以访问数组的元素,索引是从 0 开始的。下标值通常被定义为 size_t 类型,size_t 是机器相关的,被保证足够容纳所有类型的对象内存大小。但是数组的索引还可以用 int 类型并被指定为负数,表示从数组的某个位置向前 n 个位置。新标准建议现在的程序最好使用范围 for 来遍历整个数组。

记住一点:数组的类型包含了其维度。这在指针、范围 for 和引用中都会用到此特性。

重申一下,访问数组的数组越界行为同样是未定义行为,所谓未定义行为就是即便出错了,编译器也不协助检查,一切全靠程序员自己检查。而且,在运行时未定义行为可能会正确,可能会在很久之后引起系统崩溃,但绝不会抛出异常。而这正是 C/C++ 这两门语言难学之处。缓冲溢出几乎是所有重要的程序中最严重的问题。

3.5.3 指针和数组

指针和数组在很多时候可以相互替换使用,原因在于数组名其实是数组首元素的指针。但是,它们之间还是存在一些细微而且易错的却别。如:

对数组进行 sizeof 操作得到是整个数组的所占的内存的大小,而对指针的 sizeof 操作得到的是指针所占内存的大小。

int arr[] = {10,20,30,40,50,60};
int *ptr = arr;
cout << sizeof(arr) << endl;//24
cout << sizeof(ptr) << endl;//8

语言不允许对数组进行直接赋值,但是指针可以,对指针赋值使得指针指向别的位置。

int x = 10;
arr = &x; //错误!!!
ptr = &x; //指向 x

对指针进行取地址得到是指针的指针,对数组进行取地址得到是包含数组维度的数组指针。

int ** pptr = &ptr;
int (*parray)[6] = &arr;

用数组初始化的字符串常量可以改变其元素,用指针初始化的字符串常量改变其元素将是未定义行为,原因在于前者拷贝了字符串常量,而后者指向的是只读存储字符串常量的只读内存位置(称为字符串常量表)。

char amessage[] = "now is the time";
char *pmessage = "now is the time";

除此之外指针和数组就可以完全替换使用,特别是数组名可以赋值给指针变量,指向数组元素的指针可以用下标访问别的元素。通常,编译器会将数组转为一个指向首元素的指针。如:

int ia[] = {0,1,2,3,4,5,6,7,8,9};
auto ia2(ia); //int*

然而, decltype(ia) 返回的是数组 int[10],这是例外的地方。

指针是语言定义的迭代器

事实上迭代器是对指针的模拟和抽象。指针支持自增、自减和算术运算。如第二章所说,指向数组元素的指针中有一个特殊指针即指向数组尾元素下一个位置(one past the last element)的指针,这个指针叫尾后(off-the-end)指针。通过将索引指定为数组长度得到的就是尾后指针,如:

int *e = &ia[10]; //ia 是长度为 10 的 int 数组

上面 ia[10] 是一个不存在的元素,对其唯一允许的操作就是取地址,除此之外的任何操作都是未定义的。尾后指针不能解引用,向后移动亦是非法的。

标准库 begin 和 end 函数

新标准中在 <iterator> 头文件中定义了 begin 和 end 函数用于返回数组的头指针和尾后指针,行为与容器的同名函数一样。这两个方法以数组为参数。这样就将迭代器和指针统一了,范围 for 以及泛型方法就是利用了这个特点得以以统一的方式对它们进行操作。

指针算术运算

毫无疑问数组是支持随机访问的,程序员可以在任何时候访问数组中的任意位置上的元素。指针支持与整数的加减法,确保结果指针依然指向数组中的元素的工作交给了程序员完成。指向相同数组的指针间的减法将得到两者之间的距离,结果类型是 ptrdiff_t ,此类型是机器相关的有符号整数,并且保证容纳任何地址差。同样指针支持关系运算符,然而将其运用于两个不相关的对象指针上结果是未定义的。

指针和下标操作

对数组进行下标操作和对指针进行下标操作的效果是等同的,意味着可以在对指针进行下标操作。如以下方式都是等同的:

int i = ia[2];
int *p = ia;
i = *(p+2);
i = p[2];

甚至可以像如下代码这样做,将索引指定为负数,只要取出的元素确实存在于数组中。如:

int *p = &ia[2];
int j = p[-1];
int k = p[-2];

vector 和 string 的下标要求一定是无符号整数,而数组的下标可以是负数。这是它们之间的重大区别。

3.5.4 C 风格字符串

C 风格字符串并不是一种新的类型而是保存在字符数组中并且以空字符结束。通常是使用指针来操作这种形式的字符串。C 风格字符串函数定义在 <cstring> 头文件中。strlen 获取字符串长度,当遇到空字符时停止计数。strcmp(p1,p2) 比较字符串 p1 和 p2,相等时返回 0,strcat(p1,p2) 则是将 p2 拼接到 p1 上,需要程序员保证 p1 的内存足够容纳下这些字符,否则行为将是未定义的。strcpy(p1, p2) 将字符串 p2 复制到 p1 所在内存位置,亦需程序员保证内存足够。

以上函数中的参数必须是以空字符结尾的字符数组的首元素指针。否则行为是未定义的。使用 string 类型时可以直接用关系操作符进行比较,而 C 风格字符串却是必须调用函数进行比较,如果用关系操作符比较的则是指针的值。如:

const char ca1[] = "A string example";
const char ca2[] = "A different string";
if (ca1 < ca2) //错误!!!

strcat 和 strcpy 都需要程序员保证内存不会溢出,如果使用 string 则没有这样的担心,这正是应该使用 string 的原因所在:更加安全而且更加高效。缓冲溢出已经成为了 C 语言中最严重的安全问题。

3.5.5 提供给旧代码的接口

在 C++ 标准订立前 C++ 语言就已经横空出世了,那时很多程序根本没有 string 类可用,并且很多时候 C++ 程序必须给 C 程序提供接口,因而需要将 string 字符串转为 C 风格字符串。C++ 语言中 C 风格字符串可以自动转为 string 类型,但是并没有相反的转换,string 提供了 c_str 成员函数用来返回其内容的 C 风格字符串。返回结果是 const char* 类型。并且,不保证这个 C 风格字符串一直是有效的,当原始 string 改变时,此字符串就很可能会失效。所以要求使用者每次都调用 c_str 函数。

现代 C++ 程序更加推荐使用更为抽象的 string vector iterator ,好处在于更加安全且方便。

3.6 多维数组

多维数组的用途比较受限,因而较少使用。事实上,多维数组的所有元素都是顺序排列在一起的,所以用相同长度的一维数组构建是一样的。唯一的区别在于类型上的不一样:多维数组的元素是指定维度的数组。多维数组因而又称为数组的数组。

定义多维数组是一件很简单的事,多加一个中括号维度即是。如:int ia[3][4] int arr[10][20][30]。这里第一个数组的元素是 int[4] 类型,第二个数组是 int[20][30] 类型。ia 可容纳 12 个 int 值,arr 可容纳 600 个 int 值。

多维数组的初始化很有意思,根据前面的描述:所有元素都是依次排列成一线。所以内部的 {} 可以消除。如:

int ia[3][4] = {
    {0,1,2,3},
    {4,5,6,7},
    {8,9,10,11}
};
int ia[3][4] = {0,1,2,3,4,5,6,7,8,9,10,11}; //两者是完全一致的

如果不提供全所有值的话,每个内嵌的初始列表将初始化那一列,未给出的值均为 0 。

int ia[3][4] = {{0}, {4}, {8}}; //每一行后面的 3 个元素初始化为 0

而不给出内嵌大括号,将只初始化整个数组的前几个元素。如:

int ia[3][4] = {0,3,6,9}; //后面的元素都是 0

多维数组的下标引用

多维数组进行下标引用将得到多种不同类型的元素,具体看给出了多少个下标值。如:

int arr[10][20][30];
arr[0][0][0]; //int
arr[1][3]; //int[30]
arr[2]; //int[20][30]

这同样会影响到指针的类型,指向数组的指针会带上数组的维度。如:

int (*ptrarr)[20][30] = arr;
int (*ptr2)[30] = arr[0]; //或者 = *arr,但是下标形式更加易于理解
int *ptr3 = arr[0][0]; // or = **arr

如果将多维数组运用于范围 for,外部循环中的控制变量必须使用引用形式,否则得到的将是指针而不是数组,而指针是不能遍历的。如:

for (auto &row : ia)
    for (auto &col : row)
        //do something

关键术语

  • 缓冲溢出(buffer overflow):一种严重的程序 bug ,由容器或数组的越界导致的,在 C++ 最值得注意的问题之一;
  • 类模板(class template):关于怎样由编译器生成指定类型的蓝图,定义了通用的函数和数据,只要填入对应模板参数即可;
  • 编译器扩展(compiler extension):特定编译器支持的特性,通常是语言标准的超集;
  • 容器(container):用于容纳其它对象的对象,现代语言的基石;
  • 拷贝初始化(copy initialization):= 形式的初始化,将给定的初始值拷贝到即将创建的对象中去;
  • 直接初始化(direct initialization):直接调用类的构造函数的初始化;
  • 实例化(instantiation):从模板中产生一个特定的类和函数的编译器行为;
  • 尾后迭代器(off-the-end iterator):指向容器尾元素的下一个位置的迭代器;
  • 范围 for(range for):有编译器控制的用于迭代容器或数组的特殊 for;
  • 值初始化(value initialization):一种初始化,内置类型将初始化为 0,类类型将调用默认构造函数进行初始化;

Leave a Reply

Your email address will not be published. Required fields are marked *