第12章 类 (part1) 类的定义和声明 & 类隐含的 this 指针

在 C++ 中,用类来定义自己的抽象数据类型(abstract data types)

12.1. 类的定义和声明

从第一章开始,程序中就已经使用了类。已经用过的标准库类型,比如 vectoristream 和 string,都是类类型。还定义了一些简单的类,如 Sales_item 和 TextQuery 类。为了扼要重述,再来看年 Sales_item 类:

class Sales_item {
public:
    // operations on Sales_item objects
    double avg_price() const;
    bool same_isbn(const Sales_item &rhs) const
        { return isbn == rhs.isbn; }
    // default constructor needed to initialize members of built-in type
    Sales_item(): units_sold(0), revenue(0.0) { }
private:
    std::string isbn;
    unsigned units_sold;
    double revenue;
};

double Sales_item::avg_price() const
{
    if (units_sold)
        return revenue/units_sold;
    else
        return 0;
}
12.1.1. 类定义:扼要重述
<Note>:

最简单地说,类就是定义了一个新的类型和一个新作用域。

类成员
每个类可以没有成员,也可以定义多个成员,成员可以是数据、函数或类型别名。
一个类可以包含若干公有的、私有的和受保护的部分。

我们已经使用过 public 和 private 访问标号:在 public 部分定义的成员可被使用该类型的所有代码访问;在 private 部分定义的成员可被其他类成员访问。

所有成员必须在类的内部声明,一旦类定义完成后,就没有任何方式可以增加成员了。

构造函数

创建一个类类型的对象时,编译器会自动使用一个构造函数来初始化该对象。构造函数是一个特殊的、与类同名的成员函数,用于给每个数据成员设置适当的初始值。

构造函数一般就使用一个构造函数初始化列表,来初始化对象的数据成员:

// default constructor needed to initialize members of built-in type
Sales_item(): units_sold(0), revenue(0.0) { }

成员函数

在类内部,声明成员函数是必需的,而定义成员函数则是可选的。在类内部定义的函数默认为 inline

在类外部定义的成员函数必须指明它们是在类的作用域中Sales_item::avg_price 的定义使用作用域操作符来指明这是 Sales_item 类中 avg_price 函数的定义。

成员函数有一个附加的隐含实参,将函数绑定到调用函数的对象——当我们编写下面的函数时:

trans.avg_price()
就是在调用名  trans 的对象的  avg_price 函数。

如果 trans 是一个 Sales_item 对象,则在 avg_price 函数内部对 Sales_item 类成员引用就是对 trans 成员的引用。

将关键字 const 加在形参表之后,就可以将成员函数声明为常量

double avg_price() const;

const 成员不能改变其所操作的对象的数据成员。const 必须同时出现在声明和定义中,若只出现在其中一处,就会出现一个编译时错误。

12.1.2. 数据抽象和封装
类背后蕴涵的基本思想是 数据抽象封装

数据抽象是一种依赖于接口和实现分离的编程(和设计)技术。类设计者必须关心类是如何实现的,但使用该类的程序员不必了解这些细节。相反,使用一个类型的程序员仅需了解类型的接口,他们可以抽象地考虑该类型做什么,而不必具体地考虑该类型如何工作。

封装是一项低层次的元素组合起来的形成新的、高层次实体的技术函数是封装的一种形式:函数所执行的细节行为被 封装在函数本身这个更大的实体中。被封装的元素隐藏了它们的实现细节——可以调用一个函数但不能访问它所执行的语句。

同样地,类也是一个封装的实体:它代表若干成员的聚焦,大多数(良好设计的)类类型隐藏了实现该类型的成员。

标准库类型 vector 同时具备数据抽象和封装的特性

在使用方面它是抽象的,只需考虑它的接口,即它能执行的操作。

它又是封装的,因为我们既无法了解该类型如何表示的细节,也无法访问其任意的实现制品。

另一方面,数组在概念上类似于 vector,但既不是抽象的,也不是封装的。可以通过访问存放数组的内存来直接操纵数组。

