《A Tour of C++》要点收录

四、类

2023-10-19  本文已影响0人  akuan

Link


“经典的用户定义算术类型”是complex:

class complex {
    double re, im;      // 表征数据:两个double
public:
    complex(double r, double i) :re{r}, im{i} {}    // 用两个标量构造complex
    complex(double r) :re{r}, im{0} {}              // 用一个标量构造complex
    complex() :re{0}, im{0} {}                      // complex的默认值:{0,0}

    double real() const { return re; }
    void real(double d) { re=d; }
    double imag() const { return im; }
    void imag(double d) { im=d; }

    complex& operator+=(complex z) {
        re+=z.re;       // 加至re和im
        im+=z.im;
        return *this;   // 返回结果
    }

    complex& operator-=(complex z) {
        re-=z.re;
        im-=z.im;
        return *this;
    }

    complex& operator*=(complex);   // 定义在类外某处
    complex& operator/=(complex);   // 定义在类外某处
};

很多有用的运算无需直接访问complex的表征数据,因此可以与类定义分开:

complex operator+(complex a, complex b) { return a+=b; }
complex operator-(complex a, complex b) { return a-=b; }
complex operator-(complex a) { return {-a.real(), -a.imag()}; } // 一元负号
complex operator*(complex a, complex b) { return a*=b; }
complex operator/(complex a, complex b) { return a/=b; }

bool operator==(complex a, complex b)  { // 相等
    return a.real()==b.real() && a.imag()==b.imag();
}
bool operator!=(complex a, complex b)  { // 不等
    return !(a==b);
}
complex sqrt(complex);      // 定义在别处
// ...

complex类可以这样用:

void f(complex z) {
    complex a {2.3};    // 从 2.3 构造出 {2.3,0.0}
    complex b {1/a};
    complex c {a+z*complex{1,2.3}};
    // ...
    if (c != b)
        c = -(b/a)+2*b;
}

编译器会把涉及complex数值的运算符转换成相应的函数调用。 例如:c!=b对应operator!=(c,b)1/a对应operator/(complex{1},a)

用户定义的运算符(“重载运算符(overloaded operator)”) 应该谨慎并且遵循约定俗成的规则使用。


我们自定义的Vector有个致命的缺陷:它用new给元素分配空间,却从未释放它们。这就不太妙了,因为尽管C++定义了垃圾回收接口,但却不能确保有个垃圾回收器把未使用的内存供新对象使用。某些情况下你无法使用垃圾回收器,更常见的情形是:出于逻辑和性能原因,你倾向于更精细地控制销毁行为。我们需要一个机制确保把构造函数分配的内存释放掉;这个机制就是析构函数(destructor):

class Vector {
public:
    Vector(int s) :elem{new double[s]}, sz{s} {  // 构造函数:申请资源
        for (int i=0; i!=s; ++i)    // 初始化元素
            elem[i]=0;
    }

    ~Vector() { delete[] elem; }    // 析构函数:释放资源

    double& operator[](int i);
    int size() const;
private:
    double* elem;   // elem指向一个数组,该数组承载sz个double
    int sz;
};

析构函数的名称是取补运算符~后跟类名;它跟构造函数互补。Vector的构造函数用new运算符在自由存储区 (也叫堆(heap)或动态存储区(dynamic store))里分配了一些内存。析构函数去清理——使用delete[]运算符释放那块内存。普通delete删除单个对象,delete[]删除数组。

这些操作都不会干涉到Vector用户。 用户仅仅创建并使用Vector,就像对内置类型一样。例如:

void fct(int n) {
    Vector v(n);
    // ... 使用 v ...
    {
        Vector v2(2*n);
        // ... 使用 v 和 v2 ...
    }
    // ... 使用 v ...
}// v 在此被销毁

像int和char这些内置类型一样,Vector遵循相同的命名、作用域、内存分配、生命期等一系列规则。此处的Vector版本为简化而略掉了错误处理。

构造函数/析构函数 这对组合是很多优雅技术的根基。确切的说,它是C++大多数资源管理技术的根基。考虑如下的Vector图示:

