Skip to content

Latest commit

 

History

History
531 lines (378 loc) · 25.4 KB

11-类.md

File metadata and controls

531 lines (378 loc) · 25.4 KB

C++类

C++在C语言的基础上增加了面向对象编程,而类是C++的核心特性,通常被称为用户定义的类型。 类具备了两个功能:1是数据抽象,定义数据和函数的能力;2是封装,包括数据和函数不会随便被外部访问。 标准库类型 stringistreamostream 都定义成类。

C++面向对象特征:

  • 封装:类的定义
  • 继承:实现继承(非虚函数)、可视继承(虚函数)、接口继承(纯虚函数)
  • 多态
    • 覆盖:虚函数、接口
    • 重载:同名函数

1 定义类

c++中使用structclass定义类,struct与class唯一的区别就是默认的访问权限不一样。

  • struct的第一个访问说明符之前所有的成员都是public
  • class的第一个访问说明符之前所有的成员都是private

仅当只有数据时使用 struct,其它一概使用 class。


2 成员

每个类可以没有成员,也可以定义多个成员,成员可以是数据、函数或类型别名。

类的成员函数是指那些把定义和原型写在类定义内部的函数,就像类定义中的其他变量一样。类成员函数是类的一个成员。 类成员函数可以只在类内声明,然后在类外使用范围解析运算符::来定义,也可以直接定义在类中,在类中定义的成员函数默认是内联的,即便没有使用 inline 标识符

this

在 C++ 中,每一个对象都能通过 this 指针来访问自己的地址。this 指针是所有成员函数( static 成员函数除外)的隐含参数。 因此在成员函数内部,它可以用来指向调用对象。友元函数没有 this 指针,因为友元不是类的成员。只有成员函数才有 this 指针。

返回this的函数

Sales_data&  Sales_data::combine(const Sales_data &rhs){
    units_sold += rhs.units_sold; // add the members of rhs into 
    revenue += rhs.revenue;       // the members of ``this'' object
    return *this; // return the object on which the function was called
}

在返回类型引用的函数中,使用* this返回对象表示返回的是对象本身,而不是对象的拷贝。

const 成员函数

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

double avg_price() const;

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

mutable

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

class Screen { 
public: 
  // 省略公共成员
private: 
  mutable size_t access_ctr; //这个成员可以在const成员函数中被修改
};

//给 Screen 添加了一个新的可变数据成员 access_ctr。使用 access_ctr 来跟踪调用 Screen 成员函数的频繁程度
//尽管 do_display 是 const,它也可以增加 access_ctr。该成员是可变成员,所以,任意成员函数,包括 const 函数,都可以改变 access_ctr 的值。
void Screen::do_display(std::ostream& os) const { 
  ++access_ctr; //自增修改
  os << contents; 
}

3 访问修饰符

  • public公有成员在程序中类的外部是可访问的。可以不使用任何成员函数来设置和获取公有变量的值
  • private私有成员变量或函数在类的外部是不可访问的,甚至是不可查看的。只有类和友元函数可以访问私有成员。
  • protected保护成员变量或函数与私有成员十分相似,但有一点不同,保护成员在派生类(即子类)中是可访问的。

4 构造函数

在定义类时没有初始化它的数据成员,而是通过构造函数来初始化其数据成员。 构造函数的形参指定了创建类类型对象时使用的初始化式。通常,这些初始化式会用于初始化新创建对象的数据成员。 构造函数通常应确保其每个数据成员都完成了初始化。对于一些类类型的成员,构造函数会将其初始化为合理的默认状态

参照下面类定义:

class Sales_item { 
public: 

  double avg_price() const; 
  
  bool same_isbn(const Sales_item &rhs) const { 
        return isbn == rhs.isbn; 
  } 
  
  //初始化列表
  Sales_item(): units_sold(0), revenue(0.0) { } 
  
