赞
踩
本文旨在探讨一些在使用 C C C/ C C C++(为方便起见,考虑到 C C C++几乎完全兼容 C C C语言的特性,以下都表述为 C C C++)中的数据类型时需要注意的认识上的问题。本文仅为作者本人经验总结与个人认识。
在 C C C++中内置了多种基本数据类型,这些基本数据类型结合指针与数组理论上可以构造出无数种复合数据类型,此外两种语言都允许通过特定的语法构造用户自定义的数据类型。研究 C C C++中的数据类型,可以提升对这些数据类型的使用灵活性,也能避免一些编程错误的出现,此外对数据类型的理解也决定着自定义数据类型的编写能力以及认识境界,(事实上对数的本质也能有更深的认识),是编程的重要基本功。本文将对数据类型进行一定的讨论,在此之前,让我们先回顾一个基本的概念。
编写程序的实质是实现算法,因此需要支持基本的运算,而运算又作用于参与运算的运算数。在许多情况下,上一次运算的运算数在运算完毕后并非就失去了作用,而是仍会在后续的计算中发挥作用,这就要求计算机能够为一个运算数进行生命周期的分配,对其实现长期的存储。这些运算数主要储存在内存中 ,而为了能够指向性地查询或者使用某一内存上的运算数,需要对不同的内存区域进行编号,计算机中采用的是基数编号,也就是用连续递增的(十六进制)数来依次取定对内存的编号,这实际上是由内存到编号数字的对应关系。由内存到编号数字的映射称为地址,有了地址,就可以通过一个数字串进行对特定区域内存的访问和使用了。
地址给出了一种指向特定内存区域的方法,基于这种方法诞生了最早的机器语言,这种编程语言通过特定的0/1串组合来实现对不同地址内存的访问和使用。尽管利用地址进行访问是非常直接并且有效的,但这种访问方式存在着一些不足,其中两个主要问题如下:
1. 程序易读性差,编写难度高。 程序员需要清晰地了解一串十六进制数到底代表着用于什么功能的运算数,甚至需要处理成百上千个这样的对应关系,并且程序的解释文档显得至关重要,一旦将代码交付给另一位程序员,在绝大多数情况下另一位程序员是绝无可能对上述的对应关系有着与原程序员同等正确的认识的,而正因如此也并不会有愿意接手这种代码的程序员;
2. 不便对于不同存储性质的运算数做特殊化的处理。 一种算法往往涉及许多不同类型的运算数,有的运算数只需要用来访问有的运算数需要支持修改,有的运算数代表着整数而有的代表小数,有的内存用于存放数值有的用来修饰数值。如果将内存完全开放给程序员使用,很难实现对不同类内存进行特殊化的处理,这会降低程序的稳定性和效率,并且也会限制计算机程序的功能。
因而我们需要一种更直观也更合理的方式来进行内存使用。为了解决第二个问题,变量的概念应运而生,而变量自然地需要拥有为其分配的一块内存空间,而为了解决第一个问题,就需要有一种新的映射关系,能将变量或者变量所在的内存区域映射为一种更易读和更简洁的形式。我们最终采取使用字符序列作为变量的映射,而这种由一个变量到特定字符序列的映射关系称为 名字 ,而构建这种映射关系的过程称为命名。
有了名字这种概念,我们可以用更直观的英文单词或者字符组合来暗示一个变量的功能或用途,并且在编程时可以更专注于算法逻辑的实现而非对地址的实际操作,有效提升了编程的效率。显然,对变量的命名方式的限制应该越少越好,有利于程序员最大自由度地去使用变量,但为了区别于其他的一般性功能语句和便于交流等原因,一定程度的命名规则是必要的。 C C C++规定:
标识符 是一个由数字,下划线,大小写拉丁字母和大多数 U n i c o d e Unicode Unicode 字符组成的任意长度的序列,而非关键词的标识符可以用于为变量命名。
至此,我们拥有了一种规范明确、直观有效的标识方式来进行对内存的操作,这是数据类型的基础。
下面我们将要进一步讨论一种数据类型的基本属性。
对变量进行分类是在变量使用到一定程度后一种自然的做法。实际问题中需要处理的数值类型不同(整数、小数)、数据规模不同(长型,短型)、精度要求不同(单精度、双精度),不同类的变量所需要完成的运算操作也有所不同(例如整数才有取模运算,小数才需要有取整操作),因而变量也需要有不同的数据类型,一般的讨论中,一种数据类型区别于其他数据类型的本质属性主要在于两个方面:存储性质与运算性质。(想一想,名字 是数据类型的属性吗?)
(注意!此处讨论的不是存储类型( s t a t i c static static / a u t o auto auto / r e g i s t e r register register) )
存储性质一般意义上指的是变量的存储形式。不同类型的变量有不同的存储方式,例如整数在计算机中的存储显然有别于浮点数在计算机中的存储,整型在计算机中以补码的形式存储,而浮点数的存储方式较为复杂,简单地说就是浮点型变量的存储空间会被划分为分别指示符号、科学计数法中的指数以及科学计数法中的底数三部分。因而可以发现存储性质区别着不同的数据类型。
但事实上,计算机并不理解它所存储的是什么类型的数据,无论什么样的数据在计算机中都是指定长度的0/1串,不同类型的数据在具体的内存表现上看不出任何区别,真正不同的是对于对应的0/1串的解释方式。固然,对于相同的数值,计算机会采用不同的0/1串作为其存储内容,但真正不同的其实是在输出或者读入这些数值时计算机所采取的解释方式。典型的例子是对于8位无符号整型数 1111 1111,其标准十进制打印应为255,但如果采用8位有符号整型数 的格式打印其结果则为-1。然而,所谓的输出 或者 解释 更恰当的理解是将其认为是一种运算而非存储的方式,而这是我们即将介绍的。从这里可以看出,至少在仅考虑利用高级语言编程这一过程的情形下,存储性质并不具有太多值得考虑的必要。
运算性质指的是对某种类型的变量所允许的运算的实现方式,它指明了一种运算的底层操作方法。事实上,比起繁琐的底层实现流程,我们更关心的是这一实现方式所具有的抽象性质,例如它大致的运算思想、针对不同数据规模和顺序的特性等。一种数据类型其个性完全要靠其支持的各种运算来彰显,缺乏定义在某种类型上性质优良的运算,这种类型就没有价值。因而:
对于具有类型 的实体而言,类型 限制了对这些实体所容许的操作,并给原本寻常的位序列提供了语义含义。
综合以上的讨论,要理解一种数据类型,核心在于掌握其运算的实现手段,而实现一种操作往往借助于利用更底层的数据类型实体来表示原数据类型、利用该底层数据类型的运算来模拟原数据类型的运算。例如整型变量的加减法运算是基于二进制数的位运算实现的。(想一想,你能用与、异或和左移来模拟二进制数的加法吗)下面我们将以 C C C++中的典型复合式数据类型——数组与指针——为例来探讨在具体场景下分析数据类型特性的方法。
(1)指针
C C C++中的指针 是用于引用其他变量的变量类型。指针的基础类型取决于其所引用的变量的类型,任何变量通过前缀取地址运算符 & 转换为对应的指针常量,而对指针变量进行前缀间接引用运算符 * 转换为对应变量的引用。对于指针变量 p t r ptr ptr以及其引用的变量 v a l val val,“引用”这一表述强调表达式 ∗ p t r *ptr ∗ptr就是变量 v a l val val的一个别名,既可以访问又可以修改。
习惯上,一个 i n t int int类型的指针有时称为一个 i n t int int型的指针,有时称为一个 i n t ∗ int* int∗类型的指针,但事实上只能说这是一个以 i n t int int为基础类型的指针或者是一个 i n t ∗ int* int∗类型的变量。习惯上并不强调这一区别,但在本文中不沿用这种习惯,会严格区分这两种说法。
指针变量的声明格式如下:
T *ptr;
声明了一个基础类型为T的指针变量。注意 ∗ * ∗在其语义上是与类型说明符 T T T关联的,即表示整个指针变量是一个 T ∗ T* T∗类型的变量。但在语法上 ∗ * ∗是与标识符联系在一起的,即声明多个基础类型为 T T T的指针变量时需要:
T *ptr1, *ptr2;
下面我们从运算的角度认识指针:
① 取地址
事实上,取地址是任何类型变量都支持的运算。任意一个 T T T类型的变量 v a l val val,表达式& v a l val val(即在变量名前加一个&)表示一个引用val的指针常量,因此这个表达式值的类型为 T ∗ T* T∗。对于一个 T ∗ T* T∗类型的变量,其取地址的结果就是这个指针变量对应的指针,即一个引用指针变量的指针变量,或者通俗地说一个指向指针的指针,即一个二级指针,类型为 T ∗ ∗ T** T∗∗。
② 赋值、加减法运算
指针支持赋值以及加减法运算,也就是说指针类型的表达式或变量可以作为 o p e r a t o r + operator+ operator+、 o p e r a t o r − operator- operator−的操作数,指针类型的变量也可以被赋值。因为在计算机中指针的实现方式是通过操作地址的方式实现的,因此指针除了在其抽象含义上是用来引用某一变量的中间变量,也可以拥有自身的值,可以进行赋值和加减等操作。也就是说,上文中我们讨论的“取地址”与前文中“地址”讨论的关于“地址”的概念是同一个。但指针与常数运算时会将常数隐式转换为乘上对应类型步长的指针类表达式。即:
T val;
T *ptr = &val;
记变量 v a l val val所占内存空间的地址是 α α α,那么对于一个整型常量 B I A S BIAS BIAS:
ptr + BIAS
等价于以 α + B I A S × s i z e o f ( T ) α+BIAS×sizeof(T) α+BIAS×sizeof(T)为值、 T ∗ T* T∗为类型的表达式。
而在指针变量之间进行减法运算时,运算的结果会自动除以对应类型的步长。即对于两个 T ∗ T* T∗类型的变量 P t r Ptr Ptr和 p t r ptr ptr,如果其值分别为地址 α α α和 β β β,那么:
Ptr - ptr
等价于以 ( α − β ) / s i z e o f ( T ) (α-β)/sizeof(T) (α−β)/sizeof(T)为值的表达式。需要注意的是,这个表达式的类型与 T T T无关,是一个 i n t int int类型的表达式,含以上表示两个变量之间的间隔(以步长为单位)。
(注意!本文强调取地址以后的表达式称为该变量的指针而不是该变量的地址,原因在于地址只有值的属性而指针有类型的属性,而后者在许多指针与数组的综合问题中至关重要)
③ 下标运算
即指针变量可以作为 o p e r a t o r [ ] operator[] operator[]的操作数。对于T*类型的变量 p t r ptr ptr和整型表达式 b i a s bias bias:
ptr[bias]
等价于表达式 ∗ ( p t r + b i a s ) *(ptr+bias) ∗(ptr+bias),语义上表示 p t r ptr ptr所引用的变量在地址顺序上的后(或前) ∣ b i a s ∣ |bias| ∣bias∣个位置上的变量的引用。故 p t r [ 0 ] < = > ∗ p t r 。 ptr[0] <=> *ptr。 ptr[0]<=>∗ptr。这一运算符具体的作用在数组中才能体现出来。
④ 作为 s i z e o f sizeof sizeof运算的操作数(略)
(2)数组
C C C++中的数组 指相同类型、 连续存储的线性变量列表。数组的简单声明格式如下:
T array[LEN];
声明了一个 T T T类型的、长度为 L E N LEN LEN的数组变量,利用标识符 a r r a y array array与这个数组变量进行链接。数组的类型指的是每一个元素的类型,数组的长度则是指数组所含元素的个数。要理解这个声明,可以把末尾的长度说明符 [ L E N ] [LEN] [LEN]提前到类型说明符T之后,即声明了一个 T [ L E N ] T[LEN] T[LEN] 类型的变量。这种理解方式有利于我们理解多维数组。 C C C++中的多维数组是数组的数组,以二维数组为例:
T matrix[ROW][COL];
将
[
C
O
L
]
[COL]
[COL]提前到类型说明符
T
T
T之后,发现这一声明声明了一个基础类型为
T
[
C
O
L
]
T[COL]
T[COL]的、长度为
R
O
W
ROW
ROW的数组变量,并利用标识符matrix与这个数组变量进行链接;再将
[
R
O
W
]
[ROW]
[ROW]提前到
T
T
T之后,发现这一声明也可以理解为声明了一个
T
[
R
O
W
]
[
C
O
L
]
T[ROW][COL]
T[ROW][COL]类型的变量。
一维数组的使用格式:
array[idx]
这个表达式引用数组变量 a r r a y array array的第 i d x + 1 idx+1 idx+1个元素。注意这里引用的表述,正因如此这个表达式既可以读该元素的值也可以将数据写入该位置。或者由于引用事实上是起别名,也可以说 a r r a y [ i d x ] array[idx] array[idx]就是 a r r a y array array第 i d x + 1 idx+1 idx+1个元素的变量名。
使用多维数组时要理解多维数组是数组的数组。因此如果要引用二维数组的第 r o w + 1 row+1 row+1个数组的第 c o l + 1 col+1 col+1个元素,先通过表达式 m a t r i x [ r o w ] matrix[row] matrix[row]引用第 r o w + 1 row+1 row+1个数组,此时 m a t r i x [ r o w ] matrix[row] matrix[row]是数组 m a t r i x matrix matrix第 r o w + 1 row+1 row+1个元素的变量名,即第 r o w + 1 row+1 row+1个数组的数组名。根据一维数组的使用格式,使用数组 m a t r i x [ r o w ] matrix[row] matrix[row]的第 c o l + 1 col+1 col+1个元素的格式:
matrix[row][col]
下面我们从运算的角度讨论数组这一数据类型。数组变量支持的主要操作为:
① 取地址
数组变量的取地址(形式上是在一个数组名前加&)结果为一个数组指针,即引用数组变量的指针,其值为数组首元素的地址,而这个地址也被定义为数组变量的地址。故:
&array
是一个 ( T [ L E N ] ) ∗ (T[LEN])* (T[LEN])∗类型(即以 T [ L E N ] T[LEN] T[LEN]为基础类型的指针类型)的表达式,其值等于表达式:
&array[0]
类似地,对于二维数组 m a t r i x matrix matrix:
&matrix
是一个 T [ R O W ] [ C O L ] ∗ T[ROW][COL]* T[ROW][COL]∗类型的表达式。其值等于表达式:
&matrix[0]
而由于matrix[0]是matrix中第一个元素或者说第一个数组变量的引用,即第一个元素的变量名或者说第一个数组变量的数组名,因而对这个数组取地址的结果在值上等于这个数组首元素的地址,即:
&matrix[0][0]
(注意!这三个表达式具有相同的值,但是类型完全不同,读者可以自行分析这三个表达式的类型)
② 作为 s i z e o f sizeof sizeof运算的操作数
表达式:
sizeof(array)
完全等价于:
sizeof(array[0]) * LEN
C C C++中的数组是一种复合数据类型,作为数组名所支持的特殊操作几乎只有以上两种。 而:
任何数组类型的左值表达式,当用于异于:
· 作为取地址运算符的操作数
· 作为 sizeof 的操作数
…(此处省略了若干不常见操作)
的语境时,会经历到指向其首元素指针的隐式转换。
也就是说,在上述两种操作以外的几乎所有场合,数组名都会被隐式转换成引用其首元素的指针常量。再次强调指针不但表示以地址作为其存储的值,还表示整个表达式以对应的指针类型作为表达式的类型。 因此在这些情景下 a r r a y array array等价于& a r r a y [ 0 ] array[0] array[0], m a t r i x matrix matrix等价于& m a t r i x [ 0 ] matrix[0] matrix[0]。注意到 m a t r i x [ r o w ] matrix[row] matrix[row]也是一个数组的数组名,故在这些情景下 m a t r i x [ r o w ] matrix[row] matrix[row]等价于& m a t r i x [ r o w ] [ 0 ] matrix[row][0] matrix[row][0]。上述的等价既包含值相等也包含类型相同,读者可以自行验证。
(注意!不能说“数组就是指针”这类表述,因为数组与指针在根本上具有不同的类型,只是存在着由数组名到其首元素指针常量的隐式转换)
在这种认识下重新看待数组的元素访问操作,我们能有新的认识。 C C C++并没有为数组变量专门设计元素访问操作,这个元素访问操作的实现基于数组名到首元素指针的隐式转换以及指针类型的下标运算。显然,这一下标运算也是依赖于数组元素连续存储的特性才得以有效。
③ 访问其内部元素
数组名后缀 [ ] [] []并在其中填入整型表达式 i d x idx idx是其第 i d x + 1 idx+1 idx+1个元素的引用。对于数组 a r r a y array array,有效的下标范围是 0 0 0 ~ L E N − 1 LEN-1 LEN−1,而对于数组 m a t r i x matrix matrix,第一个下标的有效范围是 0 0 0 ~ R O W − 1 ROW-1 ROW−1,第二个下标的有效范围是 0 0 0 ~ C O L − 1 COL-1 COL−1。(由于这种访问操作完全继承自指针的下标运算操作,编译器一般不检查是否访问到非法范围)
(3)数组与指针的综合应用
基于以上讨论,我们从运算的特征与基本实现思路的角度认识了数组 与指针 这两种数据类型。下面我们将对指针数组和数组指针这两种数组与指针的综合应用进行分析。
① 指针数组
元素类型为指针的数组被称为指针数组,只需将数组声明中的类型说明符写成特定的指针类型说明符 即可,指针类型说明符即是在对应的基本类型说明符后添加 ∗ * ∗。但需要注意的是,在语法上, ∗ * ∗仍是跟标识符绑定的。声明一个基础类型为 T T T的指针数组的格式为:
T *pArray[LEN];
声明了一个 T ∗ T* T∗类型的、长度为 L E N LEN LEN的数组。将长度说明符 [ L E N ] [LEN] [LEN]提到类型说明符之后,得到 p A r r a y pArray pArray实际上是一个 ( T ∗ ) [ L E N ] (T*)[LEN] (T∗)[LEN]类型的变量。
表达式:
pArray[idx]
是 p A r r a y pArray pArray中第 i d x + 1 idx+1 idx+1个指针变量的引用,即数组 p A r r a y pArray pArray的第 i d x + 1 idx+1 idx+1个元素的变量名。这个表达式等价于:
*(pArray + idx)
注意到 p A r r a y pArray pArray会隐式转换为首元素指针即& p A r r a y [ 0 ] pArray[0] pArray[0],由于 p A r r a y [ 0 ] pArray[0] pArray[0]类型为 T ∗ T* T∗,故& p A r r a y [ 0 ] pArray[0] pArray[0]是 T ∗ ∗ T** T∗∗类型的表达式。因而执行 + i d x +idx +idx运算时会自动给 i d x idx idx乘上步长 s i z e o f ( T ∗ ) sizeof(T*) sizeof(T∗),由于数组中的元素是连续排列的并且每个元素所占空间都是 s i z e o f ( T ∗ ) sizeof(T*) sizeof(T∗),因而 p A r r a y + i d x pArray+idx pArray+idx是在其第一个元素的指针上偏移了 i d x idx idx个单位,刚好用来引用 p A r r a y pArray pArray的第 i d x + 1 idx+1 idx+1个元素。
要引用pArray中指针变量所引用的变量,只需要在刚刚的成员访问基础上添加一个前缀的 ∗ * ∗。
不过在实际应用中,指针数组通常用来创建或维护一个二维数组。
C
C
C++中的二维数组默认为数组的数组,但二维数组也有另外一种实现方式,就是考虑一个指针数组,指针数组的每一个元素引用(或者说指向)该行数组的首元素。这时二维数组失去了连续存储的特性,并且
p
A
r
r
a
y
[
r
o
w
]
pArray[row]
pArray[row]也并不是第
r
o
w
+
1
row+1
row+1个数组的数组名,而是其首元素的指针。换而言之,在大多数情况下,
p
A
r
r
a
y
[
r
o
w
]
pArray[row]
pArray[row]可以像第
r
o
w
+
1
row+1
row+1个数组的数组名那样去使用,
p
A
r
r
a
y
[
r
o
w
]
[
c
o
l
]
pArray[row][col]
pArray[row][col]就是第
r
o
w
+
1
row+1
row+1行第
c
o
l
+
1
col+1
col+1列元素的引用,因为在这些语境中数组名都会隐式转换为首元素指针,但对
p
A
r
r
a
y
[
r
o
w
]
pArray[row]
pArray[row]取地址之后的表达式在值上也不等于其首元素的地址,进行
s
i
z
e
o
f
sizeof
sizeof也不能得到数组的长度得到的只是
s
i
z
e
o
f
(
T
∗
)
sizeof(T*)
sizeof(T∗)。(比较下文中的数组指针,想一想,如果用数组指针来维护一个二维数组,
p
A
r
r
a
y
[
r
o
w
]
[
c
o
l
]
pArray[row][col]
pArray[row][col]还具有相同的含义吗?)
(2)数组指针
数组指针 是引用数组变量的指针。例如,基础类型为 T [ L E N ] T[LEN] T[LEN]的指针变量就是一个数组指针,因为它引用了一个类型为 T T T、长度为 L E N LEN LEN的数组。但不能使用这样的声明:
T *aPtr[LEN];
因为 [ L E N ] [LEN] [LEN]会被提前到 T ∗ T* T∗这个类型说明符整体的后面,导致实际声明了一个指针数组。通过在 ∗ a P t r *aPtr ∗aPtr两端环绕括号, ∗ * ∗不再是类型说明符的一部分, [ L E N ] [LEN] [LEN]直接提前到 T T T的后面。即:
T (*aPtr)[LEN];
会被解释成以 T [ L E N ] T[LEN] T[LEN]为基础类型的指针变量,或者是一个 ( T [ L E N ] ) ∗ (T[LEN])* (T[LEN])∗类型(编译器习惯写成 T [ L E N ] ( ∗ ) T[LEN](*) T[LEN](∗))的变量。
表达式:
*aPtr
是其引用的数组元素的引用,或者说就是这个数组的数组名。因而表达式:
*aPtr[idx]
就是这个数组第 i d x + 1 idx+1 idx+1个元素的引用。
注意到 ∗ a P t r *aPtr ∗aPtr等价于 a P t r [ 0 ] aPtr[0] aPtr[0],这个表达式也可以写成 a P t r [ 0 ] [ i d x ] aPtr[0][idx] aPtr[0][idx]。而这一形式不禁让我们联想起二维数组的元素访问。那么数组指针可以用来表示一个二维数组吗?
考虑表达式:
aPtr[row][col]
其中 a P t r [ r o w ] aPtr[row] aPtr[row]等价于 ∗ ( a P t r + r o w ) *(aPtr+row) ∗(aPtr+row),注意到 a P t r aPtr aPtr是指针类型的变量,因此 r o w row row会自动乘上基础类型的步长 s i z e o o f ( T [ L E N ] ) sizeoof(T[LEN]) sizeoof(T[LEN]),因此 + r o w +row +row这一操作刚好跳过了 r o w row row个 T [ L E N ] T[LEN] T[LEN]类型的变量所占的空间,即刚好跳过了 r o w row row个长度为 L E N LEN LEN、类型为 T T T的数组空间。如果二维数组的每一个元素(是一个数组)是连续排列的话,那么 a P t r [ r o w ] aPtr[row] aPtr[row]刚好成为了第 r o w row row个数组的数组名或者引用。因此如果利用数组指针来维护一个二维数组,必须要求每一个元素数组连续存储。(想一想动态创建二维数组的方法,为什么要利用指针数组而非数组指针?)
数据类型的本质在于对数据类型所容许的操作,而对这些操作的基本特性与实现思路的了解就决定了对这些数据类型的认识深度。从各种运算的实现方式的角度分析各种复合数据类型及其使用方式,往往更清晰准确,这应当成为学习任何复合数据类型的主要思路。
参考文献:
C与CPP文档
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。