Skip to content

Latest commit

 

History

History
1469 lines (1084 loc) · 84.1 KB

File metadata and controls

1469 lines (1084 loc) · 84.1 KB

六、类

C++ 允许您创建自己的类型。 这些自定义类型可以有运算符,也可以转换为其他类型;实际上,它们可以像内置类型一样与您定义的行为一起使用。 该工具使用一种称为类的语言功能。 能够定义您自己的类型的好处是,您可以将数据封装在所选类型的对象中,并使用该类型来管理该数据的生存期。 您还可以定义可以对该数据执行的操作。 换句话说,您可以定义具有状态和行为的自定义类型,这是面向对象编程的基础。

写作课

当您使用内置类型时,任何有权访问该数据的代码都可以直接访问该数据。 C++ 提供了一种机制(const)来阻止写访问,但是任何代码都可以使用const_cast来丢弃const属性。 您的数据可能很复杂,例如指向映射到内存中的文件的指针,目的是让您的代码更改几个字节,然后将该文件写回磁盘。 这样的原始指针是危险的,因为其他有权访问该指针的代码可能会更改不应该更改的部分缓冲区。 需要一种机制来将数据封装到知道要更改哪些字节的类型中,并且只允许该类型访问数据。 这是课程背后的基本理念。

审查结构

我们已经在 C++ 中看到了一种封装数据的机制:struct。 结构允许您声明内置类型、指针或引用的数据成员。 当您从该struct创建变量时,您将创建该结构的一个实例,也称为对象。 您可以创建引用此对象的变量或指向该对象的指针。 您甚至可以将对象按值传递给一个函数,编译器将在该函数中复制对象(它将调用复制构造函数作为struct)。 我们已经看到,使用struct可以访问实例的任何代码(甚至通过指针或引用)都可以访问对象的成员(尽管这是可以更改的)。 这样使用,可以将状态struct视为包含状态的聚合类型。

通过直接使用点运算符或通过指向对象的指针使用->运算符,可以初始化struct实例的成员。 我们还看到,您可以使用初始化式列表(用大括号括起来)来初始化struct的实例。 这是非常严格的,因为初始值设定项列表必须与struct中的数据成员相匹配。 在第 4 章使用内存、数组和指针中,您看到可以将指针作为struct的成员,但是您必须显式地采取适当的操作来释放指针指向的内存;如果不这样做,则可能会导致内存泄漏。

Astruct是您可以在 C++ 中使用的类类型之一;另外两个是unionclass。 定义为structclass的自定义类型可以具有行为和状态,C++ 允许您定义一些特殊函数来控制如何创建和销毁、复制和转换实例。 此外,您可以在structclass类型上定义运算符,这样就可以像在内置类型上使用运算符一样在实例上使用运算符。 structclass之间有区别,我们将在后面讨论这一点,但总的来说,本章的其余部分都是关于类的,当提到class时,您通常可以假定struct也适用于class

定义类

类在语句中定义,它将在一个块中定义其成员,其中多个语句用大括号{}括起来。 因为它是语句,所以必须在最后一个花括号后面加一个分号。 类可以在头文件中定义(与许多C++ 标准库类一样),但您必须采取措施确保此类文件在源文件中只包含一次。 第 1 章,*从 C++*开始,描述了如何使用#pragma once、条件编译和预编译头文件来实现这一点。 但是,类中关于特定项的一些规则必须在源文件中定义,稍后将对此进行介绍。

如果您仔细阅读 C++ 标准库,您会发现类包含成员函数,并且试图将类的所有代码放入单个头文件中,这会使代码难以阅读和理解。 对于由大量专业 C++ 程序员维护的库文件来说,这可能是合理的,但是对于您自己的项目来说,可读性应该是一个关键的设计目标。 因此,可以在 C++ 头文件(包括其成员函数)中声明 C++ 类,并且可以将函数的实际实现放在源文件中。 这使得头文件更易于维护,并且更具可重用性。

定义类行为

类可以定义只能通过类的实例调用的函数;这样的函数通常称为方法。 对象将具有状态;这由类定义的数据成员提供,并在创建对象时进行初始化。 对象上的方法定义对象的行为,通常作用于对象的状态。 在设计类时,您应该这样考虑方法:它们描述执行某些操作的对象。

    class cartesian_vector 
    { 
    public: 
        double x; 
        double y; 
        // other methods 
        double get_magnitude() { return std::sqrt((x * x) + (y * y)); } 
    };

该类有两个数据成员xy,它们表示在笛卡尔 x 和 y 方向上解析的二维向量的方向。 关键字public表示在此说明符之后定义的任何成员都可以由类外部定义的代码访问。 默认情况下,类的所有成员都是private,除非您另有说明。 这样的访问说明符将在下一章中更深入地介绍,但是private意味着该成员只能被类的其他成员访问。

This is the difference between a struct and a class: by default, members of a struct are public and by default, members of a class are private.

该类有一个名为get_magnituide的方法,它将返回笛卡尔向量的长度。 此函数作用于类的两个数据成员,并返回值。 这是一种访问器方法;它提供对对象状态的访问。 这样的方法在class上是典型的,但不要求方法返回值。 与函数类似,方法也可以接受参数。 可以这样调用get_magnituide方法:

    cartesian_vector vec { 3.0, 4.0 }; 
    double len = vec.get_magnitude(); // returns 5.0

这里在堆栈上创建了cartesian_vector对象,并使用列表初始化器语法将其初始化为表示向量(3,4)的值。 该向量的长度为 5,这是通过对对象调用get_magnitude返回的值。

使用 this 指针

类中的方法具有特殊的调用约定,在 Visual C++ 中称为__thiscall。 原因是类中的每个方法都有一个名为this的隐藏参数,它是指向当前实例的类类型的指针:

    class cartesian_vector 
    { 
    public: 
        double x; 
        double y; 
        // other methods 
        double get_magnitude() 
        { 
             return std::sqrt((this->x * this->x) + (this->y * this->y)); 
        } 
    };

这里,get_magnitude方法返回cartesian_vector对象的长度。 通过->运算符访问对象的成员。 如前所述,可以在没有this指针的情况下访问类的成员,但它确实明确表示项是class的成员。

您可以在cartesian_vector类型上定义允许您更改其状态的方法:

    class cartesian_vector 
    { 
    public: 
        double x; 
        double y; 
        reset(double x, double y) { this->x = x; this->y = y; } 
        // other methods 
    };

reset方法的参数与类的数据成员具有相同的名称;但是,由于我们使用了this指针,编译器知道这不是二义性的。

您可以使用*运算符取消引用this指针以访问该对象。 当成员函数必须返回对当前对象的引用时(就像我们稍后将看到的一些操作符将返回的那样),并且您可以通过返回*this来实现这一点,这一点很有用。 类中的方法还可以将this指针传递给外部函数,这意味着它通过类型化指针通过引用传递当前对象。

使用作用域解析操作符

您可以在class语句中定义内联方法,但也可以将声明和实现分开,因此该方法在class语句中声明,但在其他地方定义。 在class语句之外定义方法时,需要使用作用域解析操作符为方法提供类型名称。 例如,使用前面的cartesian_vector示例:

    class cartesian_vector 
    { 
    public: 
        double x; 
        double y; 
        // other methods 
        double magnitude(); 
    }; 

    double cartesian_vector::magnitude() 
    { 
        return sqrt((this->x * this->x) + (this->y * this->y)); 
    }

该方法是在类定义之外定义的;但是,它仍然是类方法,因此它有一个可用于访问对象成员的this指针。 通常,类将在具有方法原型的头文件中声明,而实际方法将在单独的源文件中实现。 在这种情况下,使用this指针访问类成员(方法和数据成员)会让您粗略查看源文件时清楚地看到,函数是类的方法。

定义类状态

您的类可以有内置类型作为数据成员,也可以有自定义类型。 这些数据成员可以在类中声明(并在构造类的实例时创建),或者它们可以是指向在空闲存储中创建的对象的指针,也可以是指向在其他地方创建的对象的引用。 请记住,如果您有指向在免费商店中创建的项的指针,则需要知道释放指针所指向的内存的责任是谁。 如果您有对某个堆栈框架上创建的对象的引用(或指针),则需要确保您的类的对象不会比该堆栈帧存在的时间更长。

当您将数据成员声明为public时,这意味着外部代码可以对数据成员进行读写。 您可以决定只授予只读访问权限,在这种情况下,您可以通过访问器使成员private具有读取访问权限:

    class cartesian_vector 
    { 
        double x; 
        double y; 
    public: 
        double get_x() { return this->x; } 
        double get_y() { return this->y; } 
        // other methods 
    };

当您将数据成员设置为private时,这意味着您不能使用初始值设定项列表语法来初始化对象,但我们将在稍后解决这一问题。 您可以决定使用访问器授予对数据成员的写访问权限,并使用此访问器检查该值。

    void cartesian_vector::set_x(double d) 
    { 
        if (d > -100 && d < 100) this->x = d; 
    }

这适用于值范围必须介于(但不包括)-100100之间的类型。

创建对象

您可以在堆栈或免费商店中创建对象。 使用上一个示例,如下所示:

    cartesian_vector vec { 10, 10 }; 
    cartesian_vector *pvec = new cartesian_vector { 5, 5 }; 
    // use pvec 
    delete pvec

