11、类的详细介绍

厨子大约 10 分钟C++C++基础编程程序厨

这一节主要介绍下C++中非常重要且常用的概念:class

class是C++中的关键字,用于定义一个类,常用在面向对象的编程思想中,这里暂且不讲什么是面向对象,在入门专栏里只介绍C++中应该怎么使用class

平时编程中class很常用,我们可以把很多数据封装class内,然后对外以class对象的形式使用。

如何定义一个类

下面贴出一段示例代码:

#include <iostream>
class A {};
int main() {
    A a;
}

这样就定义了一个class A,同时在main函数中定义A的实例a,也可以叫做定义了A的对象a,都是一个意思。

注意在定义class的大括号后面有个分号,很多新手都容易忽略这个分号,最后导致编译报错。

数据成员、静态成员、成员函数

再看一段代码:

class A {
 public:
 void SetNumber();
 static int GetCount();

 private:
 int number;
 static int count;
};

参考这段代码,我介绍下数据成员、静态成员、成员函数的概念。

可以这样简单理解:

  • class中定义的变量就是数据成员class中定义的函数就是成员函数
  • 数据成员可分为普通数据成员静态数据成员
  • 成员函数可分为普通成员函数静态成员函数

class中用static修饰的变量可称为静态数据成员,用static修饰的成员函数可称为静态成员函数。

对应上述的代码中:

  • SetNumber()是普通成员函数,用static修饰的GetCount()是静态成员函数。
  • number是普通数据成员,而用static修饰的count是静态数据成员。

那普通成员和静态成员有什么区别?

如图:

暂时无法在飞书文档外展示此内容

静态数据成员属于整个类空间,所有的类实例访问静态成员时,访问的其实是类中的同一个数据成员。

而普通成员不属于整个类空间,所有的类都有自己的普通成员空间。

就像上面的图,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;
}

猜一下这段代码会输出什么?

上面代码有一个点可以注意下,我是在类外static的数据成员进行的初始化,而且一定要初始化,如果在类内初始化的话,编译会报错。

提问,为什么类的static数据成员一定要在类外初始化呢?

其实静态数据成员不是只可以通过对象来访问,通过类也可以访问,因为它本身就属于类空间中,就像这样:

int main() {
  std::cout << A::count << std::endl;
}

静态成员函数和普通成员函数的使用,与访问静态数据成员和普通数据成员的方法相同,我想我不用过多介绍了吧。

访问权限

class有三种权限:

  • public
  • private
  • protected

比如说我有一个总公司,总公司下还有一些子公司,总公司有一些文档:

  • 有一些文档可以对外公开,其它公司和个人都可以看这些文档,那这些文档就是public权限
  • 有一些文档只能总公司内部查看,子公司和外人都不可以查看,这些文档就是private权限
  • 有一些文档不对外公开,但是总公司内部和子公司可以查看,这些文档就是protected权限

那再用C++术语整理一下:

  • public权限:可以被其他任何实体访问
  • private权限:只允许本类内的成员函数访问
  • protected权限:允许本类和子类的成员函数访问(子类后面我会介绍,现在可以理解为是爸爸的儿子)

构造函数、析构函数怎么定义和使用?

看这段代码:

#include <iostream>
using namespace std;
class A {
 public:
 A() { cout << "构造函数 \n"; }

 ~A() { cout << "析构函数 \n"; }
};

int main() { A a; }

ClassName()这种格式就是构造函数,~ClassName()这种格式就是析构函数。

上面会输出:

构造函数
析构函数

C++语法规定,在创建一个类对象时,会自动调用类的构造函数,当对象的生命周期结束时,会自动调用类的析构函数。

这个标准非常nice,我们可以利用这个特点做很多有意思的事,这块后续我会介绍,这里继续介绍class

拷贝构造函数和赋值构造函数的使用

看这段代码:

class A {
 public:
  A() {
    data = new char[100];
    cout << "构造函数 \n";
  }

  ~A() {
    delete[] data;
    cout << "析构函数 \n";
  }

  A(const A& a) {
    data = new char[100];
    memcpy(data, a.data, 100);
    cout << "拷贝构造函数 \n";
  }

  A& operator=(const A& a) {
    if (this != &a) {
      if (!data) {
        data = new char[100];
      }
      memcpy(data, a.data, 100);
    }
    cout << "赋值构造函数 \n";
    return *this;
  }
 private:
  char* data{nullptr};
};

