0%

背景

在深度学习中,参数初始化也是很重要的一步,糟糕的初始参数可能会导致模型在训练时遇到梯度消失梯度爆炸这两个情况

梯度消失

梯度消失是指在训练是,参数的梯度变为0,或者是非常接近0,导致参数在迭代过程中不再更新或者说更新很小。

除了糟糕的初始化参数,另外一个导致梯度消失原因是激活函数的选择,曾经,sigmoid函数$\frac{1}{1+e^{-x}}$常常作为激活函数,但是sigmoid函数的梯度在自变量较小和自变量较大时,对应的导数值都非常接近0,所以水用sigmoid激活函数往往会导致梯度消失的问题。所以现在大家基本上都选择ReLU函数作为默认的激活函数。

梯度爆炸

梯度爆炸是指在训练时,参数的梯度变得非常大。

参数对称问题

如果初始化参数时,如果参数出现很多相似的参数,那个就可能会导致多个神经元表现得像一个神经元。一个极端的例子,如果在输出化参数时,所有参数都初始化为一个常数,如果输入样本的特征也是相同的话,那么对于每次迭代,每个参数的偏导数值也是相同的,更新之后的值也是相同的,那么这些神经元就和一个神经元的效果一样了。

参数初始化的方法

Default Initialization

在使用深度学习框架创建网络时,如果是传入模型初始化方法的参数,则会使用深度学习框架默认的参数初始化方法,通常不会出现什么大问题。

Xavier Initialization

Xavier Initialization使用期望为0,方差为$\frac{2}{n_{in} + n_{out}}$的正态分布来初始化参数,其中$n_{in}$为当前层的输入神经元个数,$n_{out}$为当前层的输出神经元的个数,即下一层神经元的个数。

others

神经网络中的参数初始化方法也是目前研究的一个领域

背景

通常,在深度学习任务中,我们可以通过在损失函数中添加正则化项来避免过拟合,L2正则化项是通常使用的正则化项,L2正则化项使得模型最终的权重值分布比较均匀,使用L2正则化项时,我们就假设了我们的模型的对于样本的每个特征都相似的看重,而不只是看重某几个可能是关键特征的特征。

对于线形模型,在样本特征多样本数量少的情况下,线形模型很有可能会过拟合,但是只要增加样本数量,线形模型大概率就能摆脱过拟合的风险。但是线形这种可靠的特性也是有代价的,线形模型不关系样本特征之间的关系。对于每个样本特征,线形模型就只是要么给一个正的权重或者负的权重,而不会考虑任何背景信息。 不同于线形模型,神经网络通常不会独立对待每个样本特征,神经网络能够学习样本之间的关系,但是,对于深度神经网络来说,即使样本数量远远多于样本特征,深度神经网络仍然会有过拟合的风险。

同时来说,使用简单的模型可以避免过拟合。使用更少纬度的特征、使用正则化项让权重值分布比较均匀都可以让模型变得简单。另外还有一种让模型变得简单的方法叫做光滑(smoothness),即模型不会对输入的细微改变而敏感。

在神经网络中,训练时,对于每层网络向后传播的过程中,引入噪声就能让神经网络变得`光滑`,这种方法被叫做dropout。这种方法已经成为了一种训练神经网络的标准方法。叫做dropout的原因是因为在训练时对于每层网络我们都会随机丢弃一些神经元。在丢弃神经元的时候,我们要保证丢弃后的该层网络神经元的期望值和丢弃前的值是一致的。

dropout函数

在标准的dropout中,其定义为:对于每层的每个神经元,都有$p$的概率丢弃掉(置零),如果没有丢弃,则值变为$\frac{x}{1-p}$,$x$为该神经元的原始输出值。即:

可以证明$E(x) = x$

drop out 代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def dropout_layer(one_layer: tf.tensor, dropout_probability: float):
assert 0 <= dropout_probability <= 1
if dropout_probability == 1:
# in this case, all elements are dropped out
return tf.zeros_like(one_layer) # tf.zeros_like: create a tensor with all elements set to zero

if dropout_probability == 0:
# in this case, all elements are kept
return one_layer

# tf.random.uniform: outputs random values from a uniform distribution.
# with (1-p)'s probability that value will become x / (1-p)
mask = tf.random.uniform(shape=tf.shape(one_layer), minval=0, maxval=1) < (1 - dropout_probability)

return tf.cast(mask, dtype=tf.float32) * one_layer / (1.0 - dropout_probability)


# 在框架中使用快速调用drop out
net = tf.keras.models.Sequential([tf.keras.layers.Dense(256, activation=tf.nn.relu,
kernel_regularizer=tf.keras.regularizers.l2(0.3)),
tf.keras.layers.Dropout(0.5),
tf.keras.layers.Dense(256, activation=tf.nn.relu,
kernel_regularizer=tf.keras.regularizers.l2(0.3)),
tf.keras.layers.Dropout(0.2),
tf.keras.layers.Dense(10)])

隐藏层

在许多学习任务中,样本的特征和输出标签之间并不是线形关系。对于单层神经网络,它的能处理的学习任务比较有限。我们可以通过在输入层和输出层之间增加一层或多层的隐藏层来解决增加模型的复杂度,来增加模型的学习能力。但是如果只是增加隐藏层,这个网络仍然是线形模型。原因是每相邻的两层神经网络之间都是一个仿射变换,而仿射变换之上再叠加一个仿射变换,仍然是一个仿射变换。所以为将线形的神经网络变成非线性的神经网络,我们需要引入激活函数。激活函数决定一个一个神经元是否应该继续参与后续的计算。激活函数通常是非线性的函数。

常用激活函数

激活函数决定一个一个神经元是否应该继续参与后续的计算。激活函数通常是非线性的函数

ReLU Function

ReLu激活函数是最流行的一种激活函数,应为它的实现简单并且使用ReLU的神经网络效果也很好。它的定义如下:

即ReLU函数只保留正值的神经元,并且原封不动的进行传播,同时将所有负值的神经元丢弃。

对于ReLU函数的导数来说,当自变量小于0时,ReLU函数的梯度等于0,当自变量大于0时,函数的梯度等于1。但是当自变量的取值为0时,ReLu函数是不可导的。通常在工程上的处理是,当自变量为0时,认为梯度等于0。

pReLU Function

pReLU函数是ReLU函数的一个变种,它允许负值输入有限的向后传播:

Sigmoid Function

在logistic-regression中我们使用过sigmoid function来作为对输出的处理。同时sigmoid 函数也可以作为神经网络中的激活函数。

sigmoid函数将取值为任意实数的输入映射到一个(0,1)的区间,所以sigmoid函数也被叫做squashing函数。

sigmoid函数是一个光滑的,任意阶可导的函数,它的一阶导数在自变量为0时最大,从0向两边逐渐减小。

sigmoid function通常用于二分类任务神经网络输出层的激活函数。

Tanh Function

类似sigmoid function,tanh(hyperbolic tangent)也将实数范围内的输入挤压到(-1,1)之间的输出。

tanh的函数图像和一阶导函数的图像和sigmoid函数十分类似。

问题引出

在多分类问题中,一些分类器是利用多个二分类器来做分类的,有些模型则能直接进行多分类学习。softmax-regress就是一种能够直接进行多分类学习的线形分类模型(虽然叫regress,但本身是分类模型,有点像logistic-regression)。

one-hot encoding

对于多分类学习任务,我们通常会把可能的分类结果种类用数字来表示,例如对于一个可能有3种分类结果的学习任务,我们可以使用${1, 2, 3}$来分别表示第一类到第三类。如果使用这个自然序编码来对分类结果种类进行编码,那么对于每一个训练样本,我们需要的输出就只有一个。

