跳转到内容

C++ 编程/类/成员函数

来自维基教科书,开放世界中的开放书籍

成员函数

[编辑 | 编辑源代码]

成员函数可以(也应该)用于与用户定义类型中包含的数据进行交互。用户定义类型在程序编写中的"分治"方案中提供了灵活性。换句话说,一个程序员可以编写一个用户定义类型并保证一个接口。另一个程序员可以使用那个预期的接口编写主程序。这两部分被组合在一起并编译以供使用。用户定义类型提供了在面向对象编程 (OOP) 范式中定义的封装

在类中,为了保护数据成员,程序员可以定义函数来对这些数据成员执行操作。成员函数和函数是用来指代类的同义词。函数原型在类定义中声明。这些原型可以采用非类函数的形式,也可以采用适合类的原型。函数可以在类定义中声明和定义。但是,大多数函数可能具有非常大的定义,使得类难以阅读。因此,可以使用范围解析运算符"::"在类定义之外定义函数。这个范围解析运算符允许程序员在其他地方定义函数。这可以允许程序员提供一个包含类定义的头文件.h,以及从包含函数定义的编译后的.cpp 文件生成的.obj 文件。这可以隐藏实现并防止篡改。用户必须重新定义每个函数才能更改实现。类中的函数可以访问和修改(除非函数是常量)数据成员,而无需声明它们,因为数据成员已经在类中声明。

简单示例

文件: Foo.h

// the header file named the same as the class helps locate classes within a project
// one class per header file makes it easier to keep the 
// header file readable (some classes can become large)
// each programmer should determine what style works for them or what programming standards their
// teacher/professor/employer has
 
#ifndef FOO_H
#define FOO_H
 
class Foo{
public:
  Foo();                  // function called the default constructor
  Foo( int a, int b );    // function called the overloaded constructor
  int Manipulate( int g, int h );
 
private:
  int x;
  int y;
};
 
#endif

文件: Foo.cpp

#include "Foo.h"
 
/* these constructors should really show use of initialization lists
Foo::Foo() : x(5), y(10)
{
}
Foo::Foo(int a, int b) : x(a), y(b)
{
}
*/
Foo::Foo(){
  x = 5;
  y = 10;
}
Foo::Foo( int a, int b ){
  x = a;
  y = b;
}

int Foo::Manipulate( int g, int h ){
  x = h + g*x;
  y = g + h*y;
}

成员函数可以被重载。这意味着同一个作用域中可以存在多个同名成员函数,但它们的签名必须不同。成员函数的签名由成员函数的名称以及成员函数的参数类型和顺序组成。

由于名称隐藏,如果派生类中的成员与基类中的成员同名,它们将对编译器隐藏。要使这些成员可见,可以使用声明从基类作用域引入它们。

构造函数和其他类成员函数(除了析构函数)可以被重载。

构造函数

[编辑 | 编辑源代码]

构造函数是一个特殊的成员函数,每当创建一个类的新的实例时就会调用它。编译器在新的对象在内存中分配后调用构造函数,并将那个“原始”内存转换为适当的类型化对象。构造函数的声明方式与普通成员函数非常相似,但它将与类同名,并且没有返回值。

构造函数负责几乎所有类操作所需的运行时设置。它的一般目的通常是在对象实例化(声明对象时)定义数据成员,它们也可以有参数,如果程序员愿意的话。如果构造函数有参数,那么在使用new 运算符时,也应该将它们添加到该类任何其他对象的声明中。构造函数也可以被重载。

Foo myTest;                 // essentially what happens is:  Foo myTest = Foo();
Foo myTest( 3, 54 );        // accessing the overloaded constructor
Foo myTest = Foo( 20, 45 ); // although a new object is created, there are some extra function calls involved
                            // with more complex classes, an assignment operator should
                            // be defined to ensure a proper copy (includes ''deep copy'')
                            // myTest would be constructed with the default constructor, and then the
                            // assignment operator copies the unnamed Foo( 20, 45 ) object to myTest

使用new 和构造函数

Foo* myTest = new Foo();           // this defines a pointer to a dynamically allocated object
Foo* myTest = new Foo( 40, 34 );   // constructed with Foo( 40, 34 )
// be sure to use delete to avoid memory leaks

