Skip to content

Latest commit

 

History

History
503 lines (351 loc) · 23.6 KB

chapter10.md

File metadata and controls

503 lines (351 loc) · 23.6 KB

第十章 对象和类

本章内容包括:

  • 过程性编程和面向对象编程;
  • 类概念;
  • 如何定义和实现类;
  • 公有类访问和私有类访问;
  • 类的数据成员;
  • 类方法(类成员函数);
  • 创建和使用类对象;
  • 类的构造函数和析构函数;
  • const 成员函数;
  • this 指针;
  • 创建对象数组;
  • 类作用域;
  • 抽象数据类型。

面向对象编程(OOP)是一种特殊的、设计程序的概念性方法,OOP的最终特性是:

  • 抽象;
  • 封装和数据隐藏;
  • 多态;
  • 继承;
  • 代码的可重用性。

为了实现这些特性并将它们组合在一起,C++所做的最重要的改进 是提供了类。

10.1 过程性编程和面向对象编程

采用过程性编程方法时,首先考虑要遵循的步骤,然后考虑 如何表示这些数据。 如果换成一位OOP程序员,又将如何呢?首先考虑数据——不仅要 考虑如何表示数据,还要考虑如何使用数据。 用户与数据交互的方式有三种:初始化、更新和报告——这就是用户接口。

总之,采用OOP方法时,首先从用户的角度考虑对象——描述对象 所需的数据以及描述用户与数据交互所需的操作。完成对接口的描述 后,需要确定如何实现接口和数据存储。最后,使用新的设计方案创建 出程序。

10.2 抽象和类

生活中充满复杂性,处理复杂性的方法之一是简化和抽象。

10.2.1 类型是什么

首先,倾向于根 据数据的外观(在内存中如何存储)来考虑数据类型。但是稍加思索就会 发现,也可以根据要对它执行的操作来定义数据类型。总之,指定基本类型完成了三项工作:

  • 决定数据对象需要的内存数量;
  • 决定如何解释内存中的位(longfloat在内存中占用的位数相同,但将它们转换为数值的方法不同);
  • 决定可使用数据对象执行的操作或方法。

10.2.2 C++中的类

类是一种将抽象转换为用户定义类型的C++工具,它将数据表示和 操纵数据的方法组合成一个整洁的包。

定义类时,一般来说,类规范由两个部分组成:

  • 类声明:以数据成员的方式描述数据部分,以成员函数(称为方法)的方式描述共有接口;
  • 类方法定义:描述如何类成员函数。

简单地说,类声明提供了类的蓝图,而方法定义则提供了细节。例子:

// stock00.h -- Stock class interface
// version 00
#ifndef STOCK00_H_
#define STOCK00_H_

#include <string>

class Stock  // class declaration
{
private:
    std::string company;
    long shares;
    double share_val;
    double total_val;
    void set_tot() { total_val = shares * share_val; }
public:
    void acquire(const std::string & co, long n, double pr);
    void buy(long num, double price);
    void sell(long num, double price);
    void update(double price);
    void show();
};    // note semicolon at the end

#endif

首先, C++关键字class指出这些代码定义了一个类设计。

1.访问控制

关键字 privatepublic 也是新的,它们描述了对类成员的访问控制。使用类对象的程序都可以直接访问公有部分,但只能通过公有成员 函数(或友元函数,参见第11章)来访问对象的私有成员。

因此,公有成员函 数是程序和对象的私有成员之间的桥梁,提供了对象和程序之间的接口。防止程序直接访问数据被称为数据隐藏(参见图10.1)。C++还提 供了第三个访问控制关键字protected,第13章介绍类继承时将讨论该关 键字。

image-20210811220839703

类设计尽可能将公有接口与实现细节分开。公有接口表示设计的抽 象组件。**将实现细节放在一起并将它们与抽象分开被称为封装。**数据隐藏(将数据放在类的私有部分中)是一种封装,将实现的细节隐藏在私 有部分中,就像Stock类对set_tot() 所做的那样,也是一种封装。封装的 另一个例子是,将类函数定义和类声明放在不同的文件中。

OOP是一种编程风格,从某种程度说,它用于任何一种语言中。

