C++之路:类基础、构造析构、拷贝构造函数
目录
- 前言
- 从结构体到类
- 类的声明与使用
- 基础声明
- 继承声明
- 数据与函数声明与调用
- 声明
- 调用
- 类的访问修饰符
- 类对象的内存分布
- 类内数据相关
- 静态变量
- 非静态变量
- 类成员函数相关
- 普通成员函数
- 友元函数
- 构造与析构函数
- 构造函数
- 析构函数
- 拷贝构造函数
- 总结
前言
面向对象编程有三大特性,分别是封装、继承、多态。这些特性在类的语法使用中都得到了充分的体现,我也预计写几篇文章来介绍一下C++的类语法。这是第一篇:类基础。
类基础主要目的是介绍一下类的基础使用,重点在于数据和函数的封装。
从结构体到类
对于新手来说 类 这个概念可能比较陌生,但是提到结构体(struct)对于有C语言基础的人应该比较熟悉。在C++中结构体和类的底层实现几乎完全一致,结构体不仅可以封装函数,甚至还可以继承类。
#include <iostream>
#include <cstring>
using namespace std; class Base_Class{
protected:int id; //占用大小为4字节的内存空间
public:Base_Class(int i=0){id = i;cout << "Base_Class constructor called." << endl;cout << "id = " << id << endl;}
};struct Derived_struct : public Base_Class
{Derived_struct(int i) : Base_Class(i) {cout << "Derived_Struct constructor called." << endl;cout << "id = " << id << endl;}
};int main() {Derived_struct obj(10);return 0;
}
输出结果为:
Base_Class constructor called.
id = 10
Derived_Struct constructor called.
id = 10
是不是对类的感觉亲切了许多?当然在C语言中结构体里是不能封装函数的,也没有继承这个说法。数据和函数不封装到一起,那么当我处理数据的时候(比如结构体)就要去其它地方找函数(公共声明函数的地方),而不是通过对象直接可以调用处理的函数。
打个比方,就像用勺子吃西瓜:C语言买了西瓜后要到厨房去找勺子才能吃西瓜,而C++封装的特性就是让买西瓜时,西瓜和勺子绑定在一起出售。因而C语言是面向过程编程的,C++是面向对象编程。
当然了,结构体和类底层实现相同,这不意味着在C++中结构体和类能混用。从规范上来说,结构体用于数据聚合,基本上不封装方法,就像C那样(例如坐标点封装,颜色封装)而类则用于对象进行封装,包括数据以及相关的方法。
类的声明与使用
基础声明
类的基础语法声明如下:
举例说明:
class Box
{public:double length; // 盒子的长度double width; // 盒子的宽度double height; // 盒子的高度int get_volume(){ //获得盒子的体积return length*width*height;}
};
类里面就两样东西:数据+方法
继承声明
继承的语法如下:
其中,derived_class是派生类的名称,base_class是基类名称,二者之间通过: + 访问修饰符
连接。访问修饰符 access-specifier 是 public、protected 或 private 其中的一个,如果未使用访问修饰符 access-specifier,则默认为 private继承。
// 基类
class Animal {
xxxx
};
//派生类
class Dog : public Animal {
xxxx
};
数据与函数声明与调用
声明
类内变量与成员函数的声明语法类外一致,值得注意的是成员函数的具体实现可以放在类外。具体方法如下:
- 首先在类内声明这个函数:
class Dog {
public:void bark(); //类内声明
};
- 然后在类外以
范围解析运算符(::) + 函数名
的形式进行定义:
//类外实现
void Dog::bark(){cout << "汪汪汪" <<endl;
}
调用
类的变量和函数的调用方法与结构体类似:
class Dog{
public: int age=10;//数据void bark() //方法{cout << "汪汪汪" <<endl;}
};
- 如果是类对象实例,使用
.
运算符访问成员变量和函数方法:
Dog wangcai;
wangcai.age; //访问变量
wangcai.bark(); //访问函数
- 如果是类指针,使用
->
运算符访问成员变量和函数方法:
Dog* wangcai;
wangcai->age; //访问变量
wangcai->bark(); //访问方法
类的访问修饰符
访问修饰符用来控制外界对类内变量以及成员函数(类成员)的访问权限。关键字 public、private、protected 称为访问修饰符。
- public(公有)
该成员变量/方法可以被外部的函数直接访问。这是权限最低的一种,类内、派生类以及类外都能访问往往用于对外接口上。 - protected(保护)
保护的类成员只能被类内以及派生类访问,类外无法访问。 - private(私有)
如果没有声明访问权限,私有是默认的访问修饰符。是保护程度最高的一种,只能类内函数访问,派生类和类外都是不能访问的。往往只用于给内部成员函数数据交换
上面的内容总结来说就是:类内成员函数访问类内的变量/方法时没有任何限制,派生类能访问public和protected,类外的普通函数就只能访问public下的变量/方法(友元函数除外)
#include <iostream>
using namespace std;class Example {
public: // 公有成员int publicVar;void publicMethod() {cout << "Public method" << endl;}private: // 私有成员int privateVar;void privateMethod() {cout << "Private method" << endl;}protected: // 保护成员int protectedVar;void protectedMethod() {cout << "Protected method" << endl;}
};
类对象的内存分布
首先要明确一下,类本身是一个抽象的概念,是不占用物理内存的。例如:
class Dog{
public: int age=10;//数据void bark() //方法{cout << "汪汪汪" <<endl;}
};
如果不实例化这个类(Dog wangcai
)是不会有内存占用的。提这个点是为了解答后面学习多态时,有的教材会说虚函数表存储在类里面,这样容易引起误解,类就像int 、float那样,如果不实例化是不会创造出内存空间的。
类对象实例的内存分布如下:
- 最开头: 虚函数表指针。
- 非静态成员变量。(按照声明顺序依次排布)
此外,
- 对于静态成员变量:存储在全局数据区,不占用类实例内存空间。
- 对于成员函数:代码存储在代码段,所有对象共享同一份函数代码。
- 对于虚函数:虚函数实现也和成员函数一样存储在代码段。
- 对于虚函数表:虚函数表在可执行文件中位于.rodata段(Linux/Unix)或.rdata段(Windows),程序加载后存于内存的常量区,具有只读属性。
类内数据相关
静态变量
静态变量是类内以static
为修饰符定义的变量。要点如下:
- 对于类中的静态变量来说,所有对象共享同一个静态变量,修改会影响所有实例。(即存储位置在全局数据区)
- 静态变量的生命周期在程序启动时初始化,程序结束时销毁。
- 静态变量在类内声明在类外定义
- 推荐使用类名访问(推荐):ClassName::staticVar
示例代码:
class Person {
public:static int count; // 静态变量声明
};
int Person::count = 0; // 必须在类外定义Person p1;
Person::count++; // 推荐:通过类名访问
p1.count++; // 不推荐:通过对象访问(合法但不清晰)
非静态变量
又被称为实例变量,是指类内定义的普通成员变量。有以下特性:
- 每个对象独立存储,不同对象的实例变量互不影响。(即存储在实例的内存区域)
- 生命周期:随对象创建而分配,随对象销毁而释放。
- 必须通过对象实例进行访问。
举例:
class Person {
public:std::string name; // 实例变量int age; // 实例变量
};
Person p1;
p1.name = "Alice"; // 访问实例变量
类成员函数相关
普通成员函数
要点:
- 普通成员函数暗含this指针,可以无需声明使用成员变量。
- 成员函数的调用要通过对象实例进行,不能单独拎出来使用。
友元函数
友元函数本质就是类外声明定义的函数,不与类对象绑定,即在类内用friend
声明的外部函数,能突破封装性直接访问类的私有和保护成员,使其数据访问权限等同于成员函数(也就是说可以访问类的私有变量与函数)。
要点:
- 必须在类内使用
friend
修饰符进行声明。 - 具体的实现放在类外。
示例:
class MyClass {
private:int secret;
public:friend void peek(MyClass& obj); // 声明友元函数
};
void peek(MyClass& obj) { obj.secret = 42; } // 实现可访问私有成员
构造与析构函数
#include <iostream>
using namespace std;class MyClass {
public:int value;// 构造函数MyClass(int v) {value = v;cout << "构造函数被调用,value=" << value << endl;}// 析构函数~MyClass() {cout << "析构函数被调用,value=" << value << endl;}
};int main() {MyClass obj1(10); // 构造函数调用{MyClass obj2(20); // 构造函数调用} // obj2离开作用域,析构函数调用return 0;
} // obj1离开作用域,析构函数调用
构造函数
构造函数是一种特殊的成员函数,有以下要点:
- 无任何返回值(连void也没有),可以传入参数。
- 函数名与类名相同。
- 在类对象创建时执行一次,主要用来初始化类对象
- 不被继承,即子类的构造函数中必须要先手动调用父类的构造函数(无参构造时编译器会自动调用)。
构造函数还有一个初始化列表的语法,可以用来初始化字段。例如上面的构造函数可以改为:
MyClass(int v):value(v){cout << "构造函数被调用,value=" << value << endl;
}
如果MyClass继承自BaseClass,初始化列表的语法也可以用来初始化构造函数,可以写成这样下面这样,以符合第四点的要求:
MyClass(int v):BaseClass(Base_args),value(v){cout << "构造函数被调用,value=" << value << endl;
};
(初始化列表时,用逗号,
隔开各个参数)
析构函数
析构函数与构造函数相呼应,就是类对象在销毁时自动调用的函数。(例如,程序结束时、局部对象离开作用域时)。要点如下:
- 无任何返回值(void也没有)以及不传入任何参数!。
- 函数名为
~
+类名
。 - 对象销毁时自动执行,用来释放类对象手动创建的内存空间,避免内存泄漏
- 同样不被继承,但是自动调用(因为是无参的,编译器会自动调用)。顺序为先调用父类的析构,再调用子类的析构
拷贝构造函数
为什么需要额外的拷贝构造函数?其实想想就能清楚,一个类对象的创建只能有两种途径:
- 自定义。使用默认的构造函数进行初始化。
- 从其它对象中复制。这时就要调用拷贝构造函数而不是构造函数进行初始化了。
why?拷贝构造函数必须要写吗?为什么之前写的类没有拷贝构造函数也能编译通过?
回答这个问题首先要了解一下浅拷贝和深拷贝。
其实C++中数据无非被分为两种:普通变量和指针变量,对于浅拷贝来说,执行的就是简单的赋值操作,不区分指针和值,这样也就会造成一种情况:对象A里有个指针p,p指向某个地址。如果此时简单的使用Class B = A
这样初始化对象B就是浅拷贝。那么对象B内的指针p的值和A当中指针p的值是一样的(浅拷贝只是简单赋值),这样就会造成对象A和B的指针p指向同一块内存空间
,如果B中的p改动了指向的变量(*p),那么A中p指向的值也会随之改变,在某些情况下这是很危险的。(即浅拷贝会造成多个指针指向同一个内存地址的情况)。
对于深拷贝来说,就会区分普通变量和指针变量。对于普通变量,如int,float等就直接把值复制过去就行了。对于指针变量则会为指针成员分配新内存并复制内容,但是这一过程必须要手动实现,这就依赖我们的拷贝构造函数了!
下面从使用场合、语法规则两方面总结拷贝构造函数:
-
使用场合:需要以一个已经实例化的类对象为基础,创建一个新的类对象(所谓构造),并且类的成员变量中有指针类型时,需要自定义拷贝构造函数为指针成员分配新内存,并将值赋值到新内存空间中(所谓深拷贝)。
若未显式定义拷贝构造函数,编译器会生成默认拷贝构造函数执行浅拷贝(逐成员复制值)。拷贝构造二者缺一不可,如果仅仅是普通的赋值,例如B = A
,那么默认触发浅拷贝,此时如果需要深拷贝应该要在类内重载运算符=
。 -
语法规则:
定义:
简单来说就是构造函数的基础上,固定传入的参数必须
为同类对象的常量引用(ClassName(const ClassName& obj)),若使用值传递(ClassName(const ClassName obj))会导致无限递归调用。
使用:
MyClass obj2 = obj1; // 隐式调用,此时=表示初始化而非赋值
MyClass obj3(obj1); // 显式调用
示例:
class DeepString {
public:char* data;DeepString(const DeepString& other) {data = new char[strlen(other.data) + 1]; //重新分配地址strcpy(data, other.data); // 复制内容而非指针}
};
总结
拷贝和构造缺一不可:
- 少了拷贝就会导致多个指针指向同一个地址。
- 少了构造就是普通的赋值操作,是在已存在对象间进行赋值(a = b)不属于根据一个已有对对象初始化一个新对象。