类和对象

类是C++中的用户自定义数据类型,它是对一类事物的抽象描述,封装了数据(成员变量)和行为(成员函数),类的概念也是OOP编程的基础。这篇笔记我们对类和对象进行介绍。

类的定义和使用

类使用class关键字定义,这里我们直接看一个例子。下面代码中,我们定义了一个Dog类代表狗这种动物,它的构造函数中传入狗的名字,此外还有一个bark()方法,调用这个方法狗对象就会输出自己的名字。

#include <iostream>

class Dog {
  std::string name;

public:
  Dog(std::string name) : name(name) {}

  void bark() { std::cout << "Woof! My name is " << name << std::endl; }
};

int main() {
  Dog dog("Spike");
  dog.bark();
  return 0;
}

调用类的过程中实际上发生了类的实例化,我们获取了Dog类的实例对象。Dog dog("Spike")会在栈上分配内存并实例化狗对象。栈内存会在当前函数返回时释放,如果我们需要在堆上分配内存,这需要用到之前章节介绍到的newdelete

Dog *dog = new Dog("Spike");
dog->bark();
delete dog;

在C++11版本之前,类成员变量的初始化只能放在构造函数中或者类实例创建后再赋值,不过从C++11版本开始,类成员变量可以在类定义时直接赋初始值。

class Dog {
  int age = 10;
  std::string name;

public:
  Dog(std::string name) : name(name) {}

  void bark() { std::cout << "Woof! My name is " << name << std::endl; }
};

关于类定义,我们还需要关注以下几个基本概念。

构造函数Dog(std::string name) {}是类的构造函数,构造函数(Constructor)是一种特殊的成员函数,用于在创建类对象时初始化对象的成员变量。构造函数的名称必须与类名相同,并且没有返回类型。另外要注意的是我们没有直接在构造函数体内实现赋值逻辑,上面这种构造函数写法被称为初始化列表,相比在函数体内赋值,使用初始化列表写法更简洁,编译后运行效率也更高,如果只是简单的赋值,我们应该优先采用这种写法。

类成员访问权限:C++中类成员有3种访问权限,privateprotectedpublic,分别对应仅本类可以访问成员、本类和子类可以访问成员,以及公开的所有位置都可以访问成员。对于类(class)来说默认是private。上面代码中,我们的name成员变量被设计为仅在本类可以使用,它依赖于这种默认行为,省略了private权限的声明,这也是实际开发中比较常见的写法,至于成员函数bark()我们将其明确声明为了public,以便在类的外部调用。

this指针:在类的内部访问类成员时可以通过this指针实现,代码中的bark()函数访问的类成员变量name,只不过代码中省略了this->,第9行代码也可以明确写作void bark() { std::cout << "Woof! My name is " << this->name << std::endl; },只不过一般来说没这个必要,实际开发中建议this->能省则省,它对可读性没有太大影响。

关于C++中的类和结构体

我们知道C语言中有结构体(struct),它也是一种自定义的封装类型,C++中继承并扩展了结构体的功能。在C++中,结构体也可以包含构造函数、成员函数等,C++结构体的用法和类完全一致,唯一的区别是类的默认成员访问权限是private,结构体的默认成员访问权限是public。由于类的存在,C++中结构体的适用场景比较有限,主要用于表达简单纯粹的数据结构(例如向量)、C/C++混编等场景使用,基本也都是仅使用纯C结构体,极少用到结构体在C++中的扩展功能,这里我们就不单独抽取一个章节介绍结构体了。

构造函数、析构函数和拷贝构造函数

构造函数是一种特殊的成员函数,在创建对象时自动调用,用于初始化对象的成员变量,而析构函数则是在对象生命周期结束时被调用,用于清理资源(如释放内存、关闭文件等),如果我们需要在对象被清理释放时加入特定逻辑,就需要在类中明确的定义析构函数。一个类一定会有构造函数和析构函数,前面代码中虽然我们没有明确的编写析构函数,但实际上编译器会帮我们添加默认的空析构函数,类似的如果没有明确定义构造函数,编译器也会帮我们添加空的无参数构造函数。

#include <iostream>

class Dog {
  std::string name;

public:
  Dog(std::string name) : name(name) { std::cout << "construcor" << std::endl; }

  ~Dog() { std::cout << "destructor" << std::endl; }

  void bark() { std::cout << "Woof! My name is " << this->name << std::endl; }
};

int main() {
  Dog dog("Spark");
  dog.bark();
  return 0;
}

我们在之前代码的基础上增加了析构函数,并在构造函数和析构函数中各打印一条信息。我们这里是在栈上为对象分配内存空间,因此对象构造时构造函数被调用,函数返回时析构函数被自动调用,运行代码后会看到构造函数和析构函数的输出。如果我们使用在堆上分配和释放内存,那么构造函数和析构函数就在我们手动newdelete的时候被调用。

除了普通构造函数和析构函数,C++类还支持一种被称为拷贝构造函数的功能,拷贝构造函数名称与类名相同且没有返回类型,但拷贝构造函数的参数是当前类对象的引用。当发生以下情况时拷贝构造函数将被调用:

  1. 使用一个已经创建完毕的对象来初始化新对象
  2. 使用值传递的方式给函数传递对象
  3. 以值传递的方式返回局部对象

下面例子代码中,我们定义了Dog类的拷贝构造函数,当我们为d2变量赋值时,拷贝构造函数被调用。

#include <iostream>