  Sales_data &combine(const Sales_data &);
  
private: 
  std::string isbn;//默认构造函数将其初始化为 ""
  unsigned units_sold;
  double revenue;
};

默认构造函数

如果类没有定义构造函数,则编译器会生成默认的构造函数,这也称为(合成的默认构造函数)

由编译器创建的默认构造函数通常称为默认构造函数,它将依据如同变量初始化的规则初始化类中所有成员(这是合成的默认构造函数的功能)。 对于具有类类型的成员,如string,则会调用该成员所属类自身的默认构造函数实现初始化。内置类型成员的初值依赖于对象如何定义。

  • 如果对象在全局作用域中定义(即不在任何函数中)或定义为静态局部对象,则这些成员将被初始化为 0。
  • 如果对象在局部作用域中定义,则这些成员没有初始化(未知的)。除了给它们赋值之外,出于其他任何目的对未初始化成员的使用都没有定义。

即内置类型的成员变量的”默认初始化”行为取决于所在对象的存储类型,而存储类型对应的默认初始化规则是不变的。 并且类中成员对象的内置类型成员变量的”默认初始化”行为取决于当前封闭类对象的存储类型,而存储类型对应的默认初始化规则仍然是不变的。

注意:合成的默认构造函数只适用于简单的类,原因是

  • 合成的默认构造函数不会自动初始化内置类型的成员
  • 某些类使用合成的默认构造函数可能会造成错误,如果类包含内置类型(即基本类型)和复合类型成员, 则只有当这些成员全被赋予了类内初始化值时,这个类才适用于合成的默认构造函数
  • 有时候编译器不能为某些类合成默认的构造函数,例如如果类中包含一个其他类型的类成员, 且这个成员的类型没有默认的构造函数时,那么编译器无法初始化该成员

没有默认构造函数的类意味着什么?假定有一个类NoDefault,它没有定义自己的默认构造函数,却有一个接受一个string 实参的构造函数。 因为该类定义了一个构造函数,因此编译器将不合成默认构造函数。则:

  • 具有 NoDefault 成员的每个类的每个构造函数,必须通过传递一个初始的 string 值给 NoDefault 构造函数来显式地初始化 NoDefault 成员。
  • 编译器将不会为具有 NoDefault 类型成员的类合成默认构造函数。如果这样的类希望提供默认构造函数,就必须显式地定义,并且默认构造函数必须显式地初始化其 NoDefault 成员。
  • NoDefault 类型不能用作动态分配数组的元素类型。
  • NoDefault 类型的静态分配数组必须为每个元素提供一个显式的初始化式。
  • 如果有一个保存 NoDefault 对象的容器,例如 vector,就不能使用接受容器大小而没有同时提供一个元素初始化式的构造函数。

类通常应定义一个默认构造函数

使用默认构造函数:

Sales_item myobj;//正确,定义了一个Sales_item
Sales_item myobj();//错误,声明了一个函数
Sales_item myobj = Sales_item();//正确,创建并初始化一个 Sales_item 对象,然后用它来按值初始化myobj。

初始化列表

冒号和花括号之间的代码称为构造函数的初始化列表 。构造函数的初始化列表为类的一个或多个数据成员指定初值。

Sales_item(): units_sold(0), revenue(0.0) { } 

初始化列表的作用:有些成员必须在构造函数初始化列表中进行初始化。对于这样的成员,在构造函数函数体中对它们赋值不起作用。 没有默认构造函数的类类型的成员,以及 const 或引用类型的成员,不管是哪种类型,都必须在构造函数初始化列表中进行初始化。

隐式的类型转换

C++在内置类型中定义了自动类型转换规则,我们也可以为类定义自动类型转换规则,如果构造函数只接收一个实参,则它实际上定义了转换为此类型的 隐式转换机制,有时把这种构造函数称为转换构造函数

加上我们调用上面定义的Sales_data类的combine函数:

string null_book = "999-999-999-0"
Sales_data item;
item.combine(null_book);//这是合法的,这里应用了隐式类型转换规则,因为Sales_data有一个以string为参数的构造函数

