文章

二:地址和数组、字符串、字面量及其操作、前缀

二:地址和数组、字符串、字面量及其操作、前缀

前言

C++的数组和字符串要联合内存讲解,毕竟cpp中实际存储的是在地址对应的存储空间上存了点数据,因此数组和指针不能分开而说。 本篇涉及到:

  • 指针的基本知识在C++基本语法中有比较详细说明:*&符号分别是取值和取地址符, int* _name_只是告诉编译器要申请一个指针并且是int型,不要在意这里的*,这和取值符没关系, 既然是申请一个地址,那为什么要告诉编译器类型,取数组值的时候就知道了。
  • 了解vs的断点、DEUBG用法、如何打开内存视图,这在C++基本语法中也有介绍。
  • 改变数据类型方法:(int)变量名

正文

数组

数组本质上是一组数据的集合,毕竟不是什么时候都需要一个一个的声明变量,语法:

1
2
3
4
5
6
7
int arr[5];
for (int i = 0; i < 5; i++)
{
    arr[i] = i * 2;
    std::cout << arr[i] << std::endl;
}
std::cout << arr << std::endl;

声明数组的语法是数据类型 变量名[大小],在这个例子中,变量名是arr,大小是5,接着循环访问每个元素, 并且相应下标位置的元素值是下标值×2,问题来了,for循环中i<5可以改成i<6吗?

是可以的,vs的release模式下运行不会报错,因为数组的本质是在内存地址上存东西,int arr[5]中意思是告诉编译器, 请分配连续5个每个占4个字节(int类型4个字节,也可能两个,这个视系统、编译器而定)的空间。因此arr[5]确实越界了,但对内存来说可不知道啊,只是在连续的5个地址的接下来一个地址存东西罢了。

好,现在进入断点BEBUG模式,在vs中,将int arr[5]打上断点,并且打开vs的内存视图,输入arr回车, 可以查看此时地址上值的情况:

1
2
+		arr	0x00000014c375f6b8 {0xcccccccc, 0xcccccccc, 0xcccccccc, 0xcccccccc, 0xcccccccc}	int[0x00000005]

会发现,内存上连续分配了5个4字节的空间,并且每个字节上十六进制是cc,这是编译器预先填充的值,继续逐过程运行发现:

1
2
3
0x00000014C375F6B8  00 00 00 00 cc cc cc cc cc cc cc cc cc cc cc cc cc c...
0x00000014C375F6B8  00 00 00 00 02 00 00 00 cc cc cc cc cc cc cc cc cc c...
...

依次排列下去的地址上写入了相应的值,这也是前面说的为什么i<6不会报错,因为本质上是在地址上存东西, 但并不推荐这样做,在越界的地址上存东西,可能不经意间改变了程序中其他变量的值。

arr代表数组的地址是什么意思,哪个地址?

std::cout << arr << std::endl;这行语句会打印出地址,即arr变量名本身会打印出数组的地址, 这个数组地址是什么呢?其实是数组第一个元素第一个字节的地址,那为什么*arr会打印出第一个元素连续4个字节的值呢?int不是4个字节吗?可是这个地址仅仅只是第一个字节的地址,这就是为什么要告诉编译器类型了,正是因为写明了类型,编译器才知道,原来*arr要打印连续4个字节的值

可以试试:

1
2
std::cout << arr[i] << std::endl;
std::cout << *(arr+i) << std::endl;

首先注意取值符*后面用括号括起来了。这两行效果是一样的,arr是第一个元素的第一个字节的地址,那+i一个字节的地址+1不是相邻的字节吗?这跟上面的原因是一样的,因为告诉了编译器类型,所以编译器知道, arr这个地址上+1并不是要arr相邻字节的地址,而是要连续4个字节后的那个字节的地址,就是这个意思。

如打印int数组每个元素的地址,每个地址间隔4

1
2
3
4
5
6
7
int arr[] = {10, 11, 12, 13, 14};
cout << arr << endl;
cout << arr + 1 << endl;
cout << arr + 2 << endl;
// 0x7ffc38898810
// 0x7ffc38898814
// 0x7ffc38898818

打印char型数组每个元素的地址,每个地址间隔1

1
2
3
4
5
6
7
char str[] = "hello";
cout << (void*)&str[0] << endl;
cout << (void*)&str[1] << endl;
cout << (void*)&str[2] << endl;
// 0x7ffc553e6470
// 0x7ffc553e6471
// 0x7ffc553e6472