这是对象的直接初始化,并假设cartesian_vector的数据成员是public。 在堆栈上创建vec对象,并使用初始化器列表进行初始化。 在第二行中,在空闲存储中创建一个对象,并使用初始化列表进行初始化。 空闲存储上的对象必须在某个时刻被释放,这是通过删除指针来实现的。 new操作符将在空闲存储中为类的数据成员和类所需的任何基础设施分配足够的内存(如下一章所述)。

C++ 11 的一个新特性是允许直接初始化以在类中提供默认值:

    class point 
    { 
    public: 
        int x = 0; 
        int y = 0; 
    };

这意味着如果在没有任何其他初始化值的情况下创建point的实例,它将被初始化,以便xy都为零。 如果数据成员是内置数组,则可以使用类中的初始化列表提供直接初始化:

    class car 
    { 
    public: 
        double tire_pressures[4] { 25.0, 25.0, 25.0, 25.0 }; 
    };

C++ 标准库容器可以用初始化列表进行初始化,因此,在tire_pressures的这个类中,我们可以使用vector<double>array<double,4>,并以相同的方式对其进行初始化,而不是将类型声明为double[4]

物体的构造

C++ 允许您定义特殊方法来执行对象的初始化。 这些函数称为构造函数。 在 C++ 11 中,默认情况下会为您生成三个这样的函数,但如果您愿意,也可以提供自己的版本。 这三个构造函数以及其他三个相关函数如下所示:

  • 默认构造函数:-调用此函数以创建具有默认值值的对象。
  • 复制构造函数:-此函数用于基于现有对象的值创建新对象。
  • Move 构造函数:-此函数用于使用从现有对象移动的数据创建新对象。
  • **析构函数:**此函数用于清理对象使用的资源。
  • **复制分配:**此操作将数据从一个现有对象复制到另一个现有对象。
  • **移动分配:**这会将数据从一个现有对象移动到另一个现有对象。

这些函数的编译器创建的版本将隐式为public;但是,您可以决定通过定义自己的版本并将其设置为private来阻止复制或赋值,或者可以使用=delete语法删除它们。 您还可以提供自己的构造函数,这些构造函数将接受您决定初始化新对象所需的任何参数。

构造函数是与类型同名但不返回值的成员函数,因此如果构造失败,则无法返回值,这可能意味着调用方将接收部分构造的对象。 处理这种情况的唯一方法是抛出异常(在第 10 章诊断和调试中解释)。

定义构造函数

当创建一个没有值的对象时,将使用默认构造函数,因此必须使用默认值初始化该对象。 前面声明的point可以这样实现:

    class point 
    { 
        double x; double y; 
    public: 
        point() { x = 0; y = 0; } 
    };

这会显式地将这些项初始化为零值。 如果要使用默认值创建实例,请不要使用圆括号。

    point p;   // default constructor called

请务必注意此语法,因为很容易错误地写出以下内容:

    point p();  // compiles, but is a function prototype!

这将进行编译,因为编译器会认为您提供的是函数原型作为转发声明。 但是,当您尝试将符号p用作变量时,会出现错误。 您还可以使用带空大括号的初始化列表语法调用默认构造函数:

    point p {};  // calls default constructor

虽然在这种情况下,数据成员是内置类型,这无关紧要,但像这样初始化构造函数主体中的数据成员涉及到调用成员类型的赋值运算符。 更有效的方法是对成员列表使用直接初始化。

下面是一个接受两个参数的构造函数,它说明了一个成员列表:

    point(double x, double y) : x(x), y(y) {}

圆括号外的标识符是类成员的名称,圆括号内的项是用于初始化该成员的表达式(在本例中是构造函数参数)。 本例使用xy作为参数名称。 您不必这样做;这里给出这一点只是为了说明编译器将区分参数和数据成员。 还可以在构造函数的成员列表中使用带括号的初始值设定项语法:

    point(double x, double y) : x{x}, y{y} {}

在创建如下对象时调用此构造函数:

    point p(10.0, 10.0);

您还可以创建对象数组:

    point arr[4];

这将创建四个point对象,可以通过索引arr数组来访问这些对象。 请注意,在创建对象数组时,会在项上调用默认的构造函数;无法调用任何其他构造函数,因此必须分别初始化每个构造函数。

您还可以为构造函数参数提供默认值。 在下面的代码中,car类具有四个轮胎(前两个是前胎)和备用轮胎的值。 有一个构造函数具有用于前胎和后胎的必需值,以及一个用于备胎的可选值。 如果没有提供备用轮胎压力的值,则将使用默认值:

    class car 
    { 
        array<double, 4> tire_pressures;; 
        double spare; 
    public: 
        car(double front, double back, double s = 25.0)  
          : tire_pressures{front, front, back, back}, spare{s} {} 
    };

可以使用两个值或三个值调用此构造函数:

    car commuter_car(25, 27); 
    car sports_car(26, 28, 28);

委托构造函数

构造函数可以使用相同的成员列表语法调用另一个构造函数:

    class car 
    { 
        // data members 
    public: 
        car(double front, double back, double s = 25.0)  
           : tire_pressures{front, front, back, back}, spare{s} {} 
        car(double all) : car(all, all) {} 
    };

在这里,接受一个值的构造函数委托给接受三个参数的构造函数(在本例中使用备件的默认值)。

复制构造函数

当您按值传递对象(或按值返回)或基于另一个对象显式构造对象时,将使用复制构造函数。 下面两行代码的最后两行都从另一个point对象创建了一个point对象,并且在这两种情况下都调用了复制构造函数:

    point p1(10, 10); 
    point p2(p1); 
    point p3 = p1;

最后一行看起来涉及赋值操作符,但实际上它调用了复制构造函数。 复制构造函数可以按如下方式实现:

    class point 
    { 
        int x = 0;int y = 0; 
    public: 
        point(const point& rhs) : x(rhs.x), y(rhs.y) {} 
    };

初始化访问另一个对象(rhs)上的private数据成员。 这是可以接受的,因为构造函数参数与正在创建的对象的类型相同。 复制操作可能不会这么简单。 例如,如果类包含一个作为指针的数据成员,您很可能希望复制指针所指向的数据,这将涉及在新对象中创建新的内存缓冲区。

在类型之间转换

您还可以执行转换。 在数学中,您可以定义一个表示方向的向量,这样在两点之间绘制的直线就是一个向量。 在我们的代码中,我们已经定义了一个point类和一个cartesian_vector类。 您可以决定使用在原点和点之间创建向量的构造函数,在这种情况下,您要将point对象转换为cartesian_vector对象:

    class cartesian_vector 
    { 
        double x; double y;  
    public: 
        cartesian_vector(const point& p) : x(p.x), y(p.y) {} 
    };

这里有一个问题,我们稍后将解决这个问题。 可以这样调用转换:

    point p(10, 10); 
    cartesian_vector v1(p); 
    cartesian_vector v2 { p }; 
    cartesian_vector v3 = p;

交朋友

上述代码的问题在于,cartesian_vector类访问point类的private成员。 由于我们已经编写了这两个类,我们很乐意改变规则,因此我们将cartesian_vector类设为point类的friend类:

    class cartesian_vector; // forward decalartion 

    class point 
    { 
        double x; double y; 
    public: 
        point(double x, double y) : x(x), y(y){} 
        friend class cartesian_point; 
    };

因为cartesian_vector类是在point类之后声明的,所以我们必须提供一个正向声明,该声明实质上告诉编译器即将使用名称cartesian_vector,并且它将在其他地方声明。 重要的一行以friend开头。 这表明整个类cartesian_vector的代码可以访问point类的私有成员(数据和方法)。

您还可以声明friend函数。 例如,您可以声明一个运算符,以便可以将point对象插入到cout对象中,这样就可以将其打印到控制台。 您不能更改ostream类,但可以定义全局方法:

    ostream& operator<<(ostream& stm, const point& pt) 
    { 
        stm << "(" << pt.x << "," << pt.y << ")"; 
        return stm; 
    }

此函数访问pointprivate成员,因此您必须使用以下命令使该函数成为point类的friend

    friend ostream& operator<<(ostream&, const point&);

这样的friend声明必须在point类中声明,但它是放在public还是private部分中无关紧要。

将构造函数标记为显式

在某些情况下,您不希望允许在作为另一种类型的构造函数的参数传递的一种类型之间进行隐式转换。 为此,需要用explicit说明符标记构造函数。 这意味着调用构造函数的唯一方法是使用圆括号语法:显式调用构造函数。 在下面的代码中,不能将double隐式转换为mytype的对象:

    class mytype  
    { 
    public: 
        explicit mytype(double x); 
    };

现在,如果要使用double参数创建对象,则必须显式地调用构造函数:

    mytype t1 = 10.0; // will not compile, cannot convert 
    mytype t2(10.0);  // OK

销毁对象

当一个对象被销毁时,调用一个称为析构函数的特殊方法。 此方法的类名以~符号为前缀,并且不返回值。

如果对象是堆栈上的自动变量,那么当变量超出作用域时,它将被销毁。 当通过值传递对象时,将在被调用函数的堆栈上创建一个副本,并在被调用函数完成时销毁该对象。 此外,函数如何完成并不重要,无论是显式调用return,还是到达最后一个大括号,或者是否抛出异常;在所有这些情况下,都会调用析构函数。 如果函数中有多个对象,则以与构造同一作用域中的对象相反的顺序调用析构函数。 如果创建对象数组,则会在声明数组的语句上为数组中的每个对象调用默认构造函数,并且所有对象都将被销毁--当数组超出作用域时,将调用每个对象上的析构函数。