隐式的类型转换只允许一步类型转换:

Sales_data item;
item.combine("999-999-999-0")//这是非法的,因为有两步转换,"999-999-999-0"->string, string->Sales_data

explicit

除非有明显的理由想要定义隐式转换,否则,单形参构造函数应该为 explicit。将构造函数设置为 explicit 可以避免错误, 并且当转换有用时,用户可以显式地构造对象。

下面使用explicit抑制由构造函数定义的隐式转换

//此时没有任何构造函数能够隐式的创建Sales_item对象
class Sales_item {
public:
    explicit Sales_item(const std::string &book = ""): isbn(book), units_sold(0), revenue(0.0) {}
    explicit Sales_item(std::istream &is);
};

explicit 关键字只能用于类内部的构造函数声明上。在类的定义体外部所做的定义上不需要再重复它

类成员的显式初始化

聚合类有特殊的初始化语法,聚合类的特点:

  • 对于没有定义构造函数
  • 全体数据成员均为 public
  • 没有类内初始值
  • 没有基类,也没有virtual函数

聚合类可以采用与初始化数组元素相同的方式初始化其成员

struct Data {
int ival;
char *ptr;
};

// val1.ival = 0; val1.ptr = 0
Data val1 = { 0, 0 };

// val2.ival = 1024;
// val2.ptr = "Anna Livia Plurabelle"
Data val2 = { 1024, "Anna Livia Plurabelle"};

显式初始化类类型对象的成员有三个重大的缺点:

  • 要求类的全体数据成员都是 public。
  • 将初始化每个对象的每个成员的负担放在程序员身上。这样的初始化是乏味且易于出错的,因为容易遗忘初始化式或提供不适当的初始化式。
  • 如果增加或删除一个成员,必须找到所有的初始化并正确更新。

字面值常量类

除了算术类型、指针、引用外,某些对象也可以是字面值类型,字面值类型可能函数constexpr成员函数,它们是隐式const的。

数据成员都是字面值的聚合类是字面值常量类,如果一个类不是聚合类,但是如何以下条件,则它也是字面值常量类:

  • 数据成员都必须是字面值类型
  • 类必须至少含有一个constexpr构造函数
  • 如果一个数据成员含有类初始值,则内置类型成员的初始值也必须是一条常量表达式;或者如果成员属于某一个类类型,则初始值必须使用成员自己的constexpr构造函数
  • 类必须使用析构函数的默认定义,该成员必须负责销毁类的对象

一个普通的类不能函数有const构造函数,但是字面值常量类必须提供至少一个constexpr构造函数,constexpr构造函数可以声明为default。 或者要么constexpr构造函数拥有唯一的返回语句,要么constexpr构造函数是空的。

class Debug {
public:
    constexpr Debug(bool b = true): hw(b), io(b), other(b) { }

    constexpr Debug(bool h, bool i, bool o): hw(h), io(i), other(o) { }

    constexpr bool any() { return hw || io || other; }

    constexpr bool hardware() { return hw || io; }

    constexpr bool app() { return other; }

    void set_io(bool b) { io = b; }
    void set_hw(bool b) { hw = b; }
    void set_other(bool b) { hw = b; }

private:
    bool hw;    // hardware errors other than IO errors
    bool io;    // IO errors
    bool other; // other errors
};

委托构造函数

C++11新标准扩展了构造函数初始化值的功能,可以定义委托构造函数。

即一个构造函数可以委托另一个构造函数进行对象创建。

拷贝构造函数

两种初始化形式

  • 拷贝初始化 int a = 5;
  • 直接初始化 int a(5);

对于其他类型没有什么区别,对于类类型直接初始化直接调用实参匹配的构造函数,拷贝初始化总是调用拷贝构造函数

A x(2);  //直接初始化,调用构造函数
A y = x;  //拷贝初始化,调用拷贝构造函数

