Skip to content

Latest commit

 

History

History
1492 lines (1095 loc) · 83 KB

File metadata and controls

1492 lines (1095 loc) · 83 KB

四、类

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

写作课

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

审查结构

我们已经在 C++ 中看到了一种封装数据的机制:struct。结构允许您声明内置类型、指针或引用的数据成员。当您从该struct创建变量时,您正在创建结构的实例,也称为对象。您可以创建引用该对象的变量或指向该对象的指针。您甚至可以通过值将对象传递给一个函数,在该函数中编译器将复制该对象(它将调用struct复制构造函数)。

我们已经看到,使用struct任何可以访问实例的代码(甚至通过指针或引用)都可以访问对象的成员(尽管这是可以改变的)。这样使用,一个struct可以认为是包含状态的集合类型。

struct实例的成员可以通过直接使用点运算符或通过指向对象的指针使用->运算符来初始化。我们还看到,您可以用初始化列表(在大括号中)初始化struct的实例。这是非常严格的,因为初始化列表必须匹配struct中的数据成员。在第 2 章使用内存、数组和指针中,您看到您可以拥有一个指针作为struct的成员,但是您必须明确采取适当的措施来释放指针所指向的内存;如果没有,那么这可能会导致内存泄漏。

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

定义类

一个类在一个语句中定义,它将在一个块中定义它的成员,多个语句用大括号{}括起来。因为它是一个语句,所以您必须在最后一个大括号后放置一个分号。一个类可以在头文件中定义(许多 C++ 标准库类也是如此),但是你必须采取措施确保这样的文件在源文件中只包含一次。但是,关于类中的特定项,有一些规则必须在源文件中定义,这将在后面介绍。

如果您仔细阅读 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返回的值。

使用这个指针

类中的方法有一个特殊的调用约定,在 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的数据成员是publicvec对象在堆栈上创建,并用初始化列表初始化。在第二行,在自由存储中创建一个对象,并用初始化列表初始化。空闲存储上的对象必须在某个时刻被释放,这是通过删除指针来实现的。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 中,默认情况下会为您生成三个这样的函数,但是如果您愿意,您可以提供自己的版本。这三个构造函数以及其他三个相关函数如下:

  • **默认构造函数:**调用该函数创建一个具有默认值的对象。
  • **复制构造函数:**用于根据已有对象的值创建新对象。
  • **移动构造器:**这用于使用从现有对象移动的数据创建新对象。
  • **析构函数:**这个函数被调用来清理一个对象使用的资源。
  • **复制分配:**这将数据从一个现有对象复制到另一个现有对象。
  • **移动分配:**这将数据从一个现有对象移动到另一个现有对象。

这些函数的编译器创建版本将隐式为public;但是,您可以决定通过定义自己的版本并使其成为private来防止复制或分配,或者您可以使用=delete语法删除它们。

您还可以提供自己的构造函数,这些构造函数将采用您决定初始化新对象所需的任何参数。

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

定义构造函数

当创建的对象没有值时,使用默认构造函数,因此对象必须用默认值初始化。之前声明的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成员,它引用将用于提供赋值的对象。赋值运算符的语义是您可以将它们链接起来,因此,例如,这段代码在两个对象上调用赋值运算符:

    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 通过移动构造函数和移动赋值操作符提供移动语义,当临时对象用于创建另一个对象或被分配给现有对象时,将调用这些操作符。在这两种情况下,因为临时对象不会超出语句,所以临时对象的内容可以移动到另一个对象,使临时对象处于无效状态。编译器将通过将数据从临时对象移动到新创建(或分配给)的对象的默认操作来为您创建这些函数。

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

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.

如果你想只使用移动而不使用复制(例如,一个文件句柄类),那么你可以删除的复制功能:

    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 
    };

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

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

移动构造函数将临时对象的指针分配给nullptr,这样为类定义的任何析构函数都不会试图删除指针。

声明静态成员

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

定义静态成员

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

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

通过对象调用非静态方法,使用点运算符(对于类实例)或对象指针的->运算符。一个static方法不需要关联对象,但是可以通过一个来调用。

这给出了两种调用static方法的方法,通过对象或通过class名称:

    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引用是为了通过引用传递对象,以避免如果通过值传递对象时会发生的复制开销。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指针是constconst关键字是函数原型的一部分,因此该方法可以重载于此。可以有一个方法在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,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)

这段代码表明 functor 对象不仅提供了一些行为(在这种情况下,对参数执行一个操作),而且它还可以有一个状态。前面两行是通过对象上的operator()方法调用的:

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