class Dog {
  std::string name;

public:
  Dog(std::string name) : name(name) { std::cout << "construcor" << std::endl; }

  Dog(const Dog &dog) : name(dog.name) {
    std::cout << "copy constructor" << std::endl;
  }

  ~Dog() { std::cout << "destructor" << std::endl; }

  void bark() { std::cout << "Woof! My name is " << this->name << std::endl; }
};

int main() {
  Dog d1("Spike");
  Dog d2 = d1;
  return 0;
}

和构造函数、析构函数类似,如果没有明确定义拷贝构造函数,编译器也会帮我们添加默认的拷贝构造函数,它默认会对当前类的成员变量拷贝(即浅拷贝)。

深拷贝和浅拷贝问题

复制对象会涉及到深拷贝浅拷贝的问题,浅拷贝是指将一个对象对应的所有内存数据原样复制,但浅拷贝有时候不能满足我们的要求,比如被复制的对象中有一段动态分配的内存,而对象中持有的是该段内存的指针,现在我们要求在对象被拷贝时,该段内存的内容也要复制一份,浅拷贝只能做到复制前后两个对象持有一个指向同一段地址的指针,这会给我们内存管理造成困难。

下面是一个典型错误的代码例子,它会在运行时报重复释放内存错误。

#include <cstdlib>
#include <cstring>
#include <iostream>

using namespace std;

class Demo {
public:
  int i;
  char *s;

  Demo() { s = (char *)malloc(10); }

  ~Demo() { free(s); }
};

int main() {
  Demo d1;
  d1.i = 1;
  strcpy(d1.s, "hello");

  Demo d2 = d1;
  d2.i = 2;
  strcpy(d2.s, "hi");

  cout << "d1 " << d1.i << " " << d1.s << endl;
  cout << "d2 " << d2.i << " " << d2.s << endl;

  return 0;
}

这段代码中,main函数返回时d1d2对象销毁,此时调用两个对象的析构函数,但是实际上d1.sd2.s指向的是同一段堆内存,此时就出现错误了。

前面我们介绍过,如果我们没有自定义拷贝构造函数编译器会自动给我们加上一个浅拷贝的实现,当我们进行对象的拷贝操作时,会自动调用拷贝构造函数而不是默认的构造函数。但对于上面例子,显然浅拷贝不能满足我们的需求,此时就需要我们自定义拷贝构造函数来解决这个问题。

#include <cstdlib>
#include <cstring>
#include <iostream>

using namespace std;

class Demo {
public:
  int i;
  char *s;

  Demo() { s = (char *)malloc(10); }

  Demo(const Demo &it) {
    this->i = it.i;
    this->s = (char *)malloc(10);
    memcpy(this->s, it.s, 10);
  }

  ~Demo() { free(s); }
};

int main() {
  Demo d1;
  d1.i = 1;
  strcpy(d1.s, "hello");

  Demo d2 = d1;
  d2.i = 2;
  strcpy(d2.s, "hi");

  cout << "d1 " << d1.i << " " << d1.s << endl;
  cout << "d2 " << d2.i << " " << d2.s << endl;

  return 0;
}

类静态成员

C++类中,我们可以使用static关键字修饰成员变量或成员函数,这种类型的成员变量或成员函数被称为静态成员变量和静态成员函数,访问静态成员需要使用类名和::操作符。

静态成员变量:所有对象共用一个静态成员变量,不管你创建多少个对象,静态成员变量只分配一次内存,它在程序运行期间始终存在。静态成员变量需要在类外进行定义和初始化。

静态成员函数:静态成员函数也被称为类函数,不依赖于任何对象,可以通过类名直接调用。静态成员函数仅能访问静态成员变量和其他静态成员函数。

下面例子代码中,我们实现了一个简单的单例模式,它使用指针类型的静态成员变量指向单例的实例,静态成员函数getInstance()负责初始化实例,而静态成员函数destroyInstance()负责释放实例。

#include <iostream>

class DemoConfig {
  std::string dbUrl;
  static DemoConfig *demoConfig;
  DemoConfig(std::string dbUrl) : dbUrl(dbUrl) {}

public:
  static DemoConfig *getInstance(std::string dbUrl) {
    if (demoConfig == nullptr) {
      demoConfig = new DemoConfig(dbUrl);
    }
    return demoConfig;
  }

  void connect() {
    std::cout << "Connecting to database at " << dbUrl << std::endl;
  }

  static void destroyInstance() {
    delete demoConfig;
    demoConfig = nullptr;
  }
};

DemoConfig *DemoConfig::demoConfig = nullptr;

int main() {
  DemoConfig *demoConfig = DemoConfig::getInstance("localhost:3306");
  demoConfig->connect();
  DemoConfig::destroyInstance();
  return 0;
}

注意C++中静态成员变量必须在类的外部初始化,上面代码中的DemoConfig *DemoConfig::demoConfig = nullptr就在做这件事,如果你在类内声明static DemoConfig *demoConfig时直接设置它的值是错误的,编译无法通过,这和大多数其它语言例如C#、Java等都不相同,这种怪异的设计有其历史原因。C++中,类定义是一个声明,也就是说编译器在看到类定义时只是记录有这个变量存在,但不会给它分配内存,真正的内存分配发生在类外定义的时候,而静态变量又是全局范围的,因此对于静态成员变量在类外部定义就是必须的,否则编译器不知道何时分配内存。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。
Copyright © 2017-2024 Gacfox All Rights Reserved.
Build with NextJS | Sitemap