拷贝构造函数的作用

拷贝构造函数是一种特殊的构造函数,它在创建对象时,是使用同一类中之前创建的对象来初始化新创建的对象。 具有单个形参,该形参(常用const修饰)是对该类类型的引用。

  • 当定义一个新对象并用一个同类型的对象对它进行初始化时,将显式使用拷贝构造函数。
  • 当该类型的对象传递给一个函数或从函数返回该类型的对象时,将隐式调用拷贝构造函数。

与默认构造函数一样,复制构造函数可由编译器隐式调用。复制构造函数可用于,拷贝构造函数通常用于:

  • 根据另一个同类型的对象显式或隐式初始化一个对象。
  • 复制一个对象,将它作为实参传给一个函数。
  • 从函数返回时复制一个对象。
  • 初始化顺序容器中的元素。
  • 根据元素初始化式列表初始化数组元素。

定义复制构造函数

什么时候定义拷贝构造函数:

  • 如果在类中没有定义拷贝构造函数,编译器会自行定义一个。
  • 如果类带有指针变量,并有动态内存分配,则它必须有一个显式的拷贝构造函数。

拷贝构造函数的最常见形式如下:

classname (const classname &obj) {
   // 构造函数的主体
}

复制构造函数就是接受单个const 的类类型引用形参的构造函数,虽然也可以定义接受非 const 引用的复制构造函数, 但形参通常是一个 const 引用。因为用于向函数传递对象和从函数返回对象,该构造函数一般不应设置为 explicit 。复制构造函数应将实参的成员复制到正在构造的对象。

定义复制构造函数最困难的部分在于认识到该类需要复制构造函数

合成复制构造函数

如果我们没有定义复制构造函数,编译器就会为我们合成一个。与合成的默认构造函数不同,即使我们定义了其他构造函数,也会合成复制构造函数。 合成复制构造函数的行为是,执行逐个成员(非static成员)初始化,将新对象初始化为原对象的副本。每个成员的类型决定了复制该成员的含义。 合成复制构造函数直接复制内置类型成员的值,类类型成员使用该类的复制构造函数进行复制。数组成员的复制是个例外。 虽然一般不能复制数组,但如果一个类具有数组成员,则合成复制构造函数将复制数组。复制数组时合成复制构造函数将复制数组的每一个元素。

禁止复制

有些类需要完全禁止复制。例如,iostream 类就不允许复制。如果想要禁止复制,似乎可以省略复制构造函数,然而,如果不定义复制构造函数,编译器将合成一个。 为了防止复制,类必须显式声明其复制构造函数为private。如果想要连友元和成员中的复制也禁止,就可以声明一个private的复制构造函数但不对其定义。

C++11 类成员的内部初始化

在C++98标准里,只有static const声明的整型成员能在类内部初始化,并且初始化值必须是常量表达式。这些限制确保了初始化操作可以在编译时期进行。 C++11的基本思想是,允许非静态(non-static)数据成员在其声明处(在其所属类内部)进行初始化。这样,在运行过程中,需要初始值时构造函数可以使用这个初始值。

 class A {
    public:
        int a = 7;
    };

5 析构函数

析构函数是构造函数的互补:当对象超出作用域或动态分配的对象被删除时,将自动应用析构函数。 析构函数可用于释放对象时构造或在对象的生命期中所获取的资源。 不管类是否定义了自己的析构函数,编译器都自动执行类中非 static 数据成员的析构函数。