数据隐藏不仅可以防止直接访问数据,还让开发者(类的用户)无需了解数据是如何被表示的。从使用类的角度看,使用哪种方 法没有什么区别。所需要知道的只是各种成员函数的功能;也就是说,需要知道成员函数接受什么样的参数以及返回什么类型的值。原则是将实现细节从接口设计中分离出来。如果以后找到了更好的、实现数据表 示或成员函数细节的方法,可以对这些细节进行修改,而无需修改程序接口,这使程序维护起来更容易。

2.控制对成员的访问:公有还是私有

无论类成员是数据成员还是成员函数,都可以在类的公有部分或私 有部分中声明它。但由于隐藏数据是OOP主要的目标之一,因此数据项 通常放在私有部分,组成类接口的成员函数放在公有部分;否则,就无 法从程序中调用这些函数。

10.2.3 实现类成员函数

还需要创建类描述的第二部分:为那些由类声明中的原型表示的成 员函数提供代码。成员函数定义与常规函数定义非常相似,它们有函数 头和函数体,也可以有返回类型和参数。但是它们还有两个特殊的特征:

  • 定义成员函数时,使用作用域解析运算符 :: 来标识函数所属的类;
  • 类方法可以访问类的 private 组件。

例如:

void Stock::update(double price) {
    ....
}

作用域解析运算符确定了方法定义对应的类的身份。

类方法的第二个特点是,方法可以直接访问类的私有成员,如同访问一个已经声明好的常用变量一样。例如,show( ) 方法可以使用这样的代码:

void Stock::show()
{
    std::cout << "Company: " << company
              << "  Shares: " << shares << '\n'
              << "  Share Price: $" << share_val
              << "  Total Worth: $" << total_val << '\n';
}

其中,company、shares等都是Stock类的私有数据成员。

另外,类声明常将短小的成员函数作为内联函数在头文件中随类声明一起定义。

内联函数的特殊规则要求在每个使用它们的文件中都对其进行定 义。确保内联定义对多文件程序中的所有文件都可用的、最简便的方法是:将内联定义放在定义类的头文件中

所创建的每个新对象都有自己的存储空间,用于存储其内部变量和 类成员;但同一个类的所有对象共享同一组类方法,即每种方法只有一 个副本。例如,假设 katejoe 都是 Stock 对象,则 kate.shares 将占据一个 内存块,而 joe.shares 占用另一个内存块,但kate.show()joe.show() 都调用同一个方法,也就是说,它们将执行同一个代码块,只是将这些代码用于不同的数据。在OOP中,调用成员函数被称为发送消息,因此将 同样的消息发送给两个不同的对象将调用同一个方法,但该方法被用于两个不同的对象。

image-20210811222542618

10.2.4 使用类

使用类与使用基本的内置类型(如int和char)尽可能相同。要创建类对象,可以声明类变量,也可以使用 new 为类对象分配存储空间。可以将对象作为函数的参数和返回值,也可以将一个对象赋给另一个。

要使用新类型,最关键的是要了解成员函数的功能,而不必考虑 其实现细节。

10.2.5 修改实现

10.2.6 小结

指定类设计的第一步是提供类声明。类声明类似结构声明,可以包 括数据成员和函数成员。声明有私有部分,在其中声明的成员只能通过 成员函数进行访问;声明还具有公有部分,在其中声明的成员可被使用 类对象的程序直接访问。通常,数据成员被放在私有部分中,成员函数 被放在公有部分中,因此典型的类声明的格式如下:

image-20210811223153090

10.3 类的构造函数和析构函数

C++的目标之一是让使用类对象就像使用标准类型一样。

image-20210811223501009

一般来说,最好是在创建对象时对它进行初始化。

就Stock类当前的实现而言,gift对象的company成员是没有值的。 类设计假设用户在调用任何其他成员函数之前调用acquire( ),但无法强 加这种假设。避开这种问题的方法之一是在创建对象时,自动对它进行 初始化。为此,C++提供了一个特殊的成员函数——类构造函数,专门 用于构造新对象、将值赋给它们的数据成员。名称与类名相同。例如,Stock类一个可能的构造函数是名为Stock( )的成员函 数。构造函数的原型和函数头有一个有趣的特征——虽然没有返回值, 但没有被声明为void类型。实际上,构造函数没有声明类型