构造函数分配这些元素并初始化Vector响应的成员变量。析构函数释放这些元素。这个数据操控器模型(handle-to-data model)常见于数据管理,管理那些容量在对象生命期内可能变化的数据。这个构造函数申请资源、析构函数释放资源的技术叫做 资源请求即初始化(Resource Acquisition Is Initialization)或者RAII,为我们消灭“裸的new操作”,就是说,避免在常规代码中进行内存分配,将其隐匿于抽象良好的实现中。与之类似,“裸的delete操作”也该竭力避免。避免裸new和裸delete,能大大降低代码出错的几率,也更容易避免资源泄漏。


容器的作用是承载元素,因此很明显需要便利的方法把元素放入容器。 可以创建元素数量适宜的Vector,然后给这些元素赋值,但还有些更优雅的方式。 此处介绍其中颇受青睐的两种:

它们可以这样声明:

class Vector {
public:
    Vector(std::initializer_list<double>);// 用一个double列表初始化
    // ...
    void push_back(double);// 在末尾新增元素,把容量加一
    // ...
};

在输入任意数量元素的时候,push_back()很有用,例如:

Vector read(istream& is) {
    Vector v;
    for (double d; is>>d; )     // read floating-point values into d
        v.push_back(d);         // add d to v return v;
}

Vector v = read(cin);// 此处未对Vector的元素进行复制

用于定义初始化列表构造函数的std::initializer_list是个标准库中的类型, 编译器对它有所了解:当我们用{}列表,比如{1,2,3,4}的时候, 编译器会为程序创建一个initializer_list对象。 因此,可以这样写:

Vector v1 = {1,2,3,4,5};            // v1有5个元素
Vector v2 = {1.23, 3.45, 6.7, 8};   // v2有4个元素

Vector的初始化列表构造函数可能长这样:

Vector::Vector(std::initializer_list<double> lst)   // 用列表初始化
    :elem{new double[lst.size()]}, sz{static_cast<int>(lst.size())}
{
    copy(lst.begin(),lst.end(),elem);       // 从lst复制到elem(§12.6)
}

很遗憾,标准库为容量和下标选择了unsigned整数, 所以我需要用丑陋的static_cast把初始化列表的容量显式转换成int。

static_cast不对它转换的值进行检查;它相信程序员能运用得当。 可它也总有走眼的时候,所以如果吃不准,检查一下值。 应该尽可能避免显式类型转换 (通常也叫强制类型转换(cast),用来提醒你它可能会把东西弄坏)。 尽量把不带检查的类型转换限制在系统底层。它们极易出错。

还有两种类型转换分别是:reinterpret_cast,它简单地把对象按一连串字节对待;const_cast用于“转掉const限制”。对类型系统的审慎运用以及设计良好的库,都有助于在顶层软件中消除不带检查的类型转换。


complex和Vector这些被称为实体类型,因为表征数据是它们定义的一部分。 因此,它们与内置类型相仿。相反,抽象类型是把用户和实现细节隔绝开的类型。为此,要把接口和表征数据解耦,并且要摒弃纯局部变量。既然对抽象类的表征数据(甚至其容量)一无所知,就只能把它的对象分配在自由存储区,并通过引用或指针访问它们。

首先,我们定义Container类的接口,它将被设计成Vector更抽象的版本:

class Container {
public:
    virtual double& operator[](int) = 0;    // 纯虚函数
    virtual int size() const = 0;           // const 成员函数
    virtual ~Container() {}                 // 析构函数
};

该类是一个用于描述后续容器的纯接口。virtual这个词的意思是“后续可能在从此类派生的类中被重新定义”。用virtual声明的函数自然而然的被称为虚函数。从Container派生的类要为Container接口提供实现。古怪的=0语法意思是:此函数是纯虚的;就是说,某些继承自Container的类必须定义该函数。因此,根本无法直接为Container类型定义对象。例如:

Container c;                                // 报错:抽象类没有自己的对象
Container* p = new Vector_container(10);    // OK:Container作为接口使用

Container只能用做作接口,服务于那些给operator和size()函数提供了实现的类。带有虚函数的类被称为抽象类。

Container可以这样用:

void use(Container& c) {
    const int sz = c.size();
    for (int i=0; i!=sz; ++i)
        cout << c[i] << '\n';
}

请注意use()在使用Container接口时对其实现细节一无所知。它用到size()和[ ],却完全不知道为它们提供实现的类型是什么。为诸多其它类定义接口的类通常被称为多态类型。

