跳转到内容

使用 C 和 C++ 的编程语言概念/C++ 中的面向对象编程

来自 Wikibooks,开放世界中的开放书籍

最初称为 C-with Classes,C++ 将类概念融入到语言中。这是一个好消息:我们在 C 中必须采用的某些约定和编程实践现在由编译器强制执行。我们不再需要包含抽象数据类型 [预期] 接口的标题文件,现在我们在标题文件中有一个类定义,它仍然旨在包含接口;实现细节 - 例如实用函数和对象的结构 - 现在被列为类定义的私有部分,而不是将它们的定义推迟到实现文件;类似构造函数的函数被真正的构造函数取代,这些构造函数是由编译器生成的代码隐式调用的。

上面的列表可以扩展其他示例。但要点仍然相同:面向对象编程在 C++ 中要容易得多。唯一的陷阱是屈服于过程范式的简单性,并将 C++ 代码编写为普通的旧 C。除此之外,以下介绍对于入门者来说应该是不言自明的。[1]

复数
#ifndef COMPLEX_HXX
#define COMPLEX_HXX

#include <cmath>
#include <iostream>
using namespace std;

namespace CSE224 {
  namespace Math {
    class Complex {

对类成员的访问限制由类主体内的标记为 publicprivateprotected 的部分指定。关键字 publicprivateprotected 称为访问说明符

  1. 一个 public 成员是可访问的 - 无论参考点如何 - 来自程序中的任何位置。对信息隐藏的正确强制执行将类 public 成员限制为可以由通用程序用于操纵类类型对象的函数。
  2. 一个 private 成员只能由其类的成员函数和友元访问。一个强制信息隐藏的类将其数据成员声明为 private
  3. 一个 protected 成员对派生类及其类的友元表现得像 public 成员,对程序的其余部分表现得像 private 成员。

一个类可以包含多个 publicprotectedprivate 部分。每个部分都保持有效,直到出现另一个部分标签或类主体的结束右大括号为止。如果未指定访问说明符,默认情况下,紧随类主体开始左大括号之后的段是 private

    public:

定义:默认构造函数是在没有用户指定参数的情况下可以调用的构造函数。

在 C++ 中,这并不意味着它不能接受任何参数。这意味着每个构造函数参数都关联一个默认值。例如,以下每个都代表一个默认构造函数

Complex::Complex(double realPart = 0.0, double imPart = 0.0) { ... } Stack::Stack(int initialCapacity = 1) { ... } List::List(void) { ... }

在 C++ 中,可以调用一个参数的构造函数,它充当转换运算符。它有助于我们提供从构造函数参数类型的 value 到类对象的隐式转换,这将在 C++ 编译器在称为函数重载解析的过程中使用的过程中使用。

在当前类中,将一个 double 传递给构造函数将将其转换为一个虚部为零的 Complex 对象。一个非常方便的工具!毕竟,实数不是没有虚部的复数吗?但是,这种方便有时会变成难以发现的错误。例如,当传递一个单独的 double 参数时,以下构造函数将充当从 doubleComplex 的转换运算符。它很可能产生意外的结果。

Complex::Complex(double imPart = 0.0, double realPart = 0.0) { ... }

当使用一个参数调用时,此构造函数将创建一个实部设置为零的 Complex 对象。也就是说,Complex(3.0) 将对应于 3i,而不是 3!为了避免这种不必要的转换,同时保持参数列表不变,您必须使用 explicit 关键字修改签名,如下所示。

explicit Complex::Complex(double impart = 0.0, double realPart = 0.0) { ... }

这种使用会禁用通过该构造函数的隐式转换。请注意,explicit 关键字只能应用于构造函数。

定义:由编译器完成的隐式类型转换称为强制转换

由于通过以下构造函数进行了这种隐式转换,因此,每当一个函数或一个运算符接受一个 Complex 对象时,我们将能够传递一个类型为 double 的参数。例如,请查看第 12 行的函数签名。除了将一个 Complex 对象分配给另一个对象外,此运算符现在还允许将一个 double 分配给一个 Complex 对象。因为这个 double 值首先 [隐式] 转换为一个 Complex 对象,然后执行此结果对象的分配。[2]

      Complex(double = 0.0, double = 0.0);

定义:复制构造函数使用第二个的副本初始化一个对象。通常,它接受一个指向该类 const 对象的引用的形式参数。

请注意,这个构造函数与之前的构造函数一样,没有返回类型。这不是打字错误!类的构造函数(以及析构函数,如果有的话)不能指定返回类型,即使是 void

