面向对象与模板

这篇文章介绍 C++ 的进阶特性,主要是面向对象编程和模板。这些知识在设计模式的代码中会大量使用,但如果你只想刷算法题,可以先跳过这篇,等用到的时候再回来看。

类与对象

定义与使用(成员变量、成员函数)

前面我们用函数把逻辑封装起来,但数据(变量)和操作(函数)是分开的。类(class) 可以把数据和操作打包在一起,形成一个自定义的类型。

打个比方,int 类型只能存一个整数,string 类型只能存一段文字。如果你想描述一个"学生",需要同时存名字、年龄这些信息,还要有"自我介绍"这种操作,这时候就需要自己定义一个类。

类里面的变量叫成员变量(也叫属性/字段),函数叫成员函数(也叫方法)。用类创建出来的具体对象叫实例

这里 class Student { ... }; 定义了一个类(注意末尾的分号不能漏),public: 表示后面的成员是公开的,外部可以直接访问。Student s1; 就创建了一个 Student 类型的对象,然后用 . 来访问它的属性和方法。

一个类就像一个"模板",你可以用它创建任意多个对象,每个对象都有自己独立的属性值。

构造函数

上面的代码中,创建对象之后还要逐个给属性赋值,有点啰嗦。构造函数可以让你在创建对象的同时就把初始值传进去:

构造函数的名字必须和类名一模一样,而且没有返回值类型(连 void 都不写)。创建对象时 Student s1("小明", 18) 就会自动调用这个构造函数。

this 指针

你可能注意到上面的构造函数里,参数名用了 na,就是为了和成员变量 nameage 区分开。但如果参数名和成员变量名一样呢?这时候就需要 this 指针。

this 是一个特殊的指针,指向当前对象本身。用 this-> 可以明确告诉编译器你要访问的是成员变量,而不是参数:

class Student {
public:
    string name;
    int age;

    // 参数名和成员变量名一样,用 this-> 区分
    Student(string name, int age) {
        this->name = name;  // this->name 是成员变量,name 是参数
        this->age = age;    // this->age 是成员变量,age 是参数
    }
};

这样参数就可以用更直观的名字了。实际开发中,构造函数的参数和成员变量同名是很常见的写法。

构造函数重载

和普通函数一样,构造函数也可以重载,只要参数列表不同就行。

C++ 还允许一个构造函数把工作委托给另一个,写法是在参数列表后面用冒号 : 连接要调用的构造函数:

Student(string name) : Student(name, 18) {} 冒号后面的 Student(name, 18) 就是在调用双参数的构造函数,这叫委托构造函数

因为所有初始化工作都委托出去了,这个构造函数自己不需要再做别的事,所以函数体 {} 是空的。

还有一点:如果你没写任何构造函数,C++ 会自动提供一个无参的默认构造函数。但只要你写了任何一个,默认的就没了,想支持 Student s3; 这种写法就得自己加一个无参版本。

访问修饰符(public、private、protected)

上面我们一直用 public:,意思是"公开的",任何地方都能访问。但有时候你不希望外部直接修改某些数据,这时候就需要封装

C++ 提供三种访问修饰符:

  • public:公开的,谁都能访问
  • private:私有的,只有类自己的方法能访问
  • protected:受保护的,类自己和子类能访问(后面讲继承时会用到)

balance 被设为 private,外部不能直接读写它,只能通过 deposit()getBalance() 这些公开方法来操作。这样就能在方法里加上校验逻辑(比如不允许存入负数),保证数据的安全。

这就是封装的思想:把数据藏起来,只暴露必要的操作接口。

继承与多态

继承

继承让你可以基于一个已有的类创建新类,新类自动拥有父类的属性和方法,还能添加自己的特有功能。

C++ 用 : public 父类名 来表示继承关系,在子类的构造函数中用初始化列表来调用父类构造函数:

Dog : public Animal 表示 Dog 继承自 Animal,Dog 自动拥有 Animal 的 name 属性和 eat() 方法。

注意子类构造函数的写法:Dog(string name, string breed) : Animal(name) 冒号后面的 Animal(name) 是在调用父类的构造函数,把 name 传给父类来初始化。这个冒号语法和前面委托构造函数的写法看起来一样,区别是前面调用的是自己类的另一个构造函数,这里调用的是父类的构造函数来初始化继承的部分。

方法重写与 virtual / override

继承的真正威力在于多态:用父类类型的指针或引用来指向子类对象,调用同名方法时会自动执行子类的版本。

但要实现多态,父类的方法必须用 virtual 关键字声明为虚函数,子类重写时加上 override 关键字:

重点来了:Animal* animals[2] 是一个父类指针数组,里面装的是 Dog 和 Cat 对象的地址。调用 animals[i]->speak() 时,虽然指针类型是 Animal*,但程序会根据指针实际指向的对象类型来决定调用哪个版本的 speak()——这就是多态

注意:virtual 关键字是多态的前提条件。如果父类方法不加 virtual,用父类指针调用时就只会执行父类的版本,不会自动找到子类的重写版本。override 关键字不是必须的,但强烈建议加上,它能让编译器帮你检查是否真的重写了父类方法,写错名字时编译器会报错。

抽象类(纯虚函数)

有时候父类只定义"接口",具体实现完全交给子类。比如"形状"这个概念,你没法计算一个抽象形状的面积,但圆形、矩形都有自己的面积公式。

在 C++ 中,给虚函数加上 = 0 就变成了纯虚函数,包含纯虚函数的类叫抽象类,抽象类不能直接创建对象,只能被继承:

virtual double area() = 0; 就是纯虚函数,Shape 类不提供具体实现,强制子类必须自己实现 area() 方法。如果子类不实现,子类也会变成抽象类,同样不能创建对象。

抽象类的意义在于定义一个统一的接口:所有形状都必须能计算面积,但每种形状的计算方式不同。这样你就可以用 Shape* 指针统一管理各种形状对象。

模板

函数模板

有时候你写了一个函数,逻辑完全一样,只是处理的数据类型不同。比如打印一个数组的内容,int 数组和 string 数组的打印逻辑完全相同,难道要写两遍?

模板就是为了解决这个问题:写一份代码,让编译器根据实际使用的类型自动生成对应版本。用 template<typename T> 声明一个类型参数 TT 在使用时会被替换成具体的类型:

我们只写了一个 printArray 函数,但它能处理 int[]string[]double[] 各种类型的数组。编译器会根据你传入的参数自动推断 T 的类型,然后生成对应版本的函数,不需要你手动指定。

类模板

函数可以模板化,类也可以。类模板可以让你定义一个通用的数据结构,使用时再指定具体类型:

Pair<string, int> 创建的对象里 keystring 类型,valueint 类型;Pair<int, string> 正好反过来。同一个类模板,填入不同的类型参数,就得到了不同的具体类。

你可能已经注意到了,前面学的 STL 容器 vector<int>map<string, int> 用的尖括号,其实就是类模板的语法。现在你知道它的原理了——vector 是一个类模板,你传入 int 就得到一个存 int 的动态数组,传入 string 就得到一个存 string 的动态数组。

小结

这篇介绍了 C++ 面向对象编程和模板的核心概念。类与对象让你把数据和操作封装在一起,构造函数简化初始化,访问修饰符实现封装。继承与多态让你用统一的方式处理不同类型的对象,virtualoverride 是多态的关键。模板让代码不限定具体类型,函数模板和类模板是 STL 的基础。

学到这里,C++ 入门教程就全部结束了。这些知识在设计模式的代码中会大量使用。