颜林林的个人网站

现代C++学习笔记(3):指针与数组

2022-01-19 00:00

Image (题图来自网络并做适当修改)

指针是C/C++的著名难点,因其总伴随崩溃级“灵异事件”,而劝退了无数人。但在现代C++中,大可不必忧心于此,因为新标准和支持库,已经让书写安全代码变得无比简单直接。遵循一定的规范和习惯,是可以避免绝大多数问题的。当然,为了运用自如,揭开这些防护包装,了解一些基本原理,知其所以然,还是有必要的。

1

之所以把指针与数组放到一起来说,是因为它俩经常是同一东西的两面。为了理解更清晰,在谈指针前,不得不回顾下关于计算机内存的一点点基础知识。

内存的基本单位是字节,一个字节通常由八个二进制位组成。每个字节通过0-1组合,可以表示 2^8 = 256 种状态。如果把这些状态用于无符号整数,则表示 [0, 255] 的范围。如果把最高位当做正负号,所表示的则是有符号整数,即 [-128, 127] 的范围。这就是 C++语言中的 unsigned char 与 signed char 类型。在上一篇中,我们还提到了其他占用不同大小内存块的整数类型(short、long、long long等),其实也就对应了不同的整数表示范围。

为了对内存进行数据存取,就需要将整个内存块进行编号,通常使用无符号整数的方式来表示,这种唯一编号每个内存字节的整数,就称为内存地址。所以,内存地址本身占据的大小(即包含多少个二进制位),就决定了其所表示的整数范围,也就决定了它能使用的内存上限。该内存地址大小,也就是指针变量所占内存的大小。想要查看自己编写的C++代码,其采用的内存地址大小,可用如下代码:

std::cout « sizeof(void*) « std::endl; 2

C++的基础语法基本都是从C语言继承下来的。除了前面提及的整数类型与浮点数类型外,通常我们还经常用到的数据类型是字符串。在C语言的内建类型中,其实是没有真正的字符串类型的。C语言可以定义字符串,例如:

const char* msg = “Hello, world!”; 然而,这只是告知了编译器,需要在程序的数据段中预留出一块连续空间,存入这段文字而已。然后,这块内存的起始地址,赋予了msg这个字符型指针变量。

上述代码还可以写成数组的形式:

const char msg[] = “Hello, world!”; 这两种写法,以及后续对它们的使用,看起来几乎是一样的效果。不过,使用 sizeof() 获取其长度时,能窥见细微的差别:

void foo() { const char* s = “Hello, world!”; const char t[] = “Hello, world!”; std::cout « sizeof(s) « std::endl; // 4 or 8 std::cout « sizeof(t) « std::endl; // 14 } 上面的代码中,s 表示的仅仅是一个指针,其存放的内容是该字符串的起始地址,而 t 表示的,则是整个数组,即包含了14个字节(含末尾表示字符串结束的 ‘\0’ )的整个内存块。

3

上述细微差别的重要性,在于我们将其用于函数参数,以及由此在遭遇模板型别推导时,会变得很关键。看如下代码:

template void f(T param); 若将上面的字符指针(s)或字符数组(t)作为参数传给该函数,将会分别得到如下扩展:

void f(const char* param); void f(const char param[]); 这两种扩展是完全等效的,其实会合并成一种展开形式,并相应生成最终的编译可执行代码。

但如果模板的定义是引用类型,则一切就会变得不一样:

template void f(T& param);

f(s); // 对于指针,扩展得到的T类型是const char* f(t); // 对于引用,扩展得到的T类型是const char(&)[14] 在后一种形式中,数组元素个数也被纳入类型的定义,参与到型别推导中。这就意味着,如果你调用了几次该模板函数,且分别传入了不同大小的数组,那么编译器会“智能”地为每个不同的元素个数,都生成对应版本的函数代码。是的,显然它“膨胀”了。

C语言中没有引用类型,非原生类型只能通过指针传入函数。而在C++语言中,引用起到了类似指针的作用,以一种更便利的语法形式,且不用担心指针可能为空而引发灾难。引用提供了无限的可能性,你可以认为它的确就是把整个变量(上面的例子就是整个数组,即那14个字节的内存块)传递到了函数中。

大多数情况下,上述知识都更像是没有实际用途的“屠龙之技”。但这种对语言的深度理解,正是熟练掌握C++这门语言,成为这个圈子中的高手的关键(此处强烈推荐《深度探索C++对象模型》一书,该书揭示了关于class背后的众多隐藏特性,那些编译器会自动帮你完成的工作,在C语言中你通常需要自己来实现它们)。正如Scott Meyers在书中说的,“了解到这个程度,会让你在面对极少数较真的人时,挣得好大一个面子”。

4

关于C/C++的原生数组,通常其元素个数(即数组长度)是个常数:

int a[] = { 1, 2, 3, 4 }; // sizeof(a) = 4 * sizeof(int) int b[5] = { 1, 2, 3, 4 }; // b[4] 会自动填充为 0 int c[3] = { 1, 2, 3, 4 }; // 编译会发生错误或警告,最后一个元素会被截掉 int n = 4; int d[n]; // 编译会发生错误,因为不能用变量来定义数组长度 为了使模板推断得到的数字,可以用于诸如数组长度定义等场合,C++新标准中引入了 constexpr 关键字。其用法可参见如下代码:

template<typename T, std::size_t N> constexpr std::size_t arraySize(T (&)[N]) noexcept { return N; }

int keyVals[] = { 1, 3, 7, 9, 11, 22, 35 }; int mappedVals[arraySize(keyVals)]; // 通过前一个数组长度,定义一个新数组 上述 arraySize 函数,会在编译过程中被完全展开,而不会出现在最终的编译结果文件中。这就是所谓的在“编译期”进行编程,这是成为C++高手所要掌握的重要技能。

在C++标准库STL中,为了让原生数组表现得与其他容器具有类似的用法,提供了一个 std::array 类型,也是值得推荐的:

#include void foo() { std::array<int, 3> a = {1,2,3}; // 相当于 int a[3] = {1,2,3}; std::cout « a.size() « std::endl; // 3 std::cout « a[1] « std::endl; // 2 } 5

指针之所以成为难点,在于它太过强大,以至于经常被超出正常范围地被误用,导致越界访问,而出现难以追查的崩溃性错误。

在C++中,为了规避此类问题,做了大量的封装,让安全的代码能被更容易且更直接地写出。数组通常可以使用 array 或 vector 等容器来替代,而指针也有各类智能指针的解决方案,最好用的当属 unique_ptr 或 shared_ptr 。学会它们,基本上就可以应付绝大多数日常情况了。关于这些内容,我们在后面的章节学习中再来详细介绍。

--- END ---

注:本文首发表于“不靠谱颜论”公众号,并同步至本站。

相关文章