正如常见的抽象类,Container也没有构造函数。毕竟它不需要初始化数据。另一方面,Container有一个析构函数,并且还是virtual的,以便让Container的派生类去实现它。这对于抽象类也是常见的,因为它们往往通过引用或指针进行操作,而借助指针销毁Container对象的人根本不了解具体用到了哪些资源。

抽象类Container仅仅定义接口,没有实现。想让它发挥作用,就需要弄一个容器去实现它接口规定的那些函数。为此,可以使用一个实体类Vector:

class Vector_container : public Container { // Vector_container 实现了 Container
public:
    Vector_container(int s) : v(s) { }      // s个元素的Vector
    ~Vector_container() {}

    double& operator[](int i) override { return v[i]; }
    int size() const override { return v.size(); }
private:
    Vector v;
};

:public可以读作“派生自”或者“是……的子类型”。 我们说Vector_container派生自Container, 并且Container是Vector_container的基类。 还有术语把Vector_container和Container分别称为 子类和亲类。 我们说派生类继承了其基类的成员,所以这种基类和派生类的关系通常被称为继承

我们这里的operator和size()覆盖(override)了基类Container中对应的成员。这里明确使用override表达了这个意向。这里的override可以省略,但是明确使用它,可以让编译器查错,比如函数名拼写错误,或者virtual函数和被其覆盖的函数之间的细微类型差异等等。在较大的类体系中明确使用override格外有用,否则就难以搞清楚覆盖关系。

这里的析构函数(~Vector_container()) 覆盖了基类的析构函数(~ Container())。请注意,其成员的析构函数(~Vector) 被该类的析构函数(~Vector_container())隐式调用了。

对于use(Container&)这类函数,使用Container时不必了解其实现细节,其它函数要创建具体对象供它操作的。例如:

void g() {
    Vector_container vc(10);// 十个元素的Vector
    // ... 填充 vc ...
    use(vc);
}

由于use()只了解Container接口而非Vector_container,它就可以对Container的其它实现同样有效。例如:

class List_container : public Container { // List_container implements Container
public:
    List_container() { }    // empty List
    List_container(initializer_list<double> il) : ld{il} { }
    ~List_container() {}
    double& operator[](int i) override;
    int size() const override { return ld.size(); }
private:
    std::list<double> ld;   // double类型的(标准库)列表 (§11.3)
};

double& List_container::operator[](int i) {
    for (auto& x : ld) {
        if (i==0)
            return x;
        --i;
    }
    throw out_of_range{"List container"};
}

void use(Container& c) {
    const int sz = c.size();
    for (int i=0; i!=sz; ++i)
        cout << c[i] << '\n';
}

void g() {
    Vector_container vc(10);
    use(vc);
}

void h() {
    List_container lc = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    use(lc);
}

use()中的c[i]调用是怎么解析到对应的operator呢?当h()调用use()时,List_container的operator必须被调用。g()调用use()时,Vector_container的operator必须被调用。

想要实现这种解析,Container对象必须包含某种信息,以便在运行时找到正确的待调用函数。常见的实现技术是:编译器把虚函数的名称转换成一个指向函数指针表的索引。这个表格通常被称为虚函数表(virtual function table),或者简称vtbl。每个带有虚函数的类都有自己的vtbl以确认其虚函数。这可以图示如下:

vtbl中的函数能够正确地使用其对象,即便调用者对该对象的容量以及数据布局全都一无所知。调用者的实现仅需要知道某个Container中指向vtbl指针的位置以及每个待用虚函数的索引。虚函数调用机制几乎能做到与“常规函数调用”机制同样高效(性能差别不到25%)。 其空间消耗是带有虚函数的类的每个对象一个指针,再加上每个类一个vtbl。

基类的析构函数往往需要定义为虚函数,如果一个基类指针指向一个 派生类对象,当调用delete操作符时,只会调用基类的析构函数而不会调用派生类的析构函数。如:

class ClxBase {// 基类
public:
    ClxBase() {}
    virtual ~ClxBase() {
        cout << "Output from the destructor of class ClxBase!\n";
    }
    virtual void DoSomething() {
        cout << "Do something in class ClxBase!\n";
    }
}

class ClxDerived : public ClxBase {// 派生类
public:
    ClxDerived() {}
    ~ClxDerived() {
        cout << "Output from the destructor of class ClxDerived!\n";
    }
    void DoSomething() {
        cout << "Do something in class ClxDerived!\n";
    }
}