使用自然序编码时,如果分类结果之间本身没有自然序的关系, 对于模型的输出和样本真实的标签之间的距离将不好表示。例如对于某个样本来说,模型的输出结果是1,表示第二类,但样本真实的标签为3,表示第3类,此时如果将距离定义为$|1-3| = 2$,这在分类结果之间本身没有自然序的关系的情况下显然是不太合理的。

另外一种表示分类结果的方法就是one-hot encoding。one-hot encoding将每一种分类结果用一个向量表示,例如,还是对于一个可能有3种分类结果的学习任务,我们可以使用${ {1, 0, 0}, {0, 1, 0}, {0, 0, 1} }$来分别表示第一类到第三类,这样每个种类之间可以看作是没有直接关系的(种类的表示向量之间两两正交)。

所以,对于种类之间有自然序关系的分类学习任务,可以使用自然序来对结果进行编码。但是如果种类之间没有自然序关系,应该尽量使用one-hot encoding。

softmax-regression模型使用的就是one-hot encoding来对分类结果进行表示。

softmax-regression 网络结构

对于一个有n个输入特征,m个可能分类结果的网络来说,其结构为:

由此可见一个softmax-regression网络是一个单层的全连接网络。

net structure

softmax函数

在softmax-regression的网络中,我们可将每个输出看作一个样本可能是某一类别的概率,输出结果最大的那个就是最有可能的分类结果。但是最为概率,就需要输出的值位于$[0, 1]$之间,并且所有值之和为1。这对于使用线形模型的输出来说有些困难。所以为了保证让网络输出的结果能够作为概率,需要对输出作为进一步处理,保证输出的结果必须为非负并且和为1。softmax函数就是一个这样的函数:

虽然softmax函数不是一个线性函数,但是softmax-regression的输出仍然是由输入特征的线形变换(仿射变换)决定的,所以softmax-regression仍然是一个线形模型。

交叉熵损失

交叉熵是一个信息论中的概念,它衡量了预测的概率分布和真实的概率分布之间的差异。在softmax-regression中,可以将网络预测的输出看作预测的概率分布,将样本的真实类别对应的ont-hot编码看作是真实概率分布,这样就可以定义模型的损失函数了。其定义如下:

训练

有了模型和损失函数后,就可以使用随机梯度下降法(Stochastic Gradient Descent)对模型进行训练了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
from liner_regression.fashion_mnist_dataset import *

# read data
batch_size = 256
train_data_iter, test_data_iter = load_data_fashion_mnist(batch_size)

# initial model parameters
# each image in dataset is a 28 * 28 image, in this section, we will flatten each image,
# treat them as vectors of length 784
# so X's size is 256 * 784, W's size is 784 * 10, b's size is 1 * 10, y's size is 256 * 10 (y = softmax(XW + b))
num_inputs = 28 * 28
num_outputs = 10

W = tf.Variable(tf.random.normal(shape=(num_inputs, num_outputs), mean=0, stddev=0.01))
b = tf.Variable(tf.zeros(num_outputs))


# define softmax operation
def softmax(linear_result: tf.Variable):
# if linear_result is n * m matrix

# exped is n * m matrix
exped = tf.exp(linear_result)
# sum_of_each_line is n * 1 matrix, if keepdims=False, then sum_of_each_line will be 1 * n matrix
sum_of_each_line = tf.reduce_sum(exped, 1, keepdims=True)
return exped / sum_of_each_line


# define modal
def net(data_x, param_w, param_b):
return softmax(tf.matmul(data_x, param_w) + param_b)


# define loss, use cross-entropy loss
def cross_entropy(predicted_y, label_y):
# predicted_y is a n * m matrix, then label_y is a 1 * n matrix
# in this example, predicted_y is 256 * 10, label_y = 1 * 256
return -tf.math.log(tf.boolean_mask(predicted_y, tf.one_hot(label_y, depth=predicted_y.shape[-1])))


# define optimizer
def stochastic_gradient_descent(params, gradients, batch_size, learning_rate: float):
# Because our loss is calculated as a sum over the mini-batch of examples,
# we normalize our step size by the batch size (batch_size),
# so that the magnitude of a typical step size does not depend heavily on our choice of the batch size.
for param, grad in zip(params, gradients):
param.assign_sub(grad * learning_rate / batch_size)


# classification accuracy
def accuracy(predicted_y, label_y):
# predicted_y is a n * m matrix, then label_y is a 1 * n matrix
# in this example, predicted_y is 256 * 10, label_y = 1 * 256
# tf.argmax returns the index with the largest value across axes of a tensor.
predicted_y = tf.argmax(predicted_y, axis=1)

# cmp is a 1 * n `boolean` matrix
cmp = tf.cast(predicted_y, label_y.dtype) == label_y
# return num of right predictions and the total num of predictions
return tf.reduce_sum(tf.cast(cmp, label_y.dtype)), label_y.shape[0]


# training
def train():
for _ in range(3):
num_right_predictions = 0
num_total_predictions = 0
for x, y in train_data_iter:
with tf.GradientTape() as g:
x = tf.reshape(x, shape=(x.shape[0], -1))
y_hat = net(x, W, b)
l = cross_entropy(y_hat, y)

dw, db = g.gradient(l, [W, b])
stochastic_gradient_descent([W, b], [dw, db], batch_size, 0.2)

right_pred, total_pred = accuracy(y_hat, y)
num_right_predictions += right_pred
num_total_predictions += total_pred

print('accuracy after one epoch is : ', float(num_right_predictions) / float(num_total_predictions))


if __name__ == "__main":
train()

clone 仓库相关

git clone仓库有两种形式,一种是以ssh协议的形式,另外一种是以https协议的形式。使用ssh协议的形式时,需要将ssh公钥上传到github或者gitlab。使用https协议需要在clone的时候输入github或者gitlab的账号和密码。

  • git clone [-b <branch name>] <repository url>
    • -b 执行需要拉取的分支,不指定则拉取master分支
  • git clone https://<username>:<password>@<repository url>
    • 在使用https协议拉取仓库的时候,直接在url中指定账户和密码,@作为分割符,如果账户或者密码中含有“@”符,则需要将其替换为“%40”

工作区、暂存区状态相关

  • git status

    • 查看工作区和暂存区的文件状态
  • git add <filename>

    • 将工作区的某个文件添加到暂存区
  • git add .

    • 将工作区全部文件添加到暂存区
  • git commit -m"[commit message]"

    • 提交暂存区的文件修改到本地仓库
  • git rm <filename>

    • 取消对文件的追踪

提交历史相关

  • git log --graph --pretty=oneline --abbrev-commit [filename]
    • 查看当前分支的提交历史
    • —graph:以图表的新式
    • —pretty=oneline:只显示commit信息,不显示提交作者和时间等其他信息
    • —abbrev-commit:commit id以简写的形式展示
    • filename: 可选项,只显示某个文件的提交历史
  • git reflog
    • 查看操作记录,借助该命令可以取消版本回退。
  • git reset [--soft|--mixed|--hard] HEAD^
    • 将当前分支的提交回退到上一个提交
    • —soft:当前提交修改会保存到暂存区,并且当前工作区和暂存区的修改也会保留
    • —mix:当前提交的修改会保存到工作区,并且当前暂存区的修改都会移到工作区
    • —hard: 丢弃当前提交的修改,并且丢弃当前工作区和暂存区的修改
    • --mix是默认的选项
    • 一个快速清除当前暂存区和工作区的命令:git add . && git reset --hard HEAD。注意没有”^”,否则就回退到了上个提交
  • git reset [--soft|--mixed|--hard] HEAD~<number>
    • 回退当前分支提交到上number个版本
  • git reset [--soft|--mixed|--hard] HEAD <commit id>
    • 回退当前分支提交到指定的commit id
  • git checkout <commit id> <filename>
    • 只将某个文件回退到某个版本