注意

虽然使用new 创建对象没有风险,但通常最好避免在对象的构造函数中使用内存分配函数。具体来说,使用 new 创建一个对象数组,其中每个对象也使用 new 在其构造过程中分配内存,通常会导致运行时错误。如果类或结构包含必须指向动态创建对象的成员,最好按顺序初始化父对象的这些数组,而不是将任务留给它们的构造函数。
这在编写包含异常的代码时尤为重要(在异常处理中),如果在构造函数完成之前抛出异常,则不会为该对象调用关联的析构函数。

构造函数可以委托给另一个(在 C++ 11 中引入)。还建议减少使用默认参数,如果维护者必须编写和维护多个构造函数,可能会导致代码重复,从而降低可维护性,因为可能会引入不一致,甚至导致代码膨胀。

默认构造函数

默认构造函数是可以不带参数调用的构造函数。最常见的是,默认构造函数不带任何参数声明,但如果所有参数都赋予了默认值,则带参数的构造函数也可以是默认构造函数。

为了创建一个类类型的对象数组,该类必须有一个可访问的默认构造函数;C++ 没有语法来指定数组元素的构造函数参数。

重载构造函数

[编辑 | 编辑源代码]

当实例化一个类的对象时,类编写者可以提供各种构造函数,每个构造函数都有不同的用途。一个大型类将拥有许多数据成员,其中一些可能在对象实例化时被定义,也可能不被定义。无论如何,每个项目都会有所不同,因此程序员应该在提供构造函数时调查各种可能性。

这些都是类 myFoo 的构造函数。

 
myFoo(); // default constructor, the user has no control over initial values
         // overloaded constructors

myFoo( int a, int b=0 ); // allows construction with a certain 'a' value, but accepts 'b' as 0
                         // or allows the user to provide both 'a' and 'b' values
 // or
 
myFoo( int a, int b ); // overloaded constructor, the user must specify both values

class myFoo {
private:
  int Useful1;
  int Useful2;

public:
  myFoo(){                     // default constructor
           Useful1 = 5;
           Useful2 = 10;  
          };

  myFoo( int a, int b = 0 ) { // two possible cases when invoked
         Useful1 = a;
         Useful2 = b;
  };

};
 
myFoo Find;           // default constructor, private member values Useful1 = 5, Useful2 = 10
myFoo Find( 8 );      // overloaded constructor case 1, private member values Useful1 = 8, Useful2 = 0
myFoo Find( 8, 256 ); // overloaded constructor case 2, private member values Useful1 = 8, Useful2 = 256

构造函数初始化列表

[编辑 | 编辑源代码]

构造函数初始化列表(或成员初始化列表)是使用非默认构造函数初始化数据成员和基类的唯一方法。成员的构造函数包含在参数列表和构造函数主体之间(用冒号与参数列表隔开)。使用初始化列表不仅效率更高,而且是保证在进入构造函数主体之前完成所有数据成员初始化的最简单方法。

// Using the initialization list for myComplexMember_ 
MyClass::MyClass(int mySimpleMember, MyComplexClass myComplexMember)
: myComplexMember_(myComplexMember) // only 1 call, to the copy constructor
{
 mySimpleMember_=mySimpleMember; // uses 2 calls, one for the constructor of the mySimpleMember class
                                 // and a second for the assignment operator of the MyComplexClass class
}

这比在构造函数主体内部将值赋给复杂数据成员效率更高,因为在这种情况下,变量将使用其相应的构造函数初始化。

注意,提供给成员构造函数的参数不需要是类构造函数的参数;它们也可以是常量。因此,可以为包含没有默认构造函数的成员的类创建一个默认构造函数。

示例

MyClass::MyClass() : myComplexMember_(0) { }

在构造函数中使用此初始化列表来初始化成员很有用。这使读者清楚地知道构造函数没有执行逻辑。初始化的顺序应该与定义基类和成员的顺序相同。否则,您可能会在编译时收到警告。一旦开始初始化成员,请确保将所有成员都保存在构造函数中,以避免混淆和可能的 0xbaadfood。

使用与成员名称相同的构造函数参数是安全的。

示例