以下是类mytype的一些示例:

    void f(mytype t) // copy created 
    { 
        // use t 
    }   // t destroyed 

    void g() 
    { 
        mytype t1; 
        f(t1); 
        if (true) 
        { 
            mytype t2; 
        }   // t2 destroyed 

        mytype arr[4]; 
    }  // 4 objects in arr destroyed in reverse order to creation 
       // t1 destroyed

当您返回一个对象时,会发生一个有趣的操作。 以下注释是您所期望的:

    mytype get_object() 
    { 
        mytype t;               // default constructor creates t 
        return t;               // copy constructor creates a temporary 
    }                           // t destroyed 

    void h() 
    { 
        test tt = get_object(); // copy constructor creates tt 
    }                           // temporary destroyed, tt destroyed

事实上,这一过程更加流畅。 在调试版本中,编译器将看到在返回get_object函数时创建的临时对象是将用作变量tt的对象,因此在get_object函数的返回值上没有额外的副本。 该函数实际上如下所示:

    void h() 
    { 
        mytype tt = get_object();  
    }   // tt destroyed

但是,编译器能够进一步优化代码。 在发布版本中(启用了优化),不会创建临时对象,调用函数中的对象tt将是在get_object中创建的实际对象t

当您显式删除指向分配在空闲存储区上的对象的指针时,该对象将被销毁。 在这种情况下,对析构函数的调用是确定性的:它是在代码调用delete时调用的。 同样,对于相同的类mytype,如下所示:

    mytype *get_object() 
    { 
        return new mytype; // default constructor called 
    } 

    void f() 
    { 
        mytype *p = get_object(); 
        // use p 
        delete p;        // object destroyed 
    }

有时,您希望使用删除对象的确定性方面(可能有忘记调用delete的危险),有时,您更希望确保对象将在适当的时间销毁(可能会在更晚的时间销毁)。

如果类中的数据成员是带有析构函数的自定义类型,则当销毁包含对象时,也会调用包含对象上的析构函数。 尽管如此,请注意,只有当对象是类成员时,才会出现这种情况。 如果类成员是指向空闲存储区中对象的指针,则必须显式删除包含对象的析构函数中的指针。 但是,您需要知道指针所指向的对象的位置,因为如果该对象不在空闲存储中,或者该对象正被其他对象使用,则调用delete会导致问题。

指定对象

当已创建的对象被赋给另一个对象的值时,将调用赋值运算符。 默认情况下,您将获得一个复制赋值运算符,该运算符将复制所有数据成员。 这不一定是您想要的,特别是如果对象的数据成员是指针,在这种情况下,您更有可能执行深度复制并复制指向的数据,而不是指针的值(在后一种情况下,两个对象将指向相同的数据)。

*如果定义复制构造函数,则仍将获得默认的复制赋值运算符;但是,如果您认为编写自己的复制构造函数很重要,则还应该提供自定义的复制赋值运算符。 (同样,如果定义复制赋值运算符,则除非定义默认复制构造函数,否则将获得默认复制构造函数。)

复制赋值操作符通常是类的public成员,它接受对将用于提供赋值的值的对象的const引用。 赋值运算符的语义是您可以链接它们,因此,例如,下面的代码对其中两个对象调用赋值运算符:

    buffer a, b, c;              // default constructors called 
    // do something with them 
    a = b = c;                   // make them all the same value 
    a.operator=(b.operator=(c)); // make them all the same value

最后两行做同样的事情,但显然第一行更具可读性。 要启用这些语义,赋值操作符必须返回对已赋值对象的引用。 因此,类buffer将具有以下方法:

    class buffer 
    { 
        // data members 
    public: 
        buffer(const buffer&);            // copy constructor 
        buffer& operator=(const buffer&); // copy assignment 
    };

尽管复制构造函数和复制赋值方法看起来做的事情相似,但有一个关键的区别。 复制构造函数创建调用前不存在的新对象。 调用代码知道,如果构造失败,则会引发异常。 使用赋值时,两个对象都已存在,因此您要将值从一个对象复制到另一个对象。 这应该被视为原子操作,并且应该执行所有复制;分配在中途失败是不可接受的,从而导致一个对象同时包含两个对象。 此外,在构造中,对象仅在构造成功后才存在,因此复制构造不能发生在对象本身上,但是代码将对象分配给自身是完全合法的(如果没有意义的话)。 副本分配需要检查此情况并采取适当的操作。

有多种策略可以做到这一点,一种常见的策略被称为复制和交换习惯用法,因为它使用标记为noexcept的标准库swap函数,并且不会抛出异常。 这个习惯用法涉及在赋值的右侧创建对象的临时副本,然后将其数据成员与左侧的对象的数据成员交换。

移动语义

C++ 11 通过 Move 构造函数和 Move 赋值操作符提供了移动语义,当使用临时对象创建另一个对象或将其分配给现有对象时,会调用这两个函数。 在这两种情况下,因为临时对象不会存在于语句之外,所以可以将临时对象的内容移动到另一个对象,从而使临时对象处于无效状态。 编译器将通过将数据从临时对象移动到新创建的(或分配给)对象的默认操作为您创建这些函数。

您可以编写自己的版本,为了表示移动语义,这些版本有一个参数是一个右值引用(&&)。

If you want the compiler to provide you with a default version of any of these methods, you can provide the prototype in the class declaration suffixed with =default. In most cases, this is self-documenting rather than being a requirement, but if you are writing a POD class you must use the default versions of these functions, otherwise is_pod will not return true.

如果您只想使用 MOVE 而从不使用 COPY(例如,文件句柄类),则可以删除COPY 功能:

    class mytype 
    { 
        int *p; 
    public: 
        mytype(const mytype&) = delete;             // copy constructor 
        mytype& operator= (const mytype&) = delete; // copy assignment 
        mytype&(mytype&&);                          // move constructor 
        mytype& operator=(mytype&&);                // move assignment 
    };

该类有一个指针数据成员,并允许移动语义,在这种情况下,将使用对临时对象的引用来调用移动构造函数。 由于该对象是临时的,因此在调用 Move 构造函数后它将无法继续存在。 这意味着新对象可以将临时对象的状态移入其自身:

    mytype::mytype(mytype&& tmp) 
    { 
        this->p = tmp.p; 
        tmp.p = nullptr; 
    }

Move 构造函数将临时对象的指针分配给nullptr,因此为该类定义的任何析构函数都不会尝试删除指针。

声明静态成员

您可以声明类的成员--数据成员或方法--static。 这在某些方面类似于在文件范围内声明的自动变量和函数上使用static关键字,但在类成员上使用该关键字时,它有一些重要且不同的属性。

定义静态成员

当您在类成员上使用static时,这意味着该项与类相关联,而不是与特定实例相关联。 在这种情况下,对于数据成员,这意味着有一个数据项由类的所有实例共享。 同样,static方法没有附加到对象,它不是__thiscall,也没有this指针。

static方法是类命名空间的一部分,因此它可以为类创建对象并访问其private成员。 默认情况下,static方法具有__cdecl调用约定,但如果愿意,可以将其声明为__stdcall。 这意味着,您可以在类中编写一个方法,该方法可用于初始化许多库使用的类 C 指针。 请注意,static函数不能调用类上的非静态方法,因为非静态方法需要this指针,但非静态方法可以调用static方法。

非静态方法通过对象调用,或者使用点运算符(对于类实例),或者使用对象指针的->运算符。 static方法不需要关联的对象,但可以通过一个对象调用。 这提供了通过对象或通过class名称调用static方法的两种方式:

    class mytype 
    { 
    public: 
        static void f(){} 
        void g(){ f(); } 
    };

在这里,该类定义了一个名为fstatic方法和一个名为g的非静态方法。 非静态方法g可以调用static方法,但是static方法f不能调用非静态方法。 因为static方法fpublic,所以class外部的代码可以调用它:

    mytype c; 
    c.g();       // call the nonstatic method 
    c.f();       // can also call the static method thru an object 
    mytype::f(); // call static method without an object

虽然可以通过对象调用static函数,但调用它根本不需要创建任何对象。

静态数据成员需要做更多的工作,因为当您使用static时,它表示数据成员不是对象的一部分,通常数据成员是在创建对象时分配的。 您必须在类之外定义static个数据成员:

    class mytype 
    { 
    public: 
        static int i; 
        static void incr() { i++ ; } 
    }; 

    // in a source file 
    int mytype::i = 42;

数据成员在文件作用域的类外部定义。 它使用class名称命名,但请注意,还必须使用类型定义它。 在本例中,数据成员使用值进行初始化;如果不这样做,则在第一次使用变量时,它将具有该类型的默认值(在本例中为零)。 如果选择在头文件中声明类(这很常见),则static数据成员的定义必须在源文件中。

您还可以在为static的方法中声明变量。 在这种情况下,在所有对象中跨方法调用维护该值,因此它具有与static class成员相同的效果,但是您没有在类外部定义变量的问题。

使用静态和全局对象

全局函数中的static变量将在第一次调用该函数之前的某个点创建。 类似地,作为类成员的static对象将在首次被访问之前的某个时刻被初始化。

