跳转至

博客导航

数据类型

变量的属性

众所周知,程序=数据结构+算法,而数据类型正是数据结构的基础,其重要性无可比拟,因而,本章将重点探讨数据类型相关的内容。首先,我们把数据类型按照下面的方式分类:

\[\begin{cases}\text{基本数据类型}\begin{cases}\text{数值型}\begin{cases}\text{整型}&\mathrm{int}\\\text{浮点型}\\\text{双精度浮点型}&\mathrm{double}&\end{cases}\\\text{字符型 char}\\\text{布尔型 bool}&\end{cases}\\\text{自定义数据类型}\begin{cases}\text{结构体 struct}\\\text{共用体 union}\\\text{枚举 enum}&\end{cases}\\\text{数组 [ ]}\\\text{指针 }^{*}\\\text{类 class}\\\text{空类型 void}&\end{cases}\]

这是最常见的一种分类方式,除此外,还可以按照存储类型进行分类,即:

\[\begin{cases}\text{单存储}\begin{cases}\text{整整 int}\\\text{字符型 char}\\\text{布尔型 bool}\\\text{指针 *}\\\text{浮点存储}\begin{cases}\text{单精度浮点型 float}\\\text{双精度浮点型 double}&\end{cases}&\end{cases}\\\text{多存储}\begin{cases}\text{单一类型}\begin{cases}\text{数组 [ ]}\\\text{枚举 enum}\\\text{复合类型}\begin{cases}\text{结构体 struct}\\\text{共用体 union}&\end{cases}&\end{cases}\\\text{空类型 void}&\end{cases}\\\text{类 class}&\end{cases}\]

字符型变量的实际存储方式是对应字符的ASCII码,布尔型变量的取值只有0(false)或1(true),而指针类型的存储的值是地址,而地址是一个整型值,因此,整型、字符型、布尔型和指针类型均属于整存储类型。

一般来说,数组是由单一类型存储多值的一个变量,可以通过下标访问某一值,而结构体和共用体含有不同的数据类型。

我们如此分类是为了方便讨论变量的属性中的共性和特性。在C++中,一个变量具有的属性很多,且部分属性复杂,本书中只涉及常用的属性,下面是常用变量属性表:

属性名称 定义方式 属性值
地址 隐式 地址值,在64位机器上占8个字节
显式、(限定)隐式 由数据类型决定
名称 显式 标识符
数据基础类型 显式、(限定)隐式 除指针、数组之外的数据类型
数据附加类型 (限定)显式、隐式 指针、数组
存储类型 显式、隐式 auto,register,extern,static
作用域 显式、隐式 由定义位置和存储类型决定
生存期 显式、隐式 由定义位置和存储类型决定
修饰类型 显式、隐式 const
表达范围 隐式 由数据类型决定
占用空间 隐式 由数据类型决定

我们将逐一介绍变量的这些属性。我们把需要人为编写代码规定的操作称为显式操作,把系统自动匹配识别进行的操作称为隐式操作。

地址是变量在内存中存储的位置,当定义变量时,系统会自动从内存中找到位置存储,绝大多数情况下无需显式定义。

值是变量的首要属性,也是唯一一个可变属性,一般来说,值必须显式定义,即必须赋初值,否则不能引用,尤其const变量必须在声明时初始化,但是以下几种变量例外:

  1. 全局变量
  2. 静态变量
  3. 类的成员变量

数值型变量会自动初始化为0,字符型变量会自动初始化空字符。

名称也是变量的首要属性,定义要求符合标识符的规则即可,因为名称也是标识符,是唯一一个需要必须显式定义的属性。

数据基础类型包括整型、单精度浮点型、双精度浮点型、布尔型、字符型、结构体、共用体、枚举、类和空类型,简单记忆就是由英文标识符表示的数据类型。其中,整型、双精度浮点型和字符型可以使用修饰符衍生出新的数据类型,修饰符有unsigned、signed、short、long四个,其中双精度浮点型只能被long修饰、字符型只能被unsigned和signed修饰、整型在不矛盾的前提下,这四个修饰符可以任意结合修饰。使用修饰符修饰后,其表达范围和占用空间会受到影响,其具体情况在表达范围中讲到。