分支相关

  • git branch

    • 查看本地有哪些分支
  • git branch <branch name>

    • 创建分支,但是目前仍然位于当前分支
  • git branch <branch name> <commit id>

    • 基于commit id创建分支,目前仍位于当前分支
  • git checkout -b <branch name>

    • 创建并切换分支
  • git checkout -b <branch name> <commit id>

    • 基于某个commit id创建并切换分支
  • git checkout <branch name>

    • 切换分支,如果本地没有对应分支但是远程仓库有,则将对应的远程分支拉取到本地并切换到对应分支
  • git branch -d <branch name>

    • 删除分支,如果分支有提交但是没有合入其他分支,会报错
  • git branch -D <branch name>

    • 强制删除某个分支
  • git checkout -f <branch name>

    • 强行切换到某个分支,如果当前工作区和暂存区有内容,将丢弃这些内容

合并相关

  • git merge <branch name>

    • 将branch name对应的分支合并到当前分支
    • 如果合并有冲突,需要手动解决冲突(到有冲突的文件进行编辑)后,执行add和commit

远程仓库相关

  • git remote add origin <remote repository url>
    • 关联远程仓库
  • git remote remove origin
    • 取消关联远程仓库
  • git push origin <branch>
    • 将本地分支的commit推送到到远程分支
  • git push --force-with-lease origin <branch>
    • 以最小强制更新的手段,将本地分支推送到远程分支
    • 尽量不要强制推送远程分支,尤其是其他人开发的分支
  • git push origin --delete <branch>
    • 删除远程分支

子模块相关

  • git submodule add <repository> <dir>

    • 添加子模块,子模块的链接为url,添加到相对于当前目录的dir目录下
  • git submodule init

    • 读取本地子模块配置文件
  • git submodule update

    • 拉取子模块
  • git submoduel update <submodule dir path>

    • 拉取指定的子模块
  • git clone --recurse-submodules

    • 拉取带有子模块的仓库时,同时也拉取子模块

tag相关

  • git tag
    • 查看所有的标签
  • git tag <tag name>
    • 创建一个轻量标签,轻量标签建议在创建临时标签时使用
  • git tag -a <tag name> -m <tag message>
    • 创建一个附注标签,正式标签使用
  • git show <tag name>
    • 查看标签信息和与之对应的提交信息
  • git push origin <tag name>
    • 将标签推送到远程仓库
  • git tag -d <tag name>
    • 删除本地标签
  • git push origin --delete <tag name>
    • 删除远程标签

rebase相关

  • git rebase <branch name>
    • 将当前分支rebase到branch name对应的分支
  • git rebase -i HEAD~<num>
    • 将当前提交和当前提交前的共num个提交合并为
  • git rebase --abort
    • rebase 有冲突时,取消rebase
  • git rebase --continue
    • rebase 冲击解决后,继续执行rebase操作

stash 相关

stash 通常用于当前在一个分支有代码的更新放在工作区或者暂存区,但是需要临时去处理其他分支的代码,又不行在当前分支进行提交操作,那么可以先将当前分支中工作区和暂存区的代码stash起来,之后再pop回来。

  • git stash save <message>
    • 将当前工作区和暂存区的修改保存到其他地方,并将当前工作区和暂存区清空
  • git stash list
    • 查看stash 的列表
  • git stash pop [index | stash_id]
    • 如果不传index或者stash id参数,将stash列表的最后一个stash恢复到工作区和暂存区,并将该stash从stash list中删除
    • stash list的存储结构是一个栈结构,最后stash的stash id最小,也最新被pop出来
    • 如果传入index或者stash id参数,则将其对应的stash恢复到工作去和暂存区,并将该stash从stash list中删除
  • git stash apply [index | stash_id]
    • 除了不删除对应的stash外,其他 和 git stash pop相同
  • git stash drop [stash id]
    • 删除一个存储进度
  • git stash clear
    • 清除stash list

特殊文件

  • .gitignore
    • 记录git应该不对哪些文件进行跟踪的配置
  • .gitmoduels
    • 记录子模块信息的文件
  • .gitattributes
    • git-lfs应该处理的文件信息
  • .gitlab-ci.yml
    • gitlab ci配置文件

其他

  • git-lfs

    • git大文件处理工具

    • git lfs install:开启lfs

    • git lfs track *.zip:将zip文件作为lfs文件管理

    • GIT_LFS_SKIP_SMUDGE=1 git xxx:此次操作不会去下载lfs文件

  • 分离头指针
    • 当前HEAD指针所指向的commit没有和任何分支关联,这种情况一般出现在直接从某个commit checkout出来的情况(即执行了git checkout <commit id> )。如果在分离头指针进行了代码修改并提交后,再切换到其他分支,那么这个提交就会丢失,活都白干了。正确做法是为该分离的头指针创建branch进行关联,这时执行git checkout -b <branch>即可。

原型模式简介

原型模式将对象的复制过程交给被复制的实际对象本身。原型模式为所有支持复制的对象声明了一个通用接口,该接口能够让你克隆对象。

单例模式

生成器模式

背景

生成器模式使你能够分步创建复杂对象。假设有这样一个复杂对象, 在对其进行构造时需要对诸多成员变量和嵌套对象进行繁复的初始化工作。 这些初始化代码通常深藏于一个包含众多参数且让人基本看不懂的构造函数中。

例如你想要示例化一个有车库、带游泳池和花园的房子对象。

一种实现方式是:设计一个”House”的基类,并让每种类型的房子继承自这个”House”基类,例如”HouseWithGarage”、”HouseWithGarden”、”HouseWithGarageAndGraden”等等。但是这种方式的结果就是你可能需要编写许多子类代码。

另外一种实现方式:无需生成子类,只设计一个”House”的类,同时这个”House”类拥有一个包括所有可能参数的超级构造函数,例如House(bool hasGarage, bool hasGraden, bool hasSwimmingPool, ...)。这种方式可以避免生成子类,但是如果需要增加一种房子类型,那么就不得不修改构造函数,这可能会导致使用之前构造函数的代码失效。另外这个超级构造函数中可能大多数参数最后都没有实际被使用到,导致构造函数的调用形式不简洁。

这种情况就可以考虑使用生成器模式了。

生成器模式简介

生成器模式将构造对象的代码从产品类中抽取出来,并将其放在一个名为生成器的独立类中。当创建对象的时候,是需要按需调用生成器提供的构造步骤即可。在一下情况下,你需要设计多种类型的生成器来构建出有相同接口但是表现形式不同的对象。

另外,你可以将用于创建对象的一系列的生成器调用步骤抽取出来形成一个单独的主管类。主管类非常适合放入各种例行构造流程,以便在程序中反复使用。

生成器代码样例

一个创建汽车和汽车使用手册的设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
//other class a car builder that need
class CarEngine;
class GPS;


//a pure abstract class to use as an interface
class CarBuilder {
public:
virtual void reset() = 0;
virtual void setSeats(int number) = 0;
virtual void setEngine(const CarEngine &engine) = 0;
virtual void setGPS(const GPS &gps) = 0;
};

class Car;
//a car builder that build car itself
class CarCarBuilder: public CarBuilder {
private:
Car *_car;
public:
virtual void reset() override {
if (_car) {
delete _car;
}
_car = new Car();
}

virtual void setSeats(int number) override {
//set seat for car itself
}

virtual void setEngine(const CarEngine &engine) override {
//set engine for car itself
}

virtual void setGPS(const GPS &gps) override {
//set gps for car itself
}

virtual Car* getResult() {
return _car;
};
};