为什么这里打印char数组地址写的这么复杂,这是因为<<符被重载了,输入数组地址(也是第一个元素地址)会输出整个字符串,char数组变量名又代表数组地址,所以在&str[0]后, 还需要改成空类型void *,不然cout << &str[0] << endlcout << str << endl是一样的,这在C++函数-重载中有提及。

自己计算偏移量给数组元素赋值

总结来说,地址上+-的偏移量是需要告诉编译器类型的。,所以可以写出下面这个奇怪的代码:

1
2
3
4
5
int arr[5];
int* p = arr;
// *(p+3) = 3;
*(int*)((char*)p + 3 * 4) = 3;
std::cout << arr[3] << std::endl;

首先*(p+3)前面已经说了,第一个arr被赋值给了p,所以p是第一个元素地址,p+3是第四个元素地址。

下面的*(int*)((char*)p + 3 * 4) = 3;呢?首先这是能正常运行的,前面说了得告诉编译器类型,所以这里分解成看是:

1
2
3
4
(char*)p
(char*)p + 3 * 4
(int*)((char*)p + 3 * 4)
*...

首先将int型的p转成char,这里注意,char是1个字节,所以此时这个p就是刚刚说的第一个字节的地址了, 那可以想到,此时偏移量得自己计算了,加上连续3个4字节的空间,也就是说此时p代表了第13个字节的地址, 因为我们要赋值int类型,所以又把这个地址转成int *型,也就是说接下来的*...赋值操作是赋了第13个字节后 连续4个字节的值,查看内存会发现:

1
0x00000097331AF9D8  cc cc cc cc cc cc cc cc cc cc cc cc 03 00 00 00 cc cc cc cc

这没问题,有趣的是,如果把上面的4改成3呢,即(char*)p + 3 * 3,那显然了,在第10个字节地址上接下来的 连续4个字节赋值,查看内存会发现:

1
0x00000007BA1AFB58  cc cc cc cc cc cc cc cc cc 03 00 00 00 cc cc cc cc c...

可以印证猜想,确实是这样,告诉编译器类型很重要。

new创建数组以及释放内存问题

1
2
3
int arr[5];
int* p = new int[5];
delete[] p;

上述第二种用new关键字创建数组也是可以的,这和第一行不同的是一个在栈上,一个在堆中,栈的跳出作用域会被销毁,堆的生命周期作用在整个程序运行期间,因此需要第三行手动删除分配的内存空间。

这里还值得注意的是:new涉及到间接寻址,这意味着p实际上的值是一个数组地址,所以可想到这样的内存跳跃会影响性能,在栈上创建数组更有效率。

当然,array、vector一些库也可以创建数组。

这其中还有一个问题,new释放内存的时候如何知道该释放连续多少字节的内存,实际上编译器是不知道的,

例如:

1
2
std::cout << sizeof(arr) << std::endl;
std::cout << sizeof(p) << std::endl;

输出分别是20, 8p8也很好理解,1个字节8bit,一共64bit,64位地址, 这意味着无法通过sizeof(arr) / sizeof(int)知道有多少个数据该释放多少内存,所以维护数组的大小是很重要的, 当然通过array等库有直接的size()函数可以知道大小,这也是前面说的间接寻址会影响性能。

字符串和结束标志

字符串也就是一些字符的集合,那问题来了,char型只有一个字节8个比特,怎么只能存256种字符,这怎么存中文其他字符呢?这也是值得了解编译器是如何运作的地方。

1
2
3
char str[] = "hello";
char* p = str;
std::cout << p << std::endl;

了解了上述数组的知识点,存储字符串一样的,首先编译器自动分配了空间存str,查看p的内存会发现:

1
0x00000046372FF574  68 65 6c 6c 6f 00 cc cc c...

其中68等就是hello每个字母对应的ASCII编码,注意后面的00,这是特意加上去的,因为编译器没有那么聪明知道字符串 的结尾在哪,std::cout能正确打出是因为它遇上了00,这是结束的标志,所以这个字符串的长度应该说是6,也很好验证, 将代码修改一下:

1
2
3
char str[5] = { 'h', 'e', 'l', 'l', 'o'};
//char* p = str;
std::cout << str << std::endl;

我们分配了5个空间分别存了对应的字母,没有分配结尾的00,断点查看内存会发现:

1
0x00000021406FFD04  68 65 6c 6c 6f cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc e0 0d fe 53 ab 01 00 0...