class MyClass : public MyBaseClassA, public MyBaseClassB {
  private:
    int c;
    void *pointerMember;
  public:
    MyClass(int,int,int);
};
/*...*/
MyClass::MyClass(int a, int b, int c):
 MyBaseClassA(a)
,MyBaseClassB(b)
,c(c)
,pointerMember(NULL)
,referenceMember()
{
 //logic
}

注意,此技术也适用于普通函数,但现在已过时,在此情况下被归类为错误。

注意
一个常见的误解是数据成员的初始化可以在构造函数主体内部完成。所有这种所谓的“初始化”实际上都是赋值。C++ 标准定义了所有数据成员的初始化在进入构造函数主体之前完成。这就是为什么某些类型(const 类型和引用)不能被赋予值,并且必须在构造函数初始化列表中初始化的原因。

还需注意,类成员的初始化顺序是根据它们声明的顺序,而不是在初始化列表中出现的顺序。为了避免鸡生蛋蛋生鸡的悖论,始终按照成员声明的顺序将它们添加到初始化列表中。

析构函数

[edit | edit source]

析构函数与构造函数类似,声明方式与普通成员函数相同,但名称与类名相同,以“~”开头作为区分,不能接收参数,也不能重载。

析构函数在类对象销毁时被调用。析构函数对于避免资源泄漏(通过释放内存)和实现 RAII 惯用法至关重要。在类构造函数中分配的资源通常在该类的析构函数中释放,以便在类不再存在后将系统恢复到已知或稳定的状态。

析构函数在对象销毁时被调用,例如在声明它们的函数返回后、使用 **delete** 运算符时或程序结束时。如果派生类型对象被销毁,首先执行最派生对象的析构函数,然后成员对象和基类对象以它们对应的构造函数完成的相反顺序递归销毁。与结构体一样,如果类没有用户声明的析构函数,编译器会隐式地将其声明为类的内联公共成员。

对象的动态类型将从最派生类型开始,随着析构函数的运行而改变,与构造函数执行时的变化方式对称。这会影响构造和销毁过程中虚拟调用所调用的函数,并导致常见的(且合理的)建议,避免在构造函数或析构函数中直接或间接调用对象的虚拟函数。

在成员函数方面,我们之前在内联函数介绍中看到的概念得到了扩展,但也有一些额外的考虑因素。

如果成员函数定义包含在类的声明中,该函数默认情况下会隐式地被标记为内联。编译器选项可能会覆盖此行为。

如果对象的类型在编译时未知,则无法内联对虚拟函数的调用,因为我们不知道要内联哪个函数。

**static** 关键字可以用四种不同的方式使用


Clipboard

待办事项
在结构固定后,将以上链接从子部分更改为书籍位置。


静态成员函数
[edit | edit source]

声明为静态的成员函数或变量在对象类型的所有实例之间共享。这意味着对于任何对象类型,成员函数或变量都只有一个副本。

无需对象即可调用的成员函数

当在类函数成员中使用时,该函数不会将实例作为隐式this参数,而是像自由函数一样工作。这意味着可以在不创建类实例的情况下调用静态类函数。

class Foo {
public:
  Foo() {
    ++numFoos;
    cout << "We have now created " << numFoos << " instances of the Foo class\n";
  }
  static int getNumFoos() {
    return numFoos;
  }
private:
  static int numFoos;
};

int Foo::numFoos = 0;  // allocate memory for numFoos, and initialize it

int main() {
  Foo f1;
  Foo f2;
  Foo f3;
  cout << "So far, we've made " << Foo::getNumFoos() << " instances of the Foo class\n";
}
命名构造函数
[edit | edit source]

命名构造函数是使用静态成员函数的一个很好的例子。命名构造函数是指用于创建类对象而无需(直接)使用其构造函数的函数。这可能用于以下目的:

  1. 绕过构造函数只能在签名不同时才可重载的限制。
  2. 通过将构造函数设为私有,使类不可继承。
  3. 通过将构造函数设为私有,阻止栈分配。

声明一个使用私有构造函数创建对象并返回对象的静态成员函数。(它也可以返回指针或引用,但这似乎没有用,并将此变成工厂模式,而不是传统的命名构造函数。)