class CarManual;
//a car builder that build car's manual
class CarManualBuilder: public CarBuilder {
private:
CarManual *_manual;

public:
virtual void reset() override {
if (_manual) {
delete _manual;
}
_manual = new CarManual()
}

virtual void setSeats() override {
//add description for how to use seats in manual
}

virtual void setEngine(const CarEngine &engine) override {
//add car engine description in manual
}

virtual void setGPS(const GPS &pgs) override {
//add gps use guide in manual
}

virtual CarManual* getResult() override {
return _manual;
}
};

//a manager class that use builder to build car
class CarCarBuilderDirector {
public:
Car* buildSUV(CarBuilder &builder) {
builder.setSeats(4);
NormalEngine normalEngine;
builder.setEngine(normalEngine);
NormalGPS normalGps;
builder.setGPS(normalGps);
}

Car* buildSportsCar(CarBuilder &builder) {
builder.setSeats(2);
SportEngine sportEngine;
builder.setEngine(sportEngine);
SportCarGPS sportCarGps;
builder.setGPS(sportCarGps);
}
};

在上面的例子中,在CarCarBuilderDirector这个生成器主管类可以创建SUV类型的车辆对象,也可以创建SportsCar类型的对象。但在上例中我们没有定义基于CarBuilder接口的SUVCarBuilder类和SportsCarBuilder类。不是说不可以,而是说要结合具体的实际情况判断是否需要再定义SUVCarBuilder和SportsCarBuilder类,如果创建SUV和创建SportsCar的实现方式有差别的话,那么就可以再去定义SUVCarBuilder类和SportsCarBuilder类。

另外,我们在生成器接口中并没有提供获取构造结果对象的方法,因为不同生成器构造的产品可能没有公共接口, 因此你就不知道该方法返回的对象类型。 但是, 如果所有产品都位于单一类层次中, 你就可以安全地在基本接口中添加获取生成对象的方法。

总之还是那句话,不基于实际应用场景的设计模式都是耍流氓

总结

生成器模式让你可以分步骤生成对象, 而且允许你仅使用必须的步骤。 应用该模式后, 你再也不需要将几十个参数塞进构造函数里了。

基本生成器接口中定义了所有可能的制造步骤, 具体生成器将实现这些步骤来制造特定形式的产品。 同时, 主管类将负责管理制造步骤的顺序。

工厂模式

工厂模式保护简单工厂模式、工厂方法模式、抽象工厂模式

简单工厂模式

简单工厂模式简介

  • 一个抽象产品类,根据这个抽象产品类可派生出多个具体产品类
  • 一个具体工厂类,具体工厂类用于生产多个具体产品类

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class GUIDrawable {
public:
virtual void draw() = 0;
};

class Button: public GUIDrawable {
public:
virtual void draw() override {
//draw button code here
}
};

class CheckBox: public GUIDrawable {
public:
virtual void draw() override {
//draw check box code here
}
};

enum GUIType {
GUITypeButton,
GUITypeCheckBox,
};

class GUIFactory {
public:
GUIDrawable* create(GUIType type) {
switch (type) {
case GUITypeButton:
return new Button();
case GUITypeCheckBox:
return new CheckBox();
default:
return nullptr;
}
}
};

工厂方法模式

工厂方法模式简介

  • 一个抽象产品类,根据这个抽象产品类可派生出多个具体产品类
  • 一个抽象工厂类,可以派生出多个具体工厂类
  • 每个具体工厂只能创建一个具体产品

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class GUIDrawable {
public:
virtual void draw() = 0;
};

class Button: public GUIDrawable {
public:
virtual void draw() override {
//draw button code here
}
};

class CheckBox: public GUIDrawable {
public:
virtual void draw() override {
//draw check box code here
}
};

class GUIDrawableFactory {
public:
virtual GUIDrawable* create() = 0;
};

class ButtonFactory {
public:
virtual GUIDrawable* create() override {
return new Button();
}
};

class CheckBoxFactory {
virtual GUIDrawable* create() override {
return new CheckBox();
}
};

抽象工厂模式

抽象工厂模式简介

  • 多个抽象产品类,每个抽象产品类可以派生出多个具体产品类
  • 一个抽象工厂类,可以派生出多个具体工厂类
  • 每个具体工厂可以创建多个具体产品

代码样例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class GUIDrawable {
public:
virtual void draw() = 0;
};

class Button: public GUIDrawable {
public:
virtual void clicked() = 0;
};

class CheckBox: GUIDrawable {
public:
virtual void checked() = 0;
};

class WinButton: Button {
public:
virtual void draw() override {
//draw win button code
}

virtual void clicked() override {

}
};

class WinCheckBox: CheckBox {
public:
virtual void draw() override {
//draw win label code
}

virtual void checked() override {

}
};

class MacButton: Button {
public:
virtual void draw() override {
//draw mac button code here
}

virtual void clicked() override {

}
};

class MacCheckBox: CheckBox {
public:
virtual void draw() override {
//draw mac label code here
}

virtual void checked() override {

}
};

class GUIFactory {
public:
virtual Button* createButton() = 0;
virtual CheckBox* createCheckBox() = 0;
};

class WinGUIFactory : public GUIFactory {
public:
virtual Button* createButton() override {
return new WinButton();
}

virtual CheckBox* createCheckBox() override {
return new WinCheckBox();
}
};

class MacGUIFactory: public GUIFactory {
public:
virtual Button* createButton() override {
return new MacButton();
}

virtual CheckBox* createLabel() override {
return new MacCheckBox();
}
};

总结

在许多设计工作的初期都会使用简单工厂模式,随后演化为使用工厂方法模式, 随后演化为使用抽象工厂模式,甚至继续演化为使用原型模式或生成器模式。

设计模式应该作为代码编写的一种指导,而不是一种准则,任何不结合实际应用场景的设计模式都是耍流氓。

设计模式中主要涉及到三种类型的设计模式:创建型模式(construct design pattern)、结构型模式(structure design pattern)、行为模式(behavior design pattern)。

  • 创建型模式主要关注如何合理地构造对象
  • 结构型模式主要关注如何组织一个系统内部的各个类
  • 行为模式主要定义类与类之间交互的方式

什么是颜色

颜色是有不同波长的光发出的能量对人眼产生刺激后,人产生的一种感觉。

  • 不同波长的电磁波对应不同的颜色
  • 人眼对在380nm(紫光)到760nm(红光)波长的光很敏感

光的光谱分布

光是一种多种波长,每种波长的强度不同的电磁波的混合。对于白光,就意味光中包含的所有波长的电磁波具有一样的强度。

光谱分布就是一个光的强度和波长的函数。

image-20200709161946882

由于颜色就是光对人眼产生的刺激,因此颜色也就可以用光谱分布表示。但是使用光谱分布来表示颜色太复杂,并且现实中也存在异谱同色的现象。

颜色表示方法

RGB颜色空间

RGB是被用得最广泛的颜色空间,颜色用三个通道(r, g, b)表示,r表示red,g表示green,b表示blue。通常每个通道中值都是0~1的浮点数,或者用8比特表示的0~255之间的数。

人类的视觉系统也是基于RBG三原色的。

但是有些颜色并不能由RBG三原色的组合表示,因为有些颜色的R通道是负的。

image-20200709200933168

CMY颜色空间

颜色用三个通道(c, m, y)表示,c表示cyan(青),m表示magenta(品红),y表示yellow,它和RBG颜色空间的对应方式为:

CMY颜色空间也被叫做减色系统,因为RBG颜色空间随着每个通道值的增加颜色逐渐从黑变为白,而CMY颜色空间的颜色随着每个通道值的增加颜色逐渐从白色变为黑色。