数据附加类型包括指针和数组,其没有直接的英文标识符,使用符号标识,且能和数据基础类型的整型、单精度浮点型、双精度浮点型、布尔型、字符型结合。注意数据附加类型可以两者同时结合,例如指针单精度浮点数组。

在程序中,语句之间存在着并列关系和嵌套(包含)关系。并列关系的语句中,在前面的语句称为前级语句,在后面的语句称为后级语句,代码在编译时,从前级往后级运行,这也就是变量需要在使用前定义的原因。嵌套关系的语句中,在外部的语句称为外层语句(父语句),在内部的语句称为内层语句(子语句),任何语句都可以和其它语句有并列关系,但只有一些语句能和其它语句有嵌套关系,这类语句有:

  1. 块语句(复合语句)。即用花括号括起来的语句。
  2. 循环语句。while、do-while语句和for语句。
  3. 条件语句。if-else语句和switch语句。

变量从时间和空间上有两个属性,分别是生存期和作用域。一般来说,一个变量被定义后,与其并列最后一个语句结束后,该变量被销毁,也就是生存期结束,一个变量的作用域也是从定义语句开始,直到并列的最后一个语句结束。也就是说,一个变量的后级语句和内层语句能调用它,而该变量的前级语句和外层语句不能调用它,这种定义在程序块或者函数体内的变量就被称为局部变量,其作用域和生存期有限。

而另一类变量定义在程序开头,主函数外部,其生存期是从程序运行开始到结束,其作用域是整个程序,这种变量被称为全局变量。作用域和生存期除定义位置外,还可以使用一些关键字修改其作用域和生存期。

变量的存储方式分为静态存储和动态存储。动态存储方式分为自动变量和寄存器变量,自动变量使用auto关键字,使用时不能指定数据类型,但必须要初始化,因为自动变量需要根据初值判断变量类型,因而使用auto关键字是一种隐式定义变量类型的方法。寄存器变量使用register关键字,这样定义的变量存储在CPU的通用寄存器中,由于CPU读写寄存器的数据速度要比读写内存里的数据速度快,可以使用register关键字提升运行效率,但由于CPU的通用寄存器数量有限,不建议使用过多的寄存器变量,如果寄存器被占用时,定义的变量会转换为自动变量。

静态存储方式分为外部变量和静态变量,外部变量是在程序运行期间分配固定的空间给变量,存储在静态存储区,是最常用的方式,使用extern关键字指定,但是这种方式是默认的,因此extern可以省略,静态变量使用static关键字,也存储在静态存储区。外部变量和静态变量对于全局变量和局部变量的表现效果不同,如下表:

extern static
全局变量 将作用域从本文件扩展其它文件
局部变量 将作用域从本代码块扩展的本文件

修饰类型是本书中自定义的一个名称,使用const修饰符(关键字),使得变量成为不可变的量,在定义时必须初始化,但是其值不能再修改,这种不可变的量称为常量。后文中均称const类型变量。 这种常量时存储在内存中实实在在的常量,其除了值不可变外,其它属性与一般变量类似,但是,还有另一种常量,多是编写程序时用到的临时值,用完就销毁,这种常量称为临时常量,也称字面量。通过实践不难发现,使用指针可以取到const常量的值,但不能取到字面量的值,会产生错误。

由于变量类型决定占用空间和表达范围,因而在已知变量类型和语言环境设定的占用空间的前提下,可以推算表达范围。如果需要记忆,可以只记忆占用空间即可(但一般情况下不会用这种要求)。例如字符型变量的占用空间为1字节,已知1字节是8位,则其表达范围为0至2^8-1,即0至255。

基本变量类型的使用语法规则:

  • 声明
C++
[修饰类型] [存储类型] 数据类型 变量名表;

方括号表示这个参数或者关键字是可选的。

  • 赋值
C++
变量名 = (字面量);

引用是指对变量的使用,例如为其它变量赋值、作函数参数、参与运算等,说明变量名即可。赋值是一种特殊的引用,赋值号的左值必须是可变的变量,而不能是常量或者字面量。

  • 初始化
C++
[修饰类型] [存储类型] 数据类型 变量名 = (字面量);