以下是一个用于存储可以在不同温度刻度中指定的温度的类的示例。

class Temperature
{
    public:
        static Temperature Fahrenheit (double f);
        static Temperature Celsius (double c);
        static Temperature Kelvin (double k);
    private:
        Temperature (double temp);
        double _temp;
};

Temperature::Temperature (double temp):_temp (temp) {}

Temperature Temperature::Fahrenheit (double f)
{
    return Temperature ((f + 459.67) / 1.8);
}

Temperature Temperature::Celsius (double c)
{
    return Temperature (c + 273.15);
}

Temperature Temperature::Kelvin (double k)
{
    return Temperature (k);
}

const

[edit | edit source]

这种类型的成员函数不能修改类的成员变量。它同时向程序员和编译器暗示给定的成员函数不会更改类的内部状态;但是,任何声明为mutable的变量仍然可以修改。

例如

 class Foo
 {
  public:
    int value() const
    {
      return m_value;
    }
 
    void setValue( int i )
    {
      m_value = i;
    }
 
  private:
    int m_value;
 };

这里value()显然不会更改 m_value,因此可以且应该为 const。但是setValue()确实修改了 m_value,因此不能为 const。

另一个经常被忽略的细节是const成员函数不能调用非 const 成员函数(如果你尝试这样做,编译器会报错)。由于const成员函数不能更改成员变量,而非 const 成员函数可以更改成员变量,因此,我们假设非 const 成员函数确实会更改成员变量,因此const成员函数被认为永远不会更改成员变量,因此不能调用更改成员变量的函数。

以下代码示例解释了const可以在不同位置使用它所产生的影响。

 class Foo
 {
 public:
    /*
     * Modifies m_widget and the user
     * may modify the returned widget.
     */
    Widget *widget();
 
    /*
     * Does not modify m_widget but the
     * user may modify the returned widget.
     */
    Widget *widget() const;
 
    /*
     * Modifies m_widget, but the user
     * may not modify the returned widget.
     */
    const Widget *cWidget();
 
    /*
     * Does not modify m_widget and the user
     * may not modify the returned widget.
     */
    const Widget *cWidget() const;
 
 private:
    Widget *m_widget;
 };

访问器和修饰符(Setter/Getter)

[edit | edit source]
什么是访问器?
访问器是不修改对象状态的成员函数。访问器函数应该声明为const.
**Getter** 是访问器的另一个常见定义,因为这类成员函数的命名方式为(GetSize())。
什么是修饰符?
修饰符(也称为修改函数)是指更改至少一个数据成员值的成员函数。换句话说,修改对象状态的操作。修饰符也被称为“mutators”。
**Setter** 是修饰符的另一个常见定义,因为其命名方式为(SetSize( int a_Size ))。

注意
这些是常用的引用标签(在标准语言中没有定义)。

动态多态性(覆盖)

[edit | edit source]

到目前为止,我们已经了解到可以通过继承向类添加新的数据和函数。但是,如果我们想让派生类继承基类的方法,但希望它有不同的实现呢?这就是我们所说的多态性,它是 OOP 编程中的一个基本概念。

正如我们在编程范式部分中所见,多态性分为两个概念:静态多态性动态多态性。本部分重点介绍动态多态性,它在 C++ 中应用于派生类覆盖基类中声明的函数时。

我们通过在派生类中重新定义方法来实现此概念。但是,在这样做时,我们需要考虑一些因素,因此现在必须介绍动态绑定、静态绑定和虚函数的概念。

假设我们有两个类:ABB 派生自 A,并重新定义了类 A 中存在的方法 c() 的实现。现在假设我们有一个类 B 的对象 b。如何解释指令 b.c()

如果 b 在堆栈中声明(不是作为指针或引用声明),编译器将应用静态绑定,这意味着它会(在编译时)解释为我们引用的是 B 中存在的方法 c() 的实现。

但是,如果我们以类 A 的指针或引用声明 b,编译器在编译时无法知道要调用哪个方法,因为 b 可以是 AB 类型。如果在运行时解析,将调用 B 中存在的方法。这称为动态绑定。如果在编译时解析,将调用 A 中存在的方法。这同样是静态绑定。

虚成员函数