// stock10.h <96> Stock class declaration with constructors, destructor added
#ifndef STOCK1_H_
#define STOCK1_H_
#include <string>
class Stock
{
private:
    std::string company;
    long shares;
    double share_val;
    double total_val;
    void set_tot() { total_val = shares * share_val; }
public:
    Stock();        // default constructor
    Stock(const std::string & co, long n = 0, double pr = 0.0);  // reload constructor;
    ~Stock();       // noisy destructor
    void buy(long num, double price);
    void sell(long num, double price);
    void update(double price);
    void show();
};

#endif

程序声明对 象时,将自动调用构造函数。

10.3.1 声明和定义构造函数

通常定义两个构造函数:一个默认空参数,在未提供显式初始值时,用来创建对象;另一个则提供对私有变量做初始化的参数。

// stock1.cpp <96> Stock class implementation with constructors, destructor added
#include <iostream>
#include "stock10.h"

// constructors (verbose versions)
Stock::Stock()        // default constructor
{
    std::cout << "Default constructor called\n";
    company = "no name";
    shares = 0;
    share_val = 0.0;
    total_val = 0.0;
}

Stock::Stock(const std::string & co, long n, double pr)
{
    std::cout << "Constructor using " << co << " called\n";
    company = co;

    if (n < 0)
    {
        std::cout << "Number of shares can't be negative; "
                   << company << " shares set to 0.\n";
        shares = 0;
    }
    else
        shares = n;
    share_val = pr;
    set_tot();
}

10.3.2 使用构造函数

C++提供了两种使用构造函数来初始化对象的方式。第一种方式是 显式地调用构造函数:

Stock food = Stock("World Cabbage", 250, 1.25);

另一种方式是隐式地调用构造函数:

Stock garment("Furry Mason", 50, 2.5);

这种格式更紧凑,它与下面的显式调用等价。

Stock garment = Stock("Furry Mason", 50, 2.5);

每次创建类对象(包括使用 new 动态分配内存)时,C++都使用类构造函数。

Stock *pstock = new Stock("Electrosgock Games", 18. 19.0);

这条语句创建一个Stock对象,将其初始化为参数提供的值,并将 该对象的地址赋给pstock指针

但无法使用对象来调用构造函数,因为在构造函数构造出对象之 前,对象是不存在的。因此构造函数被用来创建对象,而不能通过对象来调用。

10.3.3 默认构造函数

默认构造函数是在未提供显式初始值时,用来创建对象的构造函数。如果没有提供任何构造函数,则C++将自动提供默认构造函数。它是默认构造函 数的隐式版本,不做任何工作。对于Stock类来说,默认构造函数可能 如下:

Stock::Stock() {};

当且仅当没有定义任何构造函数时,编译器才会提供默 认构造函数。为类定义了构造函数后,程序员就必须为它提供默认构造 函数。如果提供了非默认构造函数(如Stock(const char * co, int n, double pr)),但没有提供默认构造函数,则下面的声明将出错:

Stock stock1;   // 没有合适的构造函数用于构造对象

定义默认构造函数的方式有两种。一种是给已有构造函数的所 有参数提供默认值:

Stock(const string &co = "Error", int n=0, double pr=0);

另一种方式是通过函数重载来定义另一个构造函数——一个没有参 数的构造函数:

Stock();

用户定义的默认构造函数通常给所有成员提供隐式初始值。

10.3.4 析构函数

用构造函数创建对象后,程序负责跟踪该对象,直到其过期为止。 对象过期时,程序将自动调用一个特殊的成员函数,该函数称为析构函数。析构函数很有用,用于对象过期时的完成清理工作。例 如,如果构造函数使用new来分配内存,则析构函数将使用delete来释放 这些内存。Stock的构造函数没有使用new,因此析构函数实际上没有需 要完成的任务。在这种情况下,只需让编译器生成一个什么要不做的隐式析构函数即可。

析构函数的名称很特殊:在类名前加上~

因此,Stock 类的析构函数为 ~Stock()。另外,和构造函数一样,析构函数也可以没有返回值和声明类型。与构造函数不同的是,析构函数没有参数,因此 Stock 析构函数的原型 必须 是这样的:

~Stock();