HSV颜色空间

HSV颜色空间用色调(Hue),饱和度(Saturation),明度(Value of Brightness)来分别颜色。

  • 色调意味着基础颜色,它是表明不同颜色的主要因素,用角度度量,取值范围为0°~360°,从红色开始按逆时针方向计算,红色为0°,绿色为120°,蓝色为240°。它们的补色是:黄色为60°,青色为180°,紫色为300°。
  • 饱和度表示颜色的纯度,取值为0~1的浮点数,饱和度越低,颜色越接近白色。
  • 明度表示颜色的明亮程度,亮度越低,颜色越接近黑色。

image-20200709202938841

RGB和CMY颜色模型都是面向硬件的,而HSV颜色模型是面向用户的。

YUV,YCbCr颜色空间

Y分量表示明亮度(Lumiance或着Luma),U(Cr)和V(Br)表示色度,人眼一般对亮度信息比较敏感,对颜色信息比较不敏感。

YUV 用在 模拟 PAL or 模拟 NTSC视频格式,不用在数字视频格式

YCbCr 是用于数字视频表示的则是色度(Chrominance或Chroma).

YUV(YCbCr)的颜色空间存储有几种方式:plane、bi-plane、packed,

plane的存储方式为先存储所有的像素中的Y通道的值,然后存储所有像素中的U通道的值,然后是U通道的值(不一定所有像素都有U和V值,这和采样有关,后续再说明)。bi-plane存储方式为先存储所有像素中的Y通道值作为一个plane,然后交替存储像素中的U和V的值作为第二个plane。packed存储方式为交替存储每个像素中的YUV值。

采样率(数字信号才谈采样),YCbCr颜色空间一般有4:4:4,4:2:2和4:2:0这几种采样方式。4:4:4——每采样一个Y通道,同时采样一个Cb、Cr通道。4:2:2——每采样两个Y,采样一个Cb和Cr,要显示4:2:2的YCbCr数据,首先将其转换为4:4:4的YCbCr数据,使用内插生成缺少的Cb和Cr样本。4:2:0并不意味着只有Cb分量,没有Cr分量。它指的是对于每行扫描线来说,4:2:0——只有一种色度分量以2:1的抽样率存储。相邻的扫描线存储不同的色度分量,也就是说一行是4:2:0的话,下一行就是4:0:2,下一行又是4:2:0,以此类推。要显示4:2:0的YCbCr数据,首先将其转换为4:4:4的YCbCr数据,使用内插生成新的Cb和Cr样本。

CIE XYZ颜色空间

由CIE(International Commission on Illumination)提出,它可以用来表示所有的可见光

图像和像素

//TODO:虽然知道相关概念,但还是写一下吧。

网格绘制

在计算机图形中,表示一个3D图形一般使用三角网格或者参数曲线(曲面)。一个三角网格数据结构一般包括一个三角形数组,

光照模型

当光照射到物体表面时,物体对光会发生反射、透射、吸收、衍射、折射、和干涉,其中被物体吸收的部分转化为热,反射、透射的光进入人的视觉系统,使我们能看见物体。为模拟这一现象,我们建立一些数学模型来替代复杂的物理模型,这些模型就称为明暗效应模型或者光照明模型。

局部光照

在真实感图形学中,仅处理光源直接照射物体表面的光照明模型被称为局部光照明模型。

全局光照

全局光照模型是基于光学物理原理的,光照强度的计算依赖于光能在现实世界中的传播情况,考虑光线与整个场景中各物体表面及物体表面间的相互影响,包括多次反射 、透射 、散射等。因此,与局部光照模型相比,全局光照模型需要相当大的计算量 ,但同时也能取得非常逼真的真实效果 。

类的声明只是说明了如何创建一个对象,并没有实际分配内存,只有当有对象被创建的时候才会分配内存。

类内定义的成员函数

在类的定义声明并实现的成员函数默认为inline函数,不论前面是否加了inline关键字

类的构造函数和析构函数

构造函数和析构函数都没有返回值

默认构造函数

当程序创建未被显示初始化的类对象时,总是调用默认构造函数

如果没有提供任何构造函数,则编译器将自动提供默认构造函数,这个构造函数不会做任何事。它是默认构造函数的隐式版本。

当且仅当没有定义任何构造函数时,编译器才会提供默认构造函数

显示定义默认构造函数的方式:

1
2
3
4
5
//方法1:给构造函数所有参数提供默认值
MyClass(const string &co = "", int n = 0);

//方法2:提供一个没有参数的构造函数
MyClass();
不要同时采用这两种方法,否则会产生二义性,编译器在需要用到默认构造函数的时候不知道使用哪一个

隐式地调用默认构造函数的时候,不要使用圆括号

1
2
3
MyClass my_class;//隐式调用默认构造函数
MyClass my_class = MyClass();//显示调用默认构造函数
MyClass my_class();//这个变成了一个函数声明

nullptr 构造函数

nullptr构造函数用于解决存在多个(如果只有一个,传入空指针也不会产生歧义)接受指针的构造函数时,如果传入空指针时函数不明确的问题,例如:

1
2
3
4
5
6
class A {
public:
A(int *data) {std::cout << "int constructor" << std::endl;}
A(double *data) {std::cout << "double constructor" << std::endl;}
A(std::nullptr_t) {std::cout << "nullptr constructor" << std::endl;}
}

构造函数的实际运行

1
MyClass my_class = MyClass();

对于上述代码,编译器有两种实现方式。第一种:不会创建临时对象,直接将对象赋给my_class;第二种:调用构造函数来创建一个临时对象,然后将该临时对象拷贝到my_class中,并丢弃该临时对象,则这样会为临时对象调用析构函数。

1
2
MyClass my_class = MyClass();
my_class = MyClass();

对于上述代码的第二个赋值语句,在这样的赋值语句中使用构造函数总会导致在赋值前创建一个临时对象。

如果既可以通过初始化,也可以通过赋值来设置对象的值,应采用初始化方式,通常这种方式的效率更高,即可以避免创建临时对象。

析构函数

每个类都只能有一个析构函数

类成员的构造和析构顺序

  • 构造时

    • 如果某个类具有父类,先执行父类的构造函数

    • 类的非静态数据成员,按照声明的顺序创建

    • 执行构造函数体内部的代码

  • 析构时

    • 调用类的析构函数

    • 销毁数据成员,与创建的顺序相反

    • 如果有父类,调用父类的析构函数

带const的类成员函数

1
2
const MyClass my_class;
my_class.func();//编译器可能会拒绝执行该方法,因为func方法可能无法保证调用对象不被修改

但是如果func()的类方法声明为const类方法就可以:

1
void func() const;//承诺不修改调用对象
只要类方法不修改调用对象,就应将其声明为const

对象数组

1
MyClass my_classes[4];

上述声明要求,这个类要么显式的定义了默认构造函数,要么没有显式地定义任何构造函数(即编译器提供了默认构造函数)

可以使用构造函数来初始化数组元素

1
2
3
4
MyClass my_classes[4] = {
MyClass();
MyClass("", 5);
};

上述代码初始化了对象数组的部分元素,剩余的2个将会使用默认构造函数进行初始化。

初始化对象数组的方案是,首先使用默认构造函数创建数组元素,然后花括号中的构造函数将创建临时对象,然后将临时对象的内容复制到相应的元素中。因此,要创建对象数组并且有部分数组元素未使用显式构造函数创建时,则这个类必须要有默认构造函数。

作用域内枚举

1
2
enum egg {Small, Medium, Large, Jumbo};
enum t_shirt {Small, Mediun, Large, Xlarge};

上述代码在两个枚举中定义了同一个枚举变量,会发生冲突,编译器会报错。

