第七章 结构体
1.复合数据类型—结构体
大多数数据类型都具有单一的值,例如整数、字符、布尔值、浮点数,这些可称为基本数据类型(Primitive Type)。但字符串是一个例外,它由很多字符组成,像这种由基本数据类型组成的数据类型称为复合2数据类型(Compound Type),正如表达式和语句有组合规则一样,由基本类型组成复合类型也有一些组合规则,如结构体、数组和字符串。复合数据类型一方面可以从整体上当作一个数据使用,另一方面也可以分别访问它的各组成单元,复合数据类型的这种两面性提供了一种数据抽象(Data Abstraction)的方法。在学一门编程语言时,要特别注意以下三方面:
1) 这门语言提供了哪些Primitive,比如基本数据类型,比如基本的运算符、表达式和语句。
2) 这门语言提供了哪些组合规则,比如复合数据类型,比如表达式和语句的组合规则。
3) 这门语言提供了哪些抽象机制,例如数据抽象和过程抽象(Procedure Abstraction)。
以结构体为例来学习数据类型的组合和抽象。至于过程抽象,最简单的形式就是把一组语句用一个函数名封装起来,当作一个整体使用。
现在我们用C语言表示一个复数。如果从直角坐标系来看,复数由实部和虚部组成,如果从极坐标系来看,复数由模和辐角组成,两种坐标系可以相互转换。如下图所示:
比如用实部和虚部表示一个复数,我们可以采用两个double型组成的结构体:
这样定义了complex_struct这个标识符,既然是标识符,那么它的命名规则就和变量一样,但它不表示一个变量,而表示一个类型,这种标识符在C语言中称为Tag,struct complex_struct {double x, y; }整个可以看作一个类型名,就像int或double一样,只不过它是一个复合类型,如果用这个类型名来定义变量,可以这样写:
这样z1和z2就是两个变量名,变量定义后面带个;号是我们早就习惯的。但即使像上面那样只定义了complex_struct这个Tag而不定义变量,后面的;号也不能少。这一点要注意,结构体定义后面少;号是初学者很常犯的错误。不管是用上面两种形式的哪一种形式定义了complex_struct这个Tag,以后都可以直接用struct complex_struct来代替类型名了。例如可以这样定义另外两个复数变量:
如果在定义结构体类型的同时定义了变量,也可以不必写Tag,例如:
但这样就没有办法再次引用这个结构体类型了,因为它没有名字。每个复数变量都有两个成员(Member) x和y,可以用 . 运算符( . 号,Period)来访问,这两个成员的存储空间是相邻的,合在一起组成复数变量的存储空间。看下面的例子:
定义和访问结构体
注意上例中变量x和变量z的成员x的名字并不冲突,因为变量z的成员x总是用 . 运算符来访问的,编译器可以区分开哪个x是变量x,哪个x是变量z的成员x,它们属于不同的命名空间(Name Space)。Tag也可以定义在函数外面,就像全局变量一样,这样定义的Tag在其定义之后的各函数中都可以使用。例如:
结构体变量也可以在定义时初始化,例如:
Initializer中的数据依次赋给结构体的成员。如果Initializer中的数据比结构体的成员多,编译器会报错,但如果只是末尾多个逗号不算错。如果Initializer中的数据比结构体的成员少,未指定的成员将用0来初始化,就像未初始化的全局变量一样。例如以下几种形式的初始化都是合法的:
其中,z1必须是函数的局部变量才能用变量x来初始化,如果是全局变量就只能用常量表达式来初始化。尽管结构体的初始化可以用这种语法,结构体赋值却不行,例如这样是错误的:
以前使用基本数据类型时,能用来初始化的表达式就能用来赋值,在这一点上结构体的语法规则有点不同。结构体类型的值用在表达式中有很多限制,不像基本数据类型那么自由,比如+-*/等算术运算符和&&、||、!等逻辑运算符都不能作用于结构体类型,if、while的控制表达式的值也不能是结构体类型。严格来说,可以做算术运算的类型称为算术类型(Arithmetic Type),算术类型包括整型和浮点型。可以做逻辑与、或、非运算的操作数或者if、for、while的控制表达式的类型称为标量类型(Scalar Type),标量类型包括算术类型和指针类型。
结构体类型之间用赋值运算符是允许的,用一个结构体初始化另一个结构体也是允许的,如:
同样地,z2必须是局部变量才能用变量z1来初始化。既然可以这样用,那么结构体可以当作函数的参数和返回值来传递就在意料之中了:
这个函数实现了两个复数相加,如果在main函数中这样调用:
那么调用传参的过程如下图所示(结构体传参):
变量z在main函数的栈帧中,参数z1和z2在add_complex函数的栈帧中,z的值分别赋给z1和z2。在这个函数里,z2的实部和虚部被累加到z1中,然后return z1;,可以看成是:
1) 把z1拷到一个临时变量里。
2) 函数返回并释放栈帧。
3) 把临时变量的值拷给变量z,释放临时变量。
2.数据抽象
复数可以用直角坐标或极坐标表示,直角坐标做加减法比较方便,极坐标做乘除法比较方便。如果我们定义的复数结构体是直角坐标的,那么应该提供极坐标的转换函数,以便在需要的时候可以方便地取它的模和辐角:
此外,我们还提供两个函数用来构造复数变量,既可以提供直角坐标也可以提供极坐标,在函数中自动做相应的转换然后返回构造的复数变量:
在此基础上就可以实现复数的加减乘除运算了:
可以看出,复数加减乘除运算的实现并没有直接访问结构体complex_struct的成员x和y,而是把它看成一个整体,通过调用相关函数来取它的直角坐标和极坐标。这样就可以非常方便地替换掉结构体complex_struct的存储表示,例如改为用极坐标来存储:
虽然结构体complex_struct的存储表示做了这样的改动,add_complex、sub_complex、mul_complex、div_complex这几个复数运算的函数却不需要做任何改动,仍可以使用,原因在于这几个函数只把结构体complex_struct当作一个整体来使用,而没有直接访问它的成员,因此也不依赖于它有哪些成员。结合下图具体分析一下。
这里要介绍的编程思想称为抽象。其实“抽象”这个概念并没有那么抽象,简单地说就是“提取公因式”:ab+ac=a(b+c)。如果a变了,ab和ac这两项都需要改,但如果写成a(b+c)的形式就只需要改其中一个因子。
在我们的复数运算程序中,复数有可能用直角坐标或极坐标表示,我们把这个有可能变动的因素提取出来组成复数存储表示层:real_part、img_part、magnitude、angle、make_from_real_img、make_from_mag_ang。这一层看到的数据是结构体的两个成员x和y,或者r和A,如果改变了结构体的实现就要改变这一层函数的实现,但函数接口不改变,因此调用这一层函数接口的复数运算层也不需要改变。复数运算层看到的数据只是一个抽象的“复数”的概念,知道它有直角坐标和极坐标,可以调用复数存储表示层的函数得到这些坐标。再往上看,其他使用复数运算的程序看到的数据是一个更为抽象的“复数”的概念,只知道它是一个数,像整数、小数一样可以加减乘除,甚至连它有直角坐标和极坐标也不需要知道。
这里的复数存储表示表示层和复数运算层称为抽象层(Abstraction Layer),从底层往上层来看,“复数”这种数据越来越抽象了,把所有这些层组合在一起就是一个完整的系统。组合使得系统可以任意复杂,而抽象使得系统的复杂性是可以控制的,任何改动都只局限在某一层,而不会搏击整个系统。著名的计算机科学加Butler Lampson说过:“All problems in computer science can be solved by another level of indirection.”这里的indirection其实就是abstraction的意思。
3.数据类型标志
前面我们通过一个复数存储表示抽象层把complex_struct结构体的存储格式和上层的复数运算函数隔开,complex_struct结构体既可以采用直角坐标也可以采用极坐标存储。但有时候需要同时支持两种存储格式,比如先前已经采集了一些数据存在计算机中,有些数据是以极坐标存储的,有些数据是以直角坐标存储的,如果要把这些数据都存储到complex_struct结构体中怎么办?一种办法是complex_struct结构体采用直角坐标格式,直角坐标的数据可以直接存入complex_struct结构体,极坐标的数据先用make_from_mag_ang函数转成直角坐标再存,但转换总是会损失精度的。这里介绍另一种方法,complex_struct结构体由一个数据类型标志和两个浮点数组成,如果数据类型标志为0,那两个浮点数就表示直角坐标,如果数据类型标志为1,那两个浮点数就表示极坐标。这样,直角坐标和极坐标的数据都可以适配(Adapt)到complex_struct结构体中,无需转换和损失精度:
Enum关键字的作用和struct关键字类似,把coordinate_type这个标识符定义为一个Tag,只不过struct complex_struct表示一个结构体类型,而enum coordinate_type表示一个枚举(Enumeration)类型。枚举类型的成员是常量,它们的值编译器自动分配,例如定义了上面的枚举类型之后,RECTANGULAR就表示常量0,POLAR就表示常量1。如果不希望从0开始分配,可以这样定义:
这样,RECTANGULAR就表示常量1,而POLAR就表示常量2,这些常量的类型就是int。有一点需要注意,结构体的成员名和变量名不在同一命名空间,但枚举的成员名和变量名却在同一命名空间,所以会出现命名冲突。例如这样是不合法的:
Complex_struct结构体的格式变了,就需要修改复数存储表示层的函数,但只要保持函数接口不变就不会影响到上层函数。例如:
4.嵌套结构体
结构体也是一种递归定义:结构体由数据类型定义,因为结构体的成员具有数据类型,而数据类型由结构体定义,因为结构体本身也是一种数据类型。换句话说,结构体也可以嵌套。例如我们在复数的基础上定义复平面上的线段:
嵌套结构体可以嵌套地初始化。例如:
也可以平坦地初始化。例如:
甚至可以混合地初始化(这样可读性很差,应避免使用):
访问嵌套结构体的成员应该用多个 . 运算符,这也是意料之中的: