2

用户定义类型

别慌!

—— 道格拉斯·亚当斯

2.1 导言

仅使用基本类型(§1.4)、const修饰符(§1.6)以及声明运算符(§1.7)构建出的类型, 被称为内置类型(built-in type)。 C++的内置类型和运算都很丰富,但有意地低级化了。 它们直接、高效地反映出了传统计算机硬件的能力。 但是在开发高级应用程序的便利性方面,它的高层设施可就捉襟见肘了。 相反,C++用一套精细的抽象机制(abstraction mechanism)强化了内置类型及运算, 借助这套机制,程序员可以构建出这些高层设施。

C++的抽象机制的主要设计意图是让程序员设计并实现自己的类型, 这些类型具备适当的表现和运算,同时,还能让程序员用起来简单而优雅。 遵循C++的抽象机制,借助其它类型构建出的类型被称为 用户定义类型(user-defined type)。 也被称为类(class)枚举(enumeration)。 构造用户定义类型时,即可以动用内置类型,也可以动用其它用户定义类型。 用户定义类型通常优于内置类型,因为更易使用,出错少, 而效率则通常与内置类型相差无几,有时候甚至更快。

本章后面的内容,介绍最简单也是最基础的用于创建和使用类型的工具。 对这种抽象机制及其支持的编程风格,在第4-7章给出了更完整的描述。 第5-8章给出了标准库的概览,同时标准库主要由用户定义类型构成, 因此对第1-7章所介绍的语言构造及编程技术而言,标准库提供了用例。

2.2 结构体(structure)

构建新类型的第一步,通常是把它所需的要素组织到一个数据类型 ——结构体(struct)里:

struct Vector
{
    int sz;         // 元素数量
    double* elem;   // 指向元素的指针
};

Vector的第一个版本包含了一个int和一个double*

Vector类型的变量可以这样定义:

Vector v;

但是,光有它自己用处不大,因为v的指针elem没有指向任何东西。 想让它有用,必须让v指向某些元素。比方说,可以这样构造Vector

void vector_init(Vector& v, int s)
{
    v.elem = new double[s]; // 分配一个数组,里面有s个double
    v.sz = s;
}

这样,v的成员elem就得到一个用new生成的指针, 而v的成员sz也得到了元素数量。 Vector&里的&意思是:v通过非const引用(§1.7)方式传递; 这样一来,vector_init()就可以修改传入的Vector了。

new运算符从一块叫自由存储(free store) (也叫动态内存(dynamic memory)堆(heap))的区域里分配内存。 分配在自由存储上的对象,与其被创建的作用域无关, 而是会一直“存活”下去,直到用delete运算符(§4.2.2)把它销毁。

Vector可以简单应用如下:

double read_and_sum(int s)
    // 从cin读取s个整数,返回它们的和;假定s时正数
{
    Vector v;
    vector_init(v,s);           // 给v分配s个元素

    for (int i=0; i!=s; ++i)
        cin>>v.elem[i];         // 向元素中读入内容

    double sum = 0;
    for (int i=0; i!=s; ++i)
        sum+=v.elem[i];         // 对元素求和
    return sum;
}

要媲美标准库中vector的优雅和灵活,Vector还有待提升。 尤其是,Vector的用户必须对Vector的细节了如指掌。 本章后续及接下来的两章内容,将逐步改进Vector,作为语言特性及技术的示例。 第11章介绍标准库的vector,它包含很多精致的改进。

我拿vector和其它标准库组件做例子是为了:

  • 展示语言特性和设计技巧,并
  • 帮助你理解和运用标准库组件

对于vectorstring这样的标准库组件,别造轮子;直接用。

通过变量名(及引用)访问struct成员用.(点), 而通过指针访问struct成员用->。例如:

void f(Vector v, Vector& rv, Vector* pv)
{
    int i1 = v.sz;      // 通过变量名访问
    int i2 = rv.sz;     // 通过引用访问
    int i3 = pv->sz;    // 通过指针访问
}

2.3 类(class)

把具体数据和运算分离有它的优势,比方说,能够随心所欲地使用数据。 但是,想让用户定义类型具有“真正的类型”那些属性,就需要让数据和运算结合得更紧密些。 具体而言,我们通常希望数据表示对用户不可访问,从而避免被使用, 确保该类型数据的使用一致性,这还让我们后续能够改进数据表示。 要达成这个目的,必须区分类型的(供任何人使用的)接口和(可对数据排他性访问的)实现。 这个语言机制叫做类(class)。 类拥有一组成员(member),成员可以是数据、函数或者类型成员。 接口由类的public成员定义,而private成员仅允许通过接口访问。例如:

class Vector{
public:
    Vector(int s) :elem{new double[s]}, sz{s} { }
    double& operator[](int i) { return elem[i]; }
    int size() { return sz; }
private:
    double* elem;   // 指向元素的指针
    int sz;         // 元素的数量
};

有了这些,就可以定义新的Vector类型的变量了:

Vector v(6);    // 具有6个元素的Vector

Vector对象可图示如下:

Vector object illustrate

大体上,Vector对象就是个“把手”, 其中装载着指向元素的指针(elem)和元素数量(sz)。 元素数量(例中是6)对不同的Vector对象是可变的,而同一个Vector对象, 在不同时刻,其元素数量也可以不同。但是Vector对象自身的大小始终不变。 在C++中,这是处理可变数量信息的基本技巧:以固定大小的把手操控数量可变的数据, 这些数据被放在“别处”(比如用new分配在自由存储上;§4.2.2)。 设计与使用这些对象的方法,是第4章的主要内容。

在这里,Vector的数据(成员elemsz)只能通过接口访问, 这些接口都是public成员:Vector()operator[]()size()。 §2.2 中的示例read_and_sum()可简化为:

double read_and_sum(int s)
{
    Vector v(s);                        // 创建持有s个元素的vector
    for (int i=0; i!=v.size(); ++i)
        cin>>v[i];                      // 把数据读入元素

    double sum = 0;
    for (int i=0; i!=v.size(); ++i)
        sum+=v[i];                      // 对元素求和
    return sum;
}

和类具有相同名称的成员“函数(function)”叫做构造函数(constructor), 就是说,这个函数的用途是构建此类对象。 因此,构造函数Vector()取代了§2.2里的vector_init()。 与一般函数不同,在构造其所属的类对象时,构造函数保证会被调用。 由此,定义构造函数,就类消除了类的“变量未初始化”问题。

Vector(int)定义了怎样构造Vector对象。 具体来说,它明确指出需要一个整数。 该整数作为元素的数量使用。 这个构造函数通过成员初始化列表来初始化Vector的成员:

:elem{new double[s]}, sz{s}

意思是说,先用一个指针初始化elem,该指针指向sdouble类型的元素, 这些元素的空间取自自由存储区。 然后用s的值初始化sz

对元素的访问由取下标函数提供,该函数叫做operator[]。 它返回相应元素的引用(即可读又可写的double&)。

函数size()把元素的数量交给用户。

一望而知,错误处理被彻底忽略了,但是我们会在§3.5讲到它。 与此类似,对于通过new获取的double数组, 我们也并未提供一个机制把它“送回去(give back)”; §4.2.2展示了用析构函数优雅地做到这一点的方式。

structclass没有本质上的区别,struct就是个class, 只不过其成员默认是public的。 比方说,你可以为struct定义构造函数和其它成员函数。

2.4 联合(union)

联合(union)就是结构体(struct),只不过联合的所有成员都分配在相同的地址上, 因此联合所占据的空间,仅跟其容量最大的那个成员相同。 自然而然,任何时候联合都只能持有其某一个成员的值。 举例来说,有个符号表条目,它包含一个名称和一个值。其值可以是Node*int

enum Type { ptr, num }; // 一个 Type 可以是ptr和num(§2.5)

struct Entry {
    string name;    // string是个标准库里的类型
    Type t;
    Node* p;        // 如果t==ptr,用p
    int i;          // 如果t==num,用i
};

void f(Entry* pe)
{
    if (pe->t == num)
        cout << pe->i;
    // ...
}

成员pi永远不会同时使用,但这样空间就被浪费了。 可以指定它们都是某个union的成员,这样空间就轻而易举地节省下来了,像这样:

union Value {
    Node* p;
    int i;
};

语言并不会追踪union保持了哪种类型的值,所以程序员要亲力亲为:

struct Entry {
    string name;
    Type t;
    Value v;    // 如果t==ptr,用v.p;如果t==num,用v.i
};

void f(Entry* pe)
{
    if (pe->t == num)
        cout << pe->v.i;
    // ...
}