      Complex(const Complex&);

除了函数名重载之外,C++ 还为程序员提供了运算符重载功能。重载的运算符允许将类类型的对象与 C++ 中定义的内置运算符[3] 一起使用,从而使它们的操纵与内置类型的操纵一样直观。

为了重载运算符,必须定义一个具有特殊名称的函数,该名称由在运算符符号之前添加 operator 一词构成。应该牢记,运算符的元数、优先级和结合性不能改变。对于一元运算符,接收消息的对象对应于运算符的唯一操作数;对于其余运算符,操作数对应关系以从左到右的方式建立。例如,根据以下声明,接收对象对应于赋值的左侧,而[显式]形式参数对应于右侧。

      Complex& operator=(const Complex&);

没错!与 C 不同,C++ 对布尔值有类型支持。不幸的是,它[C++] 继续支持 C 样式的布尔表达式语义。换句话说,您仍然可以使用整数值代替布尔值。但话说回来,您可以成为一个好孩子,开始使用 bool 类型的变量。

      bool operator==(const Complex&);
      Complex operator+(const Complex&);

如果重载赋值和加法运算符,当前类的用户会自然地寻找相应的重载复合赋值运算符,+=

      Complex& operator+=(const Complex&);
      Complex operator-(const Complex&);
      Complex& operator-=(const Complex&);

将显式参数修改为常量不是什么大问题。只需在该参数位置之前插入 const 关键字,就完成了。但是,如果我们想使接收对象——即正在向其发送消息的对象——成为常量呢?这个作为隐式参数传递给函数的参数,不能以类似的方式修改。以下签名是对此的示例:将 const 关键字放在参数列表的右括号之后,它将被视为代表接收对象。

程序员可以定义——即提供函数体——类的成员函数,无论是在类内部(在头文件中)还是在类外部(在实现文件中)。在头文件中提供函数体可能不是一个好主意,尤其是在它暴露实现细节的情况下[因为这意味着违反信息隐藏原则]。然而,对于像接下来的两个函数一样简单的函数,这不是一个坏主意。

      double real(void) const { return(_re); }
      double imag(void) const { return(_im); }

    private:
      double _re, _im;
    }; // end of Complex class definition

接下来的三个函数不是 Complex 类的成员。[4] 它们是为补充类定义而提供的。证明相等性测试运算符的情况也应该说服我们关于其他两个运算符的情况。对于两种可能类型的两个变量,我们有四种相等性测试组合。两个 Complex 对象的相等性测试以及[由于通过充当用户定义的转换函数的构造函数提供的转换]一个 Complex 和一个 double 被提供为类成员函数。两个 double 的相等性测试由编译器提供。剩下的我们需要进行的测试是在一个 double 和一个 Complex 数字之间进行的测试。这当然不是由编译器提供的。它也不能作为类成员函数提供。因为左侧操作数是 double,我们知道在 Complex 类定义中,this——隐式参数——是指向 Complex 对象的常量指针。因此,我们需要遵循不同的路径来提供此功能:一个普通的全局函数,它接受一个 double 和一个 Complex 数字作为参数。

    extern bool operator==(double, const Complex&);
    extern Complex operator+(double, const Complex&);
    extern Complex operator-(double, const Complex&);

一个内联函数在其每次调用点都被扩展到程序中,从而消除了与函数调用相关的开销。因此,只要函数被调用足够多次,它就可以提供显著的性能提升。

在类定义中定义的成员函数——例如 realimag——默认情况下是内联的,这样的函数不需要进一步指定为内联。另一方面,在类体外部定义的成员函数或任何全局函数[5],必须在定义点通过在函数原型之前添加 inline 关键字来指定它,并且应该包含在包含类定义的头文件中。

请注意,内联规范只是一个对编译器的建议。编译器可以选择忽略此建议,因为声明为内联的函数不是在调用点进行扩展的良好候选者。递归函数不能在调用点完全扩展。同样,大型函数很可能会被忽略。通常,内联机制旨在优化小型、直线型、频繁调用的函数。

请注意访问器的使用,这实际上与我们通过内联来产生更快代码的意图相矛盾。直接访问字段,而不是通过访问器函数,会更快,并且与下一行上的 inline 关键字更加一致。[6] 然而,这是不可能的。对象字段被声明为——正如预期的那样——private,这意味着类外部的任何人,包括同一文件中其他类的代码,都不能操作它。嗯,事实上,有一个例外。通过声明某些函数和/或类通过friend 机制拥有特殊权利,可以获得对类的内部结构的直接访问。在异常处理章节中将对此进行更多介绍。

