四、类

作者: akuan | 来源:发表于2023-10-19 17:13 被阅读0次

    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,然后给这些元素赋值,但还有些更优雅的方式。 此处介绍其中颇受青睐的两种:

    • 初始化列表构造函数(initializer-list constructor):用一个元素列表进行初始化。
    • push_back():在序列的末尾(之后)添加一个新元素。

    它们可以这样声明:

    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又覆盖了它。 虚析构函数对于抽象类来说是必须的,因为操控派生类的对象通常是借助抽象基类提供的接口进行的。具体地说,它可能是通过其基类的指针被销毁的。然后,虚函数调用机制确保正确析构函数被调用。该析构函数则会隐式调用其基类和成员变量的析构函数。

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

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


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

    • 接口继承(interface inheritance):派生类对象可以用在任何基类对象胜任的位置。就是说,基类充当了派生类的接口。Container和Shape这两个类就是例子。这种类通常是抽象类。
    • 实现继承(implementation inheritance):基类的函数和数据直接就是派生类实现的一部分。 Smiley对Circle的构造函数、Circle::draw()的调用就是这方面的例子。这种基类通常具有成员变量和构造函数。
    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用于指针类型。


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

    • 写Smiley的程序员可能忘记delete指向mouth的指针;
    • read_shape()的用户可能忘记delete返回的指针;
    • Shape指针容器的所有者可能忘记delete它们指向的对象。

    从这个意义上讲,指向自由存储区中对象的指针是危险的: “直白老旧的指针(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()。

    忠告

    • [1] 用代码直接表达意图;
    • [2] 实体类型是最简单的类。情况许可的时候, 请优先用实体类,而非更复杂的类或者普通的数据结构;
    • [3] 用实体类去表示简单的概念;
    • [4] 对于性能要求严苛的组件,优先用实体类,而不是选择类层次;
    • [5] 定义构造函数去处理对象的初始化;
    • [6] 只在一个函数需要直接访问类的表征数据时,把它定义为成员函数;
    • [7] 自定义运算符的主要用途应该是模拟传统运算;
    • [8] 为对称运算符使用非成员函数;
    • [9] 把不修改对象状态的成员函数定义为const
    • [10] 如果构造函数申请了资源,这个类就需要虚构函数去释放这个资源;
    • [11] 避免使用“裸的”newdelete操作;
    • [12] 利用资源操控器和 RAII 去管理资源;
    • [13] 如果类是容器,请给它定义一个初始化列表构造函数;
    • [14] 需要接口和实现完全分离的时候,请用抽象类作为接口;
    • [15] 请通过指针和引用访问多态对象;
    • [16] 抽象类通常不需要构造函数;
    • [17] 对于与生俱来就具有层次结构的概念,请使用类层次结构表示它们;
    • [18] 带有虚函数的类,应该定义虚析构函数;
    • [19] 在较大的类层次中,显式用override进行覆盖;
    • [20] 设计类层次的时候,要分清实现继承和接口继承;
    • [21] 在不可避免要在类层次中进行辨别的时候,使用dynamic_cast
    • [22] 当“转换目标不属于所需的类”需要报错时,就把dynamic_cast用于引用类型;
    • [23] 如果“转换目标不属于所需的类”可接受,就把dynamic_cast用于指针类型;
    • [24] 对于通过new创建的对象,用unique_ptrshared_ptr避免忘记delete

    相关文章

      网友评论

        本文标题:四、类

        本文链接:https://www.haomeiwen.com/subject/sgqhidtx.html