类
一般来说我们在头文件中声明方法
// animal.h
class Animal {
public:
void eat(void);
};
在源文件中给出实现
// animal.cpp
void Animal::eat(void) {
cout << "eating" << endl;
}
直接定义在类内的函数会默认 inline
,因此只有比较短小的函数建议定义在类内。
构造函数
类的构造函数是一个或一组与类同名的特殊函数
class Animal {
public:
Animal(void) { cout << "Animal created." << endl; }
Animal(int) { cout << "Animal created with int" << endl; }
}
定义对象时
Animal a(1);
Animal* a = new Animal(1);
需要注意调用默认构造的方法
Animal a; // 调用默认构造
Animal a(); // 函数声明
初始化列表
构造函数用于为类中的成员变量初始化,因此传入参数往往就是属性的值。为了简化初始化的书写,可以使用初始化列表语法
class Animal {
int age;
public:
Animal(int age) : age(age) {
// ...
};
}
初始化列表的赋值顺序是与成员变量在类中的声明顺序一致的。
// animal.h
#pragma once
class Animal {
int c;
int d;
public:
Animal(int a, int b);
};
// animal.cpp
Animal::Animal(int a, int b) : d(a + b), c(d + a) {
cout << "c: " << c << endl;
cout << "d: " << d << endl;
}
void main(int argc, char* argv[]) {
Animal a(3, 5);
return;
}
输出
c: -858993457
d: 8
成员 c
在初始化时调用的 d
实际上还没有被正确初始化,因此被赋值为乱码。
拷贝构造
拷贝构造函数是一类特殊的构造函数
class Animal {
public:
Animal(const Animal &other);
};
Animal::Animal(const Animal &other) {
cout << "copy" << endl;
}
这里我们显式的定义了一个 Animal
类的拷贝构造函数。如果没有定义,编译器会自动提供一个默认的拷贝构造函数。但是默认拷贝构造的功能仅仅是将对应的属性拷贝,因此属于浅拷贝。如果类中含有动态分配的内存,就需要考虑显式定义拷贝构造函数了。
拷贝构造的调用情景比较多。为了便于说明,我们定义 Animal
类如下
class Animal {
public:
Animal(const Animal &other) {
cout << "animal duplicated" << endl;
}
};
这样,只要拷贝构造被调用,我们可以从输出看出。
- 拷贝初始化
Animal animal;
Animal other = animal; // 拷贝初始化
- 函数参数传值(本质上就是拷贝初始化)
void fun(Animal animal) { // Animal animal = 0($a0)
// ...
}
void main(void) {
Animal animal;
fun(animal);
}
- 函数返回对象
Animal fun(void) {
Animal* animal = new Animal;
return *animal;
}
有趣的是,不管函数的返回值有没有被保存,拷贝构造都只会调用一次。
void main(void) {
fun();
Animal animal = fun();
}
转换构造
类型转换用于强制将某种类型的对象转换为另一种类型。如果要在自定义类型中增加类型转换的功能,可以借助于转换构造函数。
class Animal {
int age;
public:
Animal(int age) : age(age) {}
};
void main() {
Animal animal = 3;
}
以上写法可以视作 Animal
类的构造函数强制将 3
转换成 Animal
类的对象。如果构造函数中只有一个参数,这个构造函数大概率会被识别为转换构造函数。但是这样可能使不同类型的对象任意赋值。如果不希望通过赋值进行转换,可以在函数前标记 explicit
class Animal {
int age;
public:
explicit Animal(int age) : age(age) {}
};
void main() {
Animal a(3); // allowed
Animal b = 3; // error C2440: 'initializing': cannot convert from 'int' to 'Animal'
}
析构函数
析构函数用于对象的删除,没有返回值和参数
class Animal {
int* age;
public:
Animal(void) { age = new int(1); }
~Animal(void) { delete age; }
};
如果没有定义析构函数,则一旦删除 Animal
对象,其内部的 age
指向的内存空间将永远无法得到释放,造成内存泄漏。
封装
类的访问控制修饰符包括
- public
- protected
- private (缺省)
#pragma once
#include <string>
class Animal {
// private: // 缺省
std::string name; // 仅本类可见
protected:
int age; // 仅本类及子类可见
public:
static int cnt; // 全局可见
Animal(std::string name);
virtual ~Animal(void);
int getAge(void) { return age; }
std::string getName(void) { return name; }
};
this指针
每个对象都有一个隐含的常量指针 this
class Animal {
int age;
public:
Animal(void) : age(0) {}
void greeting(void) {
cout << "I'm " << /*this->*/age << endl;
}
};
需要注意的是,静态函数和友元函数不基于对象被调用,因此没有 this
指针。
友元
如果一个全局函数想要访问一个类的 private
或 protected
成员,可以被声明为友元函数
class Animal {
int age = 0;
public:
friend void greeting(Animal& animal);
};
void greeting(Animal& animal) {
cout << "Animal is " << animal.age << " years old" << endl;
}
void main(void) {
Animal animal;
greeting(animal);
}
输出
Animal is 0 years old
友元函数虽然在类中声明,但并不是类的成员函数。如果要使用某个对象的成员,参数中必须出现这个对象。同时 greeting
本身是一个全局函数,定义时不可以使用 Animal::greeting
。类似的方式可以将一个类中的所有函数声明为友元函数
class Animal {
int age = 0;
bool sick = false;
public:
friend class Veterinarian;
};
class Veterinarian {
public:
void check(Animal &animal) {
cout << "he is ";
if (!animal.sick) {
cout << "not ";
}
cout << "sick" << endl;
}
void getAge(Animal &animal) {
cout << "he is " << animal.age << " years old" << endl;
}
};
继承
子类可以通过继承获得父类的全部非 private
成员
class ClassName
: [virtual] Access-Spec SuperName1,
[virtual] Access-Spec SuperName2,
// ...
[virtual] Access-Spec SuperNameN
{};
virtual
关键字用于环状继承中,这里不做说明。对应于三种访问修饰符,c++
中也提供了三种 Access-Spec
- public
- protected
- private
继承方式标明了父类中各种访问权限的成员在子类中的最高权限
class Animal {
int a;
protected:
int b;
public:
int c;
};
class Cat : protected Animal {
/* private:
* int a;
* protected:
* int b;
* int c;
*/
};
重定义
重载发生在同一个类或全局中,指的是函数名相同而参数列表不同的一组函数。而重定义发生在有继承关系的两个或多个类间,其函数签名完全相同
class Animal {
public:
void speak(void) {
cout << "animal speak" << endl;
}
};
class Cat : public Animal {
public:
void speak(void) { // redefinition
cout << "cat speak" << endl;
}
};
由于不同的类基本相当于不同的命名空间,所以重定义并不会造成函数的混淆。如果没有重定义,Cat
类中可以直接使用 speak
调用父类的 speak
方法
class Cat : public Animal {
public:
void greeting(void) {
speak();
}
};
但一旦子类中重定义了 speak
方法,调用时会默认调用子类内部的 speak
方法。要调用父类的 speak
方法可以明确的指定
void Cat::speak(void) {
Animal::speak();
}
构造顺序
子类的构造函数总是首先调用父类的构造函数,即使函数定义中没有明确写出
class Animal {
public:
Animal(void) {
cout << "animal created void" << endl;
}
Animal(int a) {
cout << "animal created int " << a << endl;
}
};
class Cat : public Animal {
public:
Cat(void) {
cout << "cat created void" << endl;
}
};
void main(void) {
Cat cat;
}
输出
animal created void
cat created void
默认调用一般都是最简单的,因此调用的是父类的无参构造函数。但是如果想调用父类的有参构造函数要怎么办呢?这时需要借助初始化列表
Cat::Cat(void) : Animal(1) {
cout << "cat created void" << endl;
}
输出
animal created int 1
cat created void
可以想象,如果存在多层继承,那么创建一个子类对象将从最顶层的父类开始调用构造函数,最后调用本类构造函数。这样设计的主要原因是,子类是父类的功能扩展,在很大程度上是依赖于父类的。因此在允许子类开始执行自己特有的功能之前,首先需要搭建好一个父类的环境,这样才能保证子类正常运行。
而析构函数的调用顺序与此正好相反,因为子类中的有些对象可能依赖于父类对象被创建。如果先释放了父类的成员,可能造成子类成员无法释放。
多态
继承描述了一种 is-a
关系,也就是说子类是父类的一种
class Animal { ... };
class Cat : public Animal { ... };
void main(void) {
Animal *animal = new Cat();
// ...
}
这种父类引用指向子类对象的情况也被称为向上类型转换。
在一个程序中,任何基类出现的地方一定可以用他的子类替换
这就是面向对象设计中十分重要的原则之一里氏替换原则。但对于上述例子,当我们调用子类对象的方法
void main(void) {
Cat* cat = new Cat();
Animal* animal = (Animal*)cat;
cat->speak();
cout << "------------" << endl;
animal->speak();
delete cat;
}
cat speak
------------
animal speak
可以看到,尽管指针类型不同,但他们都指向同一个对象。然而在调用 speak
方法时,却产生了不同的结果。这就是静态绑定。
绑定 binding
绑定是将函数的调用和函数入口对应起来的过程。造成上述代码错误的主要原因是,animal->speak()
调用被编译器确定为基类中的方法。在程序执行前,编译器无法确定指针指向的对象类型,因此只能根据指针类型来确定要调用的方法。与此对应的是在执行过程中动态的确定要调用的方法。
前者被称为静态绑定,也被称为早绑定(early binding)。而后者被称为动态绑定(runtime binding)或后期绑定(later binding)。
虚函数
使用 virtual
关键字可以解决上述问题。这个关键字用于将函数声明为虚函数
class Animal {
public:
virtual void speak() {
cout << "animal speak" << endl;
}
};
class Cat : public Animal {
public:
void speak() {
cout << "cat speak" << endl;
}
};
void main(void) {
Cat* cat = new Cat();
Animal* animal = (Animal*)cat;
cat->speak();
cout << "------------" << endl;
animal->speak();
delete cat;
}
输出
cat speak
cat speak
可以看到,将 Animal::speak
方法标注为 virtual
后,即使调用 speak
方法的是一个 Animal
类型的指针,实现的方法还是 Cat::speak
。但是先别高兴得太早,我们在来看一个例子
class Creature {
public:
void speak(void) {
cout << "creature speak" << endl;
}
};
class Animal : public Creature { ... };
class Cat : public Animal { ... };
void main(void) {
Animal* animal = new Cat();
Creature* creature = new Cat();
animal->speak();
cout << "------------" << endl;
creature->speak();
delete animal, creature;
}
输出
cat speak
creature speak
多态又失灵了,但是明明在 Animal::speak
中标注了 virtual
,这是为什么呢?
虚表与虚表指针
如果一个类中包含虚函数,那么这个类就会在 .text
段中多一片内存空间,用于存储虚表 vtable
。而虚表要被使用,就必须有一个指针指向他。C++ 中,这样的类所实例化出的每个对象都有一个内置成员 _vptr
,用于存储虚表的地址。虚表中存储的就是该类对象调用不同方法时真正跳转的函数地址。值得一提的是,_vptr
在对象中具有固定的地址,也就是对象最前面的四个字节。
也就是说,animal->speak
的调用流程如下
- 查看
animal
所指对象的_vptr
成员,确定虚表地址 - 在虚表中查找
speak
方法,获得函数入口 - 跳转函数
执行以下函数可以看到 _vptr
的存在
void main(void) {
cout << sizeof(Creature) << endl;
cout << sizeof(Animal) << endl;
cout << sizeof(Cat) << endl;
}
输出
1
4
4
_vptr
是一个指针,占 4 个字节。而至于为什么 Creature
的大小是 1 ,我猜测是sizeof
函数的特判,目前还没有得到检验,欢迎大家指正。
有意思的是,Cat
类中并没有定义虚函数,但他也拥有一个 _vptr
成员。对于这个问题可以有两种理解,其一是 _vptr
由 Animal
继承而来,因此也被计算在 Cat
类的大小之中。我个人倾向于第二种理解,Cat
类继承得到了 Animal
类的 speak
方法,因此严格来说也是拥有自己的虚函数的。
无论哪一种理解,其实都暗示了 C++ 在处理继承时的存储方式。事实上,Cat
类并不是单独存储的,而是将父类 Animal
和祖先类 Creature
都搬到自己前面一同存储。所以看起来,基类的方法也是子类的方法。
这就能解释为什么 Creature::speak
没有多态性了。因为 Creature
没有父类,而自身又没有虚函数,因此他的内部是没有 _vptr
这个成员的,自然也就没法调用多态了。
顺便说一下,_vptr
这个成员是在构造函数中初始化的。由于构造函数的调用顺序是自顶向下的,因此 _vptr
最终会指向本类的虚函数表。最后我们来看一个有意思的例子
#include <iostream>
using namespace std;
class Person {
public:
virtual void print() {
cout << "I'm a person" << endl;
}
};
class Student : public Person {
public:
void print() {
cout << "I'm a student" << endl;
}
};
void main(void) {
Person a;
Student b;
Person* xa = &a;
Student* xb = &b;
memcpy(xa, xb, 4); // copy _vptr
a.print();
xa->print();
}
输出
I'm a person
I'm a student
第二个输出比较好理解,因为 Person a
的 _vptr
被换成了 Student::_vptr
。因此在调用多态时行为与 Student
一致。而 a.print
之所以没有调用多态是因为 a
本身是一个对象,直接通过静态链接确定了要调用的方法,没有通过虚表。
虚析构函数
在一些情况下,没有声明虚函数影响并不大,最多就是执行错误。但是析构时如果发生错误则有可能导致内存泄漏
// 本例为错误示例
class Animal {
int* age;
public:
Animal(void) { age = new int(0); }
~Animal(void) { delete age; }
};
class Cat : public Animal {
int* legs;
public:
Cat(void) { legs = new int(4); }
~Cat(void) { delete legs; }
};
void main(void) {
Animal *animal = new Cat();
delete animal;
}
创建对象时,分配了 age
和 legs
两个成员。然而析构时调用的是 Animal
对象的析构函数,只释放了 age
,legs
溢出。为了避免这个错误,基类的析构函数一般都需要定义为虚函数。
纯虚函数
虚函数必须有函数定义,但是在一些情况下我们无法给出具体的定义,因为函数的具体实现需要依赖于子类。这种情况下,我们可以将函数定义为纯虚函数
virtual void speak(void) = 0;
拥有纯虚函数的类被称为抽象类(可类比Java
的概念),抽象类不能实例化对象。
事实上,纯虚函数的定义代表其在虚表中的表项值为 0 ,也就是 nullptr
。可以想象,如果一个子类继承自抽象类,但没有重写纯虚函数,那么其虚表中仍然存在 nullptr
,这时子类仍然是一个抽象类。只有当子类实现了基类中所有的纯虚函数,子类才可以实例化对象。
提问:纯虚函数是否可以有函数体?
答案是可以,但是没有必要。因为即使添加了函数体,函数还是纯虚函数,类还是抽象类,仍然需要重写该方法才能实例化对象。
抽象类与接口
如果一个类中包含纯虚函数,那么这个类不能实例化对象。根据类中有无成员变量,可以将这样的类细分为抽象类与接口。上面已经介绍了抽象类的概念,他描述一个继承树的根本纲领,是最根本的基类。而接口描述类的行为,因为其中只有函数,而没有数据。例如
class FlyObj {
public:
virtual void fly(void) = 0;
};
上面的 FlyObj
显然是一个接口,他描述了一个类的飞行能力。
一般来说,抽象类不建议多继承,但接口可以多继承。因为同一个对象可以同时具有多种能力,一般情况下不会引发冲突。