    inline ostream& operator<<(ostream& os, const Complex& rhs) {
      double im = rhs.imag(), re = rhs.real();

      if (im == 0) { 
        os << re;
        return(os);
      } // end of if (im == 0)

      if (re == 0) { 
        os << im << 'i';
        return(os); 
      } // end of if (re == 0)

      os << '(' << re;
      os << (im < 0 ? '-' : '+' << abs(im) << "i)";

      return(os);
    } // end of ostream& operator<<(ostream&, const Complex&)
  } // end of namespace Math
} // end of namespace CSE224

#endif

实现

[edit | edit source]
Complex.cxx
#include <iostream>
using namespace std;

#include "math/Complex"

namespace CSE224 {
  namespace Math {

在某些情况下,C++ 编译器会隐式调用默认构造函数。这些是

  1. 基于堆的数组的所有组件都将使用组件类的默认构造函数进行初始化。
  2. 如果在成员初始化列表中没有提供显式构造函数调用,则继承的子对象将使用基类的默认构造函数进行初始化。
  3. 如果在成员初始化列表中没有提供显式构造函数调用,则构成对象的非原始字段将使用它们的默认构造函数进行初始化。

为此,作为设计过程的一部分,应该始终认真考虑是否需要默认构造函数。

由于默认参数提供的灵活性,接下来的三行都使用相同的构造函数。

Complex c(3, 5); // c 被初始化为 3.0 + 5.0i Complex real3(3); // real3 被初始化为 3.0 + 0.0i Complex zero; // zero 被初始化为 0.0 + 0.0i

正如预期的那样,C++ 会将 int 类型的实际参数强制转换为 double,并将它们传递给相应的构造函数。

    Complex::
    Complex(double realPart = 0.0, double imPart = 0.0) {
      _re = realPart;
      _im = imPart;
    } // end of Complex::Complex(double, double)

如果没有提供复制构造函数,编译器将通过调用每个实例字段的复制构造函数来复制对象。对于原始类型和指针类型,这意味着按位复制字段。

定义:所有字段都按位复制的类被称为支持浅复制

对于我们的示例,浅复制已经足够了。[7] 然而,在对象实例字段[8] 指向其他对象或需要跳过或特殊处理的情况下,例如密码字段,我们可能需要比浅复制更多的操作。考虑列表的链接实现。只复制头和尾指示器就足够了吗?还是我们必须复制项目?答案是:取决于。如果您希望共享相同的列表,那么浅复制是可行的。否则,您需要覆盖编译器的默认行为。一旦覆盖,编译器将始终在需要进行复制时使用此函数。

只要您按值传递参数,这个构造函数就会被隐式调用。请记住,按值传递的参数会在运行时堆栈上被复制。对于从函数返回对象也是如此,这需要在相反的方向上进行复制。这种复制行为将通过复制构造函数完成。因此,您应该花些时间决定是否需要这样的构造函数。

    Complex::
    Complex(const Complex& rhs) {
      _re = rhs._re;
      _im = rhs._im;
    } // end of Complex::Complex(Complex&)

与其他非静态成员函数一样,重载运算符接受指向正在应用该函数的对象的指针。也就是说,给定声明

Complex c1(3), c2, result; ... c1 + c2

可以看作

... c1.operator+(c2) ...

可以进一步看作

operator+(&c1, c2)

相应的函数定义有一个隐式的第一个形式参数。这个名为 this 的形式参数可以用来引用正在发送消息的对象。因此,给定函数定义

ClassName::FunctionName(Formal-Par-List) { ... }

编译器在内部调用函数

FunctionName(ClassName *const this, Formal-Par-List) { ... }

无论何时

c1.FunctionName(Actual-Par-List);

在代码中使用,将其转换为

FunctionName(&c1, Actual-Par-List);

注意,声明为常量的是指针,而不是指针指向的内存内容。程序员可以直接或间接地改变对象内存,但不能改变函数正在应用的对象。

以下函数定义重载了赋值运算符。这种运算符用于用另一个对象的內容修改已经存在的对象。[9] 除非提供重载版本,否则编译器——类似于复制构造函数的情况——将调用对象的每个字段的默认赋值运算符。否则,它将使用你提供的其中一个函数。至于是否需要重载,复制构造函数情况下的考虑因素适用,并且应该特别注意做出正确的决定。

我们使用 Complex& 作为函数的返回值 [和参数] 类型的原因是为了以最经济和最简单的方式促进赋值运算符的级联。[10] 例如,可以有一个像这样的语句

a = b = c;

该语句首先将 c 赋值给 b,然后将 b 赋值给 a。也就是说,b 将首先用作发送赋值消息的对象,然后用作发送到 a 的另一个赋值消息的参数。

Complex& Complex::
operator=(const Complex& rhs) {

接下来的两行可以不使用关键字 this 编写。因为所有对对象内存的非限定引用都被认为属于 this 指向的对象。因此,它可以改写为

_re = rhs._re; _im = rhs._im;

还要注意 -> 的使用。这是另一个表明这不是接收对象本身,而是一个指向它的常量指针的事实。

      this->_re = rhs._re;
      this->_im = rhs._im;

      return(*this);
    } // end of Complex::Complex& operator=(const Complex&)