初始化是指在声明变量时对其赋值。

自定义数据类型

自定义数据类型也称为构造类型,顾名思义,是指在使用时不能像基本数据类型那样直接使用,而是需要进行自定义构造之后,才能创建实例进行使用。自定义数据类型有结构体(struct)、共用体(union)和枚举(enum)。

自定义变量在语法上有相似之处,但其实质不同。

结构体用于将不同类型的数据组织在一起形成一个单一的数据单元。结构体的成员可以大多数数据类型,包括结构体。共用体在同一内存空间存储不同类型的数据。共用体的成员共享相同的内存位置,因此它们只能同时保存其中一个成员的值。枚举是一种用于定义命名整数常量的数据类型,其实质是常量列表的整合。 为了能更好的理解这三种变量类型,下面将以实例介绍:

C++
//定义一个性别变量,其值包括男性和女性。
enum sex { male, female }; //自定义枚举类型
sex student_sex; //创建枚举变量
student_sex = male; //给枚举变量赋值

//可以把上三步合到一起:
enum sex { male, female } student_sex; //自定义枚举类型并创建枚举变量
sex student_sex = male; //创建枚举变量并初始化
enum sex { male, female } student_sex = male; //自定义枚举类型并创建枚举变量并初始化

//也可以不用自定义枚举类型:
enum { male, female } student_sex = male; //不自定义枚举类型直接创建枚举变量并初始化

//当枚举类型重用时,需要自定义枚举类型:
enum sex { male, female };
sex student_sex = male;
sex worker_sex = female;

枚举变量实质时常量表的集合,从第一个枚举符开始,默认的值为0、1、2……,也可以自行定义枚举符代表的值,未定义的部分会自动顺延前面已经定义的。

C++
//定义一个学生类型变量,其值包括本科生、博士生、硕士生。
enum stype { bachelor, doctorate = 2, master }s1, s2;
s1 = stype(3); //s1枚举符为master,值为3
s2 = bachelor; //s2枚举符为bachelor,值为0

//定义一个学生信息变量,其成员包括姓名、性别、学号、成绩、类型。
enum sex { male, female };
enum stype { bachelor, doctorate, master };
struct student {
    string name;
    sex sex;
    stype type;
    int id;
    float score;
};
student s1{ "李明",male,bachelor,485784512,95.5 };

//如果需要输出其姓名和分数,则需要访问成员,当然赋值时也可以单独访问成员。
cout << s1.name << ":" << s1.score << endl;
共用体类型变量是不同的成员变量类型共用同一个内存,因此,其共用体类型变量的占用内存大小取决于内存占用最大的成员变量。如果其中一个成员变量发生变化,其它成员变量也一起发生变化。

已知有如下代码:

C++
1
2
3
union num { float f; int i; }n;
cin >> n.f;
cout << n.i;

当我们输入3.5时,输出的值为1080033280,这与数据在计算机中的表示方法有关,在上一节末尾,我们说到了float变量的存储格式,3.5的二进制为:0 10000000 11000000000 000000000000,其转换为整数即为1080033280。具体转换原理参考IEEE 754。

可以用typedef定义一个新类型的名字,其既没有创造一个新的变量类型,也没有影响原来的变量类型,原变量标识符仍然是可用的。

最后,我们探讨各自定义变量类型所占用的空间大小。首先探讨结构体类型占用空间大小,下面是几个例子:

C++
struct {
    int a;
    float b;
}z1;
struct {
    int a;
    double b;
    char c;
}z2;
struct {
    int a;
    double b;
    char c;
    long long d;
}z3;
struct {
    int a;
    double b;
    char c;
    short d;
}z4;

如果说结构体所占用空间大小是其各成员变量之和,则z1,z2,z3,z4的占用空间为8,13,21,15。试着使用sizeof取占用空间大小操作符,判断猜想是否正确。经过测试发现,其结果为8,24,32,24。这和猜测大相径庭,再进一步输出地址,查看其地址分配情况,可以发现以下规律:

  1. 默认情况下,结构体的大小是结构体中最长类型的整数倍。
  2. 结构体中的空间分布是按照结构体中最长类型对齐的。
  3. 结构体中不同类型的成员,一定是按照自己的类型对齐。