[edit | edit source]

virtual成员函数方面,概念相对简单,但常常被误解。这个概念是设计类层次结构(涉及子类化类)时不可或缺的一部分,因为它决定了特定上下文中覆盖方法的行为。

虚成员函数是类成员函数,可以在从声明它们的那个类派生的任何类中被覆盖。然后,成员函数体将被派生类中的一组新的实现所取代。

注意
当重写虚函数时,可以更改派生类成员函数的私有、保护或公有状态访问状态。

通过在方法声明之前放置关键字virtual,我们表明当编译器必须决定是应用静态绑定还是动态绑定时,它将应用动态绑定。否则,将应用静态绑定。

注意
虽然在子类定义中不需要使用 virtual 关键字(因为如果基类函数是虚拟的,那么它所有子类的覆盖也会是虚拟的),但在为将来重用而生成代码时(用于同一项目之外)这样做是一种好习惯。

再次,这应该通过一个例子更清楚地说明。

class Foo
{
public:
  void f()
  {
    std::cout << "Foo::f()" << std::endl;
  }
  virtual void g()
  {
    std::cout << "Foo::g()" << std::endl;
  }
};
 
class Bar : public Foo
{
public:
  void f()
  {
    std::cout << "Bar::f()" << std::endl;
  }
  virtual void g()
  {
    std::cout << "Bar::g()" << std::endl;
  }
};
 
int main()
{
  Foo foo;
  Bar bar;

  Foo *baz = &bar;
  Bar *quux = &bar;

  foo.f(); // "Foo::f()"
  foo.g(); // "Foo::g()"
 
  bar.f(); // "Bar::f()"
  bar.g(); // "Bar::g()"

  // So far everything we would expect...
 
  baz->f();  // "Foo::f()"
  baz->g();  // "Bar::g()"

  quux->f(); // "Bar::f()"
  quux->g(); // "Bar::g()"
 
  return 0;
}

我们对两个对象中f()g()的第一次调用很简单。然而,当我们的 baz 指针是一个指向 Foo 类型的指针时,事情变得有趣了。

f()不是virtual,因此对f()的调用将始终调用与指针类型相关联的实现 - 在这种情况下,来自 Foo 的实现。

注意
请记住,重载重写是截然不同的概念。

虚拟函数调用在计算上比普通函数调用更昂贵。虚拟函数使用指针间接,调用,并且需要比普通成员函数多执行几个指令。它们还要求包含虚拟函数的任何类/结构的构造函数初始化一个指向其虚拟成员函数的指针表。

所有这些特性都将表明性能和设计之间的权衡。应该避免在没有现有结构性需求的情况下先发制人地声明函数为虚拟函数。请记住,只能在运行时解析的虚拟函数不能内联。


Clipboard

待办事项
虚拟和内联问题示例。


注意
使用类模板可以解决使用虚拟函数的一些需求。我们将在介绍模板时介绍它。

纯虚成员函数

[edit | edit source]

还有一种有趣的情况。有时我们根本不想提供函数的实现,而是希望要求对我们的类进行子类化的人自行提供实现。这就是“纯”虚拟函数的情况。

为了表示纯virtual函数而不是实现,我们只需在函数声明后添加一个“= 0”。

再次 - 一个例子

class Widget
{
public:
   virtual void paint() = 0;
};

class Button : public Widget
{
public:
   void paint() // is virtual because it is an override
   {
       // do some stuff to draw a button
   }
};

因为paint()是 Widget 类中的一个纯virtual函数,因此我们必须在所有具体子类中提供实现。如果我们不提供,编译器将在构建时给我们错误。

这对于提供接口很有帮助 - 这是我们对基于特定层次结构的所有对象期望的内容,但我们想要忽略实现细节。

那么为什么这有用呢?

让我们以我们上面的例子为例,其中我们有一个用于绘画的纯virtual。在很多情况下,我们希望能够对小部件做一些事情,而不必担心它是什么类型的小部件。绘画就是一个简单的例子。

假设我们的应用程序中有一些东西在小部件变得活跃时重新绘制它们。它只会使用指向小部件的指针,即Widget *activeWidget() const可能是一个可能的函数签名。因此我们可能会做一些类似的事情