访问标号实施抽象和封装

在 C++ 中,使用访问标号来定义类的抽象接口和实施封装。一个类可以没有访问标号,也可以包含多个访问标号:

  • 程序的所有部分都可以访问带有 public 标号的成员。类型的数据抽象视图由其 public 成员定义。

  • 使用类的代码不可以访问带有 private 标号的成员。private 封装了类型的实现细节。

一个访问标号可以出现的次数通常是没有限制的。每个访问标号指定了随后的成员定义的访问级别。这个指定的访问级别持续有效,直到遇到下一个访问标号或看到类定义体的右花括号为止。

可以在任意的访问标号出现之前定义类成员。在类的左花括号之后、第一个访问标号之前定义成员的访问级别,其值依赖于类是如何定义的。

如果类是用 struct 关键字定义的,则在第一个访问标号之前的成员是公有的;

如果类是用 class 关键字是定义的,则这些成员是私有的。

建议:具体类型和抽象类型

并非所有类型都必须是抽象的。标准库中的 pair 类就是一个实用的、设计良好的具体类而不是抽象类。具体类会暴露而非隐藏其实现细节。

一些类,例如 pair,确实没有抽象接口。pair 类型只是将两个数据成员捆绑成单个对象。在这种情况下,隐藏数据成员没有必要也没有明显的好处。在像 pair 这样的类中隐藏数据成员只会造成类型使用的复杂化。

尽管如此,这样的类型通常还是有成员函数。特别地,如果类具有内置类型或复合类型数据成员,那么定义构造函数来初始化这些成员就是一个好主意。类的使用都也可以初始化或赋值数据成员,但由类来做更不易出错。

编程角色的不同类别
程序员经常会将运行应用程序的人看作“用户”。应用程序为最终“使用”它的用户而设计,并响应用户的反馈而完善。

类也类似:类的设计者为类的“用户”设计并实现类。在这种情况下,“用户”是程序员,而不是应用程序的最终用户。

成功的应用程序的创建者会很好地理解和实现用户的需求。同样地,良好设计的、实用的类,其设计也要贴近类用户的需求。

另一方面,类的设计者与实现者之间的区别,也反映了应用程序的用户与设计和实现者之间的区分。

用户只关心应用程序能否以合理的费用满足他们的需求。同样地,类的使用者只关心它的接口。好的类设计者会定义直观和易用的类接口,而使用者只关心类中影响他们使用的部分实现。如果类的实现速度太慢或给类的使用者加上负担,则必然引起使用者的关注。在良好设计的类中,只有类的设计者会关心实现

在简单的应用程序中,类的使用者和设计者也许是同一个人。即使在这种情况下,保持角色区分也是有益的。设计类的接口时,设计者应该考虑的是如何方便类的使用;使用类的时候,设计者就不应该考虑类如何工作。

<Beware>:

注意,C++ 程序员经常会将应用程序的用户和类的使用者都称为“用户”。

提到“用户”时,应该由上下文清楚地标明所指的是哪类用户。如果提到“用户代码”或 Sales_item 类的”用户“,指的就是使用类编写应用程序的程序员。如果提到书店应用程序的”用户“,那么指的是运行应用程序的书店管理人员。

关键概念:数据抽象和封装的好处

数据抽象和封装提供了两个重要优点:

  • 避免类内部出现无意的、可能破坏对象状态的用户级错误。

  • 随时间推移可以根据需求改变或缺陷(bug)报告来完美类实现,而无须改变用户级代码。

仅在类的私有部分定义数据成员,类的设计者就可以自由地修改数据。如果实现改变了,那么只需检查类代码来了解此变化可能造成的影响。如果数据为仅有的,则任何直接访问原有数据成员的函数都可能遭到破坏。在程序可重新使用之前,有必要定位和重写依赖原有表示的那部分代码。