什么时候应调用析构函数呢?这由编译器决定,通常不应在代码中 显式地调用析构函数。如果创建的是静态存储类对象,则其析构函数将在程序结束 时自动被调用。如果创建的是自动存储类对象,则其析构函数将在程序执行完代码块时(该对象是在其中定义 的)自动被调用。如果对象是通过new创建的,则它将驻留在栈内存或 自由存储区中,当使用delete来释放内存时,其析构函数将自动被调 用。最后,程序可以创建临时对象来完成特定的操作,在这种情况下,程序将在结束对该对象的使用时自动调用其析构函数。

10.3.5 改进 Stock

// usestok1.cpp -- using the Stock class
// compile with stock10.cpp
#include <iostream>
#include "stock10.h"

int main()
{
  {
    using std::cout;
    cout << "Using constructors to create new objects\n";
    Stock stock1("NanoSmart", 12, 20.0);            // syntax 1
    stock1.show();
    Stock stock2 = Stock ("Boffo Objects", 2, 2.0); // syntax 2
    stock2.show();

    cout << "Assigning stock1 to stock2:\n";
    stock2 = stock1;
    cout << "Listing stock1 and stock2:\n";
    stock1.show();
    stock2.show();

    cout << "Using a constructor to reset an object\n";
    stock1 = Stock("Nifty Foods", 10, 50.0);    // temp object
    cout << "Revised stock1:\n";
    stock1.show();
    cout << "Done\n";
  }
    // std::cin.get();
    return 0;
}

下面的语句表明可以将一个对象赋给同类型的另一个对象:

stock2 = stock1;

在默认情况下,将一个对象赋给同类型的另一个对象时,C++将源对象的每个数据成员的内容复制到目标对象中相应的数据成员中

构造函数不仅仅可用于初始化新对象。例如,该程序的main( ) 中包含下面的语句:

stock1 = Stock("Nifty Foods", 10, 50.0);

stock1对象已经存在,因此这条语句不是对stock1进行初始化,而 是将新值赋给它。这是通过让构造程序创建一个新的、临时的对象,然后将其内容复制给 stock1 来实现的。临时对象复制完成之后,程序调用析构函数,删除该临时对象。

image-20210811230556935

输出表明,下面两条语句有根本性的差别:

Stock stock2 = Stock("Boffo Objects", 2, 2.0);
stock1 = Stock("Nifty Foods", 10, 50.0);

第一条语句是初始化,它创建有指定值的对象,可能会创建临时对象(也可能不会);第二条语句是赋值。像这样在赋值语句中使用构造函数总会导致在赋值前创建一个临时对象

如果既可以通过初始化,也可以通过赋值来设置对象的值,则应采用初始化方式。通常这种 方式的效率更高。

6.const 成员函数

请看下面的代码片段:

const Stock land = Stock("KP", 0, 0.0);
land.show();

对于当前的C++来说,编译器将拒绝第二行。这是什么原因呢?因为 show() 的代码无法确保调用对象不被修改。

我们以前通过将函数参数声明为 const 引用或指向 const 的指针来解决这种问题。但这里存在语法问题:show() 方法没有任何参数。相反,它所使用的对象是由方法调用隐式地提供。

需要一种新的语法来保证函数不会修改调用对象。C++的解决方法是将 const 关键字放在函数的括号后面。也就是说,show() 声明应像这样:

void show() const;         // promise not to change invoking object

同样,函数定义的开头应像这样:

void stock::show() const {
    ...
}

以这种方式声明和定义的类函数被称为 const 成员函数,从而确保函数内不会修改调用对象。

10.3.6 构造函数和析构函数小结

构造函数是一种特殊的类成员函数,在创建类对象时被调用。构造 函数的名称和类名相同,但通过函数重载,可以创建多个同名的构造函 数,条件是每个函数的特征标(参数列表)都不同。另外,构造函数没 有声明类型。通常,构造函数用于初始化类对象的成员,初始化应与构 造函数的参数列表匹配。

默认构造函数没有参数,因此如果创建对象时没有进行显式地初始 化,则将调用默认构造函数。如果程序中没有提供任何构造函数,则编 译器会为程序定义一个默认构造函数;否则,必须自己提供默认构造函 数。默认构造函数可以没有任何参数;如果有,则必须给所有参数都提供默认值。

当对象被删除时,程 序将调用析构函数。每个类都只能有一个析构函数。析构函数没有返回 类型(连 void 都没有),也没有参数,其名称为类名称前加上 ~