Widget *w = window->activeWidget();
w->paint();

我们希望实际上调用“真实”小部件类型相应的 paint 成员函数 - 而不是Widget::paint()(这是一个“纯”virtual,如果使用虚拟调度调用它会导致程序崩溃)。通过使用virtual函数,我们确保调用子类(在本例中为Button::paint())的成员函数实现。


Clipboard

待办事项
提及接口类


协变返回类型

[edit | edit source]

协变返回类型是指派生类中的虚拟函数能够返回指向自身实例的指针或引用,前提是基类中的方法版本也这样做。例如

class base
{
public:
  virtual base* create() const;
};

class derived : public base
{
public:
  virtual derived* create() const;
};

这允许避免强制转换。

注意
一些较旧的编译器不支持协变返回类型。对于此类编译器,存在解决方法。

虚拟构造函数

[edit | edit source]

有一个以基类Foo为基础的类层次结构。给定一个属于层次结构的对象bar,希望能够执行以下操作

  1. 创建一个与baz相同类别的对象bar(例如,类Bar),使用该类的默认构造函数进行初始化。通常使用的语法是
    Bar* baz = bar.create();
  2. 创建一个与baz相同类别的对象bar它是bar的副本。通常使用的语法是
    Bar* baz = bar.clone();

在类Foo中,方法Foo::create()Foo::clone()声明如下

class Foo
{
    // ...

    public:
        // Virtual default constructor
        virtual Foo* create() const;

        // Virtual copy constructor
        virtual Foo* clone() const;
};

如果Foo用作抽象类,这些函数可以是纯虚拟的

class Foo
{
   // ...

    public:
        virtual Foo* create() const = 0;
        virtual Foo* clone() const = 0;
};

为了支持创建默认初始化的对象和创建复制对象,每个类Bar在层次结构中必须具有公共默认构造函数和复制构造函数。的虚拟构造函数定义如下Bar

class Bar : ... // Bar is a descendant of Foo
{
    // ...

    public:
    // Non-virtual default constructor
    Bar ();
    // Non-virtual copy constructor
    Bar (const Bar&);

    // Virtual default constructor, inline implementation
    Bar* create() const { return new Foo (); }
    // Virtual copy constructor, inline implementation
    Bar* clone() const { return new Foo (*this); }
};

上面的代码使用协变返回类型。如果你的编译器不支持Bar* Bar::create(),使用Foo* Bar::create(),类似地,对于clone().

使用这些虚拟构造函数时,您必须手动通过调用delete baz;来释放创建的对象。如果使用智能指针(例如std::unique_ptr<Foo>)作为返回类型而不是普通Foo*.

,可以避免这种麻烦。请记住,无论Foo是否使用动态分配的内存,您必须定义析构函数virtual ~Foo ()并使其virtual来处理使用指向祖先类型的指针的对象的释放。

虚拟析构函数

[edit | edit source]

特别重要的是要记住,即使在任何基类中定义一个空虚拟析构函数,因为如果不这样做,将导致默认的编译器生成的析构函数不是虚拟的,这将导致问题。

在派生类中重新定义时不会覆盖虚拟析构函数,每个析构函数的定义是累积的,它们从最后一个派生类开始到第一个基类结束。

纯虚拟析构函数

[edit | edit source]

每个抽象类都应该包含纯虚拟析构函数的声明。

纯虚拟析构函数是纯虚拟函数的一种特殊情况(旨在在派生类中被覆盖)。它们必须始终被定义,并且该定义应始终为空。

class Interface {
public:
  virtual ~Interface() = 0; //declaration of a pure virtual destructor 
};

Interface::~Interface(){} //pure virtual destructor definition (should always be empty)

三法则

[edit | edit source]

“三法则”并不是真正的法则,而是一个指导原则:如果一个类需要显式声明的复制构造函数、复制赋值运算符或析构函数,那么它通常需要这三者。

这条规则有一些例外(或者,换句话说,是细化)。例如,有时显式声明析构函数只是为了使其成为virtual;在这种情况下,没有必要声明或实现复制构造函数和复制赋值运算符。

大多数类不应该声明任何“三大”操作;管理资源的类通常需要这三者。

华夏公益教科书