同样地,如果类的内部状态是私有的,则数据成员的改变只可能在有限的地方发生。避免数据中出现用户可能引入的错误。如果有缺陷会破坏对象的状态,就在局部位置搜寻缺陷:如果数据是私有的,那么只有成员函数可能对该错误负责。对错误的搜寻是有限的,从而大大方便了程序的维护和修正。

如果数据是私有的并且没有改变成员函数的接口,则操纵类对象的用户函数无须改变。

<Note>:

改变头文件中的类定义可有效地改变包含该头文件的每个源文件的程序文本,所以,当类发生改变时,使用该类的代码必须重新编译。

12.1.3. 关于类定义的更多内容

迄今为止,所定义的类都是简单的,然而通过这些类我们已经了解到 C++ 语言为类所提供的相当多的支持。本节的其余部分将阐述编写类的更多基础知识。

同一类型的多个数据成员
正如我们所见,类的数据成员的声明类似于普通变量的声明。如果一个类具有多个同一类型的数据成员,则这些成员可以在一个成员声明中指定,这种情况下,成员声明和普通变量声明是相同的。

例如,可以定义一个名为 Screen 的类型表示计算机上的窗口。每个 Screen 可以有一个保存窗口内容的 string 成员,以及三个 string::size_type 成员:一个指定光标当前停留的字符,另外两个指定窗口的高度和宽度。可以用如下方式这个类的成员:

     class Screen {
     public:
         // interface member functions
     private:
         std::string contents;
         std::string::size_type cursor;
         std::string::size_type height, width;
     };

使用类型别名来简化类

除了定义数据和函数成员之外,类还可以定义自己的局部类型名字。如果为 std::string::size_type 提供一个类型别名,那么 Screen 类将是一个更好的抽象:

     class Screen {
     public:
         // interface member functions
         typedef std::string::size_type index;
     private:
         std::string contents;
         index cursor;
         index height, width;
     };

类所定义的类型名遵循任何其他成员的标准访问控制。将 index 的定义放在类的 public 部分,是因为希望用户使用这个名字Screen 类的使用者不必了解用 string 实现的底层细节。定义 index 来隐藏 Screen 的实现细节。将这个类型设为 public,就允许用户使用这个名字。

成员函数可被重载
这些类之所以简单,另一个方面也是因为它们只定义了几个成员函数。特别地,这些类都不需要定义其任意成员函数的重载版本。然而,像非成员函数一样,成员函数也可以被重载。

重载操作符有特殊规则,是个例外,成员函数只能重载本类的其他成员函数。类的成员函数与普通的非成员函数以及在其他类中声明的函数不相关,也不能重载它们。

重载的成员函数和普通函数应用相同的规则:两个重载成员的形参数量和类型不能完全相同。调用非成员重载函数所用到的函数匹配过程也应用于重载成员函数的调用。

定义重载成员函数

为了举例说明重载,可以给出 Screen 类的两个重载成员,用于从窗口返回一个特定字符。两个重载成员中,一个版本返回由当前光标指示的字符另一个返回指定行列处的字符:

class Screen {
public:
    typedef std::string::size_type index;
    // return character at the cursor or at a given position
    char get() const { return contents[cursor]; }//返回由当前光标指示的字符
    char get(index ht, index wd) const;//返回指定行列处的字符:
    // remaining members
private:
    std::string contents;
    index cursor;
    index height, width;
};

与任意的重载函数一样,给指定的函数调用提供适当数目和/或类型的实参来选择运行哪个版本:

     Screen myscreen;
     char ch = myscreen.get();// calls Screen::get()
     ch = myscreen.get(0,0);  // calls Screen::get(index, index)

显式指定 inline 成员函数

