class是C++中的关键字,用于定义一个类,平时编程中class很常用,我们可以把很多数据封装到class内,然后对外以class对象的形式使用。
- 基本概念理解
- 理解封装的目的和意义,即隐藏内部实现细节,只通过公共接口与外部交互。
- 掌握如何通过访问修饰符(如
public、protected、private)控制成员的访问权限。 - 理解封装有助于提高代码的复用性、安全性和可维护性。
- 成员变量和成员函数的封装
- 能够合理地定义类的成员变量(属性)和成员函数(方法),并通过访问修饰符保护成员变量不被外部直接访问。
- 成员函数用于实现对成员变量的封装操作,包括获取(
getter)和设置(setter)成员变量的值。
- 构造函数和析构函数的封装
- 理解构造函数和析构函数的作用,掌握其编写规范。
- 能够在类中正确实现构造函数来初始化对象,并在必要时实现析构函数来清理资源。
- 封装的实际应用
- 能够设计符合封装原则的类,并应用于实际场景中,如表示实体对象、管理资源等。
1. 如何定义一个类
#include <iostram>
class A {};
int main() {
A a;
return 0;
}
这样就定义了一个class A,同时在main函数中定义A的实例a,也可以叫做定义了A的对象a,都是一个意思。注意在定义class的大括号后面有个分号;。很多新手都容易忽略这个分号,最后导致编译报错。
2. 数据成员、静态成员、成员函数、静态成员函数
class A {
public:
void SetNumber();
static int GetCount();
private:
int number;
static int count;
};
int A::count = 0;
介绍下数据成员、静态成员、成员函数的概念。
可以这样简单理解:
class中定义的变量就是数据成员,class中定义的函数就是成员函数。- 数据成员可分为普通数据成员和静态数据成员。
- 成员函数可分为普通成员函数和静态成员函数。
class中用static修饰的变量可称为静态数据成员,用static修饰的成员函数可称为静态成员函数。
对应上述的代码中:
SetNumber()是普通成员函数,用static修饰的GetCount()是静态成员函数。number是普通数据成员,而用static修饰的count是静态数据成员。
2.1. 普通成员和静态成员
静态数据成员属于整个类空间,为该类的所有对象共享,静态数据成员具有静态生存期,所有的类实例访问静态成员时,访问的其实是类中的同一个数据成员。
而普通成员不属于整个类空间,所有的类都有自己的普通成员空间。
就像下面代码,class A所有的实例都有各自的数据成员number,而它们共用一个count。
class A {
public:
int number{0};
static int count;
};
int A::count = 0;
int main() {
A a1;
A a2;
++a1.count;
++a1.number;
++a2.count;
++a2.number;
std::cout << a1.count << std::endl;
std::cout << a1.number << std::endl;
std::cout << a2.count << std::endl;
std::cout << a2.number << std::endl;
}
静态成员变量,在类内声明,在类外初始化,用(::)来指明所属的类,而且一定要初始化,如果在类内初始化的话,编译会报错。
C++11支持静态常量(const或constexpr修饰)类内初始化,此时类外仍可定义该静态成员,但不可再次初始化操作。
2.2. 普通成员函数与静态成员函数
其实静态数据成员不是只可以通过对象来访问,通过类(静态成员函数)也可以访问,因为它本身就属于类空间中,就像这样:
cout << A::count << endl;
静态成员函数和普通成员函数的使用,与访问静态数据成员和普通数据成员的方法相同,普通函数可以访问静态成员以及普通成员,而静态成员函数只能访问静态成员。
3. 访问权限
class有三种权限:
public权限:可以被其他任何实体访问private权限:只允许本类内的成员函数访问protected权限:允许本类和子类的成员函数访问(子类后面我会介绍,现在可以理解为是爸爸的儿子)
4. 构造函数与析构函数
#include <iostream>
using namespace std;
class A {
public:
A() {
cout << "构造函数" << endl;
}
~A() {
cout << "析构函数" << endl;
}
};
int main() {
A a;
}
ClassName()这种格式就是构造函数,~ClassName()这种格式就是析构函数。
C++ 语法规定,在创建一个类对象时,会自动调用类的构造函数,当对象的生命周期结束时,会自动调用类的析构函数。
5. 构造函数分类
构造函数:在对象被创建时使用特定的值构造对象,将对象初始化为一个特定的初始状态。
构造函数的形式:className(.....){....}
- 函数名与类名相同;
- 不能定义返回值类型,也不能有return语句;
- 可以有形式参数,也可以没有形式参数;
- 可以是内联函数;
- 可以重载;
- 可以带默认参数值。
构造函数的调用时机:在对象创建时被自动调用。
5.1. 默认构造函数
调用时可以不需要实参的构造函数。
- 参数表为空的构造函数
- 全部参数都有默认值的构造函数
如下列代码,下面两个都是默认构造函数,如在类中同时出现,将产生编译错误。
class A {
public:
A():number{1}{}
//A(int a = 1):number(a){}
private:
int number;
}
如果程序中未定义构造函数,编译器将在需要时自动生成一个默认构造函数:
- 参数列表为空,不为数据成员设置初始值;
- 如果类内定义了成员的初始值,则使用类内定义的初始值;
- 如果没有定义类内的初始值,则以默认方式初始化;
- 基本类型的数据默认初始化的值是不确定的。
如果类中已定义构造函数,默认情况下编译器就不再隐含生成默认构造函数。如果此时依然希望编译器隐含生成默认构造函数,可以使用“=default”。
class {
public:
A(int a):number(a) {}
A() = default; // 如果类中已定义构造函数,
// 默认情况下编译器就不再隐含生成默认构造函数。
// 如果此时依然希望编译器隐含生成默认构造函数,可以使用“=default”。
private:
int number;
}
5.2. 有参构造函数
有参构造函数即有参数的构造函数。
class A {
A(int a):number(a) {}
private:
int number;
}
5.3. 拷贝构造函数与拷贝赋值函数
我们经常会需要用一个也已经存在的对象,去初始化新的对象,这时就需要一种特殊的构造函数——拷贝构造函数。默认的复制构造函数可以实现对应数据成员一一复制;自定义的默认构造函数可以实现特殊的复制功能。
复制构造函数是一种特殊的构造函数,其形参为本类的对象常量引用。作用是用一个已存在的对象去初始化同类型的新对象。
拷贝赋值函数和拷贝构造函数相似,只不过调用时机不同,一个是用一个已有的对象初始化新的对象,一个是用已有的对象给另外一个已经存在的对象赋值,参数固定都为本类的对象常量引用。
class 类名 {
public :
类名(形参);//构造函数
类名(const 类名 &对象名){...};//复制构造函数
类名& operator&(const 类名 &对象名) {...};
// ...
};
class A {
public:
A() {
data = new char[100];
cout << "构造函数" << endl;
}
~A() {
delete[] data;
cout << “析构函数” << endl;
}
A(const A& a) {
data = new char[100];
memcpy(data. a.data, 100);
cout << "拷贝构造函数" << endl;
}
A& operator=(const A& a) {
if (this != &a) {
if (!data) {
data = new char[100];
}
memcpy(data, a.data, 100);
}
cout << “拷贝赋值函数” << endl;
return *this;
}
private:
char *data{nullptr};
}
上述赋值构造函数有两点需要注意:
- 需要确保不能自己对自己赋值,否则容易把自己的内存释放掉,导致
crash - 需要先释放自己的资源,再申请新资源,再拷贝,但申请新资源时,可能会产生
exception,这会导致原对象处于一个invalid的状态,导致non exception safety异常安全问题。对象状态完全无效,后续任何操作(包括析构)都会导致未定义行为
所以,就有了copy-and-swap的方式。
如果程序员没有为类声明拷贝构造函数和拷贝赋值函数,则编译器自己生成一个隐含的拷贝构造函数和拷贝赋值函数。函数执行的功能是:用作为对象的对象的每个数据成员的值,初始化将要建立的对象的对应数据成员或者给其赋值。
对于带指针的类,都需要重写拷贝构造、拷贝赋值以及析构函数,以实现深拷贝与资源释放,关于深拷贝。
如果不希望对象被拷贝构造,C++98做法:将复制构造函数声明为private,并且不提供函数的实现。C++11做法:用“=delete”指示编译器不生成默认复制构造函数。
class A {
public:
A() = default;
A(cosnt A &a) = delete;// C++11
private:
// A(const A &a); // C++98
}
5.4. 委托构造函数
委托构造函数是 C++ 中的一个特性,它允许一个构造函数调用同一个类的其他构造函数,以此避免代码重复。这在多个构造函数有公共的初始化逻辑时特别有用。
class MyClass {
public:
// 主构造函数
MyClass(int value1, int value2) : data1(value1), data2(value2) {
// 初始化逻辑
}
// 委托构造函数,调用上面的构造函数
MyClass(int value) : MyClass(value, 0) {
// 可以添加额外的初始化逻辑
}
private:
int data1;
int data2;
};
5.5. 移动构造函数与移动赋值函数
class A {
public:
A() {
data = new char[100];
cout << "构造函数" << endl;
}
~A() {
delete[] data;
cout << "析构函数" << endl;
}
A(A && a) {
data = a.data;
a.data = nullptr;
cout << "移动构造函数" << endl;
}
A& operator=(A &&a) {
if(this != &a) {
delete[] data;
data = a.data;
a.data = nullptr;
}
cout << "移动复制函数" << endl;
return *this;
}
private:
char* data{nullptr};
}
int main() {
A a;
A d;
A b(std::move(a)); // 移动构造函数
A c = std::move(d); // 移动构造函数
c = std::move(b); // 移动赋值函数
}
注意这里的代码和上面的有一些区别,每个函数中的参数多了个&符号,其它都相同。
一个&符号表示引用,两个&&表示传递参数是个右值。
这里可以看到,拷贝构造与移动构造,赋值构造与移动赋值,这两者的区别只在于传递的参数是否是右值。
那什么是移动?其实就是字面意思,a移动到b,可以理解为把a里面的东西都给到b,对应上面的代码,移动函数中把源对象中的内存给了目的对象,同时源对象的指针置为nullptr。
移动构造函数的特点:
- 移动构造函数优于拷贝构造函数执行(实际上绑定左值也会经历这个过程,但是移动构造函数中的右值引用不能绑定左值,所以采用了拷贝构造函数)
- 移动构造函数如果不显式写出,编译器不会自动生成。
6. default的使用
在C++11中,一般用default来修饰构造函数(默认构造以及拷贝构造),通过default关键字可以要求编译器生成默认的构造函数,常见的有这个场景:
class A {
public:
A(int a) (_a = a;)
~A() {}
private:
int _a;
}
class B {
public:
A a;
}
int main() { B b;}
class B里包含了A类的成员,在定义class B的对象时,同样会构造出class B里的所有成员,也就是会构造class A,而A只有单参数的构造函数,没有无参数的构造函数,所以构造会失败。
这时default就派上用场了,可以在class A中添加这样一行代码:
A() = default;
表示让编译器生成默认构造函数,这样整个编译过程就可以顺利完成了。
上面是拿default来修饰了构造函数,其实也可以修饰拷贝构造函数等,都可以让编译器生成,使用默认的行为。
7. delete的使用
delete是C++11中引入的新特性,在定义成员函数时,可以在后面使用=delete修饰,表示该函数被禁用,比如:
class A {
public:
A() {}
~A() {}
A(const A &a) = delete;
A &operator(const A &a) = delete;
}
这是delete最常见的用法,用于修饰拷贝构造函数和赋值构造函数,表示禁止类的对象拷贝,我们常说的智能指针unique_ptr就是这个原理。
使用了上面的delete,下面的拷贝操作就会触发编译器的报错:
int main() {
A a;
A b = a; // compile error
}
使用delete可以在编译阶段就禁止对象的拷贝,减少了很多运行过程中的不确定性。
8. explicit的使用
使用explicit可以禁止进行隐式的类型转换,看这样代码:
class A {
public:
A(int a){a_ = a;}
private:
int a_;
}
int main() {
A a(1);
A b = 2; // 会触发隐式类型转换
}
上面的A b = 100会隐式的进行类型转换,转换成A的对象。然而有时候这可能是你代码写错了,可能这不是你期望的行为,为了避免这种小错误,可以使用explicit修饰构造函数:explicit A(int a){a_ = a;}。
这样就可以禁止上面的那种隐式类型转换,如果有隐式类型转换的那种代码,编译器会报错。
对单参数构造函数使用explicit修饰可以理解为是C++的开发规范啦,一般项目中都会配置成pipeline的规则,不加explicit是不会被允许提交到远端仓库主分支的。
9. 类的成员初始化
非静态数据成员可以用下列两种方式之一初始化:
初始化列表
struct S {
int n;
std::string s;
S() : n(7) {} // 直接初始化 n,默认初始化 s
};
默认成员初始化
C++11新标准规定,可以为数据成员提供一个类内初始值(in-class initializer)。创建对象时,类内初始值将用于初始化数据成员(如果没有给它提供初始值)。
可以使用花括号或赋值运算符。
#include <iostream>
using std::cout;
using std::endl;
class A {
public:
A() = default;
A(int n):number{n}{}
A(int n, int m):number{n}, count(m){}
void print() {
cout << number << " "<< count << endl;
}
private:
int number = 10;
int count = 0;
};
int main() {
A a(1);
a.print();
A a1;
a1.print();
A a2(1);
a2.print();
A a3(2, 3);
a3.print();
return 0;
}
输出结果:
1 0
10 0
1 0
2 3
如果成员拥有默认成员初始化器,并且在构造函数的成员初始化器列表中也有出现,那么对该构造函数忽略默认成员初始化器。
10. struct和class的区别
这是C++中一道常见的面试题,其实在C++中,class和struct的作用基本一致,唯一的区别就在于它们的默认访问权限不同,class的默认访问权限是private,而struct的默认访问权限是public。
同理,在继承时,class的默认继承权限是private,而struct的默认继承权限是public,除此之外就没什么区别了。