int main() {
 A a; // 构造函数
 A b(a); // 拷贝构造函数
 cout << "=== \n";
 A c = b; // 拷贝构造函数
 cout << "--- \n";
 c = a; // 赋值构造函数
}

这段代码的输出为:

构造函数
拷贝构造函数
===
拷贝构造函数
---
赋值构造函数
析构函数
析构函数
析构函数

从这段输出我们也应该大体可以看出来拷贝构造函数和赋值构造函数的触发时机。

  • 当创建一个对象,这个对象由其他对象来生成时,会调用拷贝构造函数
  • 当已经创建好了一个对象,这个对象还需要通过其他对象来赋值时,会调用赋值构造函数

移动构造函数和移动赋值函数的使用

看这段代码:

class A {
 public:
 A() {
  data = new char[100];
  cout << "构造函数 \n";
 }

 ~A() {
  delete[] data;
  cout << "析构函数 \n";
 }

 A(A&& a) {
  data = a.data;
  a.data = nullptr;
  cout << "移动构造函数 \n";
 }

 A& operator=(A&& a) {
  if (this != &a) {
   data = a.data;
   a.data = nullptr;
  }
  cout << "移动赋值函数 \n";
  return *this;
 }

 private:
 char* data{nullptr};
};

int main() {
     A a; // 构造函数
     A d; // 构造函数
     A b(std::move(a)); // 移动构造函数
     cout << "=== \n";
     A c = std::move(b); // 移动构造函数
     cout << "--- \n";
     c = std::move(d); // 移动赋值函数
}

注意这里的代码和上面的有一些区别,每个函数中的参数多了个&符号,其它都相同。

一个&符号表示引用,两个&&表示传递参数是个右值。

这里可以看到,拷贝构造与移动构造,赋值构造与移动赋值,这两者的区别只在于传递的参数是否是右值

我们这里暂时先不需要了解什么是右值,只需要知道std::move函数可以把里面的变量强制变成右值即可。

**那什么是移动?**其实就是字面意思,a移动到b,可以理解为把a里面的东西都给到b,对应上面的代码,移动函数中把源对象中的内存给了目的对象,同时源对象的指针置为nullptr

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可以在编译阶段就禁止对象的拷贝,减少了很多运行过程中的不确定性。

**explicit**的使用

使用explicit可以禁止进行隐式的类型转换,看这样代码:

class A {
 public:
 A(int a) { a_ = a; }
 ~A() {}

 private:
 int a_;
};

int main() {
 A a(1);
 A b = 100; // 会触发隐式类型转换
}

上面的A b = 100会隐式的进行类型转换,转换成A的对象。然而有时候这可能是你代码写错了,可能这不是你期望的行为,为了避免这种小错误,可以使用explicit修饰:

class A {
 public:
 explicit A(int a) { a_ = a; }
 ~A() {}

 private:
 int a_;
};

这样就可以禁止上面的那种隐式类型转换,如果有隐式类型转换的那种代码,编译器会报错。

对单参数构造函数使用explicit修饰可以理解为是C++的开发规范啦,一般项目中都会配置成pipeline的规则,不加explicit是不会被允许提交到远端仓库主分支的。

**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来修饰了构造函数,其实也可以修饰拷贝构造函数等,都可以让编译器生成,使用默认的行为。

运算符的重载

拿加法举例,普通的1+2100+200这种加法操作计算机知道怎么计算,但是如果我们定义了类对象,那两个自定义类的对象相加,会产生什么结果?

计算机肯定是不知道,肯定需要我们自己来实现,下面我们就来实现两个类A对象的相加行为:

class A {
 public:
 A(int a, int b) {
  a_ = a;
  b_ = b;
 }
 ~A() {}
 int operator+(const A& a) { return a_ + a.a_; }
 private:
 int a_;
 int b_;
};

int main() {
 A a1(1, 2);
 A a2(1, 2);
 std::cout << a1 + a2 << std::endl;
}

上面的operator+就是运算符重载,上面的加法实现是两个对象的成员a_相加,当然这个行为可以自定义,你也可以把它改成两个对象的成员b_相加。

同理,你还可以实现加减乘除好多运算操作。

再抛出个问题,你知道std::cout << a1; 会输出什么吗?如何自定义输出?

**classstruct**的区别

这是C++中一道常见的面试题,其实在C++中,classstruct的作用基本一致,唯一的区别就在于它们的默认访问权限不同,class的默认访问权限是private,而struct的默认访问权限是public

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