为了避免这种冲突,C++11提供了一种新枚举,其枚举变量的作用域为类。

1
2
enum class egg {Small, Medium, Large, Jumbo};
enum class t_shirt {Small, Mediun, Large, XLarge};

可以使用关键字struc代替关键字class,创建作用域内枚举时都需要使用枚举名来限定枚举量

1
2
egg choice = egg::Large;
t_shirt Floyd = t_shirt::Large;
常规的枚举变量可以自动转换为整型,但是作用域内枚举不能隐式地转换为整型。但是必要时可以进行显式类型转换
1
2
int a = egg::Small;//错误
int b = int(egg::Small);//b = 0

作用域内枚举变量可以设置底层数据类型,但是必须为整型数据:

1
enum class : short pizza {Small, Medium, Large, XLarge};//指定底层类型为短整型

运算符重载

要重载运算符,需使用被称为运算符函数的特殊形式。运算符函数的格式如下:

1
2
3
return-type operator op(argument-list);
//例如:
MyClass operator +(const Myclass &my_class);

当编译器发现如下代码:

1
MyClass my_class = my_class_1 + my_class_2;

编译器会使相印的运算符函数替换上述运算符:

1
MyClass my_class = my_class_1.operator+(my_class_2);

运算符重载限制:

  • 重载后的运算符必须至少有一个操作数是用户定义的类型,这将防止用户为标准类型重载运算符

  • 使用运算符时不能违反运算符原来的句法规则

运算符的重载可以通过成员函数或非成员函数进行重载,但下面的运算符只能通过成员函数进行重载: * "=" : 赋值运算符 * "()": 函数调用运算符 * "[]": 下标运算符 * "->": 通过指针访问类成员的运算符 # 友元 友元有三种: * 友元函数 * 友元类 * 友元成员函数 通过让函数成为类的友元,可以赋予该函数与类的成员函数相同的访问权限

为什么要使用友元?

对于如下代码:

1
2
3
//假设类A实现了A operator +(int value);
A b = a + 3;//转换成a.operator+(3);
A b = 3 + a;//因为3不是A类对象,编译器无法使用成员函数来替换该表达式

使用友元函数可以解决这个问题

创建友元函数

创建友元函数的第一步是将其原型放在类声明中,并在原型声明前加上关键字friend

第二步编写函数定义,因为友元函数不是成员函数,所以不要使用"::"限定符,且在定义中不要使用关键字friend

所以对于上面的这个问题,只需要实现

1
friend A operator+(int value, const A &a)
注意:只有类声明可以决定哪一个函数是友元

重载<<运算符

直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
//第一种重载方式
void opeartor<<(const A &a) {
cout << a.data;
}
//第二种重载方式
void opeartor<<(ostream &os) {
os << this->data;
}
//第三种重载方式
friend void operator<<(ostream &os, const A &a) {
os << a.data;
}
//注意这是定义同时也是实现,单独实现的时候不加friend

对于上述代码1,你觉得可以这样使用吗?

1
2
A a;
cout << a;

答案是不可以

对于上述代码2,调用的格式因该是这样的:

1
2
A a;
a << cout;

很别扭是吧

第三种方式可以实现

1
cout << a.data

但是第三种方式有种缺憾,就是不可以这样调用:

1
cout << "a.data is" << a.data << "and b.data is" << b.data;

解决方法——让第三种方式返回ostream对象的引用即可:

1
2
3
4
friend ostream & operator<<(osteam &os, const A &a) {
os << a.data;
return os;
}

重载单操作数运算符

对于某些运算符既可以作单数运算符,又可以作为双操作数运算符。例如“-”既可以作为自反运算符,又可以作为减号操作符。
那么重载这种运算符的时候,作为单操作数运算符是不同于双操作运算符的。

1
2
3
//作为成员函数的情况
void operator-();//用作单操作运算符
return_type operator-(parameter);//用作双操作运算符

有了上述成员函数的情况,作为非成员函数的情况就可以类推出来了

重载++和—运算符

++运算符和—运算符既可以做前缀可以做后缀,重载前缀++(—)和后缀++(—)的情况是不同的。

1
2
void operator++();//前缀++
void operator++(int);//后缀++

C++规定后缀形式有一个int类型的参数,但是这个参数永远不会用到,所以不必写参数名,也不要写这个参数名。

运算符重载:作为成员函数还是非成员函数?

前面说过运算符的重载可以通过成员函数或非成员函数进行重载,但是不能同时声明这两种格式,否则将造成二义性错误,导致编译错误。

对于某些运算符来说,成员函数是唯一合法的选择,例如前面说的”=”(赋值运算符)。有时,根据类设计,使用非成员函数版本可能更好(尤其是为类定义类型转换时)。其他情况下,这两种格式没有太大区别。

可以对运算符重载再进行重载

例如对于”-“运算符,它既可以表示减法,也可以表示自反,那么就可以实现两种:

1
2
3
4
5
6
7
8
9
10
11
12
13
//以向量为例
class Vector {
public:
double x;
double y;
Vector(double x, double y) : x(x), y(y) {}
Vector operator-(const Vector &v) {
return Vector(x - v.x, y - v.y);
}
Vecotor operator-(){
return Vector(-x, -y);
}
};

类的自动转换和强制类型转换

在c++中,如果一个类有接受一个参数的构造函数,则c++支持将与该参数相同类型的值转换为类。例如:

1
2
3
4
5
6
7
class A {
public:
int data;
A(int data) : data(data) {}
};

A a = 10;

程序将使用构造函数A(10)来创建一个临时对象,并将19.6作为初始化值,然后将该临时对象复制到a中。这一过程称为隐式转换。

c++中新增了关键字explicit用于关闭这种自动特性

1
2
3
4
5
6
7
8
class A {
public:
int data;
explicit A(int data) : data(data) {}
};

A a = 10;//报错,不能再隐式转换
A a = (A) 10;//仍然可以使用显示转换

explicit关键字只有用来修饰单参数的构造函数才有意义

建议不要使用隐式转换,最好将单参数的构造函数都加上explicit关键字,使用强制类型转换

转换函数

上面的例子是将整型转换成A类型,同时转换函数可以让A类型转换成整型。

1
2
3
4
5
6
7
8
9
10
11
class A {
public:
int data;
explicit A(int data) : data(data) {}
operator int() {
return data;
}
};

A a(10);
int b = a;//b = 10

声明转换函数注意以下几点

  • 转换函数必须是类方法
  • 转换函数不能指定返回类型
  • 转换函数不能有参数

同时c++11及之后可以将explicit关键字用于转换函数,这样就只能进行强制类型转换而不能进行隐式类型转换

使用转换函数的原则
谨慎使用转换函数,最好使用功能相同的非转换函数,例如上例中完全可以实现一个int to_int();的成员方法来实现相同的功能。

类和动态内存分配

static成员变量的声明及初始化

1
2
3
4
5
6
7
class MyClass {
public:
static double d;
};

//static成员变量初始化
double MyClass::d = 0.0;

一般不能在类声明中初始化静态成员变量,这是因为类声明只描述了如何分配内存,但不分配内存。对于静态类成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。初始化语句指出了类型,并使用了作用域运算符,但没有使用关键字static

但是如果静态成员是const整型或const枚举型,则可以在类声明中初始化。

static成员函数

  • 不能通过对象调用static成员函数,对于声明在共有部分的static成员函数,可以使用类名和作用域解析运算符来调用它。
  • static成员函数中不能使用this指针
  • static成员函数只能访问static成员变量

特殊成员函数