析构函数的名称与类的名称是完全相同的,只是在前面加了个波浪号~作为前缀, 它不会返回任何值,也不能带有任何参数。析构函数有助于在跳出程序(比如关闭文件、释放内存等)前释放资源。

  • 撤销类对象时会自动调用析构函数。撤销一个容器(不管是标准库容器还是内置数组)时,也会运行容器中的类类型元素的析构函数。
  • 动态分配的对象只有在指向该对象的指针被删除时才撤销。如果没有删除指向动态对象的指针, 则不会运行该对象的析构函数,对象就一直存在,从而导致内存泄漏,而且,对象内部使用的任何资源也不会释放。
  • 容器中的元素总是按逆序撤销:首先撤销下标为 size() - 1 的元素,然后是下标为 size() - 2 的元素…直到最后撤销下标为0 的元素。
  • 合成析构函数按对象创建时的逆序撤销每个非 static 成员,因此,它按成员在类中声明次序的逆序撤销成员。对于类类型的每个成员,合成析构函数调用该成员的析构函数来撤销对象。

许多类不需要显式析构函数,尤其是具有构造函数的类不一定需要定义自己的析构函数。仅在有些工作需要析构函数完成时,才需要析构函数。 析构函数通常用于释放在构造函数或在对象生命期内获取的资源。

如果类需要析构函数,则它也需要赋值操作符和复制构造函数,这是一个有用的经验法则。这个规则常称为三法则,指的是如果需要析构函数,则需要所有这三个复制控制成员。


6 友元

在某些情况下,允许特定的非成员函数访问一个类的私有成员,同时仍然阻止一般的访问,这是很方便做到的。 例如,被重载的操作符,如输入或输出操作符,经常需要访问类的私有数据成员。 这些操作符不可能为类的成员。然而尽管不是类的成员,它们仍是类的“接口的组成部分”。

如果要声明函数为一个类的友元,需要在类定义中该函数原型前使用关键字 friend修饰符

类的友元函数是定义在类外部,但有权访问类的所有私有(private)成员和保护(protected)成员。 友元声明可以出现在类中的任何地方,尽管友元函数的原型有在类的定义中出现过,但是友元函数并不是成员函数

  • 友元可以是一个函数,该函数被称为友元函数
  • 友元也可以是一个类,该类被称为友元类,在这种情况下,整个类及其所有成员都是友元
  • 友元也可以只是其他类的成员函数

通常应该将友元声明成组地放在类定义的开始或结尾。

使其他类的成员函数成为友元

class Screen { 
  // Window_Mgr必须在Screen之前定义
  //将成员函数声明为友元时,函数名必须用该函数所属的类名字加以限定
  friend Window_Mgr& Window_Mgr::relocate(Window_Mgr::index, Window_Mgr::index,Screen&); 
};

友元声明与作用域

class Screen { 
  friend Window_Mgr& Window_Mgr::relocate(Window_Mgr::index, Window_Mgr::index,  Screen&); 
};

为了正确地构造类,需要注意友元声明与友元定义之间的互相依赖。在上面的例子中: 类 Window_Mgr 必须先定义。否则,Screen 类就不能将一个 Window_Mgr 函数指定为友元。然而, 只有在定义类 Screen 之后,才能定义 relocate 函数 ——毕竟, 它被设为友元是为了访问类 Screen 的成员。更一般地讲, 必须先定义包含成员函数的类,才能将成员函数设为友元。另一方面,不必预先声明类和非成员函数来将它们设为友元。 友元声明将已命名的类或非成员函数引入到外围作用域中。此外,友元函数可以在类的内部定义,该函数的作用域扩展到包围该类定义的作用域。

重载函数和友元

类必须将重载函数集中的每一个希望设为友元的函数都声明为友元。

//两个重载的storeOn方法
extern std::ostream& storeOn(std::ostream &, Screen &); 
extern BitMap& storeOn(BitMap &, Screen &); 

class Screen { 
  //类 Screen 将接受一个 ostream& 的 storeOn 版本设为自己的友元。接受一个 BitMap& 的版本对 Screen 没有特殊访问权。
  friend std::ostream& storeOn(std::ostream &, Screen &); 
  // ... 
};

7 类的静态成员

通常,非 static 数据成员存在于类类型的每个对象中。不像普通的数据成员,static 数据成员独立于该类的任意对象而存在 ; 每个 static 数据成员是与类关联的对象,并不与该类的对象相关联。

