类作为C++中重要的概念之一,有着众多的特性,也是最迷人的部分!
类是一个加工厂,开发者使用C++提供的各种材料组装这个工厂,使得它可以生产出符合自己要求的数据,通过对工厂的改造,可以精细控制对象从出生到死亡的各种行为,真正达到我的代码我做主的境界。
类
我们经常说的面向对象三大特征:封装,继承和多态,其实说的是一种抽象维度。最简单的就是具体类,它将数据打包在一起,提供操作数据的函数,使得开发者不再需要通过传参的形式传递数据。它实现了事物的抽象,也就是所谓的封装。第二层是在一堆数据中提取出共性的部分作为基类,然后将特性作为子类,充分利用继承的优点,实现代码复用。它不仅追求数据抽象,也追求行为上的相似性。而更进一步,一套算法不关心实际的数据,只关心它可以用来完成什么工作,甚至相互都不知道对方的存在,唯一的共同点就是都继承自某个类,都能完成那个类指定的操作,至于细节都不关心,这就是多态,类只是一种规范流程。从第一层到第三层,抽象的事物从具体转向抽象,重心也从数据转向行为,只是为了更好的可维护性和解耦性。三者的关系可能是下图这样的:
为了能将跟高级的继承和多态讲明白,本篇我们将着重介绍他们的第一形态:封装,也就是具体类。
类的基本组成
类是一种自定义类型,主要由两部分组成:成员变量保存类管理的数据,成员函数操作数据。
和普通变量相比,类中的成员变量最大的不同是其生命周期。成员变量在类实例化后才占用空间,构造函数完成其初始化工作,在构造完成后,成员函数就可以无限制地使用成员变量,直到析构函数被调用。
成员函数和普通函数的不同之处是成员函数有个隐含的this
指针,这个指针指向成员变量的存储位置,也就是可以很方便地完成成员变量的访问。
由此可见,具体类研究的主体是数据。接下来我将围绕着数据的生命周期完成对类特性的解析。
对象的创建和销毁
类的第一大作用就是控制类怎么生成和销毁。和Java不同,不需要用new
也会涉及到构造函数的调用,哪怕只是个普通的局部变量,出了变量的作用范围,对象就会被销毁,内存就会被释放。
class Sample{
public:
Sample(){
std::cout<<"Creating a Sample object"<<std::endl;
}
~Sample(){
std::cout<<"Destoring a Sample object"<<std::endl;
}
};
int main(){
// Sample的构造函数被调用
Sample a;
{
// 大括号创建了一个局部作用域,对象b只存在大括号范围内,出了大括号后,b就会被销毁,调用Sample的析构函数
Sample b;
}
// 此时只有对象a还存活
}
// 输出
// Creating a Sample object
// Creating a Sample object
// Destoring a Sample object
// Destoring a Sample object
上面的Sample是最简单的类定义,我们只创建了类的构造函数和析构函数,在main
函数中,创建了两个变量。通过检查输出,我们可以确定类的构造函数和析构函数都被调用了。
上面那个类从功能上毫无用处,我们只能创建一个它的对象,然后看着它死去,什么也干不了。接下来,我们来改造下Sample
类,让它能在构造的时候告诉我们,哪一个对象在构造。
class Sample{
Sample(const std::string name){
std::cout<<"Creating a Sample object:name = "<<name<<std::endl;
}
//其余不变
};
int main(){
// 由于创建对象a时,用到了string对象,所以要先创建一个string对象
std::string str{"a"};
// 此时构造类需要一个名字了,我们已经控制了类的初始化状态
Sample a{str};
{
// Sample唯一给构造函数需要一个string的对象,但是编译器推测出传递给Sample构造函数的参数类型是字符串常量
// 参数不匹配,但这还没达到编译失败的条件,因为编译器还没检查是否存在一种从字符串常量生成字符串对象的构造函数,
// 答案是有的,string类提供了这样的构造函数
// 接下来编译器用字符串常量构造出了string对象,自动完成了string对象的创建
// 并传递给Sample的构造函数,条件满足,编译顺利完成
Sample b{"b"};
}
}
// 输出
// Creating a Sample object:name = a
// Creating a Sample object:name = b
// Destoring a Sample object
// Destoring a Sample object
上面的例子有一个值得注意的地方,那就是对象b
直接从字符串常量创建出来了,省略了中间字符串对象,其实这一步是编译器为我们完成了,它的创建过程和a
是完全一样的,这种行为称为隐式转换。
这时的Sample
类还是什么也做不了,甚至连哪一个对象被销毁了我们都不知道。析构函数是函数,那么给析构函数添加参数行不行呢?答案是不行,因为析构函数是编译器自动帮我们调用的,它不知道调用时需要什么参数,所以就只能是无参。那么有什么办法能正确标记出是哪个对象被销毁了呢,答案是成员变量。
成员变量和对象是同生共死的,它和对象使用同一块内存。对象创建就为成员变量也分配了空间,但是没有初始化,需要开发者在构造函数或者其他函数使用前初始化。在析构函数调用时,内存尚未被回收,这时候是使用成员变量的最后时机。成员变量还有另一个重要的特点,在类中定义的所有非static
函数都能使用它,不需要通过函数参数传递。这也是类设计的初衷之一,用类管理数据。
所以,接下来的析构函数可以这样写
class Sample {
private:
// 第一步,创建一个成员变量
std::string name;
public:
// 第二步,在构造函数中初始化成员变量
Sample(const std::string name) :name{ name } {
std::cout << "Creating a Sample object:name = " << name << std::endl;
}
~Sample() {
//第三步,使用成员变量
std::cout << "Destoring a Sample object:name = " << name << std::endl;
}
};
int main() {
std::string str{ "a" };
Sample a{ str };
{
Sample b{ "b" };
}
}
// 输出
// Creating a Sample object:name = a
// Creating a Sample object:name = b
// Destorin