静态和全局对象在调用main函数之前构造,并在main函数结束后销毁。 此初始化的顺序有一些问题。 C++ 标准规定,源文件中定义的static和全局对象的初始化将在使用该源文件中定义的任何函数或对象之前进行,如果源文件中有多个全局对象,则它们将按照定义的顺序进行初始化。 问题是如果您有多个源文件,每个源文件中都有static个对象。 不能保证这些对象的初始化顺序。 如果一个static对象依赖于另一个static对象,这就成了问题,因为您不能保证依赖对象将在它所依赖的对象之后创建。

命名构造函数

这是public static方法的一个应用。 其思想是,由于static方法是class的成员,这意味着它可以访问class实例的private成员,因此这样的方法可以创建一个对象,执行一些额外的初始化,然后将该对象返回给调用方。 这是工厂方法。 到目前为止使用的point类是使用笛卡尔点构造的,但我们也可以基于极坐标创建点,其中(x, y)笛卡尔坐标可以计算为:

    x = r * cos(theta) 
    y = r * sin(theta)

这里r是向量到点的长度,theta是该向量与 x 轴的逆时针角度。 point类已经有一个接受两个double值的构造函数,因此我们不能使用它来传递极坐标;相反,我们可以使用static方法作为名为的构造函数

    class point 
    { 
        double x; double y; 
    public: 
        point(double x, double y) : x(x), y(y){} 
        static point polar(double r, double th) 
        { 
            return point(r * cos(th), r * sin(th)); 
        } 
    };

该方法可以按如下方式调用:

    const double pi = 3.141529; 
    const double root2 = sqrt(2); 
    point p11 = point::polar(root2, pi/4);

对象p11是笛卡尔坐标为(1,1)的point。 在本例中,polar方法调用public构造函数,但它可以访问私有成员,因此可以编写相同的方法(效率较低)为:

    point point::polar(double r, double th) 
    { 
        point pt; 
        pt.x = r * cos(th); 
        pt.y = r * sin(th); 
        return pt; 
    }

嵌套类

您可以在类中定义类。 如果嵌套类声明为public,则可以在容器类中创建对象并将其返回给外部代码。 但是,通常情况下,您会希望声明一个由类使用的类,并且应该是private。 下面声明了一个public嵌套类:

    class outer 
    { 
    public: 
        class inner  
        { 
        public: 
            void f(); 
        }; 

        inner g() { return inner(); } 
    }; 

    void outer::inner::f() 
    { 
         // do something 
    }

请注意嵌套类的名称是如何以包含类的名称作为前缀的。

访问常量对象

到目前为止,您已经看到了许多使用const的示例,其中最常见的可能是将const作为函数参数应用于引用,以向编译器表明该函数对对象只有只读访问权限。 使用这样的const引用,以便通过引用传递对象,以避免在通过值传递对象时发生的复制开销。 class上的方法可以访问对象数据成员,并且可能会更改它们,因此如果通过const引用传递对象,编译器将只允许该引用调用不更改对象的方法。 前面定义的point类有两个访问器来访问类中的数据:

    class point 
    { 
        double x; double y; 
    public: 
        double get_x() { return x; } 
        double get_y() { return y: } 
    };

如果您定义了一个采用const引用的函数,并尝试调用这些访问器,则会从编译器收到一个错误:

    void print_point(const point& p) 
    { 
        cout << "(" << p.get_x() << "," << p.get_y() << ")" << endl; 
    }

来自编译器的错误有点模糊:

cannot convert 'this' pointer from 'const point' to 'point &'

这条消息是编译器抱怨对象是const,它是不可变的,它不知道这些方法是否会保留对象的状态。 解决方案很简单--向不更改对象状态的方法添加const关键字,如下所示:

    double get_x() const { return x; } 
    double get_y() const { return y: }

这实际上意味着this指针是const。 关键字const是函数原型的一部分,因此该方法可以在此基础上重载。 可以有一个方法在const对象上调用时调用,另一个方法在非常数对象上调用。 这使您能够实现写入时复制模式,例如,const方法将返回对数据的只读访问,非常数方法将返回可写数据的副本

当然,标有const的方法不得更改数据成员,即使是临时更改也不行。 因此,这样的方法只能调用const个方法。 在极少数情况下,数据成员被设计为通过const对象进行更改;在这种情况下,成员的声明用mutable关键字标记。

使用带有指针的对象

可以在空闲存储上创建对象,并通过类型化指针进行访问。 这提供了更大的灵活性,因为将指针传递给函数是有效的,而且您可以显式确定对象的生存期,因为对象是通过调用new创建的,而通过调用delete销毁的。

获取指向对象成员的指针

如果需要通过实例访问类数据成员的地址(假设数据成员为public),只需使用&运算符:

    struct point { double x; double y; }; 
    point p { 10.0, 10.0 }; 
    int *pp = &p.x;

在本例中,struct用于声明point,因此默认情况下成员是public。 第二行使用初始化列表构造具有两个值的point对象,然后最后一行获得指向其中一个数据成员的指针。 当然,在销毁对象之后不能使用指针。 数据成员是在内存中分配的(在本例中是在堆栈上),因此地址操作符只获得指向该内存的指针。

函数指针则不同。 无论创建了多少个class实例,内存中都只有一个方法副本,但是因为方法是使用__thiscall调用约定(使用隐藏的this参数)调用的,所以您必须有一个函数指针,该指针可以用指向对象的指针来初始化,以提供this指针。 请考虑以下内容class

    class cartesian_vector 
    { 
    public: 
        // other items 
        double get_magnitude() const 
        { 
            return std::sqrt((this->x * this->x) + (this->y * this->y)); 
        }  
    };

我们可以定义指向get_magnitude方法的函数指针,如下所示:

    double (cartesian_vector::*fn)() const = nullptr; 
    fn = &cartesian_vector::get_magnitude;

第一行声明一个函数指针。 这类似于 C 函数指针声明,不同之处在于指针类型中包含了class名称。 这是必需的,以便编译器知道它必须在通过该指针的任何调用中提供this指针。 第二行获取指向该方法的指针。 请注意,没有涉及任何对象。 您不是在获取指向对象上的方法的函数指针;而是在获取指向必须通过对象调用的class上的方法的指针。 要通过此指针调用该方法,需要使用指向对象上的成员运算符.*的指针:

    cartesian_vector vec(1.0, 1.0); 
    double mag = (vec.*fn)();

第一行创建一个对象,第二行调用该方法。 指向成员运算符的指针表示,右侧上的函数指针是通过左侧上的对象调用的。 调用方法时,左侧对象的地址用作this指针。 因为这是一个方法,所以我们需要提供一个参数列表,在本例中为空(如果您有参数,则它们应该在此语句右侧的圆括号中)。 如果您有一个对象指针,则语法类似,但您使用指向成员运算符的->*指针:

    cartesian_vector *pvec = new cartesian_vector(1.0, 1.0); 
    double mag = (pvec->*fn)(); 
    delete pvec;

运算符重载

类型的行为之一是您可以对其应用的操作。 C++ 允许您将 C++ 运算符作为类的一部分进行重载,因此运算符显然是作用于该类型。 这意味着对于一元运算符,成员方法应该没有参数,而对于二元运算符,您只需要一个参数,因为当前对象将位于运算符的左侧,因此方法参数是右侧的项。 下表总结了如何实现一元运算符和二元运算符,以及四个例外:

| 表达式 | 名称 | 成员方法 | 非成员函数 | | +a/-a | 前缀一元 | 运算符() | 营运者(A)(A) | | a,b | 二进制的 / 由两部分组成的 / 双重的 / 二元的 | 操作员(B)(B) | 运算符(a,b) | | A+/a- | 后缀一元 | 运算符(0) | 运算符(a,0) | | A=b | 任务 / 归属 / 转让 / 分配 | 操作员=(B) | | | A ( b ) | 函数调用 | 操作员()(B) | | | A [ b ] | 标引 | 操作员 | | | A->-> | 指针访问 | 运算符->() | |

这里,符号用于表示除表中提到的四个运算符之外的任何可接受的一元运算符或二元运算符。

对于运算符应该返回什么没有严格的规则,但如果自定义类型上的运算符的行为类似于内置类型上的运算符,则会有所帮助。 还必须有一些一致性。 如果实现+运算符将两个对象相加在一起,则应该对+=运算符使用相同的加号操作。 此外,您可能会争辩说,加号操作还将决定减号操作应该是什么样子,因此也就决定了--=运算符。 同样,如果您想定义<运算符,那么也应该定义<=. >>===!=

标准库的算法(例如,sort)只需要在自定义类型上定义<运算符。

该表显示,您几乎可以将所有运算符实现为自定义类型类的成员或全局函数(除了列出的四个必须是成员方法的运算符)。 通常,最好将运算符实现为类的一部分,因为它维护封装:成员函数可以访问类的非公共成员。

一元运算符的一个例子是一元负运算符。 这通常不会改变对象,但会返回一个新对象,该对象是该对象的负*。 对于我们的point class,这意味着将两个坐标都设为负值,这相当于直线y=-x中笛卡尔点的镜像:*

    // inline in point 
    point operator-() const 
    { 
        return point(-this->x, -this->y); 
    }

运算符被声明为const,因为运算符显然不会更改对象,因此在const对象上调用它是安全的。 运算符可以这样调用:

    point p1(-1,1); 
    point p2 = -p1; // p2 is (1,-1)

要理解我们为什么要实现这样的运算符,请查看一元运算符在应用于内置类型时会做些什么。 这里的第二个语句int i, j=0; i = -j;只会改变i,不会改变j,因此成员operator-应该不会影响对象的值。

