自定义类型

1. 定义类型需要做什么

在自定义类型之前,需要了解定义类型都需要做什么。C++ 的基本数据类型完成了三项工作:

  • 决定数据对象需要的内存数量;
  • 决定如何解释内存中的位;
  • 决定如何使用数据对象执行的操作或方法。

对于内置类型来说,有关操作被内置到编译器中。例如,整型可以进行 % 运算,而浮点型不可以。

2. C 和 C++ 自定义类型的演示

在 C++ 中定义用户自定义的类型时,就必须自己提供操作自定义类型的方法。付出这些劳动换来了根据实际需要定制新数据类型的强大功能和灵活性。例如,自定义一个复数类型,并进行复数加法操作。如果是 C 语言的自定义类型,无法在自定义类型时声明操作该类型的方法,只能声明一个方法,将自定义类型作为参数去处理。

  1. // C 语法的自定义类型与使用
  2. #include <stdio.h>
  3. #include <stdlib.h>
  4. // 自定义复数类型
  5. typedef struct Complex {
  6. double real;
  7. double imag;
  8. } Complex;
  9. Complex add(const Complex * c1, const Complex * c2) {
  10. Complex tmp;
  11. tmp.real = c1->real + c2->real;
  12. tmp.imag = c1->imag + c2->imag;
  13. return tmp;
  14. }
  15. int main () {
  16. Complex c1 = {10.4, 4.3};
  17. Complex c2 = {13.2, 54.2};
  18. Complex c = add(&c1, &c2); // Complex 加法操作
  19. printf("Complex: %lf, %lf\n", c.real, c.imag);
  20. return 0;
  21. }

而 C++ 中则是将操作方法和数据封装到一起,并且可以重载运算符,虽然在定义类型时增加了工作量(声明与实现类函数),但是在使用时更加方便、灵活(使用+号来计算 Complex 的加法)。

  1. // C++ 语法
  2. #include <iostream>
  3. class Complex {
  4. private:
  5. double real;
  6. double imag;
  7. public:
  8. Complex();
  9. Complex(double, double);
  10. Complex operator+(Complex &) const;
  11. void show();
  12. };
  13. Complex::Complex() {
  14. real = 0;
  15. imag = 0;
  16. }
  17. Complex::Complex(double real, double imag) {
  18. this->real = real;
  19. this->imag = imag;
  20. }
  21. Complex Complex::operator+(Complex& complex) const {
  22. Complex sum;
  23. sum.real = this->real + complex.real;
  24. sum.imag = this->imag + complex.imag;
  25. return sum;
  26. }
  27. void Complex::show() {
  28. std::cout << "C Plus Plus Complex: " << real << ", " << imag << std::endl;
  29. }
  30. int main() {
  31. Complex c1 = Complex(10.4, 4.3);
  32. Complex c2 = Complex(13.2, 54.2);
  33. Complex tmp = c1 + c2; // Complex 加法操作
  34. tmp.show();
  35. return 0;
  36. }

接下来,我们将学习如何用类来自定义类型。

类的声明

类是一种将抽象转换为用户自定义类型的工具,它将数据表示和操纵数据的方法组合成一个简洁的包。类包含数据以及操纵数据的方法,这一点也是和 C 语言中的结构体不同的地方。C 语言的结构体只包含数据,操纵结构体数据的方法并不是在结构体中声明的。
注1:C 语言的结构体不能包含方法声明,但是 C++ 对结构体进行了扩展,可以声明成员方法。
注2
:C 语言的结构体不能包含方法声明,但可以包含函数指针,进而间接的实现类似成员方法的效果。

1. Stock 类声明

下面演示自定义一个表示股票的类型。股票有很多信息,我们只需要抽象出必需的信息来自定义类型即可,这里我们将存储以下信息:

  • 公司名称;
  • 持有该公司股票的数量;
  • 每股价格;
  • 股票总值。

    PS:实际中的股票由于买入时机不同,购买时每股的价格可能也不同,这里不考虑那么复杂,默认股票价格不变。

可对股票类执行的操作有:

  • 获得股票;
  • 增持;
  • 卖出;
  • 更新股票价格;
  • 显示关于所持有股票的信息。

已经抽象出股票的描述,接下来将定义类。一般来说,类规范由两个部分组成:

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

简单来说,类声明提供了类的蓝图,而类方法定义则提供了方法的实现细节。