c++自动提供了下面这些成员函数:

  • 默认构造函数,如果没有定义构造函数
  • 默认析构函数,如果没有定义
  • 复制(拷贝)构造函数,如果没有定义
  • 赋值运算符(“=”),如果没有定义
  • 地址运算符(“&”),如果没有定义
复制(拷贝)构造函数

复制构造函数用于将一个对象复制到新创建的对象中,也就是说,它用于初始化过程中。复制构造函数的原型通常如下:

1
MyClass(const MyClass &);

调用复制构造函数的时机:

  • 新建一个对象并将其初始化为同类对象时,复制(拷贝)构造函数都将被调用。
  • 当函数按值传递对象或函数返回对象时(返回引用则不会调用),都将使用复制(拷贝)构造函数

默认的复制(拷贝)构造函数逐个复制非静态成员的值(即浅拷贝

赋值运算符

将已有对象赋值给另一个对象时,将使用重载的赋值运算符。

1
2
3
4
MyClass my_class_1;
MyClass my_class_2 = my_class_1;//使用复制(拷贝)构造函数,可能使用赋值运算符——可能先使用复制(拷贝)构造函数创建一个临时对象,然后使用赋值构造函数将这个临时对象赋值给my_class_2
MyClass my_class_3;
my_class_3 = my_class_1;//使用赋值运算符

同默认的复制(拷贝)构造函数一样,默认的赋值运算函数也对成员进行逐个复制(浅拷贝)

编写赋值运算符函数的规范:

  • 由于目标对象可能引用了以前分配的数据,所以函数应使用delete或delete[]来释放这些数据

  • 函数应当避免将对象赋给自身,首先这样做没有太大意义,并且,给对象重新赋值前,释放内存操作可能删除对象的内容(详见示例代码)

  • 函数最后返回一个指向调用对象的引用,这是为了能够连续赋值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    MyClass & operator=(const MyClass & my_class) {
    if (this == &my_class) {
    return *this;
    }
    //释放动态内存,如果有的话
    //如果没有上面的判断且this == &my_class的话,那么 delete p也会将my_class中的p delete掉,那么下面的内存拷贝就会出错
    delete p;
    data = my_class.data;
    p = new ...;//重新分配动态内存
    memcpy(...);
    }
调用拷贝构造函数还是赋值运算符函数?
1
2
3
4
MyClass my1(my2);//只调用拷贝构造函数
MyClass my1 = my2;//调用拷贝构造函数
MyClass my1;
my1 = my2;//调用赋值运算符函数

一般有新对象被创建时就会调用一个构造函数,可能就是拷贝构造函数

重载[]运算符的一个技巧

重载运算符最好能够返回引用,因为这样不仅能够使用”[index]”获取值,而且可以方便的为”[index]”处对应的值进行赋值。例如:

1
2
3
4
5
char & operator[](unsigned i);
const char & operator[](unsigned i) const;//const成员函数返回一定是const char &
string str;
char c = str[1];
str[1] = 'a';//如果返回不是引用那么修改的就只是一个临时对象而已

使用const版本的operator[]实现非const版本的operator[]

1
2
3
4
5
6
const char & operator[](unsigned i) const {
return value[position];
}
char & operator[](unsigned i) {
return const_cast<char &>(static_cast<const MyClass&>(*this)[position]);
}

第一次cast将this转换成const MyClass&类型,即为this添加const,第二次则从const operator[]的返回值中移除const。
只能用const版本来实现非const版本,注意不要用非const版本来实现const版本。是否需要const版本来实现非const版本取决于你自己。

包含类成员的类的逐成员复制

1
2
3
4
5
6
class MyBigClass {
public:
MySmallClass1 my_small_class1;
MySmallClass2 my_small_class2;
...
};

对于MyBigClass而言,默认的逐成员复制和赋值行为有一定的智能,逐成员复制或赋值将使用成员类型定义的复制(拷贝)构造函数和赋值运算符。然而,如果MyBigClass有需要定义复制(拷贝)构造函数和赋值运算符函数,则最好重新为MyBigClass编写复制(拷贝)构造函数和赋值运算符函数。

需要重新编写复制(拷贝)构造函数和赋值运算符函数的一种情况

如果一个类的成员是需要动态内存分配的,那么这个类一般是在构造函数中动态申请内存,而在析构函数中一般会释放该动态内存。

因为默认复制(拷贝)构造函数和赋值运算符函数是浅拷贝,所以浅拷贝复制的只是指向动态内存的指针的值,当其中一个对象被释放掉了,其析构函数释放掉动态内存,那个另外一个对象也将不再拥有该动态分配的内存。

这时就应该为该类重新编写复制(拷贝)构造函数和赋值运算符函数,让其进行深拷贝,即重新申请内存,而不是简单的指针赋值。

对于使用定位new运算符分配的动态对象

1
2
char *buffer = new char[100];
MyClass *p1 = new (buffer) MyClass;

由于对于定位new运算符不能使用delete,所以,对使用定义new运算符创建的对象,要显式的调用函数的析构函数

1
2
p1->~MyClass();
delete[] buffer;

嵌套结构和类

在类声明的结构、类或枚举被称为是嵌套在类中的,其作用域为整个类。这种声明不会创建数据对象,而只是指定了可以在类中使用的类型。如果声明是类的私有部分进行的,则只能在这个类使用被声明的类型,如果声明是在共有部分进行的,则可以从类的外部通过作用域解析运算符使用被声明的类。

成员初始化列表的语法

1
2
3
MyClass(int a, double b) : mem1(a), mem2(b), mem3(a * b + 1) {
...
}
  • 成员初始化列表格式只能用于构造函数
  • 必须使用这个格式来初始化非静态const数据成员,即单const修饰的成员
  • 必须用这种格式来初始化引用数据成员(一般很少有引用数据成员,因为这种设计很不好)
  • 当初始化列表包含多个项目时,这些项目被初始化的顺序为它们被声明的顺序,而不是它们在初始化列表中的顺序。如果代码使用一个成员的值作为另一个成员的初始化表达式的一部分时,初始化顺序就非常重要了。

成员初始化列表会覆盖类内初始化的成员的初始值。

1
2
3
4
5
6
7
8
9
10
class MyClass {
private:
int a = 0;//类内初始化

public:
MyClass() : a(10)//将会覆盖掉a,a=10
{

}
};

子类的初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class BaseClass {
private:
int a;
double b;
public:
BaseClass(int a, double b) : a(a), b(b) {}
};

class SubClass: public BaseClass {
private:
char c;
public:
SubClass(int a, double b, char c): BaseClass(a, b), c(c) {}
};

派生类的构造函数必须使用相邻基类的构造函数,且只能使用成员初始化列表的方式,创建派生类对象时,程序首先创建基类对象。如果不显示的调用基类的构造函数,程序将使用默认的基类构造函数(如果有的话,否则将会报错)

派生类对象过期时,程序将先调用派生类的析构函数,然后再调用基类析构函数

在派生类成员函数中调用父类的方法

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

class SubClass: public BaseClass {
public:
void func() {
BaseClass::func();
};
};

在派生类成员函数中调用父类定义的方法是使用作用域解析运算符来调用的。

多态

派生类和基类之间的特殊关系

基类指针可以在不进行显式类型转换的情况下指向派生类对象;基类引用可以在不进行显式类型转换的条件下引用派生类对象,也可以将派生对象赋给基类对象(向上强制转换)。但是不可以将基类对象赋给派生类对象和派生类引用,不可以把基类对象地址赋给派生类对象指针(向下强制转换)

虚函数(virtual function)与多态实现

如果没有使用关键字virtual,程序将根据引用类型或指针类型选择方法;如果使用virtual,程序将根据引用或指针指向的具体对象的类型来选择方法
不使用virtual:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class BaseClass {
public:
void func();
};

class SubClass: public BaseClass {
public:
void func() {};
};

BaseClass base;
SubClass sub;
BaseClass *b1 = &base;
BaseClass *b2 = ⊂
b1->func(); // 使用 BaseClass::func()
b2->func(); // 使用 BaseClass::func()
BaseClass &b3 = base;
BaseClass &b4 = sub;
b3.func(); // 使用 BaseClass::func()
b4.func(); // 使用 BaseClass::func()

使用virtual关键字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class BaseClass {
public:
virtual void func();
};

class SubClass: public BaseClass {
public:
virtual void func() override {};//只能对virtual方法标记override
};

BaseClass base;
SubClass sub;
BaseClass *b1 = &base;
BaseClass *b2 = ⊂
b1->func(); // 使用 BaseClass::func()
b2->func(); // 使用 SubClass::func()
BaseClass &b3 = base;
BaseClass &b4 = sub;
b3.func(); // 使用 BaseClass::func()
b4.func(); // 使用 SubClass::func()

经常在基类中将派生类会重新定义的方法声明为虚方法,方法在基类中被声明为虚方法后,它在所有派生类中自动成为虚方法。但是最好在派生类声明中使用virtual来指出哪些函数是虚函数,这可以增加程序的可读性

virtual关键字只用于类声明的方法原型中,而不用于方法实现中

总结实现多态的方法

  • 公有继承,因为只有公有继承是才允许将基类指针或基类引用指向派生类
  • 成员函数要用virtual修饰
  • 使用对象的引用或指针

虚析构函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class BaseClass {
public:
...
virtual ~BaseClass() {}
};

class SubClass: public BaseClass {
public:
...
virtual ~SubClass() {}
};


BaseClass *p1 = new BaseClass();
BaseClass *p2 = new SubClass();
...
delete p1;
delete p2;

对于上述代码,如果析构函数不是虚函数,当delete p2时,将只会调用基类的虚构函数。如果是虚析构函数,则当delete p2时,将会调用SubClass的析构函数。

所以,一般将类的析构函数定义为虚函数

静态联编和动态联编

将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding)。在编译过程中进行联编被称为静态联编(static binding),又称为早期联编(early binding)。在程序运行时选择正确的函数代码块,被称为动态联编(dynamic binding),又称为晚期联编(late dinding)

