面向对象程序设计 OOP

我们在过程性编程中,一定会存在以下问题:对象进行不同的操作,遵循一定的时间顺序。
我们还会发现用户和数据的交互方式有三种,初始化、更新和报告(就是我们所谓的用户接口)
OOP 的方法就是从用户的角度考虑对象,描述对象所允许的操作

抽象 abstract

基本类型解决的问题

  • 决定数据对象需要的内存数量
  • 决定如何解释内存中的位 (换言之就是解决编码问题,例如 longfloat 的占用空间一样但是 编码方式不同
  • 决定可以使用数据对象执行的操作和方法(比如 intfloat 的计算方法不同)

类的使用

访问控制

一般访问私有变量是使用 成员函数 或者 友元函数

设计思路

一般类的设计尽可能将共有接口和实现细节分开,公有接口表示设计的抽象组件

封装 encapsulation

将实现细节 ( implementation ) 放在一起并且将他们于抽象分开的过程被称为封装

什么是接口 api ?

接口是一个共享框架,供两个系统共享的时候使用,类是一种公共接口, public 是使用类的程序

类和结构体

  • 结构体的所有变量访问类型是 public
  • 类的默认访问类型是 private

实现类成员函数

成员函数具有一般函数所不具有的两个特征:

  • 需要使用作用域解析运算符 (::) 进行表示函数所属的类
  • 类的方法可以访问类的私有部分
    注:同一类的不同对象可以通过类的成员方法(包括在成员函数里面)访问其他同类对象的私有变量
    同一个类的不同对象在调用方法的时候会自动使用不同的对象的函数,二者存在于不同的内存空间之中,并不是公用的
内联函数 inline function

定义位于类声明中的函数都将自动成为内联函数,类声明将短小的成员函数作为内联函数。
也可以在类的声明之外定义成员函数使之称为内联函数, 这时候就需要使用inline 关键字进行修饰
内联函数的特殊规则: 会在每个使用的文件中进行定义,因此为了能够定义一般会将其放在类的头文件中

使用类

const 成员函数

表示不会改变类的成员变量
int index(int v) const; 也就是在结尾加上一句 const
同样在定义函数的时候也会需要 int MyClass::index(int v) const{}

调用思路

对于一个常函数,如果其内部调用其他函数,编译器会要求其被调用函数也具有常数修饰符, 即:

1
2
3
4
5
int index(int v) const {return f();}
...
int f(){}; // 打咩
const int f(){}; //
int f() const {}; // 唯一可以接受的写法

如果一个非常数函数调用常数函数,常数函数并不会要求或者失去 const 修饰符

this 指针

对于每个类一般都会需要一个指向本身的指针,或者说我们一定要明确指代对象是本体还是传入的变量,那么我们就会需要使用 this 指针进行对象明确化

对于 const 函数,this 指针会变成指向一个 const 的指针,同样,如果对象是 const 那么 this 同样会指向一个 const 的对象
区分前置的 const 和后置的 const 的区别,前置只约束返回值是 const,后置约束的是类成员变量不能被修改

构造函数 Constructor

为什么不能适用初始化结构体的方式来初始化一个类?

相较于结构体,类的元素不一定是可以直接访问的,所以我们很多时候需要额外(专门)的构造函数进行初始化定义.

构造函数一定是一个 public 成员!

调用时间

一般在初始化类的对象的时候会自动运行构造函数进行定义.

构造函数命名注意

构造函数的参数表示的不是类的成员,而是赋给类的值,也就是说参数名不能和类的成员变量相同,或者说

参数命名规范: 使用 m_ 的前缀
谷歌风格: 谷歌风格指南通常建议在类的成员变量名称末尾添加下划线 _(underscore)以区分成员变量和局部变量

使用构造函数

显式调用 MyClass my_class = MyClass("You Yuchen", 250)
隐式调用 MyClass my_class("You Yuchen", 250)
动态内存的调用 MyClass* my_class = new MyClass("You Yuchen", 250)

构造 和 对象: 在构造函数未完成之前,对象是不存在的,因此构造函数并不能采用一般的对象调用的方式使用

默认构造函数

如果我们在初始化类对象的时候并没有明显调用构造函数,那么我们就会看到构造函数出现走的是默认构造函数

定义方法

MyClass::MyClass(){...}

编译器的行为

如果用户并没有编写任何构造函数的时候编译器才会提供默认构造函数

定义和初始化的区别

如果你的默认构造函数长成 如上 定义方法 那样,那么就是没有初始化成员但是定义了类对象,但是项目上这样做很容易犯错,而且我们很多时候会需要编译器报错提示我们没有放入初始化条件,同时,我们宁可初始化一个具有默认值的函数,也不能不写初始化,所以项目上往往如以下写法:

1
MyClass my_class(cosnt std::string & name = "You Yuchen", const int & number = 250);

通过上述代码,我们可以保证:

  • 初始化默认条件是 name 为 “You Yuchen”,number 是 250
  • 如果我们直接调用 MyClass my_class; 是会出现报错的
在设计类的时候应当提供对所有类成员做隐式初始化的默认构造函数

初始化列表(280 + 谷歌 推荐使用)

1
2
3
4
5
MyClass my_class(cosnt std::string & name = "You Yuchen",
const int & number = 250)
: name("You Yuchen"), number(250){

};

这个好处在于在申请类的空间之前就定义了一些变量的值,不用进入函数再次做一遍重复的内容.

析构函数 Destructor

背景:在构造函数创造对象之后,程序负责跟踪该对象,直到其过期为止。对象过期时,程序将自动调用一个特殊的成员函数:析构函数
职责:

  • 如果构造函数采用 new 来分配内存,析构函数就要采用delete 来清除内存
  • 如果构造函数没有使用 new, 那么析构函数就大概没有什么事情要做

定义方法

MyClass::~MyClass(){...}

或者可以在析构之后输出一个内容来表示析构完成

析构结束之前对象不会销毁,所以先销毁的是对象内部的内容,最后才销毁对象本身

this 指针

继承问题

子集问题

对于一个父类我们可以找到一个子集,处理专门的某一部分问题,同时提升代码复用度,这就是副类的作用 subtype
同时子类可能会存在一些专门的函数,这样只改变部分函数或者增加一些函数也需要提升代码复用能力

基类 base class 和 派生类 derived class

原始类称为基类,继承的类称为派生类
有些时候也将被继承的称为 父类,继承的称为 子类

  • 派生类方法不能直接访问基类的私有成员,必须通过基类方法进行访问,前提是基类方法是 public 的,对于函数同理,派生类方法不能调用基类的 private 方法
    我们一般考虑的方法权限是看父类的方法是不是public,而和本身继承的效果无关,也就是说我们public 继承或者 private 继承对于使用父类方法是一样的,但是自身存储情况是不一样的
  • 基类指针可以在不进行显示类型转换的情况下指向派生类对象,直白的说,就是爹指针可以指向儿指针而不需要转换类型(当然也不能转换类型)
  • 基类引用可以在不进行显示类型转换的情况下引用派生类对象
  • 但是,基类指针只能用于调用基类方法
    同时我们会注意到,派生类指针 / 引用不能指向基类,不然我们会调用到一些奇怪的内容上去。这一条同时也适用于函数的调用时候采用指针还是引用的问题

继承逻辑问题

  1. 公有继承建立 is-a 关系
  2. 公有继承不建立 is-like-a 关系,也就是不能建立所谓 “明喻”关系
  3. 公有继承不能建立 uses-a 关系,不能说 computer 能使用 priter 就使用 printer : public computer.
  4. 公有继承不能建立 is-implemented-as-a 关系,也就是不能建立“作为……来实现”的关系

public 继承

特性:所有基类的 public 成员在子类中都是 public,所有 private 在子类都是 private

private 继承

特性:所有基类的成员变量在子类都是 private 类变量

段炼曰:为什么要使用 private 继承?

chat 答:

  • 实现组合:当子类包含基类对象时,私有继承可以隐藏基类的接口,只暴露子类自己的接口。这样做可以避免外部直接访问基类的接口,从而隐藏实现细节,提高封装性。
  • 实现接口适配:有时候,基类的接口与子类的需求不完全匹配,此时可以通过私有继承来适配基类的接口。子类可以重新实现基类的接口,同时隐藏基类的实现细节。这种方法可以实现接口的改写或者增强。
  • 实现代码复用:在某些情况下,你可能希望在子类中复用基类的实现,但不想暴露给外部。私有继承可以让子类在内部使用基类的成员和方法,但对外部是不可见的。这样可以避免子类中不必要的接口扩展。

protected 特性

子类可以访问所有基类protected的变量,但是不能被外部调用

protected 带来的坏处

由于基类变量可以被子类独有方法访问,如果改变基类方法,子类就很容易出现问题,并且编译器还往往不会报错

慎重!

函数重载方法

虚函数

内存空间

对于一般类的函数是不会占用内存空间的,但是当我们在类的定义中加入了虚函数之后,类的内存空间会增大 4 Byte,我们用伪代码表示如下:

1
2
3
4
5
6
7
8
9
10
class A{
public :
virtual void func(){};
}

// 等价于
class A{
public :
void *vptr;
}

可以看到相当于添加了一个虚函数表指针,并且在编译期间由编译器创建一个虚函数表,经过编译链接过程保存到可执行文件中
同时也说明了无论多少个虚函数总占用空间是一定的
虚函数表会被继承,但是派生类的对象会指向派生类的虚函数表,也就是虚函数表是归属于类的

虚函数表指针和虚函数表的关系

再次使用伪代码表述编译器的行为如下:

1
2
3
A(){
vptr = &A::vftable; // 即找到该虚函数表的地址
}

作用

在多态继承中,我们如果要使用父类指针指向各个子类对象,那么我们就需要确保父类指针不要调用父类自身的方法,那么我们就需要设置 virtual 来解决这个问题。

  • 父类中存在虚函数的时候,子类必须要重写虚函数

继承问题的构造析构函数使用

首先我们有以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A{
public:
A(){cout<<"A<<endl;}
~A(){cout<<"~A"<<endl;}
};
class B: A{
public:
B(){cout<<"B<<endl;}
~B(){cout<<"~B"<<endl;}
};

int main(){
B b;
}

这样我们会出现

1
2
3
4
A
B
~B
~A

也就是说

  • 构造的时候会首先调用基类函数,然后调用自身函数
  • 析构的时候首先调用自身函数,然后调用基类函数
    从功能上理解:
  • 自动使用基类的构造函数:如果派生类构造函数没有显式地调用基类的构造函数,则自动调用基类的默认构造函数。
  • 派生类的构造函数总是用于自己的初始化:不论是自动还是显式调用基类的构造函数,派生类的构造函数总是用来初始化派生类特有的成员变量。
    同理,派生类在析构的时候也会调用基类的析构函数
从上面的设计,我们大概可以知道构造函数是不可以用虚函数的

从机理上讲,虚函数表是在类初始化之后才会存在的,我们在初始化(构造)的时候是没有类的对象的,即没有虚表。这是一个先有鸡还是很先有蛋的问题

从构造函数不成立的原因,那么虚析构函数就是合理的

析构时候是否调用父类的析构函数与是否为虚函数无关,但是我们可以通过虚函数进行多态,即父类指针指向派生类,如果父类析构函数是 virtual, 那么我们就会调用子类的析构函数,反之不会调用

架构设计 - interface

对于一个接口的用户,我们需要使用的都是类的方法,同时由于常见的类都是来自于某个抽象的基类,那么很多时候我们就需要设计基类的纯虚函数:是不是派生类一定会用到?用到了之后派生类怎么应用这些函数?
但是作为用户,我们不需要知道方法是怎么实现的,我们只需要知道某几个函数的参数和功能是什么样的。

不变量

作为架构师同时也会考虑到,为了实现某些功能,类必然会具有一定的条件要求,我们这时候就需要一个独特的检查机制确保条件不出问题,这就是我们所说的不变量

  • 在进函数的初始阶段判断条件是否符合
  • 在离开函数的最后阶段判断被破坏的不变量是否恢复