类和对象

125 阅读13分钟

classC++中的关键字,用于定义一个类,平时编程中class很常用,我们可以把很多数据封装到class内,然后对外以class对象的形式使用。

  1. 基本概念理解
  • 理解封装的目的和意义,即隐藏内部实现细节,只通过公共接口与外部交互。
  • 掌握如何通过访问修饰符(如publicprotectedprivate)控制成员的访问权限。
  • 理解封装有助于提高代码的复用性、安全性和可维护性。
  1. 成员变量和成员函数的封装
  • 能够合理地定义类的成员变量(属性)和成员函数(方法),并通过访问修饰符保护成员变量不被外部直接访问。
  • 成员函数用于实现对成员变量的封装操作,包括获取(getter)和设置(setter)成员变量的值。
  1. 构造函数和析构函数的封装
  • 理解构造函数和析构函数的作用,掌握其编写规范。
  • 能够在类中正确实现构造函数来初始化对象,并在必要时实现析构函数来清理资源。
  1. 封装的实际应用
  • 能够设计符合封装原则的类,并应用于实际场景中,如表示实体对象、管理资源等。

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支持静态常量(constconstexpr修饰)类内初始化,此时类外仍可定义该静态成员,但不可再次初始化操作。

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(.....){....}

  1. 函数名与类名相同;
  2. 不能定义返回值类型,也不能有return语句;
  3. 可以有形式参数,也可以没有形式参数;
  4. 可以是内联函数;
  5. 可以重载;
  6. 可以带默认参数值。

构造函数的调用时机:在对象创建时被自动调用。

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的使用

deleteC++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++中,classstruct的作用基本一致,唯一的区别就在于它们的默认访问权限不同,class的默认访问权限是private,而struct的默认访问权限是public

同理,在继承时,class的默认继承权限是private,而struct的默认继承权限是public,除此之外就没什么区别了。