二元负运算符有不同的含义。 首先,它有两个操作数,第二,在本例中,结果与操作数的类型不同,因为结果是一个向量,它通过将一个点与另一个点分开来指示方向。 假设已经使用具有两个参数的构造函数定义了cartesian_vector,那么我们可以这样写:

    cartesian_vector point::operator-(point& rhs) const 
    { 
        return cartesian_vector(this->x - rhs.x, this->y - rhs.y); 
    }

递增和递减运算符具有特殊的语法,因为它们是一元运算符,可以作为前缀或后缀,并且它们会改变它们应用到的对象。 这两个运算符之间的主要区别在于,后缀运算符在递增/递减操作之前返回对象的值,因此必须创建一个临时运算符。 因此,前缀运算符几乎总是比后缀运算符具有更好的性能。 在类定义中,为了区分这两者,前缀运算符没有参数,后缀运算符有一个伪参数(在上表中,给出了 0)。 对于类mytype,如下所示:

    class mytype  
    { 
    public: 
        mytype& operator++() 
        {  
            // do actual increment 
            return *this; 
        } 
        mytype operator++(int) 
        { 
            mytype tmp(*this); 
            operator++(); // call the prefix code 
            return tmp; 
        } 
    };

实际的增量代码由前缀操作符实现,该逻辑由后缀操作符通过显式调用该方法来使用。

定义函数类

函数器是实现()运算符的类。 这意味着您可以使用与函数相同的语法调用对象。 请考虑以下内容:

    class factor 
    { 
        double f = 1.0; 
    public: 
        factor(double d) : f(d) {} 
        double operator()(double x) const { return f * x; }  
    };

可以这样调用此代码:

    factor threeTimes(3);        // create the functor object 
    double ten = 10.0; 
    double d1 = threeTimes(ten); // calls operator(double) 
    double d2 = threeTimes(d1);  // calls operator(double)

这段代码显示,函数器对象不仅提供一些行为(在本例中,对参数执行操作),而且还可以具有状态。 前面两行是通过对象上的operator()方法调用的:

    double d2 = threeTimes.operator()(d1);

看看语法。 函数器对象被调用,就像它是如下声明的函数一样:

    double multiply_by_3(double d) 
    { 
        return 3 * d;  
    }

假设您想要传递一个指向一个函数的指针--也许您希望该函数的行为被外部代码改变。 为了能够使用函数器或方法指针,您需要重载函数:

    void print_value(double d, factor& fn); 
    void print_value(double d, double(*fn)(double));

第一个函数引用一个函数器对象。 第二个函数有一个 C 型函数指针(您可以向其传递指向multiply_by_3的指针),并且非常不可读。 在这两种情况下,在实现代码中以相同的方式调用fn参数,但是您需要声明两个函数,因为它们的类型不同。 现在,考虑一下函数模板的魔力:

    template<typename Fn> 
    void print_value(double d, Fn& fn) 
    { 
        double ret = fn(d); 
        cout << ret << endl; 
    }

这是泛型代码;Fn类型可以是 C 函数指针或函数器class,编译器将生成适当的代码。

This code can be called by either passing a function pointer to a global function, which will have the __cdecl calling convention, or a functor object where the operator() operator will be called, which has a __thiscall calling convention.

这只是一个实现细节,但这确实意味着您可以编写一个泛型函数,该函数可以接受类似 C 的函数指针或函数器对象作为参数。 C++ 标准库使用了这种魔力,这意味着它提供的算法可以使用全局函数函数器lambda 表达式来调用。

标准库算法使用三种类型的函数类、生成器以及一元函数和二元函数;即,具有零个、一个或两个参数的函数。 此外,标准库调用返回bool谓词的函数对象(一元或二进制)。 文档将告诉您是否需要谓词函数、一元函数或二元函数。 旧版本的标准库需要知道函数对象的返回值和参数(如果有的话)的类型才能工作,因此,函数式类必须基于标准类unary_functionbinary_function(通过继承,将在下一章进行说明)。 在 C++ 11 中,这一要求已被删除,因此不需要使用这些类。

在某些情况下,当需要一元函数器时,您会希望使用二元函数器。 例如,标准库定义了greater类,当用作函数对象时,它采用两个参数和一个bool来确定第一个参数是否大于第二个参数,使用这两个参数的类型定义的operator>。 这将用于需要二元函数器的函数,因此该函数将比较两个值;例如:

    template<typename Fn>  
    int compare_vals(vector<double> d1, vector<double> d2, Fn compare) 
    { 
        if (d1.size() > d2.size()) return -1; // error 
        int c = 0; 
        for (size_t i = 0; i < d1.size(); ++ i) 
        { 
            if (compare(d1[i], d2[i])) c++ ; 
        } 
        return c; 
    }

它接受两个集合,并使用作为最后一个参数传递的函数器比较相应的项。 可以这样称呼它:

    vector<double> d1{ 1.0, 2.0, 3.0, 4.0 }; 
    vector<double> d2{ 1.0, 1.0, 2.0, 5.0 }; 
    int c = compare_vals(d1, d2, greater<double>());

greater函数类在<functional>头中定义,并使用为该类型定义的operator>比较两个数字。 如果您希望将容器中的项与固定值进行比较,也就是说,当调用函数器上的operator()(double, double)方法时,一个参数始终具有固定值,该怎么办呢? 一种选择是定义一个有状态的函数器类(如前所述),这样固定值就是函数器对象的成员。 另一种方法是用固定值填充另一个vector,然后继续比较两个vector(对于大的vector来说,这可能会相当昂贵)。