在类内部定义的成员函数,例如不接受实参的 get 成员,将自动作为 inline 处理。也就是说,当它们被调用时,编译器将试图在同一行内扩展该函数。也可以显式地将成员函数声明为 inline

     class Screen {
     public:
         typedef std::string::size_type index;
         // implicitly inline when defined inside the class declaration
         char get() const { return contents[cursor]; }
         // explicitly declared as inline; will be defined outside the class declaration
         inline char get(index ht, index wd) const;//作为其声明的一部分
         // inline not specified in class declaration, but can be defined inline later
         index get_cursor() const;//在类定义外部的函数定义上指定 inline
         // ...
      };
     // inline declared in the class declaration; no need to repeat on the definition
     char Screen::get(index r, index c) const//在类内部已声明为inline

     {
         index row = r * width;    // compute the row location
         return contents[row + c]; // offset by c to fetch specified character
     }
     // not declared as inline in the class declaration, but ok to make inline in definition
     inline Screen::index Screen::get_cursor() const  //在类定义外部的函数定义上指定 inline

     {
         return cursor;
     }

可以在类定义体内部指定一个成员为inline,作为其声明的一部分。或者,也可以在类定义外部的函数定义上指定 inline。在声明和定义处指定 inline 都是合法的。在类的外部定义 inline 的一个好处是可以使得类比较容易阅读。

<Note>:

像其他 inline 一样,inline 成员函数的定义必须在调用该函数的每个源文件中是可见的。不在类定义体内定义的 inline 成员函数,其定义通常应放在有类定义的同一头文件中。

12.1.4. 类声明与类定义

一旦遇到右花括号,类的定义就结束了。并且一旦定义了类,那以我们就知道了所有的类成员,以及存储该类的对象所需的存储空间。

在一个给定的源文件中,一个类只能被定义一次。如果在多个文件中定义一个类,那么每个文件中的定义必须是完全相同的。


将类定义在头文件中,可以保证在每个使用类的文件中以同样的方式定义类。使用头文件保护符(header guard),来保证即使头文件在同一文件中被包含多次,类定义也只出现一次。

可以声明一个类而不定义它:

     class Screen; // declaration of the Screen class

这个声明,有时称为前向声明(forward declaraton),在程序中引入了类类型的 Screen。在声明之后、定义之前,类 Screen 是一个不完全类型(incompete type),即已知 Screen 是一个类型,但不知道包含哪些成员。

<Note>:

不完全类型(incomplete type)只能以有限方式使用。不能定义该类型的对象。不完全类型只能用于定义指向该类型的指针及引用,或者用于声明(而不是定义)使用该类型作为形参类型或返回类型的函数。

在创建类的对象之前,必须完整地定义该类。必须定义类,而不只是声明类,这样,编译器就会给类的对象预定相应的存储空间。

同样地,在使用引用或指针访问类的成员之前,必须已经定义类。

为类的成员使用类声明

只有当类定义已经在前面出现过,数据成员才能被指定为该类类型。如果该类型是不完全类型,那么数据成员只能是指向该类类型的指针或引用。

     class LinkScreen {
         Screen window;
         LinkScreen *next;
         LinkScreen *prev;
     };

类的前身声明一般用来编写相互依赖的类。

12.1.5. 类对象

定义一个类时,也就是定义了一个类型。一旦定义了类,就可以定义该类型的对象。定义对象时,将为其分配存储空间,但(一般而言)定义类型时不进行存储分配:

     class Sales_item {
     public:
         // operations on Sales_item objects
     private:
         std::string isbn;
         unsigned units_sold;
         double revenue;
     };

定义了一个新的类型,但没有进行存储分配。当我们定义一个对象

     Sales_item item;
时,编译器分配了足以容纳一个  Sales_item 对象的存储空间。 item 指的就是那个存储空间

每个对象具有自己的类数据成员的副本。修改 item 的数据成员不会改变任何其他 Sales_item 对象的数据成员。

定义类类型的对象

定义了一个类类型之后,可以按以下两种方式使用。

  • 将类的名字直接用作类型名。

  • 指定关键字 class 或 struct,后面跟着类的名字:

         Sales_item item1;       // default initialized object of type Sales_item
         class Sales_item item1; // equivalent definition of item1
    

