C++ 学习笔记 3 — 面向对象

一般来说我们在头文件中声明方法

// 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 指针。

友元

如果一个全局函数想要访问一个类的 privateprotected 成员,可以被声明为友元函数

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 的调用流程如下

  1. 查看 animal 所指对象的 _vptr 成员,确定虚表地址
  2. 在虚表中查找 speak 方法,获得函数入口
  3. 跳转函数

执行以下函数可以看到 _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 成员。对于这个问题可以有两种理解,其一是 _vptrAnimal 继承而来,因此也被计算在 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;
}

创建对象时,分配了 agelegs 两个成员。然而析构时调用的是 Animal 对象的析构函数,只释放了 agelegs 溢出。为了避免这个错误,基类的析构函数一般都需要定义为虚函数

纯虚函数

虚函数必须有函数定义,但是在一些情况下我们无法给出具体的定义,因为函数的具体实现需要依赖于子类。这种情况下,我们可以将函数定义为纯虚函数

virtual void speak(void) = 0;

拥有纯虚函数的类被称为抽象类(可类比Java的概念),抽象类不能实例化对象。

事实上,纯虚函数的定义代表其在虚表中的表项值为 0 ,也就是 nullptr。可以想象,如果一个子类继承自抽象类,但没有重写纯虚函数,那么其虚表中仍然存在 nullptr,这时子类仍然是一个抽象类。只有当子类实现了基类中所有的纯虚函数,子类才可以实例化对象。

提问:纯虚函数是否可以有函数体?

答案是可以,但是没有必要。因为即使添加了函数体,函数还是纯虚函数,类还是抽象类,仍然需要重写该方法才能实例化对象。

抽象类与接口

如果一个类中包含纯虚函数,那么这个类不能实例化对象。根据类中有无成员变量,可以将这样的类细分为抽象类与接口。上面已经介绍了抽象类的概念,他描述一个继承树的根本纲领,是最根本的基类。而接口描述类的行为,因为其中只有函数,而没有数据。例如

class FlyObj {
public:
	virtual void fly(void) = 0;
};

上面的 FlyObj 显然是一个接口,他描述了一个类的飞行能力。

一般来说,抽象类不建议多继承,但接口可以多继承。因为同一个对象可以同时具有多种能力,一般情况下不会引发冲突。

  • 4
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

LutingWang

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值