为开发一个类并编写一个使用这个类的程序需要完成多个步骤。这里将开发过程分为多个阶段,而不是一次性完成。通常,C++ 程序员将类定义放在头文件中,并将类方法的实现放在源代码文件中。这里也将采取这种经典方法。

  1. // source/2.1/stock00.h
  2. #ifndef STOCK00_H_
  3. #define STOCK00_H_
  4. #include <iostream>
  5. class Stock {
  6. private:
  7. std::string company;
  8. long shares;
  9. double share_value;
  10. double total_value;
  11. void set_tot() { total_value = shares * share_value; };
  12. public:
  13. void accquire(const std::string& co, long n, double pr);
  14. void buy(long num, double price);
  15. void sell(long num, double price);
  16. void update(double price);
  17. void show();
  18. };
  19. #endif // STOCK00_H_

源码见 source/2.1/stock0.h 文件。

2. 类的对象

稍后将介绍类的细节,但是这里要先看一下更通用的特性。
C++ 用 class 关键字指出这些代码定义了一个类设计。这种语法指出 Stock 是这个新类的类型名,即自定义的类型名。该声明让我们能够像声明基本数据类型的变量一样声明 Stock 类型的变量,不过我们通常称呼某个类的变量为该类的对象或实例。声明的类名是数据类型,对象或实例是该类的变量。每个对象都表示一只股票。

注意,这里和模版参数不同,class 和 typename 不是同义词,不能用 typename 代替 class。

例如,下面的声明创建两个 Stock 对象,它们分别名为 sally 和 solly:

  1. Stock sally;
  2. Stock solly;

将数据和方法组合成一个单元是类最吸引人的一个特性。有了这种设计,创建 Stock 对象时将自动制定使用对象的规则。

3. 访问控制

private 和 public 也是 C++ 新增的关键字,它们描述了对类的访问控制。使用类对象的程序可以直接访问 public 部分,对于私有部分是不能直接访问的,需要通过公有接口(或者友元函数)来间接访问。例如,Stock 类的 shares 成员是 private,想要修改 shares 只能通过 Stock 的成员函数。因此,公有成员函数是程序和对象的私有成员之间的桥梁,提供了对象和程序之间的接口。这种防止程序直接访问数据的方式被称为数据隐藏
image.png

Q:哪些应该放在 public 中,哪些应该放在 private 中?
A:无论是类的数据成员还是成员函数,都可以在类的公有部分或私有部分中声明它。但是由于隐藏数据是面向对象编程的主要目标之一,因此一般会进行如下处理:

  • 数据项通常放在私有部分
  • 用于作为类接口的成员函数需要放在公有部分,否则无法在程序中调用这些作为接口的函数。
  • 不作为类接口的成员函数则可以放在私有部分,这些函数无法在程序中调用,但是可以在公有方法中调用。通常使用私有函数来处理不属于公有接口的实现细节。

在类声明中,可以不使用 private 关键字,因为 private 是类对象的默认访问控制权限:

  1. class Work {
  2. float mass; // private
  3. public:
  4. void tellall(void); // public
  5. }

不过,为了强调数据隐藏的概念,所有的代码都以显式使用 private 为主。

PS:类描述看上去很像是包含成员函数以及 public、private 可见性标签的结构声明。实际上,C++ 对结构进行了扩展,使之具有与类相同的特性。它们之间唯一的区别是,结构默认访问类型是 public,而类为 private。C++ 程序员通常使用类来实现类描述,而把结构限制为只表示纯粹的数据对象。

实现成员函数

1. 普通函数的定义

复习一下普通函数的定义是怎样的:

  1. 返回值类型 函数名(函数列表) {
  2. // 实现细节
  3. return 返回值;
  4. }

2. 类成员函数的定义

在类声明示例中已经声明了一个 Stock 类,还需要创建类描述的第二部分 —— 为类声明中的函数原型表示的成员函数提供实现的代码,即提供函数定义。

成员函数的定义与普通函数的定义非常相似,有函数头和函数体,也可以有返回值和参数列表。但成员函数定义有两个特殊的特征:

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

以 Stock 类的 update 函数为例,其实现如下所示:

  1. // Stock类成员函数update()的实现
  2. void Stock::update(double price) {
  3. share_val = price;
  4. set_tot();
  5. }

在 update() 中演示了这两个特殊的特征。首先是使用了作用域解析符来标识函数所属的类,一个类就是一个名称空间,因此类中的函数的实现需要用到作用域解析符,这也意味着我们可以将另一个类的成员函数也命名为 update()。
因此,作用域解析符确定了方法定义对应的类的身份。我们称标识符 update() 具有类作用域。
Stock 类的其他成员函数不必使用作用域解析符就可以使用 update() 方法,这是因为它们属于同一个类,因此它们对于 update() 是可见的。然而,在类声明和类方法的定义之外使用 update() 的时候,需要采取特殊的措施。
类方法的完整名称中包含类名。我们说 Stock::update() 是类函数的限定名(全名);而简单的 update() 是全名的缩写(非限定名),它只能在类作用域中使用。例如,类声明、类方法的定义中。