static 成员的特点:

  • static 成员的名字是在类的作用域中,因此可以避免与其他类的成员或全局对象名字冲突。
  • 可以实施封装。static 成员可以是私有成员,而全局对象不可以。
  • static 成员是与特定类关联的。这种可见性可清晰地显示程序员的意图。
class Account { 
public: 
  void applyint() { amount += amount * interestRate; } 
  static double rate() { return interestRate; } 
  static void rate(double); // sets a new rate 
private: 
  std::string owner; 
  double amount; 
  static double interestRate; 
  static double initRate(); 
};

静态成员变量

我们可以使用 static 关键字来把类成员定义为静态的。当我们声明类的成员为静态时,这意味着无论创建多少个类的对象,静态成员都只有一个副本。

可以通过作用 范围解析运算符(域操作符) 从类直接调用 static 成员,或者通过对象、引用或指向该类类型对象的指针间接调用。

void useAccount(){
    Account ac1; 
    Account *ac2 = &ac1; 
    double rate; 
    rate = ac1.rate();      
    rate = ac2->rate();
    rate = Account::rate();   // 直接从类使用范围解析运算符
}

静态成员函数

如果把函数成员声明为静态的,就可以把函数与类的任何特定对象独立开来。静态成员函数即使在类对象不存在的情况下也能被调用, 静态函数只要使用类名加范围解析运算符::就可以访问。

static 函数的一些限制:

  • static 函数没有 this 指针。static 成员是类的组成部分但不是任何对象的组成部分,因此,static 成员函数没有 this 指针。通过使用非 static 成员显式或隐式地引用 this 是一个编译时错误。
  • static 成员函数不能被声明为 const 。因为 static 成员不是任何对象的组成部分,所以static 成员函数不能被声明为 const。毕竟,将成员函数声明为 const 就是承诺不会修改该函数所属的对象。
  • static 成员函数也不能被声明为虚函数。

直接静态成员初始化

static 数据成员可以声明为任意类型,可以是常量、引用、数组、类类型等。 static 数据成员必须在类定义体的外部定义(正好一次)。不像普通数据成员, static 成员不是通过类构造函数进行初始化,而是应该在定义时进行初始化。

class Box {
public:
    static int objectCount = 4;//不合法
};

//可以在类的外部通过使用范围解析运算符 :: 来重新声明静态变量从而对它进行初始化
int Box::objectCount = 0;

特殊的整型 const static 成员

一般而言,类的 static 成员,像普通数据成员一样,不能在类的定义体中初始化。相反,static 数据成员通常在定义时才初始化。 这个规则的一个例外是,只要初始化式是一个常量表达式,整型const static数据成员就可以在类的定义体中进行初始化:

class Account { 
public: 
  static double rate() { return interestRate; } 
  static void rate(double);  // sets a new rate 
private: 
  static const int period = 30; 
  double daily_tbl[period];
};

8 指针成员

设计具有指针成员的类时,类设计者必须首先需要决定的是该指针应提供什么行为。将一个指针复制到另一个指针时,两个指针指向同一对象。 当两个指针指向同一对象时,可能使用任一指针改变基础对象。类似地,很可能一个指针删除了一对象时,另一指针的用户还认为基础对象仍然存在。

大多数 C++ 类采用以下三种方法之一管理指针成员:

  • 指针成员采取常规指针型行为。这样的类具有指针的所有缺陷但无需特殊的复制控制,可能会出现悬停指针。
  • 类可以实现所谓的“智能指针”行为。指针所指向的对象是共享的,但类能够防止悬垂指针。
  • 类采取值型行为。指针所指向的对象是唯一的,由每个类对象独立管理。

定义智能指针的通用技术是采用一个使用计数。智能指针类将一个计数器与类指向的对象相关联。 使用计数跟踪该类有多少个对象共享同一指针。使用计数为 0 时,删除对象。使用计数有时也称为引用计数。