数组、 vector 与字符串
1. 数组
1.1. 数组的概念
数组是将一到多个相同类型的对象串连到一起,所组成的类型。(数组是一种类型)
1.1.1. 如何定义数组?
int a → int b[10]
int a
:定义了一个单一变量a
,类型是int
。对其进行扩展,即可定义数组:int b[10]
,变量名称是b
,数组类型是int[10]
,包 含10个元素。以下通过一个程序简单了解下数组:
#include <iostream>
#include <type_traits>
int main()
{
int x; //x的类型是int
int b[10]; //b的类型是int[10],10这个数值是类型的一部分。
std::cout << std::is_same_v<decltype(b), int[10]> << std::endl;
}
其中, int b[10];
中b的类型是int[10]
,10
这个数值是类型的一部分。由10
这个数值是类型的一部分,可引申:
#include <iostream>
#include <type_traits>
int main()
{
int x; //x的类型是int
std::cin >> x;
int b[x]; //这样的定义是否合法?
//std::cout << std::is_same_v<decltype(b), int[10]> << std::endl;//获取b的类型来和int[10]这个类型比较
}
数组这么定义是否合法?
答:不合法。
假设他是合法的,按之前讲的,那么int b[x];
中b的类型是int[x]
?
但类型这个信息是编译器所关系的,编译器在编译期处理。而int[x]
中的具体数值是在运行期得到的数值。而编译期是在运行
期之前完成的,即在编译期时,编译器无法根据int[x]
来具体地推断出x的类型。故整个代码不合法。
小trick:
上面那样定义数组会报错主要是: int b[x];
不是c++标准所支持的。但我们如果使用gcc或clang编译器,在编译时引入编译选项,如果把pedantic -errors选项去掉,再去编译程序时并不会报错。
为什么会这样?这是因为标准和编译器差异的问题。 c++ 标准中的数组声明如下:
即int b[10];
的[]
中的东西应是常量表达式,且[]
中的东西能在编译期确定,才能作为数组的声明。而对于编译器gcc和clang,它在缺省情况下(不加–pedantic -errors),那么编译器能在一定程度上支持int b[x]
这种variable length array。但这种支持并不是c++标准规定的,可以认为是gcc和clang对c++标准的扩展。但建议不使用这样扩展方法。
接下来我们还想对数组进行额外说明。
数组里面包含的是一到多个对象。
int b[1];
这样写是可以的,b的类型是int[1]
,虽然int a;
和int b[1]
都是只包含一个int
输入,但 int
和int[1]
是完全不同的两个类型 。为什么?
实际上类型不止包含了所占的对象的尺寸,还包含了对象能支持的操作。int
和int[1]
尺寸一样大,但是他们支持的操作不一样,或者说,一些看起来相同的操作,如等于,int
和int[1]
执行行为不同(在数组赋值时再详细讨论)。
我们再来看看int b[]
的[]
中能写些什么:
1、要为常量表达式;
2、该常量表达式能被转换为size_t
(size_t
是无符号整型,基本上能表示任何机器能表示的大小)
3、[]
值一定要大于0
如: int b[1];
数组定义正确。因为1可以转化为size_t
,它是个int
型的数,且1大于0
再如下这样也可以:
#include <iostream>
#include <type_traits>
int main()
{
constexpr short x = 3; //此时的x即常量表达式,x的类型不是int,而是constexpr short
int b[x]; //代码合法。虽然x看起来不是字面值,但是x是常量表达式
//std::cout << std::is_same_v<decltype(b), int[10]> << std::endl;
}
但如下这样不行:
#include <iostream>
#include <type_traits>
int main()
{
int b[3.14]; //3.14是浮点数,不是size_t,他是一个double型,不能转换从size_t,
//std::cout << std::is_same_v<decltype(b), int[10]> << std::endl;
}
如下这样也不行: 数组中[]
值一定要大于0
#include <iostream>
#include <type_traits>
int main()
{
int b[-1]; //-1负数
//std::cout << std::is_same_v<decltype(b), int[10]> << std::endl;
}
[]
内写0也不行:
写int b[0];
时,编译器去掉 --pedantic -errors 则编译不报错,加上之后,编译报错。为什么?因为g++和clang对c++标准进行了扩展,但实际c++标准中[]
里面一定要大于0。
1.1.2. 数组的初始化方式
接下来,我们来看看数组的初始化方式:
1.1.2.1. 缺省初始化
如int b[];
即数组的缺省初始化。就如我们定义int x;
一样(缺省初始化)。
相当于把int b[3]
中的3个元素都按缺省的方式初始化,换句话说,如果我们在函数内部定义int b[3]
,则基本上3个元素的值是随机的,如果是在函数外,在全局域上定义int b[3];
那么3个元素会被初始化成0。
1.1.2.2. 聚合初始化( aggregate initialization )
int b[3] = {1,2,3};
聚合初始化的变体:
在函数内:
int b[3] = {1,2};
系统会自动转换为 int b[3] = {1,2,0};
int b[3] = {};
系统会自动转换为 int b[3] = {0,0,0};
在全局域:
int b[3] = {};
和 int b[3];
无差别,因为它们都是使用0来初始化。
如果写成:
int b[3] = {1};
系统会自动转换为 int b[3] = {1,0,0};
小trick:
把int b[3] = {1,2,3};
简化:int b[] = {1,2,3};
,这俩等价的。
但写成int b[];
则会报错。(编译器没法推导出b到底要存几个元素)
而这样也会报错:int [2] = {1,2,3};
1.2. 数组的注意事项
1.2.1. 不能使用 auto 来声明数组类型
如: auto b = {1,3,4};
这样可以编译,但b
的类型不是int[3]
,那么b
的类型是?
#include <iostream>
#include <type_traits>
#include <typeinfo>//typeinfo头文件,通过文件里面提供的一系列功能来获取变量的相关类型信息。
int main()
{
auto b = {1,3,4};
std::cout << typeid(b).name() << std::endl;
//std::cout << std::is_same_v<decltype(b), int[10]> << std::endl;
}
可以看到输出了一个非常复杂名称,这是因为输出的名称进行了mangoling变换操作,使得对链接更加友好。我们进行demangoling:
进入程序:
运行demo:
demongoling:
std::initializer_list<int>
实际上是初始化列表。 auto b = {1,3,4};
中,b
的类型是std::initializer_list<int>
。故如果用auto
来定义b
,系统并不会认为b
是一个数组,而是会认为b
是是标准库里提供的std::initializer_list<int>
(类模板所实例化出来的一个类型,b
的类型是它),故我们不能简单地使用auto
来声明数组类型。
1.2.2. 数组不能复制
如下:a复制数组b。
#include <iostream>
#include <type_traits>
#include <typeinfo>
int main()
{
int b[] = {1,3,4};
int a[3] = b;
}
如果硬要复制数组:
#include <iostream>
int main()
{
int b[] = {1,3,4};
auto a = b;
}
以上程序编译通过。但这里a
的类型不是int
型数组,而是一个指针,是个int*
型指针:
#include <iostream>
int main()
{
int b[] = {1,3,4};
auto a = b;
std::cout << std::is_same_v<decltype(a), int*> << std::endl;
}
为什么呢?
实际上在类型自动推导时提到过:如果有些类型作为右值时,会进行类型退化,int[]
数组作为右值时会退化为相应的指针。这时用auto
来进行赋值,a
会被自动推导为int*
指针类型。但如果加个引用&
,那么代码是合理的。(引用可以防止类型退化)。但此时a
的类型是int&[3]
#include <iostream>
int main()
{
int b[] = {1,3,4};
auto& a = b;
std::cout << std::is_same_v<decltype(a), int&[3]> << std::
endl;
}
为什么数组不能复制?
c++是注重性能的语言。如果对数组进行复制,这种操作是非常耗时间的,耗计算资源的(如果所要复制的数组长度过长,则系统需要开辟一大块内存)。通常来讲我们并不需要对数组进行复制。我们需要的只是访问数组中的元素,这时我们可以通过指针来访问数组元素。通常情况下,需要使用b数组中的元素时,我们把数组转换成指针。接下来我们使用a时,a实际上对应一个指针,这样同样能访问数组b中的元素。这种操作比纯粹的复制数组更有效率。
但实际上,我们后面会看到,int b[]
可以理解为c或c++中的内建数组,内建数组更关注性能。我们后面会看到vetor,string,这俩是数组的一种模拟,他们是通过c++标准库来提供的一些对象的支持,这些对象可能更多考虑的是易用性。故vetor,string类型支持复制。
1.2.3. 元素个数必须是一个常量表达式(编译期可计算的值)
1.2.4. 字符串数组的特殊性
定义一个字符串数组:
char str[] = "Hello";//"Hello"字符串字面值,字符串里面每个字符的类型是char
char str[] = { 'H', 'e', 'l', 'l', 'o' };
以上俩种方式都可以。第二种方式定义,str数组的类型是char[5]
:
#include <iostream>
int main()
{
//char str[] = "Hello";//"Hello"字符串字面值,字符串里面每个字符的类型是char
char str[] = { 'H', 'e', 'l', 'l', 'o' };
std::cout << std::is_same_v<decltype(str), char[5]> << std::endl;
}
第一种方式定义,str数组的类型是char[6]
:
#include <iostream>
int main()
{
char str[] = "Hello";//"Hello"字符串字面值,字符串里面每个字符的类型是char
//char str[] = { 'H', 'e', 'l', 'l', 'o' };
std::cout << std::is_same_v<decltype(str), char[6]> << std::endl;
}
实际上是"Hello\0"
,\0
表示字符的结束。
1.3. 数组的复杂声明
在讨论完数组的一些基本概念之后,我们来看看数组的复杂声明。在数组的复杂声明里,我们主要讨论两个主题:将指针和数组放一起会形成什么变化、将引用和数组放一起会形成什么变化。
1.3.1. 指针数组与数组的指针
#C++小trick# 指针数组与数组的指针:int *a[3];
和int (*a)[3];
的区别
1.3.2. 声明数组的引用
有指针的地方通常就有引用。我们如何声明数组的引用呢?如下:
#include <iostream>
int main()
{
int b[3];
int(&a)[3] = b;//相对于a首先是个引用。a是对象b的别名。我们在声明引用a时一定要在初始化时为引用赋予一个具体的对象b。
std::cout << std::is_same_v<decltype(a), int(&)[3]> << std::endl;//a的类型是int(&)[3]
}
但c++中不支持int a[3] = b;
这样的声明。如:
我们本质是希望构造一个数组,数组中每个元素都是一个引用(类比指针),我们希望这个引用绑定到x1,x2,x3。但这样写编译出错,因为c++中规定:不能定义引用的数组。
我们刚刚定义过指针的数组,但我们不能定义引用的数组,我们只能定义数组的引用。为什么?
引用虽然在底层实现里面,会被实现为指针。但从c++概念来讲,引用表示的是对象的别名,但它本身不是对象。但数组里面包含的元素从概念上来讲,一定是对象。因此,我们不能声明一个数组,这个数组里面包含的元素都是引用,即不能声明引用的数组。故上图写法是错误的。
1.4. 数组中的元素访问
1.4.1. 数组对象是一个左值
左值:
c语言中指能够放在等号左边的值(left value)。如下图:x是左值。
但在c++中,一些左值(locator value)不能放到等号左边,但也会被称为左值。典型地,int a[3] = { 1, 2, 3 };
中的数组a就是一个左值,或const int x;
的x也是一个左值,但x不能放到等号左边,不能修改x中的值,如:x = 3;
。
如何看对象是不是左值?
可以通过上图来判断对象是否为左值。其中T指表达式的类型,如下图,x的类型是const int,即T为const int。则如果表达式(x)
的类型也是const int&
,则(x)
是左值;如果(x)
的类型也是const int&&
,则(x)
是右值;如下图:
输出结果为1,故(x)
是左值(当x作为一个表达式使用时,一定是左值)。同理,数组a也是左值,它的类型是int(&)[3]
。但它和x一样,虽然是左值,但不能修改(如赋值)。
即我们不能把上述的数组a放到等号左边。同时,如果a放到等号右边时,a的类型会发生隐式转换(转换成int*
):
#include <iostream>
int main()
{
#include <iostream>
int main()
{
int a[3] = { 1, 2, 3 };
auto b = a;//a作为右值使用(虽然a是左值)
std::cout << std::is_same_v<decltype(b), int*> << std::endl;
std::cout << *b << std::endl;//把b打印出来
std::cout << b << std::endl;//把b中包含的地址打印出来
std::cout << &(a[0]) << std::endl;//把a[0]的地址打印出来,地址和b的一样,即b会指向a中的第一个元素。
}
}
由上代码,数组a会隐式转换成一个int型指针(即a的类型是int*),指针指向数组a所包含的元素的第一个元素。则b的类型是int*,b指向数组a所包含的元素的第一个元素。但注意,只是在auto b = a;
中,a的类型才隐式转换为int*,其他行代码中,a的类型还是int[3]。
在此基础上,如果我们写一个a[0]
,即把a翻译成一个指针,就像我们写auto b = a;
一样,系统会把a翻译成一个相应的int*指针,故a[0]和b[0]等价(都是指向数组a的第一个元素1),我们打印以下b[0],b[1],b[2]:
#include <iostream>
int main()
{
int a[3] = { 1, 2, 3 };
auto b = a;//a作为右值使用(虽然a是左值)
std::cout << b[0] << std::endl;
std::cout << b[1] << std::endl;
std::cout << b[2] << std::endl;
}
由以上,我们也知道,中括号[ ],这种写法不是数组专属的,指针也可以这么处理。如:
ptr是一个指针,ptr[0]指访问x中的第一个元素100,而x刚好只有一个元素100,故结果输出100。
那么中括号[ ]是什么含义?
1.4.2. 使用数组时,通常会转换成相应的指针类型
如果我们写成b[1]这样的形式,即x[y]的形式(x和y都是内建数据类型),则x[y]会被解析成*(x + y)即(x+y)解引用:
输出2。即*(a+1)可翻译成a[0]。
trick:*(a+1)
可写成*(1+a)
,a[0]
可写成0[a]
。
故,数组中元素的访问(如:a[0]
),本质上是先转换成指针(如:(a+1)
),再进行解引用(*(a+1)
)的操作。
1.4.3. x[y] → *((x) + (y))
我们要小心数组中的元素的访问溢出。如下代码输出时,会输出乱码。
1.5. 从数组到指针
1.5.1. 数组到指针的隐式转换
1.5.1.1. 使用数组对象时,通常情况下会产生数组到指针的隐式转换
如:
int a[3] = { 1, 2, 3 };
std::cout << a[2] << std::endl;
此时的a[2]
即数组a隐式转换成指针(a+2)
,再对(a+2)
进行解引用。
再如:
int a[3] = { 1, 2, 3 };
auto b = a;
此时a也会进行隐式转换(decay)成指针。
故在使用数组时,大部分情况下,都会产生这种数组到指针的转换,但是也有一些情况不会发生这种准换。如:
#include <iostream>
int main()
{
int a[3] = { 1, 2, 3 };
decltype(a);// 求a的基本类型
}
以上,此时会返回int[3]
,即此时的数组a并不会隐式转换成指针。
再如sizeof(a)
:
#include <iostream>
int main()
{
int a[3] = { 1, 2, 3 };
sizeof(a);// 返回对象a的尺寸,返回3个int型元素的尺寸的和
}
1.5.1.2. 隐式转换会丢失一部分类型信息
#include <iostream>
int main()
{
int a[3] = { 1, 2, 3 };//a的类型是int[3]
auto b = a;//此时a的类型是int*
}
由上代码,我们可以看出,auto b = a;
中的a类型是int*,已经把int[3]中的3丢失了,此时a的数组的元素个数无法确定。访问溢出时,编译也不会报错。
那么如何避免这种情况呢?
1.5.1.3. 可以通过声明引用来避免隐式转换
即我们不太希望这么写:
auto b = a;//此时a的类型是int*
我们可以声明引用:
#include <iostream>
int main()
{
int a[3] = { 1, 2, 3 };//a的类型是int[3]
auto& b = a;//此时b声明为引用了,b是对象a的别名。此时b的类型就会继承a的类型,绑定a的一系列信息,就不会产生类型隐式转换。此时b的类型是int(&)[3]
}
由上,此时b的类型就会继承a的类型,绑定a的一系列信息,就不会产生类型隐式转换。此时b的类型是int(&)[3]
,即b会保留数组a中的元素个数,长度等信息。
我们在写程序时,并不会去显式地区分数组和指针,因为大部分情况下,数组使用时都会产生隐式类型转换。但从概念上,一定要区分清楚数组和指针,如下图:
上图,定义了一个数组array,里面包含了4个整数。当程序执行到此处初始化时,系统会连续分配4个int型所占的空间,我们假设一个int型占4个字节,那么系统会分别给元素0,1,2,3分配64~67
,68~71
,72~75
,76~79
这样的空间,其中array对应的是64。
那么接下来,array进行隐式转换成int型指针,这个int*指针是个右值,这个指针的值是64。
假设我们写了一个pointer,pointer是个实际的指针,里面会保存64,但是他会有他自己的地址120。即指针在系统中会有自己的内存空间,也有自己的内存空间编号,同时该内存空间里面会保留着一个地址信息。而数组也会有自己的内存空间,这块内存空间也会有编号,但数组内部并不是保留地址信息,它保留的是数组中的元素。
1.5.1.4. 注意:不要使用extern 指针来声明数组
由上知,数组和指针的差异还是很大的,这种差异通常情况下并不会产生多大的影响,但在某些情况下,会产生意想不到的效果。
如:不要使用extern 指针来声明数组
这是数组的定义(初始化):
int array[4] = { 1, 2, 3, 4 };//这是数组的定义,而不是声明(因为这是在初始化)
这是数组的声明:
extern int array[4];
我们写一个main.cpp文件:
#include <iostream>
extern int array[4];
int main()
{
std::cout << array[2] << std::endl;
}
再写一个source.cpp文件:
int array[4] = { 1, 2, 3, 4 };
这样的程序是没问题的。但在一些时候,在使用数组时,我们不希望包含数组的全部信息(如数组元素的个数)
如果我们在程序修改过程中,把source.cpp文件修改成:
int array[5] = { 1, 2, 3, 4 };
则main.cpp文件中也需要相应的修改:
#include <iostream>
extern int array[5];
int main()
{
std::cout << array[2] << std::endl;
}
但这样麻烦,我们不希望每次修改extern int array[5];的数组array长度,都要对数组array的声明:extern int array[5];
中的数组长度进行相应的修改。
那么错误的做法:
数组和指针很相似,把extern int array[5];
改成extern int* array;
,这样,我们在source.cpp文件中把数组长度怎么修改,main.cpp中extern int* array;
都不需要修改(反正数组也会隐式转换成指针类型)。
错在哪?
这是运行期的错误。
不是编译期错误(单纯编译翻译单元source.cpp和main.cpp,编译都是合法的,编译时系统只会看单独的cpp文件)。
也不是链接错误:
首先终端进行我们的程序:
以上,系统编译完之后会产生main.cpp.o和source.cpp.o文件,我们来看一下这两文件的导出信息和导入信息:
以上,无论是main.cpp.o还是source.cpp.o中的array数组,都是没有类型信息的(D和U不是类型信息),即在链接时,这里的array仅是个名称而已。
为什么会这样?其实类型只是在编译期时有效,编译完之后,大部分情况下我们不再需要类型信息。而链接时,不需要链接信息是有好处的,因为省略了类型信息,则c++编译完的结果可以和c编译完的结果链接,可以和其他语言编译完的结果链接。因为不同语言,可能类型信息是不一样的,链接时加了类型信息,可能会在一定程度上影响不同的编译单元之间的链接这种关系。故c++编译完之后会把类型信息丢掉,故链接时,系统并不会发现int array[5] = { 1, 2, 3, 4 };
中的array是一个数组,而extern int* array;
中的array是一个指针,这俩类型不匹配。换句话说,这样声明数组,链接时不会报错。
为什么运行时会报错?
我们写一个main.cpp文件:
#include <iostream>
extern int* array;
int main()
{
std::cout << array << std::endl;//此时是把array打印出来,而不是把array[1]打印出来
}
source.cpp文件:
int array[5] = { 1, 2, 3, 4 };
运行后得一个16进制的数(代表array的地址),通常来讲std::cout认为array是一个指针时才会输出16进制的数
此时修改source.cpp文件,把source.cpp中array的地址打印出来:
int array[5] = { 1, 2, 3, 4 };
这时再改一下main.cpp和source.cpp:
main.cpp:
#include <iostream>
void fun();//声明fun函数
extern int* array;
int main()
{
fun();//调用fun函数
std::cout << array << std::endl;//认为array是一个指针,这时打印出来的是指针pointer里面包含的数(这个数是指针所指向的地址),这个00000001指的是十进制的1
}
source.cpp:
#include <iostream>
int array[4] = { 1, 2, 3, 4 };
void fun()
{
std::cout << array << std::endl;//fun和array属于同一个编译单元,系统在看到fun里面编译时,系统知道array是一个数组,故array会转换成指向array数组中第一个元素的指针。故会输出一个准确的地址。
}
得:
第一个是从fun函数里面打印出来的array地址,第二个是从main函数里面打印出来的array地址。
source.cpp中:std::cout << array << std::endl;//fun和array属于同一个编译单元,系统在看到fun里面编译时,系统知道array是一个数组,故array会转换成指向array数组中第一个元素的指针。故会输出一个准确的地址。
main.cpp中:std::cout << array << std::endl;//认为array是一个指针,这时打印出来的是指针pointer(如下图)里面包含的数(这个数是指针所指向的地址)。)。
这个00000001指的是十进制的1(一个int占4个字节,表示成16进制数的话,需要两个16进制数来表示1个字节,故表示“1”时,“1”是int型,占4个字节,则需要8个16进制数来表示“1”:00000001,表示“2”:00000002。当然只是从逻辑上是表示成00000001,00000002。在系统中,由于大端法,小端法的原因,因此,从低地址到高地址来讲,我们会把“1”保存为01 00 00 00和02 00 00 00。我们说,一个数组里面的元素是连续存储的,即array数组内存空间中的数:1,2,3,4,会保存为“01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00”。
那么我们再看,array被定义成指针,指针里面保存的内容是地址,在64位机里面,地址是占8位,如果我们用一个指针去解析数组array,则会取“01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00”中的前8个字节,把它拼成一个内存位置,即以下代码会打印“01 00 00 00 02 00 00 00”,因为大端小端法的原因,会把“01 00 00 00 02 00 00 00”打印成“00 00 02 00 00 00 00 01”,即“20000001”。
#include <iostream>
void fun();//声明fun函数
extern int* array;
int main()
{
//fun();//调用fun函数
std::cout << array << std::endl;
}
由以上,我们能看出数组和指针还是有差异的。如果一个数组不是通过隐式类型转换,而是强行把内部的一些数据解析成地址(把数组强行看成指针),那么得到的数据是错的。故此时在main函数中,std::cout << array[0] << std::endl;
运行时会出错。
故不能混用数组和指针,这俩在内部的数据存储的逻辑完全不同。
为什么数组会产生隐式转换呢?为什么要引入这样的概念——extern int* array;
?
因为我们可能在另外一个翻译单元里面,实际上可能并不关心数组里面包含多少个元素——int array[5] = { 1, 2, 3, 4 };
或int array[4] = { 1, 2, 3, 4 };
还是其他个元素,我们都并不关心。因为关心数组里面有几个元素,一旦在数组初始化语句int array[4] = { 1, 2, 3, 4 };
中,更新了数组元素个数,则相应地在该含有该数组声明的文件中也要相应地进行更新。这样太麻烦,我们可以这样去声明:
extern int array[];
此时main.cpp:
#include <iostream>
void fun();//声明fun函数
extern int array[];
int main()
{
fun();//调用fun函数
std::cout << array << std::endl;//这里的array被认为是数组,不再是指针
std::cout << array[0] << std::endl;//打印数组中第一个元素
}
source.cpp:
#include <iostream>
int array[4] = { 1, 2, 3, 4 };
void fun()
{
std::cout << array << std::endl;
}
结果:
即此时,main.cpp中std::cout << array << std::endl;
的array被当做数组。
trick:
extern int array[];
中的int array[]
是不是很熟悉?我们见过3次。
第1次:数组定义时见过。
如果这样定义数组:int x[];
(这样定义属于编译错误)
这样的定义是非法的,因为虽然声明了x是数组,但是没有给出数组x中的元素个数。
第2次:我们这样定义数组:int x[] = {1, 2, 3};
,这样是合法的。(虽然没给出数组元素的个数,但是编译器可以根据大括号中元素(即数组初始化列表)的具体信息推断出数组元素的个数)
第3次:就在这里见到——extern int array[];
。这个是合法的。因为这个是数组array的声明,而不是array的定义。在其他源文件中有定义好该数组具体的元素个数,此处只是声明,我们在main函数中只是类似于指针的方式去使用它。
extern int array[];
在c++中有专门的术语——Unknown Bounded Array 声明。
什么情况下使用Unknown Bounded Array 声明?
我所知的 唯一一个情况:比如我们在source.cpp中定义了一个数组——int array[4] = { 1, 2, 3, 4 };
,在另外一个程序中要使用这个数组如mian.cpp中打印array这个数组(std::cout << array << std::endl;
),但是我们在使用数组时,并不关注数组中元素的个数,因此我们可以使用extern int array[];
进行数组声明,接下来我们就类似指针的方式去使用该数组(但不能把数组array声明成指针)。
关于Unknown Bounded Array ,还有一个trick:
int array[]
在c++中还有一个专门的术语:incomplete type(不完整类型)(类,其实也是incomplete type)
这些incomplete type,通常情况下,我们要在不同编译单元里面去share一个类型声明,同时在某个翻译单元里面只是会使用这个类型的一部分信息,这时我们就能引入这种不完整的声明来完成我们相应的操作。
那么,从数组到指针,我们还能干什么?
1.5.2. 获得指向数组开头与结尾的指针
我们可以根据数组的信息, 获得指向数组开头与结尾的指针 。
比如,我们可以这样获得数组开头(指向数组第一个元素的指针)的指针:
int a[3] = { 1, 2, 3 };
//获得指向数组开头的指针
&(a[0]);
或:
int a[3] = { 1, 2, 3 };
//获得指向数组开头的指针
a;
我们还能获得数组结尾的指针。这里有个trick,以下图来说明:
上图数组array,指向数组开头元素的指针,指向数组的第一个元素,即指向64。指向数组结尾元素的指针,不是指向76,而是指向80,为什么?后面会看到,由于有了开头和结尾这俩指针,我们把指向结尾的这个指针稍微往后错一点点,凑够一个元素,然后我们就能够在一定程度上简化很多数组相关的使用。
那么如何获得指向数组结尾的指针:
用a + 3;
:
int a[3] = { 1, 2, 3 };
//获得指向数组结尾的指针
a + 3;//a+0指向数组第一个元素1,a+1指向数组第二个元素2,a+3指向数组最后一个元素再错后一个元素,此时a+3才是指向数组结尾的指针
获得用&(a[3])
:
int a[3] = { 1, 2, 3 };
//获得指向数组结尾的指针
&(a[3])
详细代码如下:
当然,我们还有另外两个方式获得指向数组开头与结尾的指针 : std::(c)begin
, std::(c)end
如下程序,分别使用a,&(a[0],std::begin(a)获取数组a开头元素:
#include <iostream>
int main()
{
int a[3] = { 1, 2, 3 };
std::cout << a << ' ' << &(a[0]) << ' ' << std::begin(a) << std::endl;//分别使用a,&(a[0]),std::begin(a)获取数组a开头元素。
}
如下程序,分别使用a + 3,&(a[3]),std::end(a)获取数组a结尾元素:
#include <iostream>
int main()
{
int a[3] = { 1, 2, 3 };
std::cout << a + 3 << ' ' << &(a[3]) << ' ' << std::end(a) << std::endl;
}
trick: std::cbegin(a)
, std::cend(a)
和 std::begin(a)
, std::end(a)
都可以用来获得指向数组开头与结尾的指针,他们有何差别?
std::begin(a)
获得的是一个int*型指针,我们可以通过这个指针进行读和写,可以这个指针来改变数组中的内容。
std::cbegin(a)
获得的是const int*类型的指针,我们可以通过这个指针(通过这个东西——std::cbegin(a)
)进行读,但不能进行数组元素的写。建议使用这种。
另外,之前我们说,数组可能会隐式转换成指针,如果数组转换成指针,我们可能会丢失一些信息,丢失的这部分信息可能会产生不好的影响:
以上,auto b = a;
,a会隐式转换为int型指针,从而b也是int型指针,此时再std::begin(b)
、std::end(b)
则会报错,因为b不是数组。
但如果把auto b = a;
改成auto& b = a;
,即b是a的别名,即b就是a这个数组,则可以使用std::begin(b)
、std::end(b)
。
那么之前提到的这个代码中的array数组可以使用std::begin、std::end来获取数组开头结尾元素吗?:
main.cpp:
#include <iostream>
void fun();//声明fun函数
extern int array[];
int main()
{
std::cout << std::begin(array) << std::endl;
std::cout << std::end(array) << std::endl;
}
source.cpp:
#include <iostream>
int array[4] = { 99, 2, 3, 4 };
void fun()
{
std::cout << array << std::endl;
}
结果:
即array是个Unknown Bounded Array,故我们无法通过begin、end来获取数组开头结尾的元素。我们可以使用array
来获取指向数组开头元素的指针,但无法在main.cpp中获取指向数组结尾元素的指针(因为我们无法确定array数组中有几个个元素)。下面代码:array + 4
这样获得的只是指向数组第四个元素的指针,但不一定是指向数组结尾元素的指针。
main.cpp:
#include <iostream>
void fun();//声明fun函数
extern int array[];
int main()
{
std::cout << array << std::endl;
std::cout << array + 4 << std::endl;//这样获得的只是指向数组第四个元素的指针,但不一定是指向数组结尾元素的指针
}
source.cpp:
#include <iostream>
int array[4] = { 99, 2, 3, 4 };
void fun()
{
std::cout << array << std::endl;
}
结果:
既然我们能通过数组变量名来获取指向开头结尾的指针了,为什么还要引入std::(c)begin
、std::(c)end
?
std::(c)begin
、std::(c)end
这俩是c++标准库里面定义的函数,这俩函数是元函数,它们能够被应用到一些不同的
类型上面,我们以上中,std::(c)begin
、std::(c)end
是被应用到数组上,来获取数组开头元素和结尾元素。
后面我们还可以定义vector、string这样不同的类型,这些不同的类型,我们也可以使用begin、end来获取它们的开头元素,结尾元素。通过这样的方法,我们就使用了同样的函数名来去调用了不同的数据类型(数组,vector,string等)。
比如,我们把int a[3] = { 1, 2, 3 };
这个数组,换成vector。获取vector开头和结尾,就不能使用a
,a + 3
来获取vector的开头结尾元素。我们的通过begin、end。因为begin、end会针对vector进行相关的处理。
1.5.3. 指针算数:
1.5.3.1. 增加、减少
我们定义数组a
,定义一个指针ptr
,该指针指向数组a中的第一个元素:
#include <iostream>
int main()
{
int a[3] = { 1, 2, 3 };//定义数组a
auto ptr = a;//定义一个指针ptr,类型是int*,该指针指向数组a中的第一个元素
}
接下来我们对指针ptr
增加、减少。
#include <iostream>
int main()
{
int a[3] = { 1, 2, 3 };//定义数组a
auto ptr = a;//定义一个指针ptr,类型是int*,该指针指向数组a中的第一个元素
ptr = ptr + 1;//ptr+1:指针ptr指向数组下一个元素(ptr类型是int*,ptr + 1表示把ptr里面存储的数据加上一个int长度,假设ptr原本储存的是64(如下图的pointer即这里的ptr),则现在会变成64+4=68)
}
1.5.3.2. 比较
ptr
指向数组开头元素1,ptr2
指向数组结尾元素3:
#include <iostream>
int main()
{
int a[3] = { 1, 2, 3 };//定义数组a
auto ptr = a;//定义一个指针ptr,类型是int*,该指针指向数组a中的第一个元素
auto ptr2 = a + 3;
std::cout << (ptr == ptr2) << '\n';
std::cout << (ptr != ptr2) << '\n';
std::cout << (ptr > ptr2) << '\n';
std::cout << (ptr < ptr2) << '\n';
std::cout << (ptr >= ptr2) << '\n';
std::cout << (ptr <= ptr2) << '\n';
}
注意:我们之前提到过,指针虽然支持算术,但通常情况下我们不建议使用指针去做这种大于、小于的比较。但有个例外:如果参与这种大于、小于的比较的两个指针都是指向同一个数组中的两个元素,那么这样是可以合理的。但如果这两个指针分别是指向不同数组元素,则不建议使用指针去做这种大于、小于的比较。但我们还是能对指向不同数组元素的两个指针使用==
和!=
的操作。
1.5.3.3. 求距离
两个指针相减,返回的是两个指针之间包含的元素个数:
#include <iostream>
int main()
{
int a[3] = { 1, 2, 3 };//定义数组a
auto ptr = a;//定义一个指针ptr,类型是int*,该指针指向数组a中的第一个元素
auto ptr2 = a + 3;
std::cout << ptr2 - ptr << '\n';//把ptr2里面存储的值80减去ptr存储的值64,然后再除以int型所占的长度(4),即(80-64)/4=4,即两个指针相减,返回的是两个指针之间包含的元素个数。
std::cout << ptr - ptr2 << '\n';
}
ptr - ptr2
得到-3,负号指ptr
在ptr2
的前面,3指二者之间的距离是3个int
型的数:
#include <iostream>
int main()
{
int a[3] = { 1, 2, 3 };//定义数组a
auto ptr = a;//定义一个指针ptr,类型是int*,该指针指向数组a中的第一个元素
auto ptr2 = a + 3;
std::cout << ptr2 - ptr << '\n';//把ptr2里面存储的值80减去ptr存储的值64,然后再除以int型所占的长度(4),即(80-64)/4=4,即两个指针相减,返回的是两个指针之间包含的元素个数。
std::cout << ptr - ptr2 << '\n';//即ptr在ptr2的前面,二者之间的距离是3个int型的数
}
以上即指针相减的功能,其功能是能求出这俩指针之间的距离。但这俩指针之间的距离实际上是跟指针之间的类型相关的,如:两个int型指针相减,则会求出这俩指针之间包含int元素的个数。如果是俩double型指针相减,则会求出这俩指针之间包含double元素的个数。
1.5.3.4. 解引用
指针还可以解引用。注意,auto ptr2 = a + 3;
、*a
中的a
的类型转换成int*
型指针了,但数组a的类型还是int[3]
。
#include <iostream>
int main()
{
int a[3] = { 4, 3, 1 };//定义数组a
auto ptr = a;//定义一个指针ptr,类型是int*,该指针指向数组a中的第一个元素
auto ptr2 = a + 3;
std::cout << *ptr << '\n';//ptr是指针,*ptr是对指针ptr解引用,得到的是数组a中的第一个元素
std::cout << *a << '\n';//a是数组,类型是int[3],但数组一旦被使用,则会先隐式转换成指针,即a的类型转换成int*型指针,解引用(*a)后则,得到是数组a中的第一个元素
std::cout << std::is_same_v<decltype(a), int*> << std::endl;//实际上a的类型还是int[3]
}
我们还可以使用ptr[0]
,对数组a
的第一个元素解引用;使用ptr[1]
,对数组a
的第二个元素解引用;使用ptr[2]
,对数组a
的第三个元素解引用。ptr[2]
本质上即*(ptr + 2)
。如下:
#include <iostream>
int main()
{
int a[3] = { 4, 3, 1 };//定义数组a
auto ptr = a;//定义一个指针ptr,类型是int*,该指针指向数组a中的第一个元素
auto ptr2 = a + 3;
std::cout << *ptr << '\n';//ptr是指针,*ptr是对指针ptr解引用,得到的是数组a中的第一个元素
std::cout << ptr[2] << '\n';//ptr[2]本质上即*(ptr + 2)
}
1.5.3.5. 指针索引
#include <iostream>
int main()
{
int a[3] = { 4, 3, 1 };//定义数组a
auto ptr = a;//定义一个指针ptr,类型是int*,该指针指向数组a中的第一个元素
auto ptr2 = a + 3;
std::cout << *ptr << '\n';//ptr是指针,*ptr是对指针ptr解引用,得到的是数组a中的第一个元素
std::cout << ptr[2] << '\n';//ptr[2]本质上即*(ptr + 2)
}
指针索引即:比如,ptr[2]
,指的是对数组a
中第三个元素进行访问。2
即数组a
中第三个元素的下标。
1.6. 数组的其他操作
1.6.1. 求元素的个数
为什么要求数组元素的个数?
main.cpp:
#include <iostream>
int main()
{
int a[3] = { 4, 3, 1 };//定义数组a
//....
}
source.cpp:
#include <iostream>
int array[4] = { 1, 2, 3, 4 };
void fun()
{
std::cout << array << std::endl;
}
在不同翻译单元,一个单元声明数组(main.cpp)、一个单元定义数组(source.cpp),后续可能需要在main.cpp程序中引入一系列操作,这些操作都涉及数组相关的内容(如:对数组元素遍历),这些操作都会涉及数组元素的个数,如果在这些操作上都使用数组原先的元素个数3
,接下来如果对数组进行更新,把数组元素个数由3变成4——如:int a[4] = { 4, 3, 1, 9 };
这时,main函数下面所有操作中使用原先数组元素个数3
的语句都要进行相应的数组元素个数的修改,这样显然麻烦且容易造成遗漏。
针对以上情况,我们定义一个数组,但不直接数组中元素个数,而是使用一个间接的方法,能够根据数组的对象或数组的类型,推导出数组元素的个数,那么接下来我们如果把数组元素修改,程序也能够自动编译,凡是涉及到数组元素个数的相关内容进行更新。这样就不用担心由于引入数组,后续对数组定义进行修改,导致代码错误。
这也就是为什么要引入求数组元素个数这样的操作。以下讨论3种方法:
1.6.1.1. sizeof
方法
这也是c语言中常见的方法。
sizeof
:表示对象或类型所占的尺寸。
在我们64位的系统中,一个int型占4个字节,故sizeof(int)
即得到4。而数组a是包含3个int型的数组,故sizeof(a)
即3*4=12
:
#include <iostream>
int main()
{
int a[3] = { 4, 3, 1 };//定义数组a
std::cout << sizeof(a) << std::endl;//打印数组a的
std::cout << sizeof(int) << std::endl;
}
我们可以根据sizeof
来反推出数组元素个数:
#include <iostream>
int main()
{
int a[3] = { 4, 3, 1 };//定义数组a
std::cout << sizeof(a) << std::endl;//打印数组a的
std::cout << sizeof(int) << std::endl;
std::cout << sizeof(a) / sizeof(int) << std::endl;
}
以上,即,数组a元素个数是3。
注意:很多情况下,我们使用数组时,数组都会退化成指针int,指向数组的第一个元素。* 但使用sizeof(a)
时,数组a并不会退化成指针,故我们可以通过这种方式来获取数组的元素的个数。
1.6.1.2. std::size 方法
实际上,c++的库提供了另一种方法(size
函数)来获取数组中元素的个数:
#include <iostream>
int main()
{
int a[3] = {1, 2, 3};//定义数组a
std::cout << std::size(a) << std::endl;//打印数组a的元素个数
}
1.6.1.3. (c)end
- (c)begin
方法
前面我们使用end、begin来获取数组结尾和开头的指针,可用(c)end - (c)begin
得到数组元素个数:
#include <iostream>
int main()
{
int a[3] = {1, 2, 3};//定义数组a
std::cout << std::end(a) - std::begin(a) << std::endl;//打印数组a的元素个数
}
同理,用std::cend(a) - std::cbegin(a)
也可以,通常来说,我们只是想获得数组元素个数,而不是对指针对数组的内容进行读写操作,因此通常使用std::cend(a) - std::cbegin(a)
来获取元素个数,返回的是一个指向常量的数组,防止获取数组元素个数时不小心修改了数组。
比较一下这三种方法:
以上3中方法都是对数组进行操作。如果我们不能通过上述3种方法获取Unknown Bounded Array的数组元素个数。因为上述3种方法都是得先知道数组完整的定义,才能推导出数组中包含的元素个数。如果想通过上述3中方法获取指针或Unknown Bounded Array的数组元素个数,都是不行的。如:
#include <iostream>
int main()
{
int a[3] = {1, 2, 3};//定义数组a
auto b = a;
std::cout << sizeof(b) / sizeof(int) << std::endl;//打印数组b的元素个数
}
以上,在64位机中,一个指针占8个字节,故 sizeof(b)
返回8
,sizeof(int)
返回4
,故最终输出结果为2
。即无论数组元素个数是多少,打印出来的b中的元素都是2
std::cend(a) - std::cbegin(a)
是在运行期执行,来获取数组a所指向元素的位置,然而我们是想获取数组中包含了几个元素,这个信息可以在编译期就看到,在运行期才获取数组元素个数,无疑会在运行期增加运行时间。
sizeof
方法有很多不便的地方。比如上述代码:sizeof(b) / sizeof(int)
,使用sizeof
方法会带来不必要的麻烦:
#include <iostream>
int main()
{
double a[12];//定义数组a
std::cout << sizeof(a) / sizeof(int) << std::endl;//int忘记改成double
//std::cout << std::size(a) << std::endl;
}
而使用std::size
方法,哪怕数组的类型变换了,比如变成double型数组,我们也不需要想sizeof
方法那样,把sizeof(a) / sizeof(double)
,哪怕数组a的类型发生改变,我们也不需要对程序进行如何修改。
#include <iostream>
int main()
{
double a[12];//定义数组a
std::cout << sizeof(a) / sizeof(double) << std::endl;
std::cout << std::size(a) << std::endl;//哪怕数组a的类型发生改变,我们也不需要对程序进行如何修改
}
这也是我们引入求元素个数的初衷,我们对数组元素个数、类型等信息进行更新,但我们跟该数组相关的一系列操作的程序也不需要修改,就能够直接使用size(a)
这样一个值来进行后续操作。
1.6.2. 元素遍历
从上两节,我们能够对数组进行元素访问,求数组元素的个数,故自然而然地,就能够对数组中的元素进行遍历。我们可以使用while循环来遍历数组当中的元素,如:
1.6.2.1. 基于元素个数
基于元素个数进行数组元素遍历:
#include <iostream>
int main()
{
int a[4] = {2, 4, 5, 7};//定义数组a
//遍历数组元素、打印数组元素:
size_t index = 0;//索引值(下标)初始设为0
while (index < std::size(a))//如果index小于数组a元素个数时就进入循环(注意这里不能写小于等于号,因为索引值是从0开始的)
{
std::cout << a[index] << std::endl;
index = index + 1;//索引值+1
}
}
1.6.2.2. 基于 (c)begin
/(c)end
基于 (c)begin
/(c)end
进行数组元素遍历:(注意:std::end(a)指的是数组最后一个元素再错后一个元素的那块内存所对应的指针
,并不是指指向数组a中元素7
的那个指针),循环条件中的!=
也可改成<
。
#include <iostream>
int main()
{
int a[4] = {2, 4, 5, 7};//定义数组a
//遍历数组元素、打印数组元素:
auto ptr = std::cbegin(a);//ptr即指针,ptr中保存了指向数组开头元素的指针
while (ptr != std::end(a))//判断ptr指针是否等于指向结尾元素的指针,如果不等于,则执行循环操作。这里的!=也可改成<
{
std::cout << *ptr << std::endl;//*ptr:把ptr指针所指向的内容解引用
ptr = ptr + 1;//ptr指针指向下一个元素
}
}
1.6.2.3. 基于 range-based for 循环
c++11引入的新语法。
#include <iostream>
int main()
{
int a[4] = {2, 4, 5, 7};//定义数组a
//遍历数组元素、打印数组元素:
for (int x : a)//
{
std::cout << x << std::endl;//
}
}
由上图可知,上述代码和前小节的while循环遍历数组元素的方法差不多。
1.7. C 字符串
1.7.1. C 字符串本质上也是数组
char str[] = "Hello"; //str的类型是char[6]
1.7.2. C 语言提供了额外的函数来支持 C 字符串相关的操作 : strlen, strcmp…
strlen
求字符数组的长度:
#include <iostream>
#include<cstring>
int main()
{
char str[] = "Hello"; //str的类型是char[6]
auto ptr = str;
std::cout << strlen(str) << std::endl;
std::cout << strlen(ptr) << std::endl;//用str和ptr一样的,strlen(str)中的字符数组str也会先隐式转换成char指针ptr
}
但下面代码这样写,输出的不一定是5,5。因为,一段内存中,字符数组str包含5个字符,此时使用strlen对数组str或指针ptr遍历,那么会依次遍历数组的每个元素,知道遇到“\0”才停止。然而下面代码中数组这样的写法,我们对’o’后面的元素是否为“\0”是未知的,故输出的不一定是5,5。
应写为:
strcmp用来c字符串的比较。
所有的这些操作,字符数组后面都需要’\0’
1.8. 多维数组
1.8.1. 本质:数组的数组
前面看到的都是一维数组。一位数组中每个元素都是一个类型,而数组本身也是类型,故我们可以构造数组的数组(表现为多维数组)。如:int a[3][4];
1.8.1.1. int a[3][4];
#include <iostream>
int main()
{
int x1[3]; //构造了名为x1的数组,数组中包含3个元素,每个元素都是int型
int x2[3][4]; //构造了名为x2的多维数组,数组中含3*4=12个元素,每个元素都是int型。或者理解为:先看x2[3],这是一个数组,包含3个int型元素,每个元素都是int[4]类型的数组
int x3[3][4][5]; //x3[3]是一个包含3个元素的数组,每个元素是int[4][5]类型的数组,int[4][5]是二维数组,包含了4个元素,每个元素是int[5]类型的数组
}
其中,int x2[3][4]
理解为:先看x2[3]
,这是一个数组,包含3个int
型元素,每个元素都是int[4]
类型的数组。
int x3[3][4][5]
:x3[3]
是一个包含3个元素的数组,每个元素是int[4][5]
类型的数组,int[4][5]
是二维数组,包含了4个元素,每个元素是int[5]
类型的数组。
为什么要这么理解?
我们来看看x2[0]
指的是啥?
#include <iostream>
int main()
{
int x1[3]; //构造了名为x1的数组,数组中包含3个元素,每个元素都是int型
int x2[3][4]; //构造了名为x2的多维数组,数组中含3*4=12个元素,每个元素都是int型。或者:先看x2[3],这是一个数组,包含3个int型元素,每个元素都是int[4]类型的数组
std::cout << sizeof(int) << std::endl;
std::cout << sizeof(x2[0]) << std::endl;
std::cout << std::is_same_v<decltype(x2[0]),int(&)[4]> << std::endl;//x2[0],且它是左值,故它的类型要加&,即int(&)[4]
}
即,x2[0]
指含有四个int
型元素的数组,类型是int[4]
。
接下来,我们看一下 x2[3][4]
在内层中的布局:
x2
指包含3个元素的数组,x2[0]
类型是int[4]
,int[4]
在内存中如何布局?——int int int int
。
故 int x2[3][4]
在内层中的布局:(int int int int) (int int int int) (int int int int)。也可看成3行4列的矩阵。
int x3[3][4]
在内层中的布局:(int int int) (int int int) (int int int) (int int int)。也可看成4行3列的矩阵。
1.8.2. 多维数组的聚合初始化:一层大括号 V.S. 多层大括号
多维数组如何初始化?
缺省初始化:(对数组中每个元素都进行缺省初始化,函数内部是随机初始化,函数外部缺省初始化是用0来初始化)
聚合初始化:
一层大括号:
以下代码中,数组x2即:(1, 2, 3, 4), (5, 0, 0, 0), (0, 0, 0, 0)
多层大括号:
以下代码中,数组x3即:(1, 2, 3, 4), (5, 6, 7, 8), (0, 0, 0, 0)
我们下面访问数组x3中第二组元素中的第一个元素:5
:
访问x3[1][3]
,得到的是0:(数组中元素的大括号没填满元素,系统会自动补充0上去)
访问x3[0][3]
,输出的是0。而不是4。故这种两层的大括号能明确的说明元素到底是为哪个子数组的元素初始化,一层大括号不具备这样的操作,只能在数组末尾补充0。
trick:
以下代码,系统能自动推导出x数组中有两个元素,元素类型是int[2]
型的数组。
以下代码,系统能自动推导出x数组中有两个元素,元素类型是int[3]
型的数组。(数组再系统自动补充两个0来完成数组初始化),即x类型是int[2][3]
以下这样会报错:
我们至少要在[ ]内填一个值。
以下这样也会报错:(我们只能省略第一个[]中的值)
1.8.3. 多维数组的索引与遍历
1.8.3.1. 使用多个中括号来索引
如下图,使用多个中括号来索引:x2[0][3]
表示数组的元素4
1.8.3.2. 使用多重循环来遍历
之前是使用一重循环对一重数组遍历。而多重数组也可使用多重循环遍历。
1、使用range-based for循环:
#include <iostream>
int main()
{
int x2[3][4] = {1, 2, 3, 4, 5}; //构造了名为x2的多维数组,数组中含3*4=12个元素,每个元素都是int型。或者:先看x2[3],这是一个数组,包含3个int型元素,每个元素都是int[4]类型的数组
for (auto& p : x2)
{
for (auto q : p)
{
std::cout << q << '\n';
}
}
}
以上,for (auto& p : x2)
为什么要加&
?
如果不加&
,我们去c++ insight里面看看:
上图可知,由insight后第12行,我们知道p已经是一个指针。而本质上我们想让p指向一个一维数组的具体元素(_begin1指针解引用),但是此处对p进行了隐式类型转换,把p转换成一个数组的指针,既然p是数组的指针,则没办法再使用range-based for,因为range-based for没办法对一个int型的指针再去调用begin,end来获得数组开头结尾元素,故下面代码的写法,会导致编译错误:
#include <iostream>
int main()
{
int x2[3][4] = {1, 2, 3, 4, 5}; //构造了名为x2的多维数组,数组中含3*4=12个元素,每个元素都是int型。或者:先看x2[3],这是一个数组,包含3个int型元素,每个元素都是int[4]类型的数组
for (auto p : x2)
{
for (auto q : p)
{
std::cout << q << '\n';
}
}
}
那么我们加上&
可防止这种隐式类型转换。如下图,在insight后第12行我们可以看到,一个数组的引用,此时的p是一个引用,绑定到int[4]类型的数组 (此时p不再是指针,而是二维数组解引用后得到的一维数组),在此基础上,即可对其进行后续处理。
如果是遍历三维数组?加多一个range-based for循环:
#include <iostream>
int main()
{
int x2[3][4][5] = {1, 2, 3, 4, 5};
for (auto& p : x2)
{
for (auto& q : p)
{
for (auto r : q)
{
std::cout << r << '\n';
}
}
}
}
2、使用while循环来遍历多重数组
x[0][0~4]
,x[1][0~4]
,x[2][0~4]
式遍历:
也可以:
#include <iostream>
int main()
{
int x2[3][4] = {1, 2, 3, 4, 5};
size_t index0 = 0;
while (index0 < std::size(x2))//x2数组里面含3个元素,故数组长度是3,使用std::size不会引入类型退化,故std::size(x2)即为3
{
size_t index1 = 0;
while (index1 < std::size(x2[index0]))//x2[index0]数组里面含4个元素,故数组长度是4,使用std::size不会引入类型退化,故std::size(x2[index0])即为4
{
std::cout << x2[index0][index1] << std::endl;
index1 = index1 + 1;
}
index0 = index0 + 1;
}
}
x[0~2][0]
,x[0~2][1]
,x[0~2][2]
,x[0~2][3]
式遍历:(不建议)
#include <iostream>
int main()
{
int x2[3][4] = {1, 2, 3, 4, 5};
size_t index1 = 0;
while (index1 < std::size(x2[0]))//x2数组里面含3个元素,故数组长度是3
{
size_t index0 = 0;
while (index0 < std::size(x2))//x2数组里面含3个元素,故数组长度是3
{
std::cout << x2[index0][index1] << std::endl;
index0 = index0 + 1;
}
index1 = index1 + 1;
}
}
1.9. 指针与多维数组
1.9.1. 多维数组可以隐式转换为指针,但只有最高维会进行转换,其它维度的信息会被保留
如上图,多维数组可以隐式转换为指针,但只有最高维会进行转换,其它维度的信息会被保留。如我们写一个:
int x2[3][4];
那么x2[1]
:首先把数组x2
转换成一个指针——*(x2 + 1)
(即对x2+1
取地址(解引用)),而*(x2 + 1)
指x2
移动了一个元素所占的空间(不是移动一个int
型数据的内存空间,而是移动了一个int
型数组所占的内存空间,其中该数组包含了4个int
型元素)。为什么能移动一个包含了一个int
型数组(内含4个int
型元素)所占的空间,显然是因为*(x2 + 1)
中会记录x2
转换成指针之后,x2
的类型是int[4]
,只有记录了这个[4]
,那么x2 + 1
才能移动包含4个int
的数组所占的空间,故多维数组隐式转换为指针时,最高维([3]
)会进行转换(丢掉),其它维度([4]
)的信息会被保留。
又:
ptr1
也会隐式转换成指针,但也是只会丢掉最高维的信息,接下来ptr1+1
即ptr1
也会移动5个int
型数据所占的空间。
故,要注意,如果一个多维数组转换成指针,只会丢掉最高维的信息,保留低维信息。
1.9.2. 使用类型别名来简化多维数组指针的声明
ptr
代表一个int
型指针,指向A2(A2类型是int[4][5]
):
1.9.3. 使用指针来遍历多维数组
2. vector
之前讨论的数组实际上是c++的内建数据结构(c++语言本身支持的数据结构,不需要如何第三方库,任何c++标准库,就可以直接使用数组),除此之外,c++还提供了若干数据结构来模拟数组,如vertor。vertor可以实现类似数组的功能,但更侧重易用性。但即使就c++标准模板库来讲,除了vertor之外,它还包含了一系列类似的东西——“序列容器”。为什么称为序列容器?
数组我们可以把它称为容器,容器里包含了一堆的元素,这些元素排成一个序列。那么我们在c++标准库包含了若干类型的序列容器,其中vertor是使用最广泛的一种序列容器。但除了vertor,还会有其他序列容器。本章简单介绍一下vertor,后续章节会系统性讨论c++中的其他容器。
2.1. vector是 C++ 标准库中定义的一个类模板
类模板:有点像一个框架,往里面添加一些东西,就能构造出实际的类,让我们通过一个程序来看看:
#include <iostream>
#include<vector>
int main()
{
std::vector<int> x;//创造了对象x,对象类型是std::vector<int>,其中std是名字空间(c++标准库),即vector位于c++标准库这样一个名字空间里。vector<int>才是x的类型,即,我们是使用int填充到vector这个模板中
}
以上,对象x的类型是std::vector<int>
,其中std
是名字空间(c++标准库),即vector
位于c++标准库这样一个名字空间里。vector<int>
才是x
的类型,即,我们是使用int
填充到vector
这个模板中
2.2. 与内建数组相比,更侧重于易用性
vector支持复制:
首先,数组是不支持复制的,如下,编译会报错:(c++为了能更大地提高系统性能,在一定程度上限制了程序能干的事情,因为数组的复制需要占用更多的资源)
int a[3];
int b[3] = a;
但vector
支持复制:
#include <iostream>
#include<vector>
int main()
{
std::vector<int> x;//创造了对象x,对象类型是std::vector<int>,其中std是名字空间(c++标准库),即vector位于c++标准库这样一个名字空间里。vector<int>才是x的类型,即,我们是使用int填充到vector这个模板中
std::vector<int> y;
y = x;
}
vector可在运行期动态改变元素个数:
我们定义以下这么个数组,则数组的元素个数在编译期已确定,数组包含3个元素,故在运行期只能使用a[0]
,a[1]
,a[2]
来读和写数组a
当中的元素。
int a[3];
但如果我们按如下定义vector
,vector
中元素是0个(缺省初始化):
std::vector<int> x;
实际上,后续我们可以在vector
当中插入、删除元素,而插入、删除元素是在运行期执行的,vector
正是因为有这样的特性,所以我们可以在运行期改变vector
中包含的元素个数。
但,vector
虽然易用性比数组好,但性能相对差一些。
2.3. 构造与初始化
2.3.1. 缺省初始化
int a[3];//数组缺省初始化,数组a包含3个元素,每个元素是随机的
std::vector<int> x;//vector缺省初始化,x中包含0个元素
2.3.2. 聚合初始化
数组和vector
的聚合初始化类似:
int a[3] = {1, 2, 3};//使用1,2,3来初始化数组a中的3个元素
std::vector<int> x = {1, 2, 3};//使用1,2,3来初始化vertor,初始化之后,x中才会包含3个元素
2.3.3. 其它的初始化方式
以下是vector类的构造函数,每一种构造函数都对应一种初始化方式。如(1)是缺省构造函数,
如:std::vector<int> x;
,相当于我们调用了constexpr vector() noexcept(()),即缺省构造函数,其解释是:
(包含了一个空容器,容器中包含一个元素:0)
再如:(4)中的构造函数:
我们可以在vector构造时传入一个count值来表示vertor初始化式包含多少个元素,如:
以上,表示x
包含3个元素(3个int
值,会被初始化为0),即类似于数组初始化:int a[3];
(但该数组中的3个int值是随机的)
再如:(3)中的vector初始化:
以上,x
中包含3个元素,其中每个元素的值均为1。类似于:
或:
大trick:
上面俩图含义不同,写成小括号的指vector中包含3个1,写成大括号的指vector中含3和1两个元素。
为什么?
因为采用小括号,调用的是上图(3)中的构造函数。采用大括号,调用的是下图(10)中的构造函数:
2.4. 其它方法
以上是vector的构造与初始化,那么构造出来后,我们就得使用vector了。
2.4.1. 获取vector元素个数
#include <iostream>
#include<vector>
int main()
{
std::vector<int> x1(3,1);
std::cout << x1.size() << std::endl;//x1.size():调用了x1当中的一个size方法,该方法会返回x1当中的元素个数
}
x1
中包含3个元素。其中x1.size()
是一个函数调用。但它与一般的函数调用不一样的地方是前面加了x1
,而x1是vector所对应的对象名(变量名),x1
后面加个.
表示size是位于x1
这个类模板中所定义的一个函数,它和我们之前看到的fun
函数不一样,fun
函数是定义在全局域当中(如下图),而类也能形成一个域。如果我们在类当中定义一个函数,通常来讲我们会使用x1.size()
这样的方式来进行调用函数(JAVA中的方法,我们构造了x1这个对象之后,就可以使用对象.size来去调用方法)。x1.size()
获取到的是对象所包含的内容(即对象所包含的元素个数)。
2.4.2. 判断vector是否为空
#include <iostream>
#include<vector>
int main()
{
std::vector<int> x1(3,1);
std::cout << x1.empty() << std::endl;
}
返回0,即返回fasle,表明vector中包含元素。
2.4.3. vector中还可以插入元素
#include <iostream>
#include<vector>
int main()
{
std::vector<int> x1;//x1中包含0个元素
std::cout << x1.size() << std::endl;
std::cout << x1.empty() << std::endl;
x1.push_back(2);//给x1插入一个元素2
std::cout << x1.size() << std::endl;
std::cout << x1.empty() << std::endl;//此时返回0,即x1非空。
}
其中,push_back
是在运行期执行。即vector可在运行期动态改变元素个数。
2.4.4. vector中还可以删除元素
#include <iostream>
#include<vector>
int main()
{
std::vector<int> x1;//x1中包含0个元素
std::cout << x1.size() << std::endl;
std::cout << x1.empty() << std::endl;
//往x1中插入元素
x1.push_back(2);//给x1最结尾的地方插入一个元素2
std::cout << x1.size() << std::endl;
std::cout << x1.empty() << std::endl;//此时返回0,即x1非空。
//往x1中删除元素
x1.push_back(2);//把x1中最后的元素弹出来
std::cout << x1.size() << std::endl;
std::cout << x1.empty() << std::endl;
}
除了在vector中插入、删除元素,还可以通过以下函数改变vector中的内容:
2.4.5. vector 的比较
vector还支持vector的比较。
下图,比较x1和x2:
vector是如何进行比较?
实际上是按照字典序列进行比较。如上图两个vector(x1和x2),先比较vector中第一个元素,比完之后再比较第二个元素,以此类推。
vector的比较相比于数组的比较(需要调用函数)而言,无疑是更加简单。
2.5. vector 中元素的索引
2.5.1. [] V.S. at
使用[ ]进行vector 中元素的索引:
在数组中,我们可以使用a[]
的方式对数组进行索引:
而对于vector,我们也可以使用x1[]
对vector进行索引:
使用at 函数(方法)进行vector 中元素的索引:
上图同样会输出3
。
为什么要引用at?
在数组中,使用a[2]
有一定风险,比如数组可能会产生越界,如:a[-1]
,这个程序还能编译,但结果并不是我们想要的。
在vector中同样可能会出现越界,如:x1[20]
,这个程序还能编译(有的编译环境下会直接报错),但结果可能并不是我们想要的。如果使用x1.at(20),则会直接告诉你错误:(即,20比现在vector尺寸大)
2.5.2. (c)begin
/ (c)end
函数 V.S. (c)begin
/ (c)end
方法
之前在数组中,我们使用begin
和end
来获取一个指针,该指针指向数组开头元素和结尾元素的下一位。如下图:
©begin/ ©end函数:
同样地,我们使用begin
函数和end
函数来获取一个“东西” (不是指针),来指向x1中的第一个元素和x1中最后一个元素的下一位:
©begin / ©end 方法
还可以:(与上面的方法行为是一致的)
2.5. vector 中元素的遍历
和之前数组的遍历方法很相似。
还可以使用 range-based for 循环遍历:
trick:
如果去调x1.begin()
和x1.end()
,b
和e
不再是指针了。即:
如上图,我们使用std::begin(a)、std::end(a),获得的是指针。
但如上图,调用x1.begin()、x1.end()或std::begin(x1)、std::end(x1),得到的不再是一个指针了,得到的是iterator(迭代器)
2.7. 迭代器
迭代器是一个特殊的数据类型。它有什么用呢?
2.7.1. 模拟指针的行为
2.7.2. 包含多种类别,每种类别支持的操作不同
为什么会出现多种类别呢?
比如vector,我们调用begin和end,返回的是随机访问迭代器,后面我们会看到其他容器,有些容器调用begin和end返回的不是随机访问迭代器,可能返回的是其他迭代器。
2.7.3. vector 对应随机访问迭代器
而每一种迭代器所支持的操作不同,随机访问迭代器可以解引用与下标访问、 移动、两个迭代器相减求距离、两个迭代器比较。
2.7.3.1. 解引用与下标访问
如,我们构造一个迭代器,可以解引用:
#include <iostream>
#include<vector>
int main()
{
std::vector<int> x1 = { 1, 2, 3 };
auto b = std::begin(x1);
std::cout << *b << std::endl;//*b:对随机访问迭代器b进行解引用,获取vector中的元素1
}
如上,*b
:对随机访问迭代器b进行解引用,获取vector中的元素1。
我们也可以对随机访问迭代器b
进行下标访问,获取vector中相应元素。如b[1]
:获取vector中元素2:
#include <iostream>
#include<vector>
int main()
{
std::vector<int> x1 = { 1, 2, 3 };
auto b = std::begin(x1);//这里的b是迭代器,从x1中第一个元素1开始
std::cout << b[1] << std::endl;
}
2.7.3.2. 移动
往后移动两位:
2.7.3.3. 两个迭代器相减求距离
结果输出3
。
2.7.3.4. 两个迭代器比较
两个迭代器比较进行比较时,这两个迭代器必须指向相同的vector。就像之前数组那样,两个指针比较时,不能两个指针指向不同的数组。
以上这些功能,指针也有提供。我们的vector只是在最大程度上模拟指针的行为。
2.8. vector 相关的其它内容
2.8.1. 在vector中添加元素可能使迭代器失效
如果我们调用下图函数时,这些函数调用完了,可能会使迭代器失效。
如:我们使用x1.push_back(2);
给vector x1
元素末尾加入3
,这时我们的迭代器b、e可能失效:
2.8.2. 多维 vector
之前讲过的多维数组:
二维vector:
上图,x是一个vector,作为vector,它里面可以存储一系列数据,其中,每个数据类型又是std::vector。通过这样的方式即可构造多维vector。
那么我们如何去使用多维vector?
如:我们构造std::vector<int>()
(缺省初始化)对象,然后把它放入x.push_back
函数中,而x.push_back
中,x是个二维的vector,x[0]是个一维vector,x[0]
这个一维vector里面包含一个元素1:
输出结果是1。
也可聚合初始化:
输出结果为6。
多维vector的好处:可以建立每一维vector内部包含的元素个数不一样。如下图,第一个vector内包含3个元素,第二个vector包含2个元素。
2.8.3. 从 . 到 -> 操作符
之前提到过,我们可以使用x.size
函数来获取vector中包含的元素个数。但我们知道x是对象,故我们可以构造对象的指针(构造std::vector<int> x
的指针),然后可以使用ptr来访问x,即可使用ptr调用x中的方法(函数):
但是这样使用指针去调用方法,写法比较麻烦。
故引入-> 操作符,->能直接调用对象的方法(但->左边一定要是指针类型)。其实,只要是一个指针指向某个类的对象,则可用->操作符来调用类所包含的方法。
2.8.4. vector 内部定义的类型
这些类型都是定义在vector内部的一些类型别名。如 size_type
2.8.4.1. size_type
什么情况下系统会使用 size_type
?我们看看size函数声明:
size返回的就是size_type
类型的值,而size_type
在哪里定义的呢?在Member types(上图)里定义的。
2.8.4.2. iterator
/ const_iterator
与之类似,如果调用begin时,返回的是iterator;如果调用的是cbegin方法时,返回的是const iterator。而iterator和const iterator也是在Member types定义的。
3. 字符串string
3.1. string是 C++ 标准库中定义的一个类模板特化别名,用于内建字符串的代替品
std::basic_string
是C++ 标准库中定义的一个类模板:
在std::basic_string
里面能看到一系列的类型别名:
我们通过例子来看看string
的使用方法:
std::string x = "Hello world";
为什么要引入string?
实际上c++支持内建字符串,但与内建字符串相比,string更侧重于易用性。
3.2. 与内建字符串相比,string更侧重于易用性
我们可以可复制、可在运行期动态改变字符个数:
#include <iostream>
#include<string>
int main()
{
std::string x = "Hello world";
std::string y = x;
y = x + " !";//实现了string和内建字符串的拼接
}
3.3. 构造与初始化
构造字符串x、y(如下图),第一行使用了c++内建字符串(“Hello world”)来进行初始化,第二行使用string来进行初始化:
std::string x = "Hello world";//使用了c++内建字符串("Hello world")来进行初始化
std::string y = x;//使用string来进行初始化
关于string初始化的详细说明可直接查看basic_string:
如上图(1),第一种初始化方式是构造空的字符串。
如(2)我们可以构造出一个string,其里面包含一堆字符,给定字符个数以及具体的字符:(构造了包含3个字符,每个字符是a的string(字符串))
而std::string y = x;//使用string来进行初始化
调用的是(3)中的构造函数:
而这个构造函数相当于调用了如下图的方法:(该方法实际上是传入了另外的一个字符串来构造)
trick:
std::string y = x;
如上,对于string这种具体类型,我们可以使用拷贝(y = x
)的方式进行初始化。也可以使用小括号大括号的方式进行初始化(如下图),这样本质上是调用了string类当中的构造函数来进行初始化。
3.4. 其它方法
除了构造和初始化,string还支持其他的操作。如下:
3.4.1. 尺寸相关方法( size / empty )
我们可以调用size来判断字符串(string)里面包含多少个字符;我们也可以调用empty来判断字符串(string)是否为空。
3.4.2. 比较
我们还可以对字符串进行比较,如:
3.4.3. 赋值
对字符串y赋予新的值:
3.4.4. 拼接
两个string拼接:
#include <iostream>
#include<string>
int main()
{
std::string x = "Hello world";
std::string y = x;
y = x + x;//实现了两个string的拼接
std::cout << y << std::endl;
}
通过以上也可以看出,也可以在运行期字符串string所包含的元素的个数,但是如果单纯写两个内建字符串拼接(如下图),这是不行的:
我们可以这样拼接(一个string和一个内建字符串拼接):
为什么可以这样拼接?
在cppreference中,我们可以看到operator+这样一个内建的方法(如下图),这个方法即实现了一个string和一个内建字符串拼接,本质上来说会调用operator+这样一个内建的方法。
如由(1),我们即可实现两个string拼接;如(11),我们也可实现charT*
(它也是c++的一个内建字符串)和string拼接;但不能这么写:
上图代码的补救:(黄色的含义是我构造了一个临时的对象,该对象没有名称,类型是string,传入的参数是"Hello"
(即构造了个临时对象,对象里保存的是"Hello"
这样的临时信息)),此时相当于string+内建字符串+string:
3.4.5. 索引
对字符串string进行索引:
#include <iostream>
#include<string>
int main()
{
std::string x("Hello world");
std::string y("Hello");
std::cout << y[2] << std::endl;
std::cout << x[7] << std::endl;
}
3.4.6. 转换为 C 字符串
#include <iostream>
#include<string>
int main()
{
std::string x("Hello world");
std::string y("Hello");
auto ptr = y.c_str();//y是string这样的一个抽象的数据结构,对于这样的数据结构,我们可以调用它其中的方法,
std::cout << ptr << std::endl;
}
y是string这样的一个抽象的数据结构,对于这样的数据结构,我们可以调用它其中的方法,如:c_str
(代表c类型所支持的字符串),auto ptr = y.c_str();
实际上会返回一个指针(char),该指针会指向一个使用了“\0”进行结束的字符数组,这个字符数组里面的元素与y中包含的信息是相同的。
即auto ptr = y.c_str();
中的ptr是一个char*类型的指针,指向的内容时Hello
又如:
#include <iostream>
#include<string>
int main()
{
std::string x("Hello world");
std::string y("Hello");
y = y + "world";
auto ptr = y.c_str();//y是string这样的一个抽象的数据结构,对于这样的数据结构,我们可以调用它其中的方法,
std::cout << ptr << std::endl;
}
我们可以通过以上方式,实现string和c字符串(内建字符串)之间抽象的转换,即我们可以使用内建字符串来构造string,我们可以调用string的构造与初始化方法来构造string。也可以使用c_str
把string转换成内建字符串,为什么要实现这样的转化?因为我们可能在调用某些接口时,这些接口可能接收的是c++的字符串,另外一些接口接收的可能是c的内建字符串。
总结
我们在这一章重点讨论了数组,数组是一个基本数据类型(int ,char等等)的延伸(把基本数据类型串联起来排成一个序列,就构成一个数组)。基本上来讲,我们可以构造各种对象的数组,构造数组的数组。数组在使用时可能会被隐式地转换成对象的指针,即我们可以对数组进行索引与遍历等等操作。
在此基础上,我们讨论了vector和字符串(实际上不是字符串,而是c++的string这样的一个类),vector和string实际上是数组的代替品,因为数组更侧重性能,而vector和string更侧重易用性。