两种引用类类型方法是等价的。第二种方法是从 C 继承而来的,在 C++ 中仍然有效。第一种更为简练,由 C++ 语言引入,使得类类型更容易使用。

为什么类的定义以分号结束

我们在第 2.8 节中指出,类的定义分号结束。分号是必需的,因为在类定义之后可以接一个对象定义列表。定义必须以分号结束:

class Sales_item { /* ... */ };
class Sales_item { /* ... */ } accum, trans;

通常,将对象定义成类定义的一部分是个坏主意。这样做,会使所发生的操作难以理解。对读者而言,将两个不同的实体(类和变量)组合在一个语句中,也会令人迷惑不解。

12.2. 隐含的 this 指针

第 7.7.1 节中已经提到,成员函数具有一个附加的隐含形参,即指向该类对象的一个指针。这个隐含形参命名为 this与调用成员函数的对象绑定在一起

成员函数不能定义 this 形参,而是由编译器隐含地定义。成员函数的函数体可以显式使用 this 指针,但不是必须这么做。如果对类成员的引用没有限定,编译器会将这种引用处理成通过 this 指针的引用。

何时使用 this 指针

尽管在成员函数内部显式引用 this 通常是不必要的,但有一种情况下必须这样做:当我们需要将一个对象作为整体引用而不是引用对象的一个成员时。

最常见的情况是在这样的函数中使用 this:该函数返回对调用该函数的对象的引用。

某种类可能具有某些操作,这些操作应该返回引用,Screen 类就是这样的一个类。迄今为止,我们的类只有一对 get 操作。逻辑上,我们可以添加下面的操作。

  • 一对 set 操作,将特定字符或光标指向的字符设置为给定值。

  • 一个 move 操作,给定两个 index 值,将光标移至新位置。

理想情况下,希望用户能够将这些操作的序列连接成一个单独的表达式:

     // move cursor to given position, and set that character
     myScreen.move(4,0).set('#');

这个语句等价于:

     myScreen.move(4,0);
     myScreen.set('#');
返回 *this

在单个表达式中调用 move 和 set 操作时,每个操作必须返回一个引用,该引用指向执行操作的那个对象:

     class Screen {
     public:
          // interface member functions
          Screen& move(index r, index c);
          Screen& set(char);
          Screen& set(index, index, char);
          // other members as before
     };

注意,这些函数的返回类型是 Screen&指明该成员函数返回对其自身类类型的对象的引用。每个函数都返回调用自己的那个对象。使用 this 指针来访问该对象。

下面是对两个新成员的实现:

     Screen& Screen::set(char c)
     {
         contents[cursor] = c;
         return *this;
     }
     Screen& Screen::move(index r, index c)
     {
         index row = r * width; // row location
         cursor = row + c;
         return *this;
     }
函数中唯一需要关注的部分是  return 语句。在这两个操作中,每个函数都返回  *this
在这些函数中,this 是一个指向非常量 Screen 的指针如同任意的指针一样,可以通过对 this 指针解引用来访问 this 指向的对象。

从 const 成员函数返回 *this

在普通的非 const 成员函数中,this 的类型是一个指向类类型的 const 指针可以改变 this 所指向的值,但不能改变 this 所保存的地址

在 const 成员函数中,this 的类型是一个指向 const 类类型对象的 const 指针。既不能改变 this 所指向的对象,也不能改变 this 所保存的地址。

<Note>:

不能从 const 成员函数返回指向类对象的普通引用。const 成员函数只能返回 *this 作为一个 const 引用。

例如,我们可以给 Screen 类增加一个 display 操作。这个函数应该在给定的 ostream 上打印 contents。逻辑上,这个操作应该是一个 const 成员。打印 contents 不会改变对象。如果将 display 作为 Screen 的 const 成员,则 display 内部的 this 指针将是一个 const Screen* 型的 const

然而,与 move 和 set 操作一样,我们希望能够在一个操作序列中使用 display

     // move cursor to given position, set that character and display the screen
     myScreen.move(4,0).set('#').display(cout);