另一种方法是重用函数器类,但是一个值绑定到它的一个参数。 可以这样编写compare_vals函数的一个版本,即只取一个vector

    template<typename Fn>  
    int compare_vals(vector<double> d, Fn compare) 
    { 
        int c = 0; 
        for (size_t i = 0; i < d.size(); ++ i) 
        { 
            if (compare(d[i]) c++ ; 
        } 
        return c; 
    }

编写代码的目的是只对一个值调用函数器参数,因为假定函数器对象包含要比较的另一个值。 这是通过将函数器类绑定到参数来实现的:

    using namespace::std::placeholders; 
    int c = compare_vals(d1, bind(greater<double>(), _1, 2.0));

bind函数是可变的。 第一个参数是函数器对象,后跟将传递给函数器的operator()方法的参数。 向compare_vals函数传递一个绑定器对象,该对象将函数绑定到值。 在compare_vals函数中,对compare(d[i])中的函数器的调用实际上是对绑定器对象的operator()方法的调用,该方法将参数d[i]和绑定值转发给函数器的operator()方法。

在对bind的调用中,如果提供了实际值(这里为2.0),则该值将传递给函数调用中该位置的函数(这里,2,0传递给第二个参数)。 如果使用前面有下划线的符号,则它是占位符。 在std::placeholders名称空间中定义了 20 个这样的符号(_1_20)。 占位符的意思是“将在此位置传递的值用于活页夹对象operator()方法调用,以调用占位符指示的函数器调用operator()方法。” 因此,此调用中的占位符表示“传递调用活页夹的第一个参数,并将其传递给greater函数operator()的第一个参数。”

前面的代码将vector中的每一项与2.0进行比较,并保留大于2.0的项的计数。 您可以这样调用它:

    int c = compare(d1, bind(greater<double>(), 2.0, _1));

参数列表被交换,这意味着2.0将与vector中的每个项目进行比较,并且函数将保留2.0大于该项目的次数的计数。

bind函数和占位符是 C++ 11 新增的。在以前的版本中,您可以使用bind1stbind2nd函数将值绑定到函数的第一个或第二个参数。

定义转换运算符

我们已经看到,如果您的自定义类型具有接受您要转换的类型的构造函数,则可以使用构造函数将其从另一个类型转换为您的自定义类型。 您还可以在另一个方向上执行转换:将对象转换为另一种类型。 为此,您需要为不带返回类型的运算符提供要转换到的类型的名称。 在这种情况下,您需要在operator关键字和名称之间留一个空格:

    class mytype 
    { 
        int i; 
    public: 
        mytype(int i) : i(i) {} 
        explicit mytype(string s) : i(s.size()) {} 
        operator int () const { return i; } 
    };

此代码可以将intstring转换为mytype;在后一种情况下,只能通过显式提到构造函数。 最后一行允许您将对象转换回int

    string s = "hello"; 
    mytype t = mytype(s); // explicit conversion 
    int i = t;            // implicit conversion

您可以设置这样的转换运算符explicit,以便只有在使用显式强制转换时才会调用它们。 在许多情况下,您可能不想使用此关键字,因为当您希望将资源包装在类中并使用析构函数为您执行自动资源管理时,隐式转换非常有用。

使用转换操作符的另一个示例是从有状态函数器返回值。 这里的想法是,operator()将执行一些操作,结果由函数器维护。 问题是如何获得函数器的这种状态,特别是当它们经常被创建为临时对象时? 转换操作符可以提供此功能。

例如,当您计算平均值时,分两个阶段进行:第一个阶段是累加值,第二个阶段是通过除以项目数来计算平均值。 下面的函数类通过在转换为double的过程中执行除法来实现这一点:

    class averager 
    { 
        double total; 
        int count; 
    public: 
        averager() : total(0), count(0) {} 
        void operator()(double d) { total += d; count += 1; } 
        operator double() const 
        {        
            return (count != 0) ? (total / count) : 
                numeric_limits<double>::signaling_NaN(); 
        } 
    };

这可以这样称呼:

    vector<double> vals { 100.0, 20.0, 30.0 }; 
    double avg = for_each(vals.begin(), vals.end(), averager());

for_each函数为vector中的每一项调用函数器,而operator()只是对传递给它的项求和并维护计数。 有趣的是,在for_each函数迭代了vector中的所有项之后,它返回函数器,因此存在到double的隐式转换,后者调用计算平均值的转换操作符。

管理资源

我们已经看到一种需要仔细管理的资源:内存。 使用new分配内存,使用完内存后,必须使用delete释放内存。 释放内存失败将导致内存泄漏。 内存可能是最基本的系统资源,但大多数操作系统还有许多其他资源:文件句柄、图形对象句柄、同步对象、线程和进程。 有时,拥有这样的资源是独占的,会阻止其他代码访问通过该资源访问的资源。 因此,重要的是要在某个时候释放这些资源,并且通常要及时释放它们。

在这里,类通过 C++ 的作者 Bjarne Stroustrup 发明的称为Resource Acquisition is Initialization(RAII)的机制提供帮助。 简而言之,资源在对象的构造函数中分配,在析构函数中释放,因此这意味着资源的生存期就是对象的生存期。 通常,这样的包装器对象是在堆栈上分配的,这意味着当对象超出作用域时,您可以保证资源将被释放,无论这种情况是如何发生的

因此,如果在代码块中为循环语句(whilefor)声明了对象,则在每个循环结束时将调用每个对象的析构函数(按创建的相反顺序),并且在循环重复时将再次创建该对象。 无论循环是因为已到达代码块的末尾而重复,还是通过调用continue重复循环,都会发生这种情况。 另一种退出代码块的方法是调用break、agoto,或者如果代码调用return退出函数。 如果代码引发异常(参见第 10 章诊断和调试),则当对象超出范围时将调用析构函数,因此如果代码由try块保护,则在调用catch子句之前将调用块中声明的对象的析构函数。 如果没有保护块,则在销毁函数堆栈和传播异常之前将调用析构函数。

编写包装类

在编写类来包装资源时,您必须解决几个问题。 构造函数将用于使用某个库函数(通常通过某种不透明的句柄访问)获取资源,或者将该资源作为参数。 此资源存储为数据成员,以便类上的其他方法可以使用它。 资源将使用您的库提供的任何函数在析构函数中释放。 这是最低限度。 此外,您还必须考虑如何使用对象。 通常,如果您可以像使用资源句柄一样使用实例,这样的包装类是最方便的。 这意味着您可以保持相同的编程风格来访问资源,但不必太担心资源的释放。

您应该考虑是否希望能够在包装类和资源句柄之间进行转换。 如果您确实允许这样做,这意味着您可能必须考虑克隆资源,这样您就不会有句柄的两个副本--一个由类管理,另一个副本可以由外部代码释放。 您还需要考虑是否允许复制或分配对象,如果允许,则需要适当地实现复制构造函数、移动构造函数以及复制和移动赋值操作符。

使用智能指针

C++ 标准库提供了几个类来包装通过指针访问的资源。 为了防止内存泄漏,您必须确保在空闲存储上分配的内存在某个时候被释放。 智能指针的思想是将实例视为指针,因此使用*运算符取消引用以访问它所指向的对象,或使用->运算符访问包装对象的成员。 智能指针类将管理其包装的指针的生存期,并将适当地释放资源。

标准库有三个智能指针类:unique_ptrshared_ptrweak_ptr。 每个函数处理如何以不同的方式释放资源,以及如何或是否可以复制指针。

管理独占所有权

unique_ptr类是用指向它将维护的对象的指针构造的。 该类提供操作符*来提供对对象的访问,取消对包装指针的引用。 它还提供了->运算符,因此如果指针指向某个类,则可以通过包装指针访问成员。

以下命令在空闲存储上分配对象并手动维护其生存期:

    void f1() 
    { 
       int* p = new int; 
       *p = 42; 
       cout << *p << endl; 
       delete p; 
    }

在本例中,您将获得一个指针,指向为int分配的空闲存储上的内存。 要访问内存--无论是写入内存还是从内存读取--使用*操作符取消对指针的引用。 使用完指针后,必须调用delete来释放内存并将其返回到空闲存储。 现在考虑相同的代码,但使用智能指针:

    void f2() 
    { 
       unique_ptr<int> p(new int); 
       *p = 42; 
       cout << *p << endl; 
       delete p.release(); 
    }

两个主要区别在于,智能指针对象是通过调用构造函数显式构造的,该构造函数接受用作模板参数的类型的指针。 此模式强化了资源只应由智能指针管理的想法。

第二个更改是通过调用智能指针对象上的release方法来获得包装指针的所有权,从而释放内存,这样我们就可以显式删除指针。

考虑一下release方法,它将指针从智能指针的所有权中释放出来。 在此调用之后,智能指针不再包装资源。 unique_ptr类还有一个方法get,它将提供对包装指针的访问,但智能指针对象仍将保留所有权;不要删除通过这种方式获得的指针

请注意,unique_ptr对象包装了一个指针,并且只包装了该指针。 这意味着对象在内存中的大小与它包装的指针的大小相同。 到目前为止,智能指针添加的内容很少,所以让我们看看另一种释放资源的方法:

    void f3() 
    { 
       unique_ptr<int> p(new int); 
       *p = 42; 
       cout << *p << endl; 
       p.reset(); 
    }

这是资源的确定性释放,意味着资源恰好在您希望它发生的时候释放,这与指针的情况类似。 这里的代码没有释放资源本身;它允许智能指针使用删除器来释放资源。 unique_ptr的默认删除器是一个名为default_delete的函数器类,它调用换行指针上的delete运算符。 如果您打算使用确定性销毁,reset是首选方法。 您可以通过将自定义函数器类的类型作为第二个参数传递给unique_ptr模板来提供您自己的删除器:

    template<typename T> struct my_deleter 
    { 
        void operator()(T* ptr)  
        { 
            cout << "deleted the object!" << endl; 
            delete ptr; 
        } 
    };

在代码中,您将指定需要自定义删除器,如下所示:

    unique_ptr<int, my_deleter<int> > p(new int);

在删除指针之前,您可能需要执行额外的清理,或者指针可能是由new以外的机制获得的,因此您可以使用自定义删除器来确保调用适当的释放函数。 请注意,删除器是智能指针类的一部分,因此,如果您有两个不同的智能指针以这种方式使用两个不同的删除器,则即使它们包装相同类型的资源,智能指针类型也是不同的。

When you use a custom deleter, the size of a unique_ptr object may be larger than the pointer wrapped. If the deleter is a functor object, each smart pointer object will need memory for this, but if you use a lambda expression, no more extra space will be required.

当然,您最有可能允许智能指针为您管理资源生存期,要做到这一点,您只需允许智能指针对象超出作用域:

    void f4() 
    { 
       unique_ptr<int> p(new int); 
       *p = 42; 
       cout << *p << endl; 
    } // memory is deleted

由于创建的指针是单个对象,这意味着您可以在适当的构造函数上调用new运算符来传递初始化参数。 向unique_ptr的构造函数传递一个指向已经构造的对象的指针,该类在此之后管理该对象的生存期。 虽然unique_ptr对象可以通过调用其构造函数直接创建,但不能调用复制构造函数,因此不能在构造期间使用初始化语法。 相反,标准库提供了一个名为make_unique的函数。 它有几个重载,因此它是基于此类创建智能指针的首选方式:

    void f5() 
    { 
       unique_ptr<int> p = make_unique<int>(); 
       *p = 42; 
       cout << *p << endl; 
    } // memory is deleted

此代码将调用包装类型(int)上的默认构造函数,但您可以提供将传递给该类型的相应构造函数的参数。 例如,对于具有两个参数的构造函数的struct,可以使用以下内容:

    void f6() 
    { 
       unique_ptr<point> p = make_unique<point>(1.0, 1.0); 
       p->x = 42; 
       cout << p->x << "," << p->y << endl; 
    } // memory is deleted

make_unique函数调用为成员分配非默认值的构造函数。 ->操作符返回一个指针,编译器将通过该指针访问对象成员。

对于数组,还有unique_ptrmake_unique的专门化。 此版本的unique_ptr的默认删除程序将在指针上调用delete[],因此它将删除数组中的每个对象(并调用每个对象的析构函数)。 该类实现了索引器操作符([]),因此您可以访问数组中的每一项。 但是,请注意,没有范围检查,因此,就像内置的数组变量一样,您可以在数组末尾之后进行访问。 没有取消引用运算符(*->),因此只能使用数组语法访问基于数组的unique_ptr对象。

make_unique函数有一个重载,允许您传递要创建的数组的大小,但您必须单独初始化每个对象:

    unique_ptr<point[]> points = make_unique<point[]>(4);     
    points[1].x = 10.0; 
    points[1].y = -10.0;

这将创建一个数组,其中四个point对象最初设置为默认值,以下几行将第二个点初始化为值(10.0, -10.0)。 使用vectorarray来管理对象数组几乎总是比使用unique_ptr更好。

Earlier versions of the C++ Standard Library had a smart pointer class called auto_ptr. This was a first attempt, and worked in most cases, but also had some limitations; for example, auto_ptr objects could not be stored in Standard Library containers. C++ 11 introduces rvalue references and other language features such as move semantics, and, through these, unique_ptr objects can be stored in containers. The auto_ptr class is still available through the <new> header, but only so that older code can still compile.

关于unique_ptr类的重要一点是,它确保指针只有一个副本。 这一点很重要,因为类析构函数将释放资源,因此如果您可以复制unique_ptr对象,则意味着将有多个析构函数尝试释放资源。 unique_ptr的对象拥有独占所有权;实例总是拥有它所指向的东西。

不能复制 ASSIGNunique_ptr智能指针(复制赋值运算符和复制构造函数已删除),但可以通过将资源的所有权从源指针转移到目标指针来移动它们。 因此,函数可以返回unique_ptr,因为所有权是通过 Move 语义转移给被赋给函数值的变量的。 如果将智能指针放入容器中,则会有另一个移动。

共享所有权

在某些情况下,您需要共享一个指针:您可以创建多个对象,然后将指向单个对象的指针传递给每个对象,以便它们可以调用此对象。 通常,当一个对象具有指向另一个对象的指针时,该指针表示应该在销毁包含对象期间销毁的资源。 如果一个指针是共享的,这意味着当其中一个对象删除该指针时,所有其他对象中的指针都将无效(这称为悬挂指针,因为它不再指向某个对象)。 您需要一种机制,其中多个对象可以持有一个指针,该指针将一直保持有效,直到所有使用该指针的对象都表示它们将不再需要使用它。

C++ 11 为该工具提供了shared_ptr类。 该类在资源上维护个引用计数,该资源的每个shared_ptr副本都会增加引用计数。 当该资源的shared_ptr的一个实例被销毁时,它将递减引用计数。 引用计数是共享的,因此它意味着非零值表示至少存在一个shared_ptr在访问资源。 当最后一个shared_ptr对象将引用计数递减到零时,就可以安全地释放资源了。 这意味着必须以原子方式管理引用计数才能处理多线程代码。

由于引用计数是共享的,这意味着每个shared_ptr对象持有一个指向称为控制块的共享缓冲区的指针,这意味着它持有原始指针和指向控制块的指针,因此每个shared_ptr对象将比unique_ptr对象持有更多的数据。 控制块不仅仅用于参考计数。 可以创建shared_ptr对象以使用自定义删除器(作为构造函数参数传递),并且删除器存储在控制块中。 这一点很重要,因为这意味着自定义删除器不是智能指针类型的一部分,因此包装相同资源类型但使用不同删除器的几个shared_ptr对象仍然是同一类型,并且可以放入该类型的容器中。

您可以从另一个shared_ptr对象创建shared_ptr对象,这将使用原始指针和指向控制块的指针来初始化新对象,会递增引用计数。

    point* p = new point(1.0, 1.0); 
    shared_ptr<point> sp1(p); // Important, do not use p after this! 
    shared_ptr<point> sp2(sp1); 
    p = nullptr; 
    sp2->x = 2.0; 
    sp1->y = 2.0; 
    sp1.reset(); // get rid of one shared pointer

这里,第一个共享指针是使用原始指针创建的。 这不是建议使用shared_ptr的方式。 第二个共享指针是使用第一个智能指针创建的,因此现在有两个指向同一资源的共享指针(将p分配给nullptr以防止其进一步使用)。 此后,可以使用sp1sp2访问相同的资源。 在这段代码的末尾,一个共享指针被重置为nullptr;这意味着sp1不再具有对资源的引用计数,您不能使用它来访问资源。 但是,您仍然可以使用sp2访问资源,直到它超出范围,或者调用reset

在此代码中,智能指针是从单独的原始指针创建的。 因为共享指针现在已经接管了资源的生命周期管理,所以不再使用原始指针很重要,在本例中,它被分配给nullptr。 最好避免使用原始指针,标准库通过一个名为make_shared的函数来实现这一点,该函数的用法如下:

    shared_ptr<point> sp1 = make_shared<point>(1.0,1.0);

该函数将使用对new的调用创建指定的对象,由于它接受数量可变的参数,因此您可以使用它来调用包装类上的任何构造函数。

您可以从unique_ptr对象创建shared_ptr对象,这意味着指针将移动到新对象,并创建引用计数控制块。 由于资源现在将被共享,这意味着资源上不再有独占所有权,因此unique_ptr对象中的指针将成为nullptr。 这意味着您可以拥有一个工厂函数,该函数返回一个指向包装在unique_ptr对象中的对象的指针,并且调用代码可以确定它是使用unique_ptr对象来获得对资源的独占访问,还是使用shared_ptr对象来共享它。

shared_ptr用于对象数组几乎没有什么意义;存储对象集合(vectorarray)有更好的方法。 在任何情况下,都有一个索引运算符([]),默认删除器调用的是delete,而不是delete[]

处理悬空指针

在本书的前面,我们已经指出,当您删除资源时,应该将指针设置为nullptr,并且应该在使用指针之前检查它是否为nullptr。 这是为了使您不会为已删除的对象调用指向内存的指针:悬挂指针。

在某些情况下,可能会故意出现悬空指针。 例如,对象可以创建具有指向父对象的向后指针对象,以便子对象可以访问父对象。 (这方面的一个示例是创建子控件的窗口;子控件访问父窗口通常很有用。)。 在这种情况下使用共享指针的问题在于,父控件对每个子控件都有引用计数,而每个子控件对父控件都有引用计数,这就产生了循环依赖关系。

另一个例子是,如果您有一个包含观察者对象的容器,目的是能够通过调用每个观察者对象上的一个方法,在事件发生时通知每个观察者对象。 维护此列表可能很复杂,特别是在可以删除观察者对象的情况下,因此在完全删除对象之前,您必须提供一种从容器中删除对象的方法(其中将有shared_ptr个引用计数)。 如果您的代码可以简单地将指向对象的指针添加到容器中,而不维护引用计数,但允许您检查何时使用指针(如果指针悬空或指向现有对象),则会变得更容易。

这样的指针称为弱指针,C++ 11 标准库提供了一个名为weak_ptr的类。 您不能直接使用weak_ptr对象,并且没有取消引用运算符。 相反,您可以从shared_ptr对象创建一个weak_ptr对象,当您想要访问资源时,可以从weak_ptr对象创建一个shared_ptr对象。 这意味着weak_ptr对象具有与shared_ptr对象相同的原始指针,并访问相同的控制块,但它不参与引用计数。

创建后,weak_ptr对象将使您能够测试包装指针是指向现有资源还是指向已销毁的资源。 有两种方法可以做到这一点:要么调用成员函数expired,要么尝试从weak_ptr创建一个shared_ptr。 如果要维护weak_ptr个对象的集合,可以决定定期迭代该集合,对每个对象调用expired,如果该方法返回true,则从集合中删除该对象。 由于weak_ptr对象可以访问由原始shared_ptr对象创建的控制块,因此它可以测试以查看引用计数是否为零。

测试weak_ptr对象是否悬挂的第二种方法是从它创建一个shared_ptr对象。 有两种选择。 您可以通过将弱指针传递给其构造函数来创建shared_ptr对象,如果该指针已过期,则构造函数将抛出一个bad_weak_ptr异常。 另一种方法是调用弱指针上的lock方法,如果弱指针已过期,则会将shared_ptr对象赋给nullptr,您可以对此进行测试。 这三种方式如下所示:

    shared_ptr<point> sp1 = make_shared<point>(1.0,1.0); 
    weak_ptr<point> wp(sp1); 

    // code that may call sp1.reset() or may not 

    if (!wp.expired())  { /* can use the resource */} 

    shared_ptr<point> sp2 = wp.lock(); 
    if (sp2 != nullptr) { /* can use the resource */} 

    try 
    { 
        shared_ptr<point> sp3(wp); 
        // use the pointer 
    } 
    catch(bad_weak_ptr& e) 
    { 
        // dangling weak pointer 
    }

由于弱指针不会改变资源上的引用计数,这意味着您可以将其用于反向指针,以打破循环依赖(尽管,通常使用原始指针是有意义的,因为子对象在没有父对象的情况下不能存在)。

模板

类可以模板化,这意味着您可以编写泛型代码,编译器将使用代码使用的类型生成一个类。 参数可以是类型、常量整数值或可变版本(零个或多个参数,由使用该类的代码提供)。 例如:

    template <int N, typename T> class simple_array 
    { 
        T data[N]; 
    public: 
        const T* begin() const { return data; } 
        const T* end() const { return data + N; } 
        int size() const { return N; } 

        T& operator[](int idx)  
        { 
            if (idx < 0 || idx >= N) 
                throw range_error("Range 0 to " + to_string(N)); 
            return data[idx]; 
        }  
    };

下面是一个非常简单的数组类,它定义了基本的迭代器函数和索引运算符,因此您可以这样调用它:

    simple_array<4, int> four; 
    four[0] = 10; four[1] = 20; four[2] = 30; four[3] = 40; 
    for(int i : four) cout << i << " "; // 10 20 30 40 
    cout << endl; 
    four[4] = -99;            // throws a range_error exception

如果选择在class声明之外定义函数,则需要将模板及其参数作为class名称的一部分提供:

    template<int N, typename T> 
    T& simple_array<N,T>::operator[](int idx) 
    { 
        if (idx < 0 || idx >= N) 
            throw range_error("Range 0 to " + to_string(N)); 
        return data[idx]; 
    }

您还可以设置模板参数的默认值:

    template<int N, typename T=int> class simple_array 
    { 
        // same as before 
    };

如果您认为应该为模板参数提供特定的实现,则可以提供该版本的代码作为模板的专门化:

    template<int N> class simple_array<N, char> 
    { 
        char data[N]; 
    public: 
        simple_array<N, char>(const char* str)  
        {  
            strncpy(data, str, N);  
        } 
        int size() const { return N; } 
        char& operator[](int idx) 
        { 
            if (idx < 0 || idx >= N) 
                throw range_error("Range 0 to " + to_string(N)); 
            return data[idx]; 
        } 
        operator const char*() const { return data; } 
    };

请注意,使用专门化时,您不会从完全模板化的类中获得任何代码;您必须实现想要提供的所有方法,并且如此处所示,实现与专门化相关但在完全模板化的类中不可用的方法。 本例是部分专门化,这意味着它只专门化一个参数(T,数据类型)。 该类将用于simple_array<n, char>类型的声明变量,其中n是整数。 您可以自由拥有完全专门化的模板,在本例中,它将是固定大小和指定类型的专门化:

    template<> class simple_array<256, char> 
    { 
        char data[256]; 
    public: 
        // etc 
    };

它在这种情况下可能没有什么用处,但其想法是,对于需要 256 个字符的变量,将有特殊的代码。

使用类

资源获取是初始化技术对于管理其他库(如 C 运行时库或 Windows SDK)提供的资源很有用。 它简化了您的代码,因为您不必考虑资源句柄将在哪里超出范围,并在每个点提供清理代码。 如果清理代码很复杂,在 C 代码中通常会看到它放在函数的末尾,函数中的每个出口点都会有一个goto跳转到该代码。 这会导致代码混乱。 在本例中,我们将用类包装 C 文件函数,以便自动维护文件句柄的生存期。

C 运行时_findfirst_findnext函数允许您搜索匹配模式(包括通配符)的文件或目录。 函数_findfirst返回与该搜索相关的intptr_t,并将其传递给_findnext函数以获得后续值。 此intptr_t是指向 C 运行时为搜索维护的资源的不透明指针,因此当您完成搜索时,您必须调用_findclose来清除与其相关的任何资源。 为了防止内存泄漏,调用_findclose很重要。

Beginning_C++ 文件夹下,创建一个名为Chapter_06的文件夹。 在 Visual C++ 中,创建一个新的 C++ 源文件,将其保存到Chapter_06文件夹,并将其命名为search.cpp。 应用将使用 Standard Library 控制台和字符串,并将使用 C Runtime 文件函数,因此请将以下行添加到文件的顶部:

    #include <iostream> 
    #include <string> 
    #include <io.h> 
    using namespace std;

将使用文件搜索模式调用应用,并且它将使用 C 函数搜索文件,因此您需要一个带参数的main函数。 将以下内容添加到文件底部:

    void usage() 
    { 
        cout << "usage: search pattern" << endl; 
        cout << "pattern is the file or folder to search for " 
             << "with or without wildcards * and ?" << endl; 
    } 

    int main(int argc, char* argv[]) 
    { 
        if (argc < 2) 
        { 
            usage(); 
            return 1; 
        } 
    }

第一件事是为管理该资源的搜索句柄创建一个包装类。 在 Usage 函数上方,添加一个名为search_handle的类:

    class search_handle 
    { 
        intptr_t handle; 
    public: 
        search_handle() : handle(-1) {} 
        search_handle(intptr_t p) : handle(p) {} 
        void operator=(intptr_t p) { handle = p; } 
        void close()  
        { if (handle != -1) _findclose(handle); handle = 0; } 
        ~search_handle() { close(); } 
    };

这个类有一个单独的函数来释放句柄。 这是为了让该类的用户能够尽快释放包装资源。 如果在可能引发异常的代码中使用该对象,则不会直接调用close方法,而是调用析构函数。 包装器对象可以使用intptr_t值创建。 如果此值为-1,则句柄无效,因此只有在句柄没有此值时,Close 方法才会调用_findclose

我们希望该类的对象拥有句柄的独占所有权,因此通过将以下内容放入类的公共部分来删除复制构造函数和复制赋值:

    void operator=(intptr_t p) { handle = p; } 
 search_handle(search_handle& h) = delete; void operator=(search_handle& h) = delete;

如果移动了对象,则必须释放现有对象中的任何句柄,因此在刚添加的行后添加以下内容:

    search_handle(search_handle&& h)  { close(); handle = h.handle; } 
    void operator=(search_handle&& h) { close(); handle = h.handle; }

包装器类将通过调用_findfirst来分配,并将传递给对_findnext的调用,因此包装器类需要两个运算符:一个用于转换为intptr_t,以便该类的对象可以在需要intptr_t的任何地方使用;另一个用于在需要bool时使用对象。 将这些内容添加到类的public部分:

    operator bool() const { return (handle != -1); } 
    operator intptr_t() const { return handle; }

转换为bool后,您可以编写如下代码:

    search_handle handle = /* initialize it */; 
    if (!handle) { /* handle is invalid */ }

如果您有一个返回指针的转换操作符,那么编译器将在转换到bool之前调用它。

您应该能够编译这段代码(记住使用/EHsc开关)以确认没有输入错误。

接下来,编写一个包装类来执行搜索。 在search_handle类下面添加一个file_search类:

    class file_search 
    { 
        search_handle handle; 
        string search; 
    public: 
        file_search(const char* str) : search(str) {} 
        file_search(const string& str) : search(str) {} 
    };

这个类是用搜索条件创建的,我们可以选择传递一个 C 或 C++ 字符串。 该类有一个search_handle数据成员,由于默认析构函数将调用成员对象的析构函数,因此我们自己不需要提供析构函数。 但是,我们将添加一个close方法,以便用户可以显式释放资源。 此外,为了让类的用户可以确定搜索路径,我们需要一个访问器。 在类的底部,添加以下内容:

    const char* path() const { return search.c_str(); } 
    void close() { handle.close(); }

我们不希望复制file_search对象的实例,因为这将意味着搜索句柄的两个副本。 您可以删除复制构造函数和赋值运算符,但没有必要。 试试这个:在main函数中,添加此测试代码(在哪里并不重要):

    file_search f1(""); 
    file_search f2 = f1;

编译代码。 您将看到一个错误和一个解释:

 error C2280: 'file_search::file_search(file_search &)': attempting to reference a deleted function
 note: compiler has generated 'file_search::file_search' here

如果没有复制构造函数,编译器将生成一个复制构造函数(这是第二行)。 第一行有点奇怪,因为它说明您正在尝试调用编译器生成的已删除方法! 实际上,错误是说生成的复制构造函数试图复制已删除的handle数据成员和search_handle复制构造函数。 因此,您可以在不添加任何其他代码的情况下防止复制file_search对象。 删除您刚刚添加的测试线。

接下来,将以下行添加到main函数的底部。 这将创建一个file_search对象并将信息打印到控制台。

    file_search files(argv[1]); 
    cout << "searching for " << files.path() << endl;

然后,您需要添加代码来执行搜索。 这里使用的模式将是一个具有 out 参数并返回bool的方法。 如果对该方法的调用成功,则在 out 参数中将返回找到的文件,并且该方法将返回true。 如果调用失败,则 out 参数保持不变,该方法返回false。 在file_search类的public部分,添加此函数:

    bool next(string& ret) 
    { 
        _finddata_t find{}; 
        if (!handle) 
        { 
            handle = _findfirst(search.c_str(), &find); 
            if (!handle) return false; 
        } 
        else 
        { 
            if (-1 == _findnext(handle, &find)) return false; 
        } 

        ret = find.name; 
        return true; 
    }

如果这是第一次调用此方法,则handle将无效,因此将调用_findfirst。 这将用搜索结果填充_finddata_t结构,并返回intptr_t值。 将search_handle对象数据成员赋给此函数返回的值,如果_findfirst返回-1,则该方法返回false。 如果调用成功,则使用_finddata_t结构中的 C 字符串指针初始化 OUT 参数(对string的引用)。

如果有更多的文件与模式匹配,则可以重复调用next函数,然后在这些后续调用中调用_findnext函数以获取下一个文件。 在本例中,search_handle对象被传递给函数,并通过类的转换操作符隐式转换为intptr_t。 如果_findnext函数返回-1,则表示搜索中没有更多的文件。

main函数的底部,添加以下行以执行搜索:

    string file; 
    while (files.next(file)) 
    { 
        cout << file << endl; 
    }

现在,您可以编译代码并使用搜索条件运行它。 请记住,这受_findfirst/_findnext函数功能的限制,因此您可以执行的搜索将非常简单。 尝试在命令行中使用参数运行此命令,以搜索Beginning_C++ 文件夹中的子文件夹:

 search Beginning_C++ Ch*

这将给出子文件夹的列表,这些子文件夹以Ch开头。 由于search_handle没有理由成为单独的类,因此将整个类移动到search_handleprivate部分,位于handle数据成员声明的上方。 编译并运行代码。

简略的 / 概括的 / 简易判罪的 / 简易的

通过类,C++ 提供了一种强大而灵活的机制来封装数据,并提供了方法来提供作用于数据的行为。 您可以将此代码作为模板,这样您就可以编写泛型代码,并让编译器为您需要的类型生成代码。 在本例中,您已经看到类是面向对象的基础。 类封装数据,因此调用方只需要知道预期的行为(在本例中,获取搜索中的下一个结果),而不需要知道类如何做到这一点的细节。 在下一章中,我们将进一步研究类的特性;特别是通过继承实现代码重用。***