    bool Complex::
    operator==(const Complex& rhs) {
      return(this->_re == rhs._re && this->_im == rhs._im);
    } // end of bool Complex::operator==(const Complex&)

    Complex Complex::
    operator+(const Complex& rhs) {
      return(Complex(_re + rhs._re, _im + rhs._im));
    } // end of Complex Complex::operator+(const Complex&)

    Complex& Complex::
    operator+=(const Complex& rhs) {
      this->_re += rhs._re;
      this->_im += rhs._im;

      return(*this);
    } // end of Complex& Complex::operator+=(const Complex&)

    Complex Complex::
    operator-(const Complex& rhs) {
      return(Complex(_re - rhs._re, _im - rhs._im));
    } // end of Complex Complex::operator-(const Complex&)

    Complex& Complex::
    operator-=(const Complex& rhs) {
      this->_re -= rhs._re;
      this->_im -= rhs._im;

      return(*this);
    } // end of Complex& Complex::operator-=(const Complex&)

    bool operator==(double lhs, const Complex& rhs) {
      return(rhs.imag() == 0 && rhs.real() == lhs)
    } // end of bool operator==(double, const Complex&)

    Complex operator+(double lhs, const Complex& rhs) {
      return(Complex(lhs + rhs.real(), rhs.imag()));
    } // end of Complex operator+(double, const Complex&)

