C++基础-类和对象
本文为 C++ 学习笔记,参考《Sams Teach Yourself C++ in One Hour a Day》第 8 版、《C++ Primer》第 5 版、《代码大全》第 2 版。
面向对象编程有四个重要的基础概念:抽象、封装、继承和多态。本文整理 C++ 中类与对象的基础内容,涉及抽象和封装两个概念。《C++基础-继承》一文讲述继承概念。《C++基础-多态》一文讲述多态概念。这些内容是 C++ 中最核心的内容。
抽象
抽象是一种忽略个性细节、提取共性特征的过程。当用“房子”指代由玻璃、混凝土、木材组成的建筑物时就是在使用抽象。当把鸟、鱼、老虎等称作“动物”时,也是在使用抽象。
基类是一种抽象,可以让用户关注派生类的共同特性而忽略各派生类的细节。类也是一种抽象,用户可以关注类的接口本身而忽视类的内部工作方式。函数接口、子系统接口都是抽象,各自位于不同的抽象层次,不同的抽象层次关注不同的内容。
抽象能使人以一种简化的观点来考虑复杂的概念,忽略繁琐的细节能大大降低思维及实现的复杂度。如果我们在看电视前要去关注塑料分子、玻璃分子、金属原子是如何组成一部电视机的、电与磁的原理是什么、图像是如何产生的,那这个电视不用看了。我们只是要用一台电视,而不关心它是怎么实现的。同理,软件设计中,如果不使用各种抽象层次,那么这一堆代码将变得无法理解无法维护甚至根本无法设计出来。
封装
抽象是从一种高层的视角来看待一个对象。而封装则是,除了那个抽象的简化视图外,不能让你看到任何其他细节。简言之,封装就是隐藏实现细节,只让你看到想给你看的。
在程序设计中,就是把类的成员(属性和行为)进行整合和分类,确定哪些成员是私有的,哪些成员是公共的,私有成员隐藏,公共成员开放。类的用户(调用者)只能访问类的公共接口。
1. 类与对象
// 类:人类
class Human
{
public:
// 成员方法:
void Talk(string textToTalk); // 说话
void IntroduceSelf(); // 自我介绍
private:
// 成员属性:
string m_name; // 姓名
string m_dateOfBirth; // 生日
string m_placeOfBirth; // 出生地
string m_gender; // 性别
...
};
// 对象:具体的某个人
Human xiaoMing;
Human xiaoFang;
对象是类的实例。语句 Human xiaoMing;
和 int a;
本质上并无不同,对象和类的关系,等同于变量和类型的关系。
不介意外部知道的信息使用 public 关键字限定,需要保密的信息使用 private 关键字限定。
2. 构造函数
2.1 构造函数
构造函数用于定义类的对象初始化的方式,无论何时只要类的对象被创建,就会执行构造函数。
- 构造函数名字与类名相同
- 构造函数无返回值
- 构造函数可以重载,一个类可有多个构造函数
- 构造函数不能被声明为 const,因为一个 const 对象也是通过构造函数完成初始化的,构造函数完成初始化之后,const 对象才真正取得"常量"属性。
构造函数形式如下:
class Human
{
public:
Human(); // 构造函数声明
};
Human::Human() // 构造函数实现(定义)
{
...
}
2.2 默认构造函数
可不提供实参调用的构造函数是默认构造函数(Default Constructor)。在类的使用者看来,不提供实参就完成了对象的初始化,那就是这些对象执行了默认初始化,控制这个默认初始化过程的构造函数就叫默认构造函数。
默认构造函数包括如下两种:
- 不带任何函数形参的构造函数是默认构造函数
- 带有形参但所有形参都提供默认值的构造函数也是默认构造函数,因为这种构造函数既可以携带实参调用,也可以不带实参调用
2.3 合成的默认构造函数
当用户未给出任何构造函数时,编译器会自动生成一个构造函数,叫作合成的默认构造函数(Synthesized Default Constructor)。合成的默认构造函数对类的数据成员初始化规则如下:
- 若数据成员存在类内初始化值,则用这个初始化值来初始化数据成员
- 否则,执行默认初始化。默认值由数据类型确定。参 "C++ Primer 5th" 第 40 页
下面这个类因为没有任何构造函数,所以编译器会生成合成的默认构造函数:
class Human
{
public:
// 成员方法:
void Talk(string textToTalk); // 说话
void IntroduceSelf(); // 自我介绍
private:
// 成员属性:
string m_name; // 姓名
string m_dateOfBirth; // 生日
string m_placeOfBirth; // 出生地
string m_gender; // 性别
};
2.4 参数带默认值的构造函数
函数可以有带默认值的参数,构造函数当然也可以。
class Human
{
private:
string m_name;
int m_age;
public:
// overloaded constructor (no default constructor)
Human(string humansName, int humansAge = 25)
{
m_name = humansName;
m_age = humansAge;
...
};
可以使用如下形式的实例化:
Human adam("Adam"); // adam.m_age is assigned a default value 25
Human eve("Eve", 18); // eve.m_age is assigned 18 as specified
2.5 带初始化列表的构造函数
初始化列表是一种简写形式,将相关数据成员的初始化列表写在函数名括号后,从而可以省略函数体中的相应数据成员赋值语句。
Human::Human(string humansName, int humansAge) : m_name(humansName), m_age(humansAge)
{
}
上面这种写法和下面这种写法具有同样的效果:
Human::Human(string humansName, int humansAge)
{
m_name = humansName;
m_age = humansAge;
}
2.6 拷贝构造函数和移动构造函数
2.6.1 浅复制及其问题
复制一个类的对象时,只复制其指针成员但不复制指针指向的缓冲区,其结果是两个对象指向同一块动态分配的内存。销毁其中一个对象时,delete[] 释放这个内存块,导致另一个对象存储的指针拷贝无效。这种复制被称为浅复制。
如下为浅复制的一个示例程序:
#include <iostream>
#include <string.h>
#include <stdio.h>
using namespace std;
class MyString
{
private:
char *m_buffer;
public:
MyString(const char *initString) // Constructor
{
m_buffer = NULL;
cout << "constructor" << endl;
if (initString != NULL)
{
m_buffer = new char[strlen(initString) + 1];
strcpy(m_buffer, initString);
}
}
~MyString() // Destructor
{
cout << "destructor, delete m_buffer " << hex << (unsigned int *)m_buffer << dec << endl;
delete[] m_buffer;
}
void PrintAddress(const string &prefix)
{
cout << prefix << hex << (unsigned int *)m_buffer << dec << endl;
}
};
void UseMyString(MyString str)
{
str.PrintAddress("str.m_buffer addr: ");
}
int main()
{
MyString test("12345678901234567890"); // 直接初始化,执行构造函数 MyString(const char* initString)
test.PrintAddress("test.m_buffer addr: ");
UseMyString(test); // 拷贝初始化,执行合成的默认拷贝构造函数,浅复制
return 0;
}
运行程序,输出如下:
constructor
test.m_buffer addr: 0x1513280
str.m_buffer addr: 0x1513280
destructor, delete m_buffer 0x1513280
destructor, delete m_buffer 0x1513280
从运行结果中可以看到,test 对象和 str 对象的 m_buffer 指针指向同一内存区,两个对象销毁导致这一内存区也被 delete 了两次,会导致难以预料的严重后果。
分析一下 UseMyString(test);
这一语句:
- test 对象执行直接初始化,根据参数匹配规则调用了构造函数
MyString(const char* initString)
。UseMyString(MyString str)
函数的形参 str 对象执行拷贝初始化,因此将调用编译器合成的拷贝构造函数。 - 合成拷贝构造函数执行对象浅复制,将实参 test 复制给形参 str,复制了对象中数据成员(指针)的值,但未复制成员指向的缓冲区,因此两个对象的数据成员(指针 m_buffer)指向同一内存区。
- UseMyString() 函数返回时,str 析构(调用析构函数释放内存区),内存区被回收
- main() 函数返回时,test 析构(调用析构函数释放内存区),再次回收内存区,导致段错误
2.6.2 拷贝构造函数:确保深复制
拷贝构造函数函数语法如下:
class MyString
{
MyString(const MyString& copySource); // copy constructor
};
MyString::MyString(const MyString& copySource)
{
// Copy constructor implementation code
}
拷贝构造函数接受一个以引用方式传入的当前类的对象作为参数,这个参数是源对象的引用。在拷贝构造函数中自定义复制代码,确保对所有缓冲区进行深复制。
每当执行对象的拷贝初始化时,编译器都将调用拷贝构造函数。
拷贝初始化包括如下几种情形:
- 定义对象时同时使用等号进行初始化
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
拷贝构造函数的参数必须按引用传递,否则拷贝构造函数将不断调用自己,直到耗尽系统的内存为止。原因就是每当对象被复制时,编译器都将调用拷贝构造函数,如果参数不是引用,实参不断复制给形参,将生成不断复制不断调用拷贝构造函数。
示例程序如下:
#include <iostream>
#include <string.h>
#include <stdio.h>
using namespace std;
class MyString
{
private:
char *m_buffer;
public:
MyString(const char *initString) // Constructor
{
m_buffer = NULL;
cout << "constructor" << endl;
if (initString != NULL)
{
m_buffer = new char[strlen(initString) + 1];
strcpy(m_buffer, initString);
}
}
MyString(const MyString ©Source) // Copy constructor
{
m_buffer = NULL;
cout << "copy constructor" << endl;
if (copySource.m_buffer != NULL)
{
m_buffer = new char[strlen(copySource.m_buffer) + 1];
strcpy(m_buffer, copySource.m_buffer);
}
}
~MyString() // Destructor
{
cout << "destructor, delete m_buffer " << hex << (unsigned int *)m_buffer << dec << endl;
delete[] m_buffer;
}
void PrintAddress(const string &prefix)
{
cout << prefix << hex << (unsigned int *)m_buffer << dec << endl;
}
};
void UseMyString(MyString str)
{
str.PrintAddress("str.m_buffer addr: ");
}
int main()
{
MyString test1("12345678901234567890"); // 直接初始化,执行构造函数 MyString(const char* initString)
UseMyString(test1); // 拷贝初始化,执行拷贝构造函数 MyString(const MyString& copySource),深复制
MyString test2 = test1; // 拷贝初始化,执行拷贝构造函数 MyString(const MyString& copySource),深复制
MyString test3("abcdefg"); // 直接初始化,执行构造函数 MyString(const char* initString)
test3 = test1; // 赋值,执行合成拷贝赋值运算符,因未显式定义赋值运算符,因此是浅复制
test1.PrintAddress("test1.m_buffer addr: ");
test2.PrintAddress("test2.m_buffer addr: ");
test3.PrintAddress("test3.m_buffer addr: ");
return 0;
}
运行程序,结果如下:
constructor
copy constructor
str1.m_buffer addr: 0x1da22a0
str2.m_buffer addr: 0x1da2280
destructor, delete m_buffer 0x1da22a0
copy constructor
constructor
test1.m_buffer addr: 0x1da2280
test2.m_buffer addr: 0x1da22a0
test3.m_buffer addr: 0x1da2280
destructor, delete m_buffer 0x1da2280
destructor, delete m_buffer 0x1da22a0
destructor, delete m_buffer 0x1da2280
程序分析见注释。拷贝初始化和赋值两种操作都涉及对象的复制,拷贝初始化会调用拷贝构造函数,赋值则调用拷贝赋值运算符。
程序中 MyString test2 = test1;
是拷贝初始化,因此拷贝构造函数起作用。test3=test1
这一句是赋值,因此拷贝赋值运算符起作用。因为 MyString 类没有提供复制赋值运算符 operator=,所以将使用编译器提供的默认拷贝赋值运算符,从而导致对象浅复制。
关于拷贝构造函数的注意事项如下:
- 类包含原始指针成员(char *等)时,务必编写拷贝构造函数和复制赋值运算符。
- 编写拷贝构造函数时,务必将接受源对象的参数声明为 const 引用。
- 声明构造函数时务必考虑使用关键字 explicit,以避免隐式转换。
- 务必将类成员声明为 std::string 和智能指针类(而不是原始指针),因为它们实现了拷贝构造函数,可减少您的工作量。除非万不得已,不要类成员声明为原始指针。
2.6.3 移动构造函数:改善性能
class MyString
{
// 代码同上一示例程序,此处略
};
MyString Copy(MyString& source)
{
MyString copyForReturn(source.GetString()); // create copy
return copyForReturn; // 1. 将返回值复制给调用者,首次调用拷贝构造函数
}
int main()
{
MyString test ("Hello World of C++");
MyString testAgain(Copy(test)); // 2. 将 Copy() 返回值作实参,再次调用拷贝构造函数
return 0;
}
上例中,参考注释,实例化 testAgain 对象时,拷贝构造函数被调用了两次。如果对象很大,两次复制造成的性能影响不容忽视。
为避免这种性能瓶颈, C++11 引入了移动构造函数。移动构造函数的语法如下:
// move constructor
MyString(MyString&& moveSource)
{
if(moveSource.m_buffer != NULL)
{
m_buffer = moveSource.m_buffer; // take ownership i.e. 'move'
moveSource.m_buffer = NULL; // set the move source to NULL
}
}
有移动构造函数时,编译器将自动使用它来“移动”临时资源,从而避免深复制。增加移动构造函数后,上一示例中,将首先调用移动构造函数,然后调用拷贝构造函数,拷贝构造函数只被会调用一次。
3. 析构函数
析构函数在对象销毁时被调用。执行去初始化操作。
- 析构函数只能有一个,不能被重载。
- 若用户未提供析构函数,编译器会生成一个伪析构函数,但是这个伪析构函数是空的,不会释放堆内存。
每当对象不再在作用域内或通过 delete 被删除进而被销毁时,都将调用析构函数。这使得析构函数成为重置变量以及释放动态分配的内存和其他资源的理想场所。
4. 构造函数与析构函数的其他用途
4.1 不允许复制的类
假设要模拟国家政体,一个国家只能有一位总统,则 President 类的对象不允许复制。
要禁止类对象被复制,可将拷贝构造函数声明为私有的。为禁止赋值,可将赋值运算符声明为私有的。拷贝构造函数和赋值运算符声明为私有的即可,不需要实现。这样,如果代码中有对对象的复制或赋值,将无法编译通过。形式如下:
class President
{
private:
President(const President&); // private copy constructor
President& operator= (const President&); // private copy assignment operator
// … other attributes
};
4.2 只能有一个实例的单例类
前面讨论的 President 不能复制,不能赋值,但存在一个缺陷:无法禁止通过实例化多个对象来创建多名总统:
President One, Two, Three;
要确保一个类不能有多个实例,也就是单例的概念。实现单例,要使用私有构造函数、私有赋值运算符和静态实例成员。
将关键字 static 用于类的数据成员时,该数据成员将在所有实例之间共享。
将关键字 static 用于成员函数(方法)时,该方法将在所有成员之间共享。
将 static 用于函数中声明的局部变量时,该变量的值将在两次调用之间保持不变。
4.3 禁止在栈中实例化的类
将析构函数声明为私有的。略
4.4 使用构造函数进行类型转换
略
5. 对象的拷贝控制
对类的对象的复制、移动、赋值和销毁操作可以称为拷贝控制。类通过如下五种成员函数来控制拷贝控制行为:拷贝构造函数(copy constructor), 拷贝赋值运算符(copy-assignment operator), 移动构造函数(move constructor), 移动赋值运算符(move-assignment operator), 和析构函数(destructor)。我们将这些成员函数简称为拷贝控制成员。如果类没有定义某种拷贝控制成员,编译器会自动生成它。
在 C++ 中,初始化和赋值是两个不同的操作,尽管都使用了等号 "="。初始化的含义是创建对象进赋予一个初始化值;而赋值的含义是把对象的当前值擦除,以一个新值覆盖。
以 string 类对象为例,我们看一下直接初始化和拷贝初始化的区别:
string dots(10, '.'); // direct initialization
string s(dots); // direct initialization
string s2 = dots; // copy initialization
string null_book = "9-999-99999-9"; // copy initialization
string nines = string(100, '9'); // copy initialization
直接初始化时,编译器使用普通的函数匹配来选择与实参最匹配的构造函数。拷贝初始化时,编译器将右侧对象拷贝到正在创建的对象中,拷贝初始化通常使用拷贝构造函数来完成。
构造函数控制类对象的初始化,其中拷贝构造函数用于控制拷贝初始化,其他构造函数用于控制直接初始化。赋值运算符用于控制类对象的赋值。如下:
string s1(10, '.'); // 直接初始化,s1 调用匹配的构造函数
string s2 = s1; // 拷贝初始化,s3 调用拷贝构造函数
string s3; // 直接初始化,s2 调用默认构造函数
s3 = s2; // 赋值,将使用 string 类的赋值运算符操作
6. this 指针
在类中,关键字 this 包含当前对象的地址,换句话说, 其值为 &object。在类成员方法中调用其他成员方法时, 编译器将隐式地传递 this 指针。
调用静态方法时,不会隐式地传递 this 指针,因为静态函数不与类实例相关联,而由所有实例共享。要在静态函数中使用实例变量,应显式地声明一个形参,并将实参设置为 this 指针。
7. sizeof 用于类
sizeof 用于类时,值为类声明中所有数据属性占用的总内存量,单位为字节。是否考虑对齐,与编译器有关。
8. 结构与类的不同之处
结构 struct 与类 class 非常相似,差别在于程序员未指定时,默认的访问限定符(public 和 private)不同。因此,除非指定了,否则结构中的成员默认为公有的(而类成员默认为私有的);另外,除非指定了,否则结构以公有方式继承基结构(而类为私有继承)。
修改记录
2019-05-16 V1.0 初稿
2020-02-28 V1.1 增加拷贝控制一节,优化拷贝构造函数相关内容