这个规律被称为结构体成员对齐规则。这是默认情况下的对齐方式,也可以指定对齐方式,使用预处理命令#pragma pack(n),n为常正整数,表示n字节对齐。

共用体的占用内存在前文中说明过,由其最长类型成员决定。

枚举的占用内存一般情况下为定值4,是因为其实际上是一个整型常数表。

到此为止,我们说明了数据基础类型,即有英文单词作为关键字的数据类型,包括基本数据类型和构造数据类型。从下一节开始,开始说明数据附加类型,即数组和指针。数据基础类型彼此之间不能组合,例如一个变量不能既是int,又是double,但是数据附加类型可以和数据基础类型结合,例如char数组,float指针等。

数组类型

对于少量的数据,使用几个变量就可以表示和操作,但对于大量的数据,重复定义大量个变量是非常不现实而繁杂的,因而,引用了数组作为大量数据的存储方法,对数组可以进行访问和操作。结构体是不同类型变量的集合,而数组是相同类型变量的集合。

数组属于数据附加类型,必须有一个数据基础类型,定义是需要指定,数组和指针属于可嵌套类型,例如,数组有一维数组、二维数组、三维数组……,而指针有一级指针、二级指针、三级指针……。

数组采取连续存储的方式,一维数组的元素地址计算方式为:数组元素地址=数组起始地址+元素下标,二维数组的元素地址计算方式为:元素地址=数组起始地址+(元素下标1*元素列数+元素下标2)*数组类型字节数。

更高维的数组也可以推广。在这里给出n维数组的元素地址运算公式和占用内存公式。首先是元素地址运算公式:

对于数据类型字节长为\(t\)\(n\)维数组\(a[i_1][i_2][i_3]...[i_k]...[i_n]\),符合对于任意\(j_k<i_k\),其某一元素\(a[j_1][j_2][j_3]...[j_k]...[j_n]\)的运算公式为:

\[\&a[j_1][j_2][j_3]...[j_k]...[j_n]=a+\left[\sum_{k=1}^n\left(\prod_{s=k+1}^ni_s\right)j_k\right]t\]

对于数据类型字节长为\(t\)\(n\)维数组\(a[i_1][i_2][i_3]...[i_k]...[i_n]\),其占用内存公式为:

\[s=\left(\prod_{k=1}^ni_k\right)t\]

最后讨论关于字符串的问题,字符串是指一串字符,但char只能表示单个字符,不难想到,可以使用字符数组来表示一串字符。C++中可以使用这种方法,但为了表示一串字符的结束,使用结束表示字符(也称终止符)“\0”来标识,否则会出现一些问题。

其实,除了使用字符数组,有着更为方便的定义方式,即string标识符,但string定义的字符串变量不能像字符数组那样单字符操作,但在不同的场合,使用字符串和使用字符数组各有优劣。使用string标识符前,需先引用头文件。

如果在字符串结尾不使用\0会出现一些问题,如果没有使用\0会输出“烫”,这是因为在调试时,内存会用0xcc来初始化,而0xcccc恰好是"烫"的编码。

指针类型

数组作函数参数传递时,对于形参的操作会影响到实参,而对于一般变量作函数参数传递时,对于形参的操作却不会影响实参,这种方式称为值传递。而数组名我们说过是第一个元素的地址,即数组作函数参数时的传递方式称为地址传递。值传递具有形实分离的效果,而地址传递具有形实一体的效果。也可以获得一般变量的地址,实现和数组一样的传递方式。

如果想要获取一个变量的地址,我们可以使用取地址运算符&,对于变量a,&a就是其地址,但是如果想要把这个地址存储起来,我们可以使用一种专门存储地址的变量,即指针变量。例如对于int a,可以取其地址int *p=&a。指针变量定义的方法和基本变量类似,只不过是带上了符号*,其数据类型指的是这个指针只能指向哪种变量,而不是指针变量本身的值。有关指针的一些问题值得注意:

  1. 指针不能被直接输入或使用字面量指定,但可以输出,即对于int *p=&a,cout << p是合法的而cin >> p是非法的。
  2. 指针必须现指向一个变量,即初始化后才能进行使用,例如int *p;cin >> *p是非法的。
  3. 指针运算符*没有严格的格式要求,即int * p、int* p和int *p是等价的。