类型信息(type field)union所持的类型之间的一致性很难维护。 想要避免错误,可以强化这种一致性——把联合与类型信息封装成一个类, 仅允许通过成员函数访问它们,再用成员函数确保准确无误地使用联合。 在应用层面,依赖于这种附有标签的联合(tagged union)的抽象常见且有用。 尽量少用“裸”的union

标准库有个类型叫variant,使用它就可以避免绝大多数针对 联合 的直接应用。 variant存储一个值,该值的类型可以从一组类型中任选一个(§13.5.1)。 举个例子,variant<Node*,int>的值,可以是Node*或者int

借助variantEntry示例可以写成这样:

struct Entry {
    string name;
    variant<Node*,int> v;
};

void f(Entry* pe)
{
    if (holds_alternative<int>(pe->v))  // *pe的值是int类型吗?(参见§13.5.1)
        cout << get<int>(pe->v);        // 取(get)int值
    // ...
}

很多情况下,使用variant都比union更简单也更安全。

2.5 枚举(enum)

除了类,C++还提供一种简单的用户定义类型,使用它可以把一组值逐一列举:

enum class Color { red, blue, green };
enum class Traffic_light { green, yellow, red };

Color col = Color::red;
Traffic_light light = Traffic_light::red;

注意,枚举值(比如red)位于其enum class的作用域里, 因此可以在不同的enum class里重复出现,而且不会相互混淆。 例如:Color::redColor里面的red,跟Traffic_light::red毫无关系。

枚举用于表示一小撮整数值。 使用它们,可以让代码更具有可读性,也更不易出错。

enum后的class指明了这是个强类型的枚举,并且限定了这些枚举值的作用域。 作为独立的类型,enum class有助于防止常量的误用。 比方说,Traffic_lightColor的值无法混用:

Color x = red;                  // 错误:哪个颜色?
Color y = Traffic_light::red;   // 错误:此red并非Color类型
Color z = Color::red;           // OK

同样,Color的值也不能和整数值混用:

int i = Color::red; // 错误:Color::red不是int类型

Color c = 2;        // 初始化错误:2不是Color类型

捕捉这种向 enum 的类型转换有助于防止出错, 但我们通常需要用底层类型(默认情况下是int)的值初始化一个 enum, 因此,这种初始化就像显式从底层类型转换而来一样合法:

Color x = Color{5}; // 可行,但略有些啰嗦
Color y {6};        // 同样可行

默认情况下,enum class仅定义了赋值、初始化和比较(也就是==<; §1.4)。 不过,既然枚举是用户定义类型,我们就可以给它定义运算符:

Traffic_light& operator++(Traffic_light& t)     // 前置自增:++
{
    switch (t) {
    case Traffic_light::green:  return t=Traffic_light::yellow;
    case Traffic_light::yellow: return t=Traffic_light::red;
    case Traffic_light::red:    return t=Traffic_light::green;
    }
}
Traffic_light next = ++light;   // next 将是 Traffic_light::green

如果你的枚举值不需要独立的作用域,并希望把它们作为int使用(无需显式类型转换), 可以省掉enum class中的class,以获得一个“普通”enum。 “普通”enum中的枚举值的作用域跟这个enum相同,还能隐式转换成整数值,例如:

enum Color { red, green, blue };
int col = green;

此处的col值为1。 默认情况下,枚举值的整数值从0开始,每增加一个新枚举值就递增一。 “普通”enum是C++(和C)与生俱来的,因此,尽管它问题多多却仍然很常见。

2.6 忠告

  • [1] 如果某个内置类型过于底层,请使用定义良好的用户定义类型代替它;§2.1。

  • [2] 把有关联的数据组织到结构里(成structclass);§2.2; [CG: C.1]。
  • [3] 借助class区分接口和实现;§2.3; [CG: C.3]。
  • [4] struct就是其成员默认为publicclass;§2.3。
  • [5] 为class定义构造函数,以确保执行初始化操作并简化它;§2.3; [CG: C.2]。
  • [6] 别用“裸”union,把它们和类型字段凑一起放进类里面;§2.4; [CG: C.181]。
  • [7] 使用枚举表示具名常量的集合;§2.5; [CG: Enum.2]。
  • [8] 用enum class替代“普通”enum以避免事故;§2.5; [CG: Enum.3]。
  • [9] 给枚举定义运算,可以获得安全性和便利性;§2.5; [CG: Enum.4]。

results matching ""

    No results matching ""