    Complex operator-(double lhs, const Complex& rhs) {
      return(Complex(lhs - rhs.real(), -rhs.imag()));
    } // end of Complex operator-(double, const Complex&)
  } // end of namespace Math
} // end of namespace CSE224

函数重载解析

[edit | edit source]

如头文件中默认构造函数签名之前的注释所述,函数重载解析是指找出要调用的函数。一个以三种可能结果之一结束的过程——成功、歧义和无匹配函数——函数调用在三个步骤中解析。

候选函数的识别

[edit | edit source]

函数重载解析的第一步涉及识别与被调用的函数同名且在调用点可见的函数。这组函数也称为候选函数。空集的候选函数会导致编译时错误。

使用命名空间中定义的类型作为参数类型和/或从命名空间导入标识符可能会导致此集的大小增加。

示例:引入命名空间的影响。

namespace ns { ... class Type { ... }; void f(Type t); ... }; void f(int i) { ... } void f(double d) { ... } ... using namespace ns; ... f(...);

最后一行上的函数调用将导致一个大小为三的集合: ns::f(ns::Type), f(int)f(double)。如果没有使用指令 ns::f(ns::Type) 将不可见,因此不会包含在候选函数集中。但是,用以下代码序列替换 using 指令将再次导致该函数包含在集中。

... ns::Type t; ... f(t); ...

如将在继承章节中讨论的那样,根据语言的不同,引入新的范围可能会以不同的方式影响候选函数集。例如,在 Java 中,新集合是通过取旧集合和新范围引入的集合的并集形成的;如果签名相同,则新范围的方法会隐藏旧范围中的方法。但是,在 C++ 中,具有与旧范围内找到的方法冲突的名称的函数将替换具有该名称的所有函数签名。

示例:Java 中继承对候选函数集的影响。
B.java

public class B { ... public void f(int) { } public void f(int, int) { } ... } // end of class B

D.java

public class D extends B { ... public void f(String) { } ... } // end of class D

D 对象的用户可以使用三种方法,它们都名为 fB.f(int)B.f(int, int) 以及 D.f(String)

选择可行的函数

[edit | edit source]

第二步是选择第一阶段形成的非空集合中的可调用函数。 这需要消除不匹配参数数量和类型 的函数。 此阶段结束时获得的函数集称为可行函数。 可行函数为空意味着没有匹配函数,并导致编译时错误。

请注意,我们在这里不是在寻找完美的匹配。 就参数的数量而言,默认值会增加该集合的大小。 例如,具有单个参数的函数调用不仅可以定向到具有单个参数的函数,还可以定向到具有n个参数的函数,其中除了第一个参数外,所有参数都保证具有默认值。 同样,对于参数类型,也考虑了可以应用于参数的转换。 作为这个的示例,一个 char 参数可以传递给类型为 char, short, int 等等的对应参数。

示例:C++ 中选择可行函数
  void f(int i, int i2 = 0) { ... }
  void f(char) { ... }
  void f(double d, float f) { ... }
  void f(Complex c) { ... }
  ...
  short s;
  ...
  f(s);
  ...
在第 8 行调用 f 将导致一个大小为 2 的集合: f(char)f(int, int=)。 如果 Complex 类可能有一个构造函数,它可以用于将 short 转换为 Complex 对象,该集合的大小将增加到 3。

对于来自更安全的语言(如 Java 或 C#)的程序员来说,将 f(char) 包含在此集合中可能会令人惊讶。 由于这是一个缩窄转换,将 short 参数传递给 char 参数可能会导致信息丢失,因此这些语言认为这是违反了约定。 为了使这种情况发生,必须将参数显式转换为 char。 然而,在 C++ 中却完全不同。 C++ 编译器将很乐意考虑这一点——不显式转换参数——作为节省按键的活动,并将其接受为可行函数。 如果恰好是唯一可行的函数,则将调用该函数调用来分派到 f(char)

参数转换

[edit | edit source]

这将我们带到可应用于参数的可能转换的话题。 除了精确匹配[11] 之外,C++ 编译器还会通过对参数应用一系列转换来扩展可行函数的集合。 这些可以分为两个不同的类别:精确匹配类型转换

精确匹配转换是较小的转换,可以进一步分为四个子组

  1. 左值到右值转换:将参数传递视为赋值的特殊情况,其中参数被分配给对应参数,此转换基本上获取参数中的值并将其复制到参数中。
  2. 数组到指针转换:数组作为指向其第一个元素的指针传递。 请注意,此转换和以下转换基本上是 C/C++ 语言的一部分。
  3. 函数到指针转换:类似于上一项,作为参数传递的函数标识符将转换为指向函数的指针。
  4. 限定符转换:仅适用于指针类型,此转换通过使用 const[12]volatile 修饰符中的一个或两个来修改普通指针类型。 应该注意的是,如果参数传递给具有相同类型的参数,但具有一个或两个这些修饰符,则不会进行任何类型转换。
示例:C++ 中的精确匹配转换。
  int ia[20];
  int *ia2 = new int[30];
  ...
  void f(int *arr) { ... }    // could have been void f(int arr[]) { ... }
  void g(const int i) { ... }
  ...
  f(ia);       // won't get any treatment different than f(ia2). Both will be ranked equal
  ...
  g(ia[3]);    // will be dispatched to g(const int) without any [qualification] conversion
在第 7 行,数组参数被转换为并作为指向其第一个元素的指针传递。 在第 9 行,非常量参数按值传递给相同类型的常量参数。 由于使用按值调用传递初始值的任何对参数的更改都不会反映回参数,并且常量参数永远不会改变——更不用说改变并将其反映到对应参数——将 int 参数传递给 const int 参数根本不需要任何转换。

参数转换的第二类,类型转换,可以分为两组:提升标准转换。 前者组,也称为拓宽转换,包含可以在没有信息丢失的情况下执行的转换。 这些包括以下内容

  1. 类型为 bool, char, unsigned charshort 的参数被提升为 int。 如果使用的编译器支持 int 的更大尺寸,则 unsigned short 也被提升为 int。 否则,它被提升为 unsigned int
  2. 类型为 float 的参数被提升为 double
  3. 枚举类型被提升为 int, unsigned int, longunsigned long 之一。 通过选择可以表示枚举中所有值的最小可能类型来做出决定。

类型转换的第二组,标准转换,分为五种

  1. intlong 的转换以及缩窄整数转换。
  2. doublefloat 的转换。
  3. 在浮点类型和整数类型之间进行的转换,例如 floatint, intdouble 以及 chardouble
  4. 将 0 转换为指针类型以及将任何指针类型转换为 void*
  5. 从整数类型、浮点类型、枚举类型或指针类型转换为 bool 的转换。

请注意,这份冗长的规则列表包含了许多由于 C++ 的“底层”性质和系统编程方面而继承自 C 的细节。例如,由于 C 语言没有类型来存储逻辑值,因此它采用了一种在底层编程中常用的约定:零表示假,而任何其他值都被解释为真。因此,从其他类型到 bool 的转换。类似地,体系结构往往为字长数据提供了更好的支持,在 C/C++ 中被称为 int[13] 一个典型的例子是在将数据压入硬件堆栈时进行的调整量。除非编译器将它们打包,否则所有压入的数据(读作“传递的所有参数”),小于或等于字长(读作 int)的数据都将被调整到字边界。这基本上意味着所有这样的数据将被扩展到字长,这解释了提升的第一项。再加上与环境相关的整型大小和由两种版本(signedunsigned)的存在引入的复杂性,您就可以理解为什么它突然变成了一个噩梦。

示例:Java 中的注意事项。
ConversionInJava.java

public class ConversionInJava { public static void main(String[] args) { short s = 3; f(s); } // end of void main(String[]) private static void f(int i) { System.out.println(In void f(int)...); } private static void f(long l) { System.out.println(In void f(long)...); } } // end of class ConversionInJava

编译并运行此程序会导致 f(int) 中的消息输出到标准输出。因为在 Java 中,参数始终会被提升到最接近的类型。

在转换过程中,C++ 编译器可以应用两个序列中的任何一个。在第一个序列中,称为“标准转换序列”,允许应用零个或一个完全匹配转换(除了限定符转换外),然后应用零个或一个提升或标准转换,之后可以再应用零个或一个限定符转换。第二个序列涉及应用用户定义的转换函数,该函数可以在标准转换序列之前和之后应用。如果需要,可以应用两次这个序列。

查找最佳匹配

[edit | edit source]

在解析函数调用的最后一步,C++ 编译器会选择最佳匹配的可行函数。在确定最佳匹配时,使用两个标准:应用于最佳匹配函数参数的转换不比调用任何其他可行函数所需的转换更糟糕;对某些参数的转换比调用其他可行函数时对相同参数所需的转换更好。如果最终没有这样的函数,则调用被称为不明确,并会导致编译时错误。

在查找最佳匹配时,编译器会对之前步骤中获得的可行函数进行排名。根据这种排名,完全匹配转换比提升更好,而提升比标准转换更好。可行函数的排名由用于将参数转换为对应参数的最低级别转换的排名决定。

测试程序

[edit | edit source]

接下来的程序,除了提供函数重载解析过程的示例外,还表明在 C++ 中,可以在所有三个数据区域中创建对象。在某些面向对象的编程语言中,例如 Java,对象始终在堆中创建。Java 的这种“限制”,或者换句话说,C++ 提供的这种“自由”,可以归因于语言设计理念。作为 C 的直接后代,C++ 提供了替代方案,并期望程序员选择正确的方案。同样作为 C 的后代,尽管距离更远,但 Java 往往提供了一个更简单的框架,替代方案更少。

在这种情况下,C++ 为程序员提供了无需使用指针的替代方案。与 C 的 struct 中的变量或 C# 中的值类型的对象一样,可以直接操作类对象。换句话说,通过句柄间接地操作对象并不是唯一的选择。这意味着对象占用的空间更少。然而,多态性(因此面向对象)不再是一个选项。毕竟,多态性要求根据对象的动态类型,将相同的消息分派给可能不同的子程序定义,这意味着我们应该能够使用相同的标识符来引用不同类型的对象。这进一步意味着由标识符指示的对象所需的内存可能会发生变化。由于静态数据区域处理的是固定大小的数据,因此我们不可能将对象放在程序内存的这一部分。类似地,运行时堆栈上分配的内存大小应该事先为编译器所知,运行时堆栈也不在考虑范围内。我们必须想出一个让双方都满意的解决方案:编译器获得一个固定大小的实体,同时满足了继承的变量大小对象的要求。这是通过在堆上创建对象来实现的,这是唯一剩下的地方,并通过中间体对其进行操作。这就是对象句柄的用武之地!

因此,只有在堆上创建对象并通过指针进行操作时,才有可能启用多态性。这就解释了为什么 Java(像任何其他宣称是面向对象的编程语言一样)以这种方式创建对象。但它没有提供任何关于为什么没有提供以 C++ 方式创建对象的任何解释。回答另一个问题——当我们以 C++ 方式创建对象时会获得什么好处——将提供解释:我们获得了更快、基于对象的(而不是面向对象的)解决方案。由于多态性不再是一个选项,因此动态分派也不再需要,所有函数调用都可以进行静态分派,顺便说一下,这是 C++ 中的默认方式。但之后,随着所有这些范式的出现,事情开始变得有点混乱。除了过程式编程(感谢其 C 遗产)和面向对象编程之外,我们现在还有基于对象编程。更糟糕的是,C++ 中的默认编程模式(由于默认分派类型是静态的)是基于对象的。在 Java 中,默认分派类型是动态的,因此默认编程范式是面向对象的。这意味着程序员期望进行一些面向对象编程,不会被替代方案的存在所迷惑;她不需要告诉编译器她想进行面向对象编程。再加上在实现开闭原则[14] 时利用面向对象范式,这似乎是生成可扩展软件的更安全选择。

Complex_Test.cxx


#include <iostream>
#include <iomanip>
using namespace std;

#include "math/Complex"
using CSE224::Math::Complex;

以下代码行在静态数据区域创建了一个 Complex 类的对象。因此,该对象将在程序执行的整个过程中存在,其分配/释放将由编译器负责。这行代码可以写成 Complex c(5);Complex c = Complex(5);

注意,第二种形式只有在我们将一个参数传递给构造函数时才有可能。

Complex c = 5;

int main(void) {

接下来的四次实例化在运行时堆栈上创建了四个 Complex 对象。每次调用子程序或进入一个块时,子程序/块的局部对象将被创建并分配到运行时堆栈上。从子程序/块退出时,对象将通过更改堆栈指针的值(指向运行时堆栈上的最顶层帧)自动释放。因此,局部对象的生存期仅限于它们定义的块。[15]

注意第四个对象是通过使用拷贝构造函数创建的。通过此语句,c3c4 都具有相同的对象内存配置。但请注意,它们不是同一个对象。

  Complex c1(3, -5), c2(3), c3(0, 5), c4(c3);
  cout << "c: " << c << "c1: " << c1 << " c2: " << c2;
  cout << " c3: " << c3 << " c4: " << c4 << endl;

  double d = 3;
  int i = 5;
  cout << "d: " << d << " i: " << i << endl << endl;
  cout << "d + c1 = " << d + c1 << endl;

下一行代码乍一看可能像一个编译时错误。毕竟,没有函数可以将一个 Complex 对象和一个 int 相加。如前一节所述,int 通过提升为 double 进行转换,并且执行 Complex 对象与 double 的加法运算。但你可能会说:我找不到这样的函数!由于带默认参数的构造函数,这个被提升为 doubleint 值后来被传递给该构造函数,并构造了一个 Complex 对象。现在,已经定义了两个 Complex 对象的加法运算,因此请求得到了满足。

  cout << "c1 + i = " << c1 + i << endl;

下一行代码在自由存储区(堆)上创建了一个 Complex 类的对象。这是通过使用 new 运算符让编译器知道的,该运算符还表示创建的对象将由程序员管理。

Classname *ptr = new ClassName(parList);

等同于以下伪 C++ 代码

ptr = Classname::operator new(sizeof(ClassName)); ClassName::ClassName(ptr, parList);

换句话说,new 运算符首先分配[16] 对象所需的区域,然后隐式调用相应的构造函数进行初始化。

完成下一个对象创建后,我们将得到如下所示的部分内存映像。请注意,对象已在所有三个数据区域中创建。

Object creation in C++

不要将指针与指针指向的存储空间混淆。虽然指针随着子程序的调用和返回而出现和消失,但指针指向的内存区域(如果它们已在堆区域中分配)可能比子程序的调用时间更长。这是因为此类区域由程序员管理,她可以在任何认为合适的时间返回它。因此,让我们再次重申:动态管理的不是指针本身,而是指针指向的内存区域。

  Complex *result = new Complex;
  cout << "*result = i - c1 + c2 - d = ";

假设从左到右的求值顺序(出于教学目的),下一行代码将按如下方式执行

  1. i 被提升为 double
  2. 使用 operator-(double, const Complex&)i 中减去 c1,此时 i 是一个 double 值。在此过程中,c1 首先从 Complex 转换为 const Complex
  3. 使用 Complex::operator+(const Complex&)c2 加到步骤 2 中得到的结果。与上一步类似,c2 首先用 const 限定。
  4. 利用带默认参数值的构造函数,编译器将 d 转换为一个 Complex 对象。
  5. 使用 Complex::operator-(const Complex&) 从步骤 3 中得到的结果中减去 d(现在是一个 Complex 对象)。
  6. 最后,步骤 5 中获得的值通过程序员提供的赋值运算符 [Complex::operator=(const Complex&)] 赋值到 result 指向的内存区域。
  *result = i - c1 + c2 - d;
  cout << *result << endl;
  cout << "*result += c3 = " << (*result += c3) << endl;
  cout << "*result -= d = " << (*result -= d) << endl;

delete 运算符用于释放通过 new 获取的堆内存。应确保所有未使用的内存都返回到系统以供可能重用。

delete ptr;

等同于

ClassName::~ClassName(ptr); ClassName::operator delete(ptr, sizeof(*ptr));

换句话说,在 delete 释放参数指向的内存区域之前,对象使用的其他资源将在一个名为析构函数的特殊函数中释放。这可能包括编译器不管理的任何东西。例如,操作系统资源(如文件句柄、套接字、信号量等);由 DBMS 管理的数据库连接;或由程序员管理的从指针可访问的其他堆内存。

在 C++ 中,此特殊函数的名称是类名前缀一个波浪号(~)。它既不能返回值也不能接受任何参数。这就是为什么它不能重载的原因。虽然我们可以定义多个类构造函数,但我们可以提供一个用于所有类的对象的单一析构函数。

事实上,我们也可以选择根本不提供析构函数。当已知特定类的对象不使用任何外部资源时,这是正确的选择。换句话说,如果数据成员是按值包含的(即,成员中没有指针字段),并且永远不会获取超出编译器管辖范围的资源,那么我们不需要提供析构函数。出于这个原因,我们没有为当前类实现析构函数。所有数据成员都是按值包含的。也就是说,我们在对象本身中拥有相关信息,而不是指向堆中某个位置的变量大小信息的指针。编译器可以通过简单地释放 delete 运算符的参数指向的区域来处理此类固定大小的信息。但是,当涉及到处理可变大小的信息或外部资源时,程序员必须提供一些额外的帮助。这就是我们使用析构函数的原因。缺少这种帮助意味着浪费宝贵的系统资源,这很可能导致崩溃。因此,应该认真考虑是否需要析构函数。

定义:正规规范形式是一组在实现类过程中应给予特殊处理的函数。这些函数包括:默认构造函数、复制构造函数、赋值运算符、相等性测试运算符和析构函数。

需要强调的是,自动垃圾回收并不能免除我们考虑析构函数式功能的必要性。垃圾回收器解决了一部分问题:它处理堆内存的释放。现在我们不必再考虑数据成员是内联还是非内联。所有与堆数据有关的处理都将由垃圾回收器负责。但是,其他外部资源呢?它们仍然需要在类似析构函数的功能中由程序员处理,这种功能被称为终结器。

定义:在具有自动垃圾回收的语言中,用于清理对象使用的非堆外部资源的隐式调用特殊函数被称为终结器

最后需要记住的是,析构函数和堆之间的紧密关系并不意味着析构函数只在释放堆对象时才会被调用。即使所讨论的对象没有任何外部资源,析构函数也会被调用——这次是隐式地由编译器合成的代码调用——在退出块(对于局部对象)或程序结束时(对于全局或静态局部对象)。

  delete result;
  cout << "Equality test operator..." << endl;
  cout << "c1 – c2 + c3 ?= 0...";
  if (c1 - c2 + c3 == 0) cout << "OK";
    else cout << "Failed";
  cout << endl << "c ?= i...";
  cout << (c == i ? "OK" : "Failed");

  exit(0);
} // end of int main()

运行程序

[编辑 | 编辑源代码]

g++ -I ~/include –c Complex.cxx↵ # 使用 Linux-gccc g++ -I ~/include –o Complex_Test Complex_Test.cxx Complex.o↵ ./Complex_Test↵ c: 5 c1: (3-5i) c2: 3 c3: 5i c4: 5i d: 3 i: 5 d + c1 = (6-5i) c1 + i = (8-5i) *result = i - c1 + c2 - d = (2+5i) *result += c3 = (2+10i) *result -= d = (-1+10i) 相等性 测试 运算符... c1 - c2 + c3 ?= 0...OK c ?= i...OK

  1. 事实上,这是一个相当简化和不完整的实现,它缺少你在 C++ 类中通常会寻找的许多功能。
  2. 这里提供了关于转换的更多信息。
  3. 以下运算符不能被重载:?:(if-then-else 运算符)、.(成员选择运算符)、.*(指针成员选择运算符)、::(作用域运算符)。
  4. 需要注意的是,以下运算符只能作为类成员函数重载:=(赋值运算符)、[](下标运算符)、()(函数调用运算符)、->(成员访问运算符)。
  5. 那是任何没有在类内定义的函数。
  6. ref!!!
  7. 换句话说,我们实际上复制了原本由编译器合成的代码。
  8. 不需要复制静态字段。
  9. ref9
  10. ref10
  11. ref11
  12. ref12
  13. ref13
  14. ref14
  15. ref15
  16. ref16
华夏公益教科书