int main() {
    ClxBase *p =  new ClxDerived;// 有多态
    // 当基类是虚函数时,基类的指针将表现为派生类的行为(非虚函数将表现为基类行为)
    p->DoSomething();
    delete p;
    return 0;
}

// 运行结果
Do something in class ClxDerived!
Output from the destructor of class ClxDerived!
Output from the destructor of class ClxBase!


class Shape {
public:
    virtual Point center() const =0;    // 纯虚函数
    virtual void move(Point to) =0;

    virtual void draw() const = 0;      // 在“画布”上绘制
    virtual void rotate(int angle) = 0;

    virtual ~Shape() {}                // 析构函数
    // ...
};

根据这个定义,可以写一个通用的函数操纵一个vector,其中的元素是指向图形的指针:

void rotate_all(vector<Shape*>& v, int angle) {// 把v的元素旋转给定角度
    for (auto p : v)
        p->rotate(angle);
}

要定义特定的图形,必须指明它是个Shape,定义它特有的属性(包括其虚函数):

class Circle : public Shape {
public:
    Circle(Point p, int rad);       // 构造函数

    Point center() const override {
        return x;
    }
    void move(Point to) override {
        x = to;
    }

    void draw() const override;
    void rotate(int) override {}    // 优美且简洁的算法
private:
    Point x;    // 圆心
    int r;      // 半径
};

截至目前,Shape和Circle的例子跟Container相比还没有什么亮点, 请接着往下看:

class Smiley : public Circle {  // 用圆圈作为笑脸的基类
    public:
    Smiley(Point p, int rad) : Circle{p,rad}, mouth{nullptr} { }
    ~Smiley() {
        delete mouth;
        for (auto p : eyes)
            delete p;
    }

    void move(Point to) override;
    void draw() const override;
    void rotate(int) override;

    void add_eye(Shape* s) {
        eyes.push_back(s);
    }
    void set_mouth(Shape* s);
    virtual void wink(int i);   // 让第i只眼做“飞眼”
    // ...
private:
    vector<Shape*> eyes;        // 一般是两只眼睛
    Shape* mouth;
};

现在,可以利用Smiley的基类和成员函数draw()的调用来定义Smiley::draw()了:

void Smiley::draw() const {
    Circle::draw();
    for (auto p : eyes)
        p->draw();
    mouth->draw();
}

请注意,Smiley把它的眼睛保存在一个标准库的vector里, 并且会在析构函数中把它们销毁。 Shape的析构函数是virtual的,而Smiley又覆盖了它。 虚析构函数对于抽象类来说是必须的,因为操控派生类的对象通常是借助抽象基类提供的接口进行的。具体地说,它可能是通过其基类的指针被销毁的。然后,虚函数调用机制确保正确析构函数被调用。该析构函数则会隐式调用其基类和成员变量的析构函数。

在这个简化过的例子中,把眼睛和嘴巴准确放置到代表脸的圆圈中,是程序员的的任务。

在以派生方式定义一个新类时,我们可以添加新的 成员变量 或/和 运算。 这带来了极佳的灵活性,同时又给逻辑混乱和不良设计提供了温床。


类的层次结构有两个益处:

enum class Kind { circle, triangle, smiley };

Shape* read_shape(istream& is) { // 从输入流is读取图形描述
    // ... 从 is 读取图形概要信息,找到其类型(Kind) k ...
    switch (k) {
    case Kind::circle:
        // 把圆圈的数据 {Point,int} 读取到p和r
        return new Circle{p,r};
    case Kind::triangle:
        // 把三角形的数据 {Point,Point,Point} 读取到p1、p2、和p3
        return new Triangle{p1,p2,p3};
    case Kind::smiley:
        // 把笑脸的数据 {Point,int,Shape,Shape,Shape} 读取到p、r、e1、e2 和 m
        Smiley* ps = new Smiley{p,r};
        ps->add_eye(e1);
        ps->add_eye(e2);
        ps->set_mouth(m);
        return ps;
    }
}

某个程序可以这样使用此图形读取器:

void user() {
    std::vector<Shape*> v;
    while (cin)
        v.push_back(read_shape(cin));
    draw_all(v);        // 为每个元素调用 draw()
    rotate_all(v,45);   // 为每个元素调用 rotate(45)
    for (auto p : v)    // 别忘了销毁元素(指向的对象)
        delete p;
}