看看语法。functor 对象被调用时就好像它是这样声明的函数:

    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 的函数指针或 functor 对象作为参数。C++ 标准库使用了这种魔力,这意味着它提供的算法可以用全局函数函子λ表达式来调用。

标准库算法使用三种类型的函数类、生成器以及一元和二元函数;即具有零个、一个或两个参数的函数。此外,标准库调用返回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; 
    }

这将获取两个集合,并使用作为最后一个参数传递的 functor 来比较相应的项。可以这样称呼:

    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)方法时,一个参数总是有固定值?一种选择是定义一个有状态的 functor 类(如前所示),这样固定值就是 functor 对象的成员。另一种方法是用固定值填充另一个vector,并继续比较两个vector s(对于大型vector s 来说,这可能会变得相当昂贵)。

另一种方法是重用函子类,但是要将的一个值绑定到它的一个参数上。一个版本的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; 
    }

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

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

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

在对bind的调用中,如果提供了一个实际值(这里是2.0),那么该值将被传递给在对函子的调用中该位置的函子(这里是2,0被传递给第二个参数)。如果使用以下划线开头的符号,则它是占位符。在std::placeholders命名空间中定义了 20 个这样的符号(_1_20)。占位符的意思是“使用在此位置传递给 binder 对象的值operator()方法调用来调用占位符指示的函子调用operator()方法。”因此,这个调用中的占位符意味着“从调用绑定器传递第一个参数,并将其传递给greater函子operator()的第一个参数。”

之前的代码将vector2.0中的每一项进行比较,并记录大于2.0的项目。您可以这样调用它:

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

参数列表被交换,这意味着2.0vector中的每个项目进行比较,并且该功能将记录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; } 
    };

该代码可以将一个int或一个string转换为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释放内存。未能释放内存将导致内存泄漏。内存可能是最基本的系统资源,但大多数操作系统还有很多其他资源:文件句柄、图形对象句柄、同步对象句柄、线程和进程。有时,拥有这样的资源是排他的,会阻止其他代码访问通过该资源访问的资源。因此,重要的是在某个时候释放这些资源,并且通常是及时释放。

类在这里通过一种叫做资源获取是初始化 (RAII)的机制提供帮助,这种机制是由 C++ 的作者比雅尼·斯特劳斯特鲁普发明的。简单地说,资源在对象的构造函数中分配,在析构函数中释放,所以这意味着资源的生存期就是对象的生存期。通常,这样的包装对象是在堆栈上分配的,这意味着保证当对象超出范围时资源将被释放*,而不管这是如何发生的*。

因此,如果在循环语句的代码块中声明了对象(whilefor),那么在每个循环结束时,将调用每个对象的析构函数(按照与创建相反的顺序),并且当循环重复时,将再次创建对象。无论循环是因为到达了代码块的末尾而重复,还是通过调用continue而重复,都会出现这种情况。另一种离开代码块的方法是通过调用break,一个goto,或者如果代码调用return离开该功能。如果代码引发异常(参见第 7 章诊断和调试),则当对象超出范围时将调用析构函数,因此如果代码由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以外的机制获得,因此您可以使用自定义删除器来确保调用适当的释放函数。请注意,deleter 是智能指针类的一部分,因此,如果您有两个不同的智能指针以这种方式使用两个不同的 deleter,则智能指针类型是不同的,即使它们包装了相同类型的资源。

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标的拥有独家所有权;实例总是拥有它所指向的内容。

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

共享所有权

有时您需要共享一个指针:您可以创建几个对象,并将一个指向单个对象的指针传递给每个对象,这样它们就可以调用这个对象。通常,当一个对象有一个指向另一个对象的指针时,该指针代表一个应该在包含该对象的销毁过程中被销毁的资源。如果一个指针被共享,这意味着当其中一个对象删除该指针时,所有其他对象中的指针都将无效(这被称为悬空指针,因为它不再指向一个对象)。您需要一种机制,其中几个对象可以持有一个指针,该指针将保持有效,直到所有使用该指针的对象都表示不再需要使用它为止。

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。应用将使用标准库控制台和字符串,并且它将使用 C 运行时文件函数,因此将这些行添加到文件的顶部:

    #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; 
        } 
    }

第一件事是为管理这个资源的搜索句柄创建一个包装类。在使用功能上面,增加一个名为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++ 提供了一种强大而灵活的机制来封装数据和方法,以提供作用于数据的行为。您可以将此代码模板化,以便编写泛型代码,并让编译器为您需要的类型生成代码。在示例中,您已经看到了类是如何成为面向对象的基础的。一个类封装了数据,因此调用者只需要知道预期的行为(在这个例子中,获取搜索的下一个结果),而不需要知道该类如何做到这一点的细节。**