如果构造函数使用了 new,则必须提供使用 delete 的析构函数。

10.4 this 指针

有时候类方法可能涉及到两个对象,在这种情况下需要使用 this 指针。

如何将方法的答案传回给调用程序呢?最直接的方法是让方法返回一个引用,该引用指向股价总值较高的对象。因此,用于比较的类方法 topval 的原型如下:

const Stock & topval(const Stock &s) const;

该函数隐式地访问一个对象,而显式地访问另一个对象,并返回其 中一个对象的引用。括号中的const 表明,该函数不会修改被显式地访问的对象;而括号后的 const 表明,该函数不会修改被隐式地访问的对象。 由于该函数返回了两个 const 对象之一的引用,因此返回类型也应为 const 引用。

比较之后,返回引用时有一个问题需要解决:

image-20210811232755584

C++解决这种问题的方法是:使用被称为 this 的特殊指针。this 指针指向用来调用成员函数的对象(this被作为隐藏参数传递给方法)。这样,函数调用 stock1.topval(stock2)this 设置为 stock1 对象的地址,使得这个指针可用于 topval() 方法。

一般来说,所有的类方法都将 this 指针设置为调用它的对象的地址。而 topval() 中的 total_val 只不过是 this->total_val 的简写。

image-20210811233043535

const Stock & Stock::topval(const Stock & s) const
{
    if (s.total_val > total_val)
        return s;
    else
        return *this;
}

10.5 对象数组

声明对象数组的方法与 声明标准类型数组相同。

Stock mystuff[4];

可以用构造函数来初始化数组元素。在这种情况下,必须为每个元 素调用构造函数:

/ create an array of initialized objects
    Stock stocks[4] = {
        Stock("NanoSmart", 12, 20.0),
        Stock("Boffo Objects", 200, 2.0),
        Stock("Monolithic Obelisks", 130, 3.25),
        Stock("Fleep Enterprises", 60, 6.5)
    };

这里的代码使用标准格式对数组进行初始化:用括号括起的、以逗 号分隔的值列表。其中,每次构造函数调用表示一个值。如果类包含多 个构造函数,则可以对不同的元素使用不同的构造函数。

10.6 类作用域

在类中定义的名称(如类数据成员名和类成员函数名)的作用域都 为整个类,作用域为整个类的名称只在该类中是已知的,在类外是不可 知的。因此,可以在不同类中使用相同的类成员名而不会引起冲突。

总之,在类声明或成员函数定义中,可以使用未修饰的成员名称 (未限定的名称),就像sell( ) 调用 set_tot() 成员函数时那样。构造函数名称在被调用时,才能被识别,因为它的名称与类名相同。在其他情况下,使用类成员名时,必须根据上下文使用直接成员运算符、间接成员运算符 -> 或作用域解析运算符 ::

image-20210811235956703

10.6.1 作用域为类的常量

有时候,使符号常量的作用域为类很有用。例如,类声明可能使用 字面值30来指定数组的长度,由于该常量对于所有对象来说都是相同 的,因此创建一个由所有对象共享的常量是个不错的主意。你以为可以这样:

class Bakery {
private:
    const int Months = 12;
    double costs[Months];
}

这是不行的!! 因为声明类只是描述了对象的形式,并没有创建对象。

C++提供了另一种在类中定义常量的方式——使用关键字 static

class Bakery {
private:
    static const int Months = 12;
    double costs[Months];
}

10.6.2 作用域内枚举(C++11)

10.7 抽象数据类型

10.8 总结

面向对象编程强调的是程序如何表示数据。使用OOP方法解决编程问题的第一步是根据它与程序之间的接口来描述数据,从而指定如何使 用数据。然后,设计一个类来实现该接口。

通常,将类声明分成两部分组成,这两部分通常保存在不同的文件 中。类声明(包括由函数原型表示的方法)应放到头文件中。定义成员 函数的源代码放在方法文件中。这样便将接口描述与实现细节分开了。

类是用户定义的类型,对象是类的实例

每个对象都存储自己的数据,而共享类方法。

如果希望成员函数对多个对象进行操作,可以将额外的对象作为参数传递给它。如果方法需要显式地引用调用它的对象,则可以使用 this 指针。this 指针被设置为调用对象的地址,因此*this 是该对象的别名。