*称为间址运算符,又成为解引用运算符,在定义时起到指针变量的标识符,在调用时起到指向地址的所对应的量,而指针变量被定义后,如果不带*表示地址,带*表示地址对应的量。

指针可以通过加、减、自增和自减运算进行移动,例如对于int型指针p的p++是指向下移动一个整型变量的位置,若p初值为5000,则移动后的值为5000+1*4=5008。指针可以进行比较,低地址端的存储单元小于高地址端的存储单元。

两个指针变量相加减对于非数组和结构体变量没有实际意义。对于数组或者结构体具有一定意义。

一般而言,数组中元素存储的地址是连续的,我们可以根据这一规律使用指针访问数组内的不同元素,首先我们研究一维数组如何使用指针表示,已有数组a[n],且*p=a或(*p=&a[0]),则对于数组a的元素,可有以下表示方法:

下标法 指针法
a[0]、p[0] *a、*p
a[i]、p[i] *(a+i)、*(p+i)

接下来是二维数组使用指针指定元素的方法:

下标法 下标-指针法 指针法
首地址 - &a[0]
首值 a[0][0] *a[0]
i行首地址 - a[i]、&a[i][0]、&a[i]
i行首值 a[i][0] *a[i]
i行j列首地址 - a[i]+j、&a[i][j]
i行j列首值 a[i][j] *(a[i]+j)、(*(a+i))[j]

同样也能得到三维及 n 维数组的指针指定元素的方法。使用数组地址运算公式即可得出,具体方法是展开这个公式,然后提公因式,最后在每一层括号外添加指针符号即可。例如取 n=5 时,可以展开为:

\[\&a[j_1][j_2][j_3][j_4][j_5]=a+\left[\sum_{k=1}^5\left(\prod_{s=k+1}^5i_s\right)j_k\right]t\]
\[=a+(j_{1}i_{2}i_{3}i_{4}i_{5}+j_{2}i_{3}i_{4}i_{5}+j_{3}i_{4}i_{5}+j_{4}i_{5}+j_{5})t\\=a+(((j_{1}i_{2}+j_{2})i_{3}+j_{3})i_{4}+j_{4})i_{5}+j_{5})t\]

\(a\)的一维数组指针代入内部,由于指针运算时会自动乘以其字长,因此 t=1:

\[=(((((a+j_1)i_2+j_2)i_3+j_3)i_4+j_4)i_5+j_5)\]

最后变换得到:

\(a[j_{1}][j_{2}][j_{3}][j_{4}][j_{5}]=*(*(*(*(a+j_{1})+j_{2})+j_{3})+j_{4})+j_{5})\)

即为五维数组的指针。

由此容易得到用指针表示数组的一般规律:

\[a[j_1][j_2][j_3]...[j_k]...[j_n]=*(...*(...*(*(*(a+j_1)+j_2)+j_3)...+j_k)...+j_n)\]

可以求出 n 维数组的一种表示后,再求出 n-m(m<n)维数组的另一种表示,再求出 n-\(k(k<n,k\neq m)\)维数组的还一种表示,然后彼此带入,形成混合表示,例如:

\(\(*\left(*\left(*\left(*\left(*\left(a+j_1\right)+j_2\right)+j_3\right)+j_4\right)+j_5\right)\)\) \(a[j_1][j_2][j_3][j_4]\)

下式带入上式得到:

\[*(a[j_1][j_2][j_3][j_4]+j_5)\]

这是简单的一种混合,可以自由进行混合使用。

如同前面这样,进行连续解引用,或着定义时使用多个指针标识符*,这种指针被称为多级指针,和数组类似,也分为一级指针、二级指针、三级指针……,注意,一级指针是指向一般变量的指针,而二级指针是指向一级指针的指针,三级指针是指向二级指针的指针……,不能直接越级指定。

由于指针属于数据附加类型,因此可以有常量指针,常量指针既可以指向常量,也可以指向非常量,当指向非常量时,被指向的非常量可变,但指针本身不可变。