为什么有两种类型的联编?:
由于动态联编需要采用一些方法来追踪基类指针或引用指向的对象类型,这增加了额外的开销,所以静态联编的效率比动态联编的效率高,因此静态联编也被设置为c++的默认选择

虚成员函数与动态联编

编译器会对非虚方法使用静态联编,对虚方法使用动态联编。但是由于动态联编需要额外开销,所以对不需要重新定义的成员函数,不要将这些函数设置为虚函数,这样有两种好处:首先效率更高;其次,指出不要重新定义该函数。仅将那些需要被重新定义的方法声明为虚的

虚函数的工作原理

编译器处理虚函数的方法是:给每个对象添加一个隐藏成员,隐藏成员中保存了一个指向函数地址数组的指针,这个指针一般会成为对象的第一个数据成员。这个数组被称为虚函数表。虚函数表中存储了为类对象进行声明的虚函数的地址。无论类中包含的虚函数还是1个还是10个,都只需要在对象中添加一个地址成员,只是表的大小不同而已。使用虚函数表也就是为了不增大类所占的内存,并加快函数查找速度
虚函数机制

对于上图,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,虚函数表将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到虚函数表中。

如果使用类声明中定义的第一个虚函数,则程序将使用数组 中的第一个函数地址,并执行具有该地址的函数。如果使用类声明中的第三个虚函数,程序将使用地址为数组中第三个元素的函数。

虚表指针初始化的时机

序表指针的初始化在进入类构造函数之前,
一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base {
public:
Base() {
func();
}
virtual void func() {
std::cout << "Base" << endl;
}
};

class SubClass: Base {
public:
virtual void func() {
std::cout << "SubClass" << endl;
}
}

SubClass s;

最后打印的结构为“Base”,因为在进入s的构造函数之前,会先进行Base的构造,而在进入Base的构造函数之前,虚表指针被初始化为指向Base的虚函数表,所以这是执行虚函数调用的是父类的虚函数。当父类被构造完,进入自己的构造函数之前,虚表指针再被初始化为指向自己的虚函数表

关于虚函数的注意事项

  • 构造函数不能是虚函数,派生类不继承基类的构造函数,所以将类构造函数声明为虚的没什么意义。
  • 析构函数最好设计成虚函数,除非类不用做基类。也就是说即使基类不需要显示析构函数提供服务,也不应该依赖于默认析构函数,而应该提供析构函数,
  • 友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数。

在派生类中重新定义方法将隐藏方法

1
2
3
4
5
6
7
8
9
10
class Dwelling {
public:
void showperks(int a) const;
...
};

class Hovel : public Dwelling {
public:
void showperks() const;
};

如果不在继承类中重新定义showperks方法,则继承类中可以使用基类所有的showperks方法。但是重新定义将showperks()定义为一个不接受任何参数的函数。重新定义不会生成函数的两个重载版本,而是隐藏了接收一个int参数的基类版本。总之,重新定义继承的方法并不是重载

这引出了两条经验准则
第一,如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类应用或指针,则可以修改为指向派生类的引用或指针。这种特性被称为返回类型协变。

1
2
3
4
5
6
7
8
9
class Dwelling {
public:
Dwelling & build(int n);
};

class Hovel: public Dwelling {
public:
Hovel & build(int n);
};

注意这种例外只适用于返回值,而不适用于参数

第二,如果基类声明被重载了,则应在派生类中重新定义所有的基类版本,或者使用继承方法函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Dwelling {
public:
void showperks(int a) const;
void showperks(double x) const;
void showperks() const;
};
//方法1:重新定义所有的基类版本
class Hovel: public Dwelling {
public:
void showperks(int a) const;
void showperks(double x) const;
void showperks() const;
};
//方法2:使用继承方法函数
class Hovel: public Dwelling {
public:
using Dwelling::showperks;//can use all showperks now
void showperks(double x) const;//还可以定义自己的showperks(double x),而不使用基类的showperks(double x)。
void showperks(const char *) const;//还可以定义其他的showperks
};

如果派生类中只定义一个版本,则另外两个版本将被隐藏,派生对象将无法使用它们。如果不需要需要修改,则新定义可只调用基类版本

1
2
3
void Hovel::showperks() const {
Dwelling::showperks();
}

当typedef出现在类定义的私有部分

则只有在类中使用,在类外和子类中不能使用

私有继承

使用私有继承,基类的公有成员和保护成员都将成为派生类的私有成员,基类的私有成员子类无法访问。使用私有继承时,只能在派生类中访问基类的非私有成员及成员函数。

使用私有继承,不支持隐式向上转换(隐式向上转换:无需进行显示类型转换,就可以将基类指针或引用指向派生类对象)

保护继承

基类的公有成员和保护成员都将成为派生类的保护成员

使用using重新定义访问权限

使用保护继承或私有继承时, 基类的公有成员将成为保护成员或私有成员。假设要让基类的方法在派生类外面可用,方法之一是定义一个使用基类方法的派生类方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
class BaseClass {
public:
void pri_func() {
...
}
};

class SubClass : protect BaseClass {
public:
void pri_func() {
...
}
};

另一种方法是使用一个using声明,来指出派生类可以使用特定的基类成员,即使采用的是私有派生

1
2
3
4
class SubClass : private BaseClass {
public:
using BaseClass::pri_func;
};

注意using声明只使用成员名——没有圆括号、函数特征标和返回类型。且using声明只适用于继承,而不适用于包含