类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口和实现分离的编程,封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节。
1、类的定义
1.1、类的组成
类体定义类的成员,所有成员必须在类的内部声明。类对象只拥有数据成员,成员函数只定义一次,被所有的类对象共享。
- 数据成员(属性)
- 成员函数(行为)
数据成员:定义了类对象的具体内容,每个对象都有自己的一份数据成员拷贝。C++ 11 新标准中,可以为数据成员提供类内初始值(必须以等号或花括号表示)。创建对象时,类内初始值将用于初始化数据成员。
可变数据成员:关键字 mutable,在任何成员函数,包括 const 函数内都能改变它的值。
class Screen { public: void some_member() const; private: // 可变数据成员即使在一个 const 对象内也能修改 mutable size_t access_ctr; }; void Screen::some_member() const { ++access_ctr; }
成员函数:类的成员函数在类的内部定义是隐式的 inline 函数,也可以在类的外部定义。
// 类的外部定义成员函数,可以用 inline 修饰,定义为内联函数 inline void Screen::some_member() const { ++access_ctr; }
1.2、this 指针
类的指针,指向对象本身,非静态成员函数通过该隐式参数来访问调用它的对象。
- 位置:隐藏在每个非静态的成员函数的第一个参数处,由编译器自动补全,存放位置与编译器有关。
- 形式:常量指针
*const
,不允许改变指向,可以修改指向的内容。
使用类对象调用非静态成员函数等价于把类对象的地址传递给 this 指针。类的所有对象共用成员函数体。 当程序被编译之后,成员函数地址即已确定。成员函数依靠 this 指针将类的不同实例对象的数据区别开,函数体内对所有类数据成员的访问,都会转换为 this-> 数据成员
的方式。
// 定义类实例,使用类对 Sales_data total; // 类对象调用非静态成员函数等价于编译器把类实例的地址传递给 this 指针 total.isbn(); <--> Sales_data::isbn(&total)
1.3、访问控制
访问说明符
类的封装性通过访问说明符来实现
- public:公有数据成员和成员函数
- protected:保护数据成员和成员函数,子类访问
- private:私有数据成员和成员函数,类内访问
class 与 struct 唯一区别就是默认访问权限不同:class 默认访问权限是 private,struct 的默认访问权限是 public。
友元
友元 friend:不受访问权限的控制。此时,类允许其他类或函数访问它的非公有成员。
友元的性质
- 友元是单向的,无传递性,不能被继承。
- 友元破坏了类的封装性,所以需要慎用。
友元的形式
- 友元函数:普通函数和成员函数
- 友元类
class Point; class Line { public: float distance(const Point &lhs, const Point &rhs); float distance(Point &pt, int ix); }; class Point { // 友元形式1:普通函数(自由函数、全局函数) friend float distance(const Point &lhs, const Point &rhs); // 友元形式2:成员函数 friend float Line::distance(const Point &lhs, const Point &rhs); // 友元形式3:友元类 friend class Line; ... private: int _ix; int _iy; }; // 友元形式1:普通函数(自由函数、全局函数) float distance(const Point &lhs, const Point &rhs) { return hypot(lhs._ix - rhs._ix, lhs._iy - rhs._iy); } // 友元形式2:成员函数 float Line::distance(const Point &lhs, const Point &rhs) { return hypot(lhs._ix - rhs._ix, lhs._iy - rhs._iy); }
1.4、类作用域
一个类就是一个作用域。在类作用域外,普通成员只能由对象、引用、指针使用成员访问运算符来访问;类类型成员使用作用域运算符进行访问。这也很好解释了为什么在类的外部定义成员函数时必须同时提供类名和函数名。
一旦遇到了类名,定义的剩余部分(函数名,参数列表、函数体)就在类域内了,此时可以直接使用类的成员而无需授权。而对于返回类型, 则必须指明它是哪个类的成员,这是因为返回类型出现在类名前。
Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &s) {}
类的定义分两步进行
- 编译成员的声明
- 直到类全部可见后,编译函数体
成员函数体直到整个类可见后才会被处理,所以它能使用类中定义的任何名字。在成员函数中名字查找的解析顺序为:成员函数内 -> 类内 -> 全局作用域。
2、对象的创建与销毁
2.1、构造函数
创建和初始化对象。
- 形式:函数名与类名相同,没有返回值,但是可以传参数,构造函数可以重载
- 作用:完成数据成员的初始化
- 系统会提供一个默认无参的构造函数
初始化列表
初始化数据成员的地方。构造函数体内部为赋值操作,成员在构造函数体前执行默认初始化。
注意:数据成员的初始化顺序,只与类成员声明的顺序有关,与其在初始化表达式中的顺序无关。
面试题:必须使用初始化列表的情况
- 常量成员:常量必须在初始化时赋值。
- 引用成员:引用初始化后,不能改变指向的对象。
- 未提供默认构造函数的类类型
- 基类的构造函数
默认构造函数
默认构造函数,没有任何参数。按以下规则初始化类的数据成员
- 值初始化:若存在类内初始值,则用它初始化成员。否则,默认初始化。
- 默认初始化:块作用域内不使用任何初始值定义一个非静态变量,当一个类本身含有类类型成员且使用默认构造函数,类类型的成员没有在构造函数的初始化列表中显式地初始化。
类名() = default;
必须自定义默认构造函数的情况
- 已经定义了其他的构造函数
- 含有内置类型或复合类型的成员的类。否则,用户在创建类实例时可能得到未定义的值
- 编译器不能为某些类合成默认构造函数。例如类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数
委托构造函数
委托构造函数:C++11 扩展了构造函数初始值的功能,一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程。受委托的构造函数的初始化列表和函数体依次执行,然后控制权才会交还给委托者的函数体。
class Sales_data { public: // 非委托的构造函数 Sales_data(string s, unsigned cnt, double price): bookNo(s), units_sold(cnt), revenue(cnt * price) {} // 委托构造函数 Sales_data(): Sales_data("", 0, 0) {} Sales_data(string s): Sales_data(s, 0, 0) {} Sales_data(istream &is): Sales_data() { read(is, *this); } };
隐式类型转换
转换构造函数:只接受一个实参的构造函数,定义了从构造函数参数类型向类类型隐式转换的机制。只允许一步类类型转换。
string null_book = "9-999-99999-9"; // 显式类型转换:显式使用构造函数 item.combine(Sales_data(null_book)); item.combine(static_cast<Sales_data>(cin)); // 隐式类型转换:构造函数参数类型:string / istream -> 类类型:临时对象 Sales_data item.combine(null_book); item.combine(cin);
使用 explicit 抑制构造函数定义的隐式转换,只对一个实参的构造函数有效,因为多个实参的构造函数不会触发隐式类型转换。
class Sales_data { public: Sales_data(string s, unsigned cnt, double price): bookNo(s), units_sold(cnt), revenue(cnt * price) {} Sales_data() = default; explicit Sales_data(string s): bookNo(s) {} explicit Sales_data(istream &is); };
vector 将其单参数的构造函数定义成 explicit,而 string 则不是,这是因为:string 的单参数构造函数的参数类型是 const char* 类型,自动转换成 string 类类型符合逻辑;而 vector 单参数构造函数的参数类型有多种,若发生隐式类型转换,可能造成错误的调用。例如:将 int 参数类型自动转换成 vector 类类型,该过程不符合语言习惯,且会存在误用问题。
2.2、析构函数
一个对象被销毁时,自动调用其析构函数。
- 形式:函数名与类名相同,并带一个取反符号
~
。没有返回值,没有参数,具有唯一性,因此不支持函数重载。 - 作用:完成对象的销毁,执行清理任务。
- 系统会提供一个默认的析构函数,但是没有做资源回收操作。
* 构造函数与析构函数的执行顺序
- 构造函数的执行顺序:基类构造函数,成员变量的构造函数,自身的构造函数。
- 析构函数的执行顺序:自身的析构函数、成员变量的析构函数,基类的析构函数
3、拷贝控制
拷贝控制操作包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数。若一个类没有定义拷贝控制成员,编译器则会定义合成(默认)的操作。
3.1、拷贝构造函数
- 形式:构造函数的第一个参数是自身类型的引用。
类名(const 类名 &rhs)
- 作用:用一个已经存在的对象副本初始化一个新对象。
- 系统会提供一个默认的拷贝构造函数,但是只提供浅拷贝
拷贝构造函数的参数必须是引用和 const 的原因
- 引用:值传递,调用拷贝构造函数,拷贝实参,为了拷贝实参,调用拷贝构造函数,无限循环,栈溢出。
- const:当传递右值,非 const 左值无法绑定右值,无法调用拷贝构造函数
拷贝构造函数调用的时机
- 一个实例化对象初始化一个新对象
- 对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员。
浅拷贝 & 深拷贝
浅拷贝:拷贝指针。两个指针指向同一片地址空间。如果原地址中对象被改变,则浅拷贝出来的对象也相应改变。若调用析构函数释放空间的时候,会出现 double free 的错误。
Computer::Computer(const Computer &rhs) : _brand(rhs._brand) // 浅拷贝 , _price(rhs._price) { cout << "Computer(const Computer &)" << endl; }
深拷贝:拷贝指针指向的空间。拷贝值,并申请堆空间。在调用析构函数释放空间的时候,会分别释放自己的内存空间,防止浅拷贝带来的重复释放。
Computer::Computer(const Computer &rhs) : _brand(new char[strlen(rhs._brand) + 1]()) //深拷贝 , _price(rhs._price) { cout << "Computer(const Computer &)" << endl; strcpy(_brand, rhs._brand); }
3.2、拷贝赋值运算符
- 形式:
类名& operator=(const 类名 &rhs)
- 作用:将一个对象的值复制到另一个对象中
- 系统会提供一个默认的赋值运算符函数
拷贝赋值运算符的参数必须是引用和 const 的原因
- 引用:否则会多调用一次拷贝构造函数。影响效率。
- const:当传递右值的时候,非 const 左值引用不能绑定右值。
拷贝赋值运算符的返回值必须是类类型的引用
- 引用:否则多调用一次拷贝构造函数,影响效率,且执行完毕后左操作数是存在的。
- 类类型:不能为 void,考虑连等 a = b = c。
实现方法
四步:自复制、释放左操作数、深拷贝、返回 *this。
Computer &Computer::operator=(const Computer &rhs) { /* 1、检查自复制,防止自我复制导致堆空间重复释放。 判断是否是同一个对象 方法一:判断对象是否相同:*this != rhs,需要!=运算符重载 方法二:判断指向对象的指针是否相同:this != &rhs,简单易行。*/ if(this != &rhs) { /* 2、释放左操作数(防止内存泄漏) 深拷贝开辟新空间,指向旧空间的指针丢失,内存泄漏 */ delete [] _brand; _brand = nullptr; /* 3、深拷贝 若直接拷贝,可能右操作数比左操作数空间大,内存溢出*/ _brand = new char[strlen(rhs._brand) + 1](); strcpy(_brand, rhs._brand); _price = rhs._price; } // 4、返回*this return *this; }
3.3、移动语义函数
移动语义可以将资源通过浅拷贝方式从一个对象转移到另一个对象,只是转移,没有内存拷贝,这样能够减少不必要的临时对象的创建、拷贝以及销毁。传递右值,优先调用移动语义的函数,即移动语义函数的执行优先于复制控制语义函数的执行。
移动操作通常不抛出异常,若不抛出异常,则必须将其标记为 noexcept,显式告诉标准库可以安全使用。移动操作后,移动后源对象必须保持有效的、可析构的状态,但用户不能对其值做出任何假设。
只有当一个类没有定义任何版本的拷贝控制成员,且所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
更多关于移动语义的内容,见我的博客:C++ 11:移动语义
移动构造函数
- 浅拷贝
- 转以后将其置为 nullptr
// 移动构造函数:右值引用作为函数参数 String(String &&rhs) noexcept // 1、浅拷贝 : _pstr(rhs._pstr) { // 2、移动后,销毁源对象,这里将其设为 nullptr rhs._pstr = nullptr; }
移动赋值运算符
- 自移动
- 释放左操作数
- 浅拷贝
- 返回 this 指针
// 移动赋值运算符函数:右值引用作为函数参数 String &operator=(String &&rhs) noexcept { // rhs 右值引用,拥有名字的变量,在函数内部是一个左值 // 1、自移动 if(this != &rhs) { // 2、释放左操作数 delete [] _pstr; _pstr = nullptr; // 3、浅拷贝 _pstr = rhs._pstr; rhs._pstr = nullptr; } // 4、返回*this return *this; }
3.4、三五法则
35 法则用于控制类的拷贝操作。3 法则(C++ 98)拷贝构造函数,拷贝赋值运算符,析构函数;5 法则(C++ 11)增加移动构造函数,移动赋值运算符。所有五个拷贝控制成员应当看作一个整体,当一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。
两个基本原则
- 需要析构函数的类需要拷贝和赋值操作:一个类需要自定义析构函数,说明合成析构函数不足以释放类拥有的资源。此时,若使用合成的拷贝构造、拷贝赋值,则会产生浅拷贝的问题。此时,需要自动以拷贝和赋值操作,深拷贝。
- 需要拷贝操作的类需要赋值操作,反之亦然。
3.5、例:实现 String 类
#include <string.h> #include <iostream> #include <vector> using std::cout; using std::endl; using std::vector; class String { public: friend std::ostream & operator<<(std::ostream &os, const String&); String() : _pstr(new char[1]()) { cout << "String()" << endl; } String(const char * pstr) : _pstr(new char[strlen(pstr) + 1]()) { cout << "String(const char*)" << endl; strcpy(_pstr, pstr); } String(const String & rhs) : _pstr(new char[strlen(rhs._pstr) + 1]()) { cout << "String(const String &)" << endl; strcpy(_pstr, rhs._pstr); } String & operator=(const String & rhs) { cout << "String & operator=(const String&)" << endl; if(this != &rhs) { delete [] _pstr; _pstr = new char[strlen(rhs._pstr) + 1](); strcpy(_pstr, rhs._pstr); } return *this; } String(String && rhs) noexcept : _pstr(rhs._pstr) { cout << "String(String&&)" << endl; rhs._pstr = nullptr; } String & operator=(String && rhs) noexcept { cout << "String& operator=(String&&)" << endl; if(this != &rhs) { delete [] _pstr; _pstr = rhs._pstr; rhs._pstr = nullptr; } return *this; } ~String() { cout << "~String()" << endl; if(_pstr){ delete [] _pstr; _pstr = nullptr; } } private: char * _pstr; }; std::ostream & operator<<(std::ostream &os, const String& rhs) { os << rhs._pstr; return os; }
4、特殊成员的初始化
4.1、常量成员
只读特性,常量数据成员只能在初始化列表中进行初始化,而且不能在任何成员函数内部赋值。
常量成员函数
形式:函数类型 函数名(参数列表)const
。const 修改 this 指针类型,指向常量。
特点:
- 常量对象、常量对象的引用或指针只能调 const 成员函数
- 非常量对象可以调用 const 和非 const 成员函数,默认调用非 const 成员函数。
- const 成员函数与非 const 成员函数可以进行重载,一般先写 const 版本。
4.2、引用成员
引用成员只能在初始化列表中进行初始化,并且占一个指针大小空间。
4.3、类对象成员
自定义类型创建的对象(子对象)作为另一个类的对象成员时,必须在初始化列表中显示初始化。否则系统自动调用子对象的默认构造函数,会与预期的构造不一致。
class Line { public: Line(int x1, int y1, int x2, int y2) : _pt1(x1, y1) , _pt2(x2, y2) { cout << "Line(int,int,int,int)" << endl; } private: // 类对象成员 Point _pt1; Point _pt2; };
4.4、* 静态成员
类的静态成员不属于类的任何一个对象,对象中不包含任何与静态数据成员有关的数据。也就是说,静态成员并不是由类的构造函数初始化的,因此必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态成员只能定义一次。如果有头文件与实现文件,则在实现文件进行初始化,否则出现重定义问题。
// static 关键字只能出现在类内部的声明语句 class Account { ... private: static double interestRate; }; // 在类的外部定义静态成员,注意不能重复 static 关键字 double Account::interestRate = initRate();
静态成员特点
- 必须在类外进行初始化,被类的所有对象所共享。
- 静态数据成员存储在全局静态区,并不占据对象的存储空间
面试题:静态成员与普通成员的区别
- 生命周期:静态成员从类被加载就一直存在;普通成员只有在类实例化后存在,对象结束,生命期结束
- 共享方式:静态成员类的所有对象共享;普通成员每个对象独享。
- 定义位置:静态成员存储在静态全局区,并不占用对象的存储空间;普通成员存储在堆或栈。
- 初始化位置:静态成员必须在类外初始化。
- 应用场景:静态成员能用于某些场景,而普通成员不能,这是因为静态成员独立于任何对象。
- 静态成员可以是不完全类型(类声明后定义前)。特别地,可以是它所属的类类型;非静态成员只能声明成它所属类的指针或引用。这是因为只有当类全部完成后类才算被定义,所以一个类的成员不能是自己。
- 静态成员可以作为默认实参;非静态成员不能作为默认实参,因为它的值本身属于对象的一部分。
静态成员函数
形式:类名::静态成员函数名
。静态的成员函数的没有 this 指针
特点:
- 只能访问静态的成员和成员函数
- 非静态的成员函数能访问静态的数据成员和成员函数