这个用法暗示了  display  应该返回一个 Screen 引用,并接受一个  ostream 引用。如果  display 是一个  const 成员,则它的返回类型必须是  const Screen&

不幸的是,这个设计存在一个问题。如果将 display 定义为 const 成员,就可以在非 const 对象上调用 display,但不能将对 display 的调用嵌入到一个长表达式中。下面的代码将是非法的:

     Screen myScreen;
     // this code fails if display is a const member function
     // display return a const reference; we cannot call set on a const
     myScreen.display().set('*');

问题在于这个表达式是在由 display 返回的对象上运行 set。该对象是 const,因为 display 将其对象作为 const 返回。我们不能在 const 对象上调用 set


基于 const 的重载
为了解决这个问题, 我们必须定义两个 display 操作:一个是 const,另一个不是 const。基于成员函数是否为  const,可以重载一个成员函数;同样地,基于一个指针形参是否指向  const,可以重载一个函数。 const 对象只能使用  const 成员。非  const 对象可以使用任一成员,但非  const 版本是一个更好的匹配。

在此,我们将定义一个名为 do_display 的 private 成员来打印 Screen。每个 display 操作都将调用此函数,然后返回调用自己的那个对象:

     class Screen {
     public:
         // interface member functions
         // display overloaded on whether the object is const or not
         Screen& display(std::ostream &os)
                       { do_display(os); return *this; }
         const Screen& display(std::ostream &os) const
                       { do_display(os); return *this; }
     private:
          // single function to do the work of displaying a Screen,
          // will be called by the display operations
          void do_display(std::ostream &os) const
                            { os << contents; }
          // as before
      };

现在,当我们将 display 嵌入到一个长表达式中时,将调用非 const 版本。当我们 display 一个 const 对象时,就调用 const 版本:

     Screen myScreen(5,3);
     const Screen blank(5, 3);
     myScreen.set('#').display(cout); // calls nonconst version
     blank.display(cout);             // calls const version
可变数据成员

有时(但不是很经常),我们希望类的数据成员(甚至在 const 成员函数内)可以修改。这可以通过将它们声明为 mutable 来实现。

可变数据成员(mutable data member)永远都不能为  const,甚至当它是  const 对象的成员时也如此。因此, const 成员函数可以改变  mutable 成员。要将数据成员声明为可变的,必须将关键字  mutable 放在成员声明之前:

class Screen {
     public:
     // interface member functions
     private:
         mutable size_t access_ctr; // may change in a const members
         // other data members as before
      };

我们给 Screen 添加了一个新的可变数据成员 access_ctr。使用 access_ctr 来跟踪调用 Screen 成员函数的频繁程度:

     void Screen::do_display(std::ostream& os) const
     {
         ++access_ctr; // keep count of calls to any member function
         os << contents;
     }

尽管 do_display 是 const,它也可以增加 access_ctr。该成员是可变成员,所以,任意成员函数,包括 const 函数,都可以改变 access_ctr 的值。


建议:用于公共代码的私有实用函数

有些读者可能会奇怪为什么要费力地单独定义一个 do_display 内部所做的操作更简单。为什么还要如此麻烦?我们这样做有下面几个原因。

  1. 一般愿望是避免在多个地方编写同样的代码。

  2. display 操作预期会随着类的演变而变得更复杂。当所涉及的动作变得更复杂时,只在一处而不是两处编写这些动作有更显著的意义。

  3. 很可能我们会希望在开发时给 do_display 增加调试信息,这些调试信息将会在代码的最终成品版本中去掉。如果只需要改变一个 do_display 的定义来增加或删除调试代码,这样做将更容易。

  4. 这个额外的函数调用不需要涉及任何开销。我们使 do_display 成为内联的,所以调用 do_display 与将代码直接放入 display 操作的运行时性能应该是相同的。

    实际上,设计良好的 C++ 程序经常具有许多像 do_display 这样的小函数,它们被调用来完成一些其他函数的“实际”工作。






  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值