示例中的第二个特点是,类方法可以访问类的私有成员。私有访问控制权限是限制类外访问的,而类方法都是类内,不会被限制访问私有成员。

友元函数除外,它可以打破私有访问权限,从类外访问类的私有成员。

3. Stock 成员函数实现

这里将成员函数的实现放在了另一个独立的实现文件中。

源码见 source/2.1/stock0.cpp 文件。

  1. #include "stock0.h"
  2. void Stock::acquire(const std::string &co, long n, double pr) {
  3. company = co;
  4. if (n < 0) {
  5. std::cout << "购买股票的数目无效," << company << " 公司股票数目设置为 0.\n";
  6. shares = 0;
  7. }
  8. else {
  9. shares = n;
  10. }
  11. share_value = pr;
  12. // 更新 total_value
  13. set_tot();
  14. }
  15. void Stock::buy(long num, double price) {
  16. if (num < 0) {
  17. std::cout << "购买 " << company << " 公司股票数目异常,购买无效\n";
  18. return;
  19. }
  20. shares += num;
  21. share_value = price;
  22. set_tot();
  23. }
  24. void Stock::sell(long num, double price) {
  25. if (num < 0) {
  26. std::cout << "出售 " << company << " 公司股票数目异常,出售无效\n";
  27. } else if (num > shares) {
  28. std::cout << "出售数目多于持有数目\n";
  29. } else {
  30. shares -= num;
  31. share_value = price;
  32. set_tot();
  33. }
  34. }
  35. void Stock::update(double price) {
  36. share_value = price;
  37. set_tot();
  38. }
  39. void Stock::show() {
  40. std::cout << "Company: " << company << std::endl
  41. << "Shares: " << shares << std::endl
  42. << "Share Price: $" << share_value << std::endl
  43. << "Total Worth: $" << total_value <<std::endl;
  44. }

2.1. 成员函数说明

  • acquire() 函数管理对某个公司股票的首次购买。
  • buy() 和 sell() 确保买入或卖出的股数不为负数。另外,sell() 还确保用户无法卖出超过他持有的股票数量。
  • update() 函数更新股票价格。
  • 以上四个成员函数都涉及对 total_val 成员值的设置,这个类中并非将计算代码编写了 4 次,而是让每个函数都调用 set_tot() 函数。如果计算代码很长,这种方法可以省去许多输入代码的工作,并可节省空间。不过,这种方法的主要价值在于,通过函数调用,而不是每次重新输入计算代码,可以确保执行的计算完全相同。另外,如果需要修改计算代码,只需要在一个地方进行修改即可。

    2.2. 内联方法

    函数定义位于类声明中的函数都将自动成为内联函数,因此 Stock::set_tot() 是一个内联函数。类声明常常将短小的成员函数作为内联函数。
    如果愿意,也可以在类声明之外定义成员函数,并使之成为内联函数。只需要在类实现部分中定义函数时使用关键字 inline 即可: ```cpp class Stock { private: //… void set_tot(); //… public: //… }

inline void Stock::set_tot() { total_value = shares * share_value; } ``` 根据改写规则,在类声明中定义方法等同于用函数原型替换方法定义,然后在类声明后面将定义改写为内联函数。也就是说,以上代码中的 set_tot() 的内联定义与之前在类声明中定义是等价的。

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

2.3. 成员函数使用那个对象?

调用成员函数时,它将使用被用来调用该方法的对象的数据成员。不同的对象有着各自的数据,但是成员函数是共用一组成员函数。
image.png

总结

  • 程序员可以通过 class 来自定义类型,将数据和操作数据的方法封装在一起。
  • 类名称空间。C++ 的每一个类都是一个名称空间,因此在实现类方法的时候必须使用 类名::方法名(...) {...} 的格式。
  • 什么是对象?class 声明的类就是数据类型,而对象就是该类型的一个变量,只是通常称为对象。
  • 创建对象。可以像创建基本数据类型变量那样创建自定义类型的对象,也可以进行初始化操作,不过 class 创建的自定义类型的初始化需要用到构造函数,使用上和基本数据类型变量不同。
  • 访问控制权限。类外可以访问 public 的成员;private 修饰的成员只能在类内访问,其实也就是类方法中可以直接使用 private 的成员(友元函数除外)。
  • 内联方法
    • 直接将成员方法的定义放在类声明之中,将自动成为内联函数,不需要 inline 关键字修饰。
    • 在类声明中声明成员函数,在类声明之后显式的使用 inline 修饰成员函数的定义。但是这种需要注意,最好将内联方法的定义和类声明放在一个文件中,而不是和类成员方法实现放在一个文件中。