还有一种指针是void指针,也称为通用指针,可以用来存放任何数据类型的引用。通用指针有四个性质:

  1. void指针具有与char指针相同形式的内存对齐方式。
  2. void指针和别的指针永远不会相等,但赋值为NULL的两个void指针相等。
  3. void指针可以被任何指针赋值,且都可以转换为原来的指针类型。
  4. void指针只能做数据指针,不能做函数指针。

先思考下面程序的可行性,再试着上机运行以验证你的猜想。

C++
1
2
3
int a = 0, * p = &a;
for (int i = 0; i < 5; i++)cin >> *(p + i);
for (int i = 0; i < 5; i++)cout << *(p + i) << " ";
把代码复制进编辑器后,点击运行,没有报错,然后输入数据调试,结束运行的一刻,出现了调试错误窗口(这是可能的情况之一)。但可以看到控制台上,能进行正确的输出。这个程序可以说是擅自创建了一个一维数组,但这种直接对内存操作的方式属于越权访问,是禁止的,但是我们可以对系统进行申请空间,而不是擅自使用,申请空间使用到关键字new,我们可以使用一个指针变量将申请的空间的首地址存储起来,当我们使用完后,再进行释放,把空间归还给系统。这样一来,就解决了不能用变量指定数组元素个数的问题。这种操作被称为动态申请内存空间,也称为动态内存分配。

下面是动态申请内存空间的常见操作:

C++
//申请一个整型变量
int *num = new int; 
//释放
delete num; 

//用变量指定n个元素的一维数组
int *num = new int[n];
//释放
delete []num; 

//用变量指定i行j列的二维数组(内存不连续)
int** num = new int*[i];
for (int s = 0; s < i; s++) {
    num[s] = new int[j];
}
//释放
for (int s = 0; s < i; s++) {
    delete []num[s];
}
delete []num;

//用变量指定i行j列的二维数组(内存连续)
int* num = new int[i * j];
for (int s = 0; s < i; s++) {
    for (int t = 0; t < j; ++t) {
        num[s * j + t] = s * j + t;
    }
}
//释放
delete []num;

但这种内存连续的二维数组本质还是一维数组,只是将一维数组模拟成了二维数组,使用这类方法还能建立不规则的二维数组(即不同列的行数不一定相同)。

使用动态申请内存空间后,当内存使用结束后,需要对内存进行释放,即把已使用完的内存归还给系统,还需要注意保存内存地址的指针变量不能再赋其它值,如果这样做,就丢失了原来的内存地址。上面两种情况被称为内存泄漏,前者称为隐式内存泄漏,后者称为显示内存泄漏。

如果内存已经释放,但是指针还在引用原始内存,这样的指针被称为迷途指针,这种指针没有指向有效对象,迷途指针存在潜在的安全隐患,若有不慎可能会造成严重后果。

如果内存已经释放,又再一次释放了内存,这种情况称为重复释放,会产生程序异常。解决这种问题的方法是在释放指针后,将指针的值置为NULL,大部分堆管理器会忽略后续对空指针的释放。

有这样一种数组,不预先指定其有多少个元素,而是直接进行输入,直到检测到输入的值有某一标识符,这时停止输入。实现这种数组使用到动态申请内存空间的方式,具体方法如下:

C++
int* array = nullptr;
int size = 0, input, key; 
cin >> key;
do {
    cin >> input;
    if (input != key) {
        int* tempArray = new int[size + 1];
        for (int i = 0; i < size; ++i) {
            tempArray[i] = array[i];
        }
        tempArray[size] = input;
        delete[] array;
        array = tempArray;
        size++;
    }

} while (input != key);
//释放
delete[] array;

指针可以为简单或者复杂的数据结构提供更多的灵活性,例如链表、队列、栈和树。

该文章由作者完全原创,请在转载时标明出处。

C++17 文件与目录操作<filesystem>

当你需要对计算机上的文件和目录进行操作时,C++17标准库中的头文件可以为你提供方便的工具。它提供了一系列的类和函数来处理文件和目录的操作,包括路径操作、目录遍历、文件检查、文件操作等等。本文将为你介绍头文件的使用方法和功能。