6f后面没有跟着00,那cout会输出什么呢?答案是会一直输出直到00结束,所以要手动加上结尾标志:

1
2
3
4
5
char str[6] = { 'h', 'e', 'l', 'l', 'o', '\0'};
//char* p = str;
std::cout << str << std::endl;
// 此时str内存:
// 0x00000008326FFA44  68 65 6c 6c 6f 00 cc c...

string类和«重载

string类是C++提供的字符串库:

1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <string>

int main()
{
	std::string str = "hello";
	std::cout << str.size() << std::endl;
	std::cout << str << std::endl;
	return 0;
}

这里值得注意的是:<<在string库中被重载了,所以能输出string流到终端,在C++基本语法长文中也提到过函数重载和 运算符重载。

string类也提供了很多有用的函数,如例子中的获取大小size(),此外还有查询子串等。

字符串字面量

所谓的字符串字面量意思是说,如果我们把字符串当成数组来编写,是这样的:char str[] = {'x', 'x'...}, 这很麻烦,接触过编程语言的都知道,如果能这样写string str = "hello"就好了,C++中当然也是这样。 不过双引号括起来在这里称为字符串字面量,在这里C++中,字符串字面量有个特点,就是只读。 不知道大家有没有好奇,双引号包含的字符串是什么?如:

1
2
3
4
std::string str = "hello";
std::cout << str << std::endl;
str += " world";
std::cout << str << std::endl;

如这里hello world编译器会把它当成什么处理然后+=

想知道是什么也简单,在vs中鼠标悬浮在 world上就行了,会发现显示const char[7], 编译器将这个当成一个长度7的数组处理了,至于为什么是7,因为结尾还有一个\0

这意味着跟你前面写的类型无关,string也好,char*也好,双引号括起来编译器会当成字面量处理。

题外话,如果改成:

1
2
str += " wo\0rld";
std::cout << str << std::endl;

上述呢,我们自己加一个\0在其中会输出什么?会输出hello wo,这个也是前面说的, 输出字符串找到\0结束符就会结束。

字符串字面量是const

注意到上节中显示const没,实际上,编译器并不希望修改这个字符串,直接写char *str = "hello";, 一般编译器是不会让通过的(这个得视编译器而定),得写成char* str = (char *)"hello";

这并不意味着修改字符串行为得到允许,如下代码:

1
2
3
char* str = (char *)"hello";
str[1] = 'a';
std::cout << str << std::endl;

在vs得DEBUG模式下,这会报错,因为const是不允许修改的,但在release模式下,不会报错, 但是虽然不会报错,输出结果还是hello,而不是想象中的hello

那这个变量存在了哪呢?如果此时查看汇编代码会发现它在const segment下。 这意味着字符串字面量存在内存的只读区域中

字符串字面量的拼接、字符类型、前缀

如果字面量是常量不可修改,那怎么对其操作呢?

第一个简单的方式当然是用cout连续输出,第二个就是上上节中用string类,string类是C++98拓展的库, 它可以让字符串跟处理变量一样+-+=,例如将两个string类型相加,将string类型加等上一个c风格的字符数组。

如C风格想把一个字符串复制给另外一个需要strcpy(char2, cahr1),这个char_x均是字符数组,而在string类中, 仅需要用str2 = str1就行了。

到此,除了上节中查看汇编值得字面量存在哪,其余的主要是

  • 字符串字面量是只读的,请不要修改
  • 想修改请使用char型数组或者string类

最后介绍字符的几种类型:

1
2
3
4
const char* name1 = u8"hello";
const wchar_t* name2 = L"hello"; //加上L表示下面的字符串字面值由宽字符组成,两字节的字符
const char16_t* name3 = u"hello";// 两个字节的16比特的字符
const char32_t* name4 = U"hello";// 四个字节的32比特的字符

首先注意使用其他类型需要加上前缀,这里wchar_t是两个字节长度,为什么还需要char16_t, 因为不是每个编译器都是两个字节,就像int在一些地方是两个字节,一些地方是四个字节。

此外,C++11中提供了R前缀,表示原始类型,这意味着不会进行转译\,如:

1
2
std::cout << "hello\0\nworld" << std::endl;
std::cout << R"(hello\0\nworld)" << std::endl;

分别输出:hellohello\0\nworld,请注意R需要和()一起用。

本文由作者按照 CC BY 4.0 进行授权

热门标签