显而易见,这个例子被简化过了——尤其是错误处理相关的内容—— 但它清晰地表明了,user()函数对其所操纵图形的类型一无所知。 user()的代码仅需要编译一次,在程序加入新的Shape之后可以继续使用。

请留意,没有任何图形的指针流向了user()之外,因此user()就要负责回收它们。 这实用运算符delete完成,且严重依赖Shape的虚析构函数。 因为这个析构函数是虚的,delete调用的是距基类最远的派生类里的那个。 这至关重要,因为可能获取了各式各样有待释放的资源(比如文件执柄、锁及输出流)。在本例中,Smiley要删除其eyes和mouth的对象。删完这些之后,它又去调用Circle的析构函数。对象的构建通过构造函数“自下而上”(从基类开始), 而销毁通过虚构函数“从顶到底”(从派生类开始)

read_shape()函数返回Shape*,以便我们对所有Shape一视同仁。 但是,如果我们想调用某个派生类特有的函数, 比方说Smiley里的wink(),该怎么办呢? 我们可以用dynamic_cast运算符问这个问题 “这个Shape对象是Smiley类型的吗?”:

Shape* ps {read_shape(cin)};
if (Smiley* p = dynamic_cast<Smiley*>(ps)) {    // ... ps指向一个 Smiley 吗? ...
    // ... 是 Smiley;用它
} else {
    // ... 不是 Smiley,其它处理 ...
}

在运行时,如果dynamic_cast的参数(此处是ps)指向的对象不是期望的类型 (此处是Smiley)或其派生类,dynamic_cast就返回nullptr。如果其它类型不可接受,我们就直接把dynamic_cast用于引用类型。 如果该对象不是期望的类型,dynamic_cast抛出一个bad_cast异常:

Shape* ps {read_shape(cin)};
Smiley& r {dynamic_cast<Smiley&>(*ps)}; // 某处可以捕捉到 std::bad_cast

当“转换目标不属于所需的类”需要报错时,就把dynamic_cast用于引用类型;如果“转换目标不属于所需的类”可接受,就把dynamic_cast用于指针类型。


经验丰富的程序员可能注意到了我有三个纰漏:

从这个意义上讲,指向自由存储区中对象的指针是危险的: “直白老旧的指针(plain old pointer)”不该用于表示所有权。例如:

void user(int x) {
    Shape* p = new Circle{Point{0,0},10};
    // ...
    if (x<0) throw Bad_x{}; // 资源泄漏潜在危险
    if (x==0) return;       // 资源泄漏潜在危险
    // ...
    delete p;
}

除非x为正数,否则就会导致资源泄漏。 把new的结果赋值给“裸指针”就是自找麻烦。

这类问题有一个简单的解决方案:在需要释放操作时, 使用标准库的unique_ptr而非“裸指针”:

class Smiley : public Circle {
    // ...
private:
    vector<unique_ptr<Shape>> eyes; // 一般是两只眼睛
    unique_ptr<Shape> mouth;
};

这是一个示例,展示简洁、通用、高效的资源管理技术。

这个修改有个良性的副作用:我们不需要再为Smiley定义析构函数了。 编译器会隐式生成一个,以便将vector中的unique_ptr销毁。 使用unique_ptr的代码跟用裸指针的代码在效率方面完全一致。

重新审视read_shape()的使用:

unique_ptr<Shape> read_shape(istream& is) {// 从输入流is读取图形描述
    // ... 从 is 读取图形概要信息,找到其类型(Kind) k ...
    switch (k) {
        case Kind::circle:
            // 把圆圈的数据 {Point,int} 读取到p和r
            return unique_ptr<Shape>{new Circle{p,r}};
        // ...
}

void user() {
    vector<unique_ptr<Shape>> v;
    while (cin)
        v.push_back(read_shape(cin));
    draw_all(v);        // 为每个元素调用 draw()
    rotate_all(v,45);   // 为每个元素调用 rotate(45)
} // 所有 Shape 都隐式销毁了

现在每个对象都被一个unique_ptr持有,当不再需要这个unique_ptr,也就是它离开作用域的时候,就会销毁持有的对象。

想让unique_ptr版本的user()正常运作, 就需要能够接受vector<unique_ptr<Shape>>版本的 draw_all()和rotate_all()。

忠告

上一篇 下一篇

猜你喜欢

热点阅读