Oizys's Blog


  • Home

  • Categories

  • Archives

  • Tags

  • About

线性代数的本质(Essense of Linear Algebra)

Posted on 2018-01-03 | In 数学 |

本文主要是一篇系列视频观后总结,Essense of Linear Algebra 系列视频以几何的角度理解线性代数,形象地描述了线性代数的基本概念。视频原版位于Youtube,中英双语版可在BiliBili上访问。

一. 向量是什么 (What are Vectors?)

很精辟概括,一句话,

相加与数乘运算构成向量。

二. 线性组合 张成的空间和基 (Linear Combinations, Span and Basis Vectors)

线性相关(Linearly Dependent):

线性无关(Linearly Independent):

基的严格定义:向量空间的一组基(Basis)是张成(Span)改空间的一个线性无关(Linearly Independent)的向量集。

三. 矩阵和线性变换(Linear Transformation and Matrices)

变换和线性变换的定义

变换(Transformation)本质上就是函数(Function),接收一个向量(Vector Input),输出一个向量(Vector Output),变换是复杂的,可能将原本笔直的输入向量变得扭曲。

线性变换(Linear Transformation)则保持向量的加法和数乘运算。它有两条性质:

  1. Lines remain lines 直线依旧是直线
    **注:不止平行与轴的直线,斜线(比如下图中的对角线)在变换后也应该是直线。
  2. Origin remain fixed 原点保持固定

在向量中,线性变换可以看作是保持网格线平行且等距分布。

这是在这里形象直观的定义,在本文的最后一节,我们将给出确切的抽象定义。当然,抽象定义并不只是适用于网格中的有方向的直线。

线性变换和矩阵的关系

空间中的任意向量都可以用该空间的基向量线性组合表示。

一个二维线性变换可以由四个数字完全确定,即两个变换后的基向量i的坐标和基向量j的坐标,分别作为列向量组合成二维矩阵,就可以用矩阵表示一个线性变化,因此,

线性变换和矩阵是等价的。

一个矩阵与向量相乘就意味着对这个向量做了一次线性变换,

其中,[a, c] 为变换后基向量i的坐标, [b, d] 为变换后基向量j的坐标。

下图说明了一个具体的例子。

列线性相关

当变换后的两个向量线性相关时,空间会被压缩成一条直线。

三维空间下的线性变换

四. 矩阵乘法与复合线性变换(Matrix Multiplication as Composition)

由上一节的理解,我们其实可以很自然的推论出,两个矩阵相乘的几何意义,其实就是两个线性变换的相继作用。

过程是这样进行的,M1中的[e, g] 和[f, h]就是第一变换后的基向量,随后,M2作用于M1,即分别与第一次变换后的基变量相乘,由将这两个变量又进行了一次变换,最终得到二次变换后的基变量[ae+bg, ce+dg]和[af+bh, cf+dh]。

五. 行列式 (The Determinant)

行列式的几何意义

在二维空间中,当进行线性变换时,会改变基向量i和基向量j围成的单位正方形的形状,从而形成一个新的平行四边形。平行四边形面积的缩放比,就是代表线性变换矩阵的行列式大小。

行列式的方向,则取决于空间的定向,即满足右手定则为正,反之为负。对于二维矩阵,线性变换后的基向量i在基向量j的
右侧,则矩阵行列式为正,在左侧则为负。

三维空间中,行列式代表着1*1*1的单位立方体的体积缩放比例。

行列式的几何计算

二维空间中,由行列式和面积的关系,我们只要计算出变换后的平行四边形的面积,就可以求出行列式的大小,然后再根据空间定向确定其方向。

类似的,三维空间中,就是要计算出变换后平行六面体的体积,这里直接给出计算公式。(或许这个比直接套用行列式公式要复杂得多。。)

行列式为零

当行列式等于0,就意味着这个线性变换使得空间的维度降低了。

对于二维空间,有可能变换后的空间变成一维(一条直线),零维(一个原点)。
对于三维空间,有可能变换后的空间变成二维(一个平面),一维(一条直线),零维(一个原点)。

行列式为零的特性在后面的章节中有及其重要的作用。

六. 逆矩阵,秩,列空间和零空间 (Inverse Matrices, Rank, Column Space adn Null Space)

逆矩阵

首先,我们先引入线性方程组(Linear System of Equations)。

很显然能发现线性方程组和矩阵向量乘法相似。

这个方程的解依赖于矩阵A所代表的变换,是将空间压缩成一条线或者一个点的低维变换,还是变换成和初始状态一样的完整二维空间。由上节分析的结论,我们将它们分成两种情况,A的行列式为零,A的行列式不为零。

  1. 当detA != 0时
    要求出向量x,就需要将A的逆矩阵与向量v相乘,进行逆变换求出向量x的解。
    也就是说,只要A的行列式为零,它的逆矩阵就一定存在。

  2. 当detA = 0时
    空间被压缩成低维,这时不存在逆矩阵。但是却可能存在解,只要变换后的向量落在变换后的空间中。

秩和列空间

行列式这一节以及上述情况讨论,我们知道行列式为零代表着变换后的空间降维了,具体变成哪种低维度,但从行列式的值看不出来,因此,我们引入秩(Rank)的概念。

秩代表着变换后空间的维数。

三维空间中,一个变换矩阵的最大秩为3,这意味着基向量仍然能张成整个三维空间。
当变换后的空间被压缩成一个平面,则这个变换矩阵的秩为2;
当变换后的空间被压缩成一条直线,则这个变换矩阵的秩为1;
当变换后的空间被压缩成一个原点,则这个变换矩阵的秩为0。

不管是一条直线,一个平面还是三维空间,所有可能的变换的结果的集合被称为矩阵的列空间(Column Space)。

所有可能的输出向量的集合,叫做A的列空间。

我们已经知道,矩阵的列告诉我们基向量变换后的位置。列空间其实就是矩阵的列张成的空间。

所以更精确的秩的定义是:列空间的维数。

当秩最大时和列数相等,称之为满秩(Full Rank)。

零空间

零向量[0,0]一定在列空间中,当满秩时,唯一能在变换后落在原点的就是零向量本身。

但对于一个非满秩矩阵,它将空间压缩到一个更低的维度,
如果将一个二维线性变换将空间压缩到一条直线,那么沿着某个不同方向直线上所有的向量就会被压缩到原点。
如果将一个三维线性变换将空间压缩到一个平面,也会有某条直线上的所有向量被压缩到原点。
如果将一个三维线性变换将空间压缩到一条直线,则某个平面上的所有向量都会被压缩到原点。

变换后落到原点的向量的集合,被称为“零空间”(Null Space)或者 “核”(Kernel)。

对线性方程组来说,当时,零空间刚好是线性方程组的解。

七. 非方阵 (Nosquare Matrices as Transformation Between Dimensions)

非方阵(Nosquare Matrices)可以进行不同维度空间中的转换。

二维升三维:

三维降二维:

八. 点积和对偶性 (Dot Product and Duality)

点积的几何意义是,

点积的大小:向量w在向量v方向的投影长度与向量v的长度的乘积。
点积的方向:根据两个向量的夹角确定,小于90度为正,大于90度为负。

1X2变换矩阵的作用是将二维向量变成数轴上的一个数。1X2矩阵和二维向量之间存在着对偶性,点积可以转化成一个1X2矩阵作用在一个向量上。

九. 叉积 (Cross Product)

叉积的绝对值就是两个向量围成的平行四边形的面积。计算方法就是:矩阵的行列式。

以上主要是针对二维空间中的向量而言,对于三维空间中的向量,叉积的计算与二维空间不同,它并不是输出一个有垂直于二维平面方向的数,而是接受两个向量,输出一个向量。它的公式是:

所以叉积严格的几何定义是,两个向量叉积产生的是第三个向量。向量的长度是两个输入向量张成的平行四边形的面积,向量的方向垂直于两个向量张成的平行四边形,正负取决于右手定则。

要理解这个公式的几何意义,是一个很有趣的过程。

假设两个三维空间中的向量v和w,我们采取以下步骤说明:

  1. 根据v和w定义一个三维到一维的线性变换。
  2. 找到它的对偶向量。
  3. 说明这个对偶向量就是。

首先确定将叉积看成一个函数,对于任一输入变量[x, y, z],与一直的向量v和w组成任意平行六面体。

为什么把它看成一个函数呢?因为叉积运算具有一个非常重要的性质,它是线性的(线性的定义会在最后一节抽象出来)。一旦知道它是线性的,就可以用矩阵来描述这个函数。这个矩阵就是步骤1需要我们定义的三维到一维的变换矩阵。

看到这里,根据上一节的对偶性,有没有想将这个1X3矩阵与向量的乘法写成两个三维向量点积的冲动?

没错,到这里我们就完成了步骤2,将这个对偶向量定义为向量p。与此同时,我们也可以通过代数运算(待定系数法),解出向量p的值。

那么算出的这个向量p到底是什么呢?答案就是,这个向量p就是向量v与向量w的叉积!下面就是最有酷的一部分(Now for the Cool Part),步骤3了。

要理解p是什么,我们需要回顾之前的两个知识点。

  1. 行列式的几何意义
  2. 向量点积的几何意义

对于1,观察等式右边,是一个三维矩阵的行列式。它代表着由向量v,向量w和任意向量[x, y, z]组成的平行六面体的体积。

对于2,观察等式左边,向量p与任意向量[x, y, z]点积。它的几何意义是,将任意向量[x, y, z]投影到p上,然后将投影长度与p的长度相乘。

平行六面体的体积 = 向量v和w组成的平面面积 * 垂直于vw平面的高 
p与[x, y, z]的点积 = p的长度 * [x, y, z]在p上的投影长度

观察两个等式以及示意图,是不是发现了p到底是什么?很显然,p必然与vw垂直,并且长度等于两个向量张成的平行四边形的面积。往前翻一翻叉积的几何定义,神奇的发现与p的描述一致,

因此,这个向量p就是向量v和向量w的叉积。

十. 基变换 (Change of Basis)

基变换其实就是从自然坐标系([1, 0], [0, 1])变换到任一个坐标系([a, c], [b, d])的过程。这个变换矩阵就是

反过来的变换,则需要这个矩阵的逆矩阵。

例如在自然坐标系下的[-1, 2],在另一坐标系下就变成了[-4, 1]

可以形象的描述成两种语言的翻译。

基变换的定义给了相似矩阵(Similar Matrix)直观的解释。

相似矩阵就是:用Jennifer的语言来表述变换后的向量。换句话说,实际上相似矩阵就是在不同坐标系下进行的相同的变换,因为变换相同,所以相似矩阵和原矩阵具有相等的行列式。

变换前后的向量都是在Jennifer的坐标系下,而中间的变换矩阵则是同一个变换,由图中颜色可知,这里代表着在自然坐标系下。

十一. 特征向量和特征值 (Eigenvectors and Eigenvalues)

特征向量几何意义

特征向量和特征值的几何意义:特征向量就是进行线性变换之后仍然留在它们所张成空间的向量,特征值就是这个向量拉伸的倍数,当向量反向时,特征值为负。

特征向量有什么应用呢?考虑一个三维空间中的旋转,如果你能找到这个旋转的特征向量,也就是旋转之后仍留在它所张成空间的向量,那么你就找到了它的旋转轴。

另外,像这个旋转的特征值必为1,因为旋转并不缩放任何一个向量。

特征向量特征值的计算思想。

用符号表示的话,以下是特征向量的概念,

等式左边是一个矩阵向量乘积,右边则是一个向量数乘,经过一系列等式转化后,得出如果要求特征值,在特征向量非零的情况下,行列式必须等于0。从而求出特征值,最后,代入原表达式,利用线性方程组求出特征向量。

特征基

变换后的基向量正好是特征向量,则这组基被称为特征基(Eigenbasis)。

特征基变换矩阵是一个对角矩阵(Diagonal Matrix),所有的基向量都是特征向量,矩阵的对角元是它们所属的特征值。

对角矩阵有一个性质,n个相同对角矩阵相乘是比较好计算的,每个对角元都是自己的n次幂。

然而,如果要直接计算n个相同的非对角矩阵相乘,则是一件很痛苦的事。

所以很自然的想到,可不可以将一个非对角变换矩阵变成一个对角变换矩阵。答案是肯定的,只要这个非对角矩阵能选出一组特征向量来张成全空间的,那么就可以变换坐标系,使得这些特征向量就是基向量,而这个非对角变换矩阵将变成一个对角变换矩阵。

这个过程又叫做,相似对角化。

因此n个非对角矩阵相乘,就可以先计算相似对角化后的对角矩阵相乘,再通过基变换变回自然坐标系下的结果,计算复杂度就简便了很多。

当然,并不是所有的非对角矩阵都能相似对角化,矩阵的特征向量必须足够多,满足可以张成一个全空间。比如剪切矩阵就只有一个特征向量,数量不够,无法相似对角化。

十二. 抽象向量空间 (Abstact Vector Spaces)

最后的最后,我们回到最初的问题,什么是向量?(What are vectors?)。

相加与数乘运算构成向量。

我们上面讨论的向量都是一个箭头或者一组数字,从某种意义上来说,函数(Function)也是一种向量。函数拥有相加的性质,以及与实数数乘的性质。同时,函数的线性变换有一个完全合理的解释,那就是导数。

这里,我们终于可以给出线性的严格定义而不是以网格来描述了,这是一个由直观到抽象的过程。满足以下两条性质的变换是线性的,这两条性质是“可加性”和“成比例”。

求导运算满足以上两条性质,所以它是一种线性变换。而只要是线性变换,都可以用矩阵表示,这是本文最重要的一个思想。因此,我们可以将求导运算抽象成矩阵与向量相乘的运算。

乍一看毫不相关的两者,其实是一家人。实际上我们讨论的线性代数中的一些概念,在函数中都有直接的类比。

回到“向量是什么”这个问题上来,数学上有很多类似向量的事物,只要你处理的对象集具有数乘和相加的概念,不管是空间中的箭头,一组数,还是函数的集合,甚至是你自己定义的某些奇怪的东西,线性代数所有关于向量,线性变换和其他的一些概念都应该适用于它。这些类似向量的事物构成的集合被称为“向量空间(Vector Space)”。

如果你作为数学家,可能很想说,

“大伙听好了,我可不想考虑你们构思出来的乱七八糟的向量空间!”

所以你需要做的事建立一系列向量加法和数乘必须遵守的规则,这些规则被称为“公理(axioms)”。

如果要让所有已建立好的理论和概念适用于一个向量空间,它必须满足这八大公理。

这,就是数学家眼里的“向量”。

它是抽象的,普适的。

C++11 新特性概述

Posted on 2017-12-26 | In cpp |

本文概述了C++11在C++98基础上主要的一些新特性,内容主要转自这里。

一、关键字和新语法

1.1 auto关键字

C++11以前,auto是用来表明变量是放在自动存储区(栈区)的。C++11开始,废弃了这个功能,转而用来进行类型判断,编译器会根据上下文来判断auto变量的类型。auto作为函数返回值时,只能用于定义函数,不能用于声明函数。下面的写法是可行的。也就是说auto附近必须有上下文可以推断类型。

1
2
3
4
5
6
7
#pragma once
class Test {
public:
auto TestWork(int a, int b) {
return a + b;
}
};

1.2 nullptr关键字

以前,我们都是用NULL 或者0来表示空指针,但是由于NULL可以隐式转换为整型,在函数调用时可能会出现问题。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Test {
public:
void TestWork(int index) {
std::cout << "TestWork 1" << std::endl;
}
void TestWork(int* index) {
std::cout << "TestWork 2" << std::endl;
}
};
int main() {
Test test;
test.TestWork(NULL);
test.TestWork(nullptr);
}

上面的代码,用NULL调用时,会先匹配整型,这不是我们希望的。而使用nullptr就可以规避这个问题,因为nullptr不允许隐式转换为整型,只允许隐式转换为bool型(false)和任意指针类型(空指针)。

1.3 基于范围的for循环

C++11 引入了一种更为简单的for语句,这种for语句可以很方便的遍历容器或其他序列的所有元素。

1
2
3
4
5
6
int main() {
int numbers[] = { 1,2,3,4,5 };
std::cout << "numbers:" << std::endl;
for (auto number : numbers) {
std::cout << number << std::endl;
}

1.4 大括号初始化器

C++11中全面加入了列表初始化的功能,包括对vector,map,值类型,struct等等都可以使用列表初始化,还可以在函数中返回一个花括号括起来的列表,而在这之前我们只能对数组进行列表初始化。

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
//数组列表初始化
int xx[5]={1,2,3,4,5};
int yy[]={6,7,8,9,0};
//值类型进行初始化
int a{10};
int b={10};
int c={10.123}; // 编译器报错,g++ 5.3.1当列表初始化用于值类型的时候,如果有精度损失,编译器会报错。
//列表初始化还可以用结构体
typedef struct Str{
int x;
int y;
} Str;
Str s = {10,20};
//列表初始化类,必须是public成员,如果含有私有成员会失败
class Cls {
public:
int x;
int y;
};
Cls c = {10,20};
//vector不仅可以使用列表初始化,还可以使用列表进行赋值,数组不能用列表赋值
vector<int>v1={1,2,3,4,5,6,7,8,9}; // 初始化
vector<int>v2;
v2={3,4,5,6,7}; //赋值
//map列表初始化
map<string ,int> m = {
{"x",1},
{"y",2},
{"z",3}
};
//用函数返回初始化列表只展示关键代码,相关头文件自行添加
//同理结构体,类,map的返回也可以使用初始化列表返回
vector<int> getVector() {
return {1,2,3,4,5};
}
int main() {
vector<int> v = getVector();
cout<<v[0]<<v[1]<<v.size()<<endl;
return 0 ;
}

1.5 long long类型(长整型)

long long 类型实际上没有在C++ 98中存在,而之后被C99标准收录,其实现在市面上大多数编译器是支持 long long 的,但是这个类型正式成为C++的标准类型是在C++11中。标准要求long long至少是64位也就是8个字节。一个字面常量使用LL后缀表示long long类型,使用ULL后缀表示unsigned long long 类型。

1.6 char16_t 与 char32_t 类型(宽字符型)

为了表示更宽的字符集,C++11提供了2字节和4字节字符整型

1.7 raw 字符串 R”( )”

C++11提供了写字符串常量的方式,R"()"内的内容会原原本本的显示出来,不会有转义的问题,正则表达式这种字符串很友好。

1.8 using 类型别名

类型别名其实早在C语言中就有了,一般情况下我们使用关键字typedef来声明一个类型的别名,在C++11中增加了另一种声明类型别名的方法就是使用using关键字,using关键字在C++11以前一般用来引用命名空间。

1
2
3
4
5
typedef int INT; // 右侧符号代表左侧
using INT2 = int; // 左侧符号代表右侧
INT a = 20;
INT2 b = 30;

1.9 decltype类型指示符

有时候会有这样的需求,我们需要知道一个表达式的类型,并使用该类型去定义一个变量,例如:

1
2
3
int a = 10;
int b = 20;
auto c = a + b; // OK a+b的类型是int,此时c的类型是int,并且c的值是 a+b

auto可以解决部分问题,例如我们定义的变量的类型就是表达式 a+b 的类型,但是如果我们仅仅需要定义一个与表达式 a+b 的类型相同的变量,但是我们又不希望将表达式a+b的值赋值给刚刚定义的变量,我们希望赋另外一个值或者是仅仅定义变量而不赋值呢。 这就需要用到C++11 提供的另一个类型说明符 decltype了。decltype作用于一个表达式,并且返回该表达式的类型,在此过程中编译器分析表达式的类型,并不会计算表达式的值。例如

1
2
3
int a = 10;
int b = 20;
decltype(a+b) c = 50; // OK c的类型就是 a+b 的类型int

对于引用类型decltype有一些特别的地方:

1
2
3
4
int a = 20 ;
int &b = a;
decltype(b) c ; // Error c是引用类型必须赋值
decltype(b) d = a; // OK d是引用类型,指向a

可以看到decltype如果作用于一个引用类型,其得到的还是一个引用类型。我们知道一个引用类型在使用的时候一般会当作其关联的那个变量的同义词处理,例如如果使用 cout<<b<<endl; 其中b实际上相当于a,但是decltype作用于引用类型的时候会保留引用性质。

如果一个表达式是一个解指针引用的操作,decltype得到的也是一个引用类型:

1
2
3
4
5
int a = 20 ;
int *p = &a;
decltype(*p) c = a; // c的类型是int&
c = 50;
cout<<a<<endl; // 输出50

当decltype作用于一个变量的时候,变量加不加括号是有区别的,例如:

1
2
3
int a = 20;
decltype(a) b = 30; //ok b的类型是 int
decltype((a)) c = a ; // ok c的类型是int& 其关联变量 a

加上括号之后编译器会把(a)当作是一个表达式处理,而变量是一种可以作为赋值语句左值的表达式,所以会解释成引用类型。

二、智能指针与内存管理

C++11 从boost 库中引入了share_ptr,unique_ptr,weak_ptr做智能指针,进行内存管理。

C++11以前,有auto_ptr,但是一个对象只能有一个auto_ptr持有(防止重复析构所引用的对象),它利用的是栈内存释放规则,回收auto_ptr时,调用它的析构函数,在析构函数里调用所引用对象的析构。

C++11摒弃了auto_ptr,使用另外三个来做动态内存管理。智能指针是为了解决堆内存管理而存在的。要是用智能指针,必须包含memory头文件,这些智能指针的定义都在这个文件中:

1
#include <memory>

智能指针重载了解除引用运算符 * 使得他们可以像普通指针一样来获得所引用的对象的内容。

1
2
operator * 接触引用
T* get() 获得对象的地址

2.1 unique_ptr

unique_ptr作为auto_ptr的替代品,比auto_ptr更安全,因为它不允许赋值,也就是不能把对象所有权转移,会抛出编译错误。如:

1
2
3
unique_ptr<string> p1(new string("hello")); // #1
unique_ptr<string> p2 = p1; // #2
p2 = p1; // #3

#2和#3都是违法的,不允许使用复制构造函数和赋值运算符,这种特性是通过C++11的移动构造函数和右值引用实现的。

而在auto_ptr中,上面的语法都是正确的:

1
2
3
4
auto_ptr<string> p1(new string("string"));
auto_ptr<string> p2 = p1;
p2 = p1;
int n = p1.size(); // 错误,p1已经丧失所有权

p2会获得堆变量new string("string")所有权,如果再次使用p1,程序在运行时会崩溃,这样很不安全,于是引入了unique_ptr来禁止引用转移。

2.2 shared_ptr

shared_ptr允许多个shadred_ptr引用同一个对象,和unique_ptr相反。
shared_ptr允许多个指针指向同一个对象,unique_ptr则独占所指向的对象,我们主要说明shared_ptr的使用。通过使用make_shared<type>()函数产生智能指针对象。

1
2
shared_ptr<int> p = make_shared<int>(40); // p指向一个值为40的int对象
shared_ptr<string> p2 = make_shared<string>(10,'c'); //指向值为'cccccccccc'的string对象

make_shared<type>()函数中传递的值要与对应的type的构造函数相匹配,实际上应该就是直接调用的对应type的构造函数。

我们可以使用new初始化的指针来初始化智能指针:

1
2
share_ptr<int> p (new int(40));
p.get(); // 使用share_ptr<type>的get()函数来获得其关联的原始指针。

shared_ptr对象在离开其作用域(例如一个函数体),会自动释放其关联的指针指向的动态内存,就像局部变量那样。另外多个shared_ptr可以指向一个对象,当最后一个shared_ptr对象销毁的时候才会销毁其关联的那个对象的动态内存。这里使用了引用记数。

有个地方需要注意,当删除一个智能指针时,并不影响其它两个智能指针的继续使用。因为该片内存添加了一个引用计数,每shared_ptr一次,引用计数+1;每次调用析构函数,引用计数减一。直到最后一个智能指针删除,才会释放内存。

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
#include<iostream>
using namespace std;
template<typename T>
class smartptr{
public:
smartptr(T* ptr=0);
smartptr(const smartptr& src);
smartptr& operator= (const smartptr& src);
T& operator* (){
if(_ptr){
return *_ptr;
}
}
T* operator-> (){
if(_ptr){
return _ptr;
}
}
~smartptr();
void print_data(){//不能重载运算符,不然会多算一个指针
cout<<(*_ptr)<<" "<<*_referece_count<<endl;
}
private:
size_t* _referece_count;//引用计数
T* _ptr;
void releasecount();
};
template<typename T>
void smartptr<T>::releasecount(){
if(_ptr){
//--(*_referece_count);
if(--(*_referece_count)==0){
delete _ptr;
delete _referece_count;
}
}
}
template<typename T>
smartptr<T>::smartptr(T* ptr):_ptr(ptr),_referece_count(new size_t){
if(ptr){
*_referece_count=1;
}
else{
*_referece_count=0;
}
}
template<typename T>
smartptr<T>::smartptr(const smartptr& src){
if(this!=&src){
_ptr=src._ptr;
_referece_count = src._referece_count;
(*_referece_count)++;
}
}
template<typename T>
smartptr<T>& smartptr<T>::operator= (const smartptr& src){
if(_ptr == src._ptr){
return *this;
}
releasecount();
_ptr = src._ptr;
_referece_count=src._referece_count;
(*_referece_count)++;
return *this;
}
template<typename T>
smartptr<T>::~smartptr(){
if(--*(_referece_count)==0){
delete _ptr;
delete _referece_count;
}
}
int main() {
smartptr<char>cp1(new char('a'));
cp1.print_data();
smartptr<char>cp2(cp1);
cp2.print_data();
cp1.print_data();
smartptr<char>cp3;
cp3 = cp2;
cp2.print_data();
smartptr<char>cp4(new char('b'));
cp4.print_data();
}

三、新增容器

3.1 std::array

用于创建位于栈区的定长数组,和数组的区别是增加了迭代器。

1
2
3
4
5
6
7
8
9
10
11
#include <array>
int main() {
std::array<int, 4> arrayDemo = { 1,2,3,4 };
std::cout << "arrayDemo:" << std::endl;
for (auto itor : arrayDemo) {
std::cout << itor << std::endl;
}
int arrayDemoSize = sizeof(arrayDemo);
std::cout << "arrayDemo size:" << arrayDemoSize << std::endl;
return 0;
}

3.2 std::forward_list 单向链表

std::forward_list为C++新增的线性表,与list区别在于它是单向链表。我们在学习数据结构的时候都知道,链表在对数据进行插入和删除是比顺序存储的线性表有优势,因此在插入和删除操作频繁的应用场景中,使用list和forward_list比使用array、vector和deque效率要高很多。

3.3 std::unordered_map

std::unordered_map与std::map用法基本差不多,但STL在内部实现上有很大不同,std::map使用的数据结构为二叉树,而std::unordered_map内部是哈希表的实现方式,哈希map理论上查找效率为O(1)。但在存储效率上,哈希map需要增加哈希表的内存开销。

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
#include <iostream>
#include <string>
#include <unordered_map>
int main()
{
std::unordered_map<std::string, std::string> mymap =
{
{ "house","maison" },
{ "apple","pomme" },
{ "tree","arbre" },
{ "book","livre" },
{ "door","porte" },
{ "grapefruit","pamplemousse" }
};
unsigned n = mymap.bucket_count();
std::cout << "mymap has " << n << " buckets.\n";
for (unsigned i = 0; i<n; ++i)
{
std::cout << "bucket #" << i << " contains: ";
for (auto it = mymap.begin(i); it != mymap.end(i); ++it)
std::cout << "[" << it->first << ":" << it->second << "] ";
std::cout << "\n";
}
return 0;
}

3.4 std::unordered_set

std::unordered_set的数据存储结构也是哈希表的方式结构,除此之外,std::unordered_set在插入时不会自动排序,这都是std::set表现不同的地方。

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
#include <iostream>
#include <string>
#include <unordered_set>
#include <set>
int main() {
std::unordered_set<int> unorder_set;
unorder_set.insert(7);
unorder_set.insert(5);
unorder_set.insert(3);
unorder_set.insert(4);
unorder_set.insert(6);
std::cout << "unorder_set:" << std::endl;
for (auto itor : unorder_set) {
std::cout << itor << std::endl;
}
std::set<int> set;
set.insert(7);
set.insert(5);
set.insert(3);
set.insert(4);
set.insert(6);
std::cout << "set:" << std::endl;
for (auto itor : set) {
std::cout << itor << std::endl;
}
}

四、lambda表达式(匿名函数)

lambda表达式是C++11最重要也最常用的一个特性之一。lambda来源于函数式编程的概念,也是现代编程语言的一个特点。

4.1 函数式编程简介

定义:简单说,“函数式编程”是一种“编程范式”。它属于“结构化编程”的一种,主要思想是把运算过程尽量写成一系列嵌套的函数调用。

特点:

  1. 函数是“第一等公民”,可以赋值给他其他变量,也可以做为参数,返回值。

  2. 只用“表达式”,不用“语句”。“表达式”是一个单纯的运算过程,总是有返回值;“语句”是执行某种操作,没有返回值。

  3. 没有副作用。函数保持独立,所有功能就是返回一个新的值,其他什么都不做,不修改外部变量的值。

  4. 引用透明。函数的运行不依赖于外部变量或“状态”,只依赖于输入的参数,只要参数相同,返回值就相同。

4.2 lambda 表达式

lambda表达式提供了一种匿名函数的实现方式。

1
[捕获列表](参数列表) -> 返回类型 {函数体}

其中,参数列表、返回类型可以省略,其余不可省略,如:

1
[x] {} ; // 可以,最起码得这样

lambda表达式的返回值由函数体的return决定,如果函数体内无return ,默认是void。只有被捕获的变量才能在函数体重访问到lambda表达式。

1
[capture_block](parameters) mutable exception_specification->return_type{ body }

lambda表达式包含以下部分:

捕捉块(catpure block): 指定如何捕捉所在作用域中的变量,并供给lambda主体使用。

参数(parameter): (可选)lambda表达式使用的参数列表。只有在不使用任何参数,并且没有自定mutable、一个exception_specification 和一个return_type的情况下可以忽略该列表,返回类型在某些情况下也是可以忽略的,详见对return_type的说明:eg: [] {return 10;}

参数列表和普通函数的参数列表类似,区别如下:

  • 参数不能有默认值。
  • 不允许变长参数列表。
  • 不允许未命名的参数。

mutable:(可选)如果所在作用域的变量是通过值捕捉到,那么lambda表达式主体中可以使用这些变量的副本。这些副本默认标记为const,因此lambda表达式的主体不能修改这些副本的值。如果lambda表达式标记为mutable,那么这些副本则不是const,因此主体可以修改这些本地副本。

exception_specification:(可选)用于指定lambda可以抛出的异常。

return_type:(可选)返回值的类型。如果忽略了return_type,那么编译器会根据以下原则判断返回类型:
如果lambda表达式主体的形式为{return expression;},那么表达式return_type的类型为expression类型。
其他情况下的return_type为void。

下面的例子演示了如何创建一个lambda表达式并立即执行这个表达式。这行代码定义了一个没有返回值也没有任何参数的lambda表达式。

注意:尾部的(),这对括号使得这个lambda表达式立即执行:

1
[] {cout << "Hello from Lambda" << endl;} ();
1
2
string result = [](const string & str) -> string { return "Hello from " + str; }("second Lambda");
cout << "Result: " << result << endl;

输出如下:

1
Result: Hello from second Lambda

根据前面的描述,这个例子中的返回值可以忽略:

1
string result = [](const string & str){ return "Hello from " + str; }("second Lambda");

还可以保存lambda表达式的指针,并且通过函数指针执行这个lambda表达式。使用C++11的auto关键字可以轻松地做到这一点:

1
2
3
auto fn = [](const string& str) {return "hello from " + str; };
cout << fn("call 1") << endl;
cout << fn("call 2") << endl;

输出如下:

1
2
Hello from call 1
Hello from call 2

4.3 捕捉块详解

lambda表达式的方括号部分称为lambda捕捉块(capture block),在这里可以指定如何从所在作用域中捕捉变量。捕捉变量的意思是可以在lambda表达式主体中使用这个变量。有两种方式:

[=]:通过值捕捉所有变量
[&]:通过引用捕捉所有变量

指定空白的捕捉块[]表示不从所在作用域中捕捉变量。还可以酌情决定捕捉那些变量以及这些变量的捕捉方法,方法是指定一个捕捉列表,其中带有可选的默认捕捉选项。前缀为&的变量通过引用捕捉。不带前缀的变量通过值捕捉。默认捕捉应该是捕捉列表中的第一个元素,可以是=或&。

例如:
[&x] 只通过引用捕捉x,不捕捉其他变量。
[x] 只通过值捕捉x,不捕捉其他变量。
[=, &x,&y] 默认通过值捕捉,变量x和y例外,这两个变量通过引用捕捉。
[&, x] 默认通过引用捕捉,变量x例外,这个变量通过引用捕捉。
[&x, &y] 非法,因为标志符不允许重复。
[this] 捕获当前类中的this指针,让lambda表达式拥有和当前类成员函数同样的访问权限。如果已经使用了&或者=,就默认添加此选项。捕获this的目的是可以在lamda中使用当前类的成员函数和成员变量。

通过引用捕捉变量的时候,一定保证当lambda表达式在执行的时候,这个引用还是可用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class A {
public:
int i_ = 0;
void func(int x,int y) {
auto x1 = [] { return i_; }; //error,没有捕获外部变量
auto x2 = [=] { return i_ + x + y; }; //OK
auto x3 = [&] { return i_ + x + y; }; //OK
auto x4 = [this] { return i_; }; //OK
auto x5 = [this] { return i_ + x + y; }; //error,没有捕获x,y
auto x6 = [this, x, y] { return i_ + x + y; }; //OK
auto x7 = [this] { return i_++; }; //OK
}
};
int a=0 , b=1;
auto f1 = [] { return a; }; //error,没有捕获外部变量
auto f2 = [&] { return a++ }; //OK
auto f3 = [=] { return a; }; //OK
auto f4 = [=] {return a++; }; //error,a是以复制方式捕获的,无法修改
auto f5 = [a] { return a+b; }; //error,没有捕获变量b
auto f6 = [a, &b] { return a + (b++); }; //OK
auto f7 = [=, &b] { return a + (b++); }; //OK

两个问题说明:

1.一个容易出错的细节是lambda表达式的延迟调用,lambda表达式按值捕获了所有外部变量。在捕获的一瞬间,a的值就已经被复制了。如果希望lambda表达式在调用时能即时访问外部变量,我们应当使用引用方式捕获。

1
2
3
4
5
6
7
8
9
int a = 0;
auto f = [=] { return a; };
a+=1;
cout << f() << endl; //输出0
int a = 0;
auto f = [&a] { return a; };
a+=1;
cout << f() <<endl; //输出1

2.虽然按值捕获的变量值均补复制一份存储在lambda表达式变量中, 修改他们也并不会真正影响到外部,但我们却仍然无法修改它们。

那么如果希望去修改按值捕获的外部变量,需要显示指明lambda表达式为mutable。

需要注意:被mutable修饰的lambda表达式就算没有参数也要写明参数列表。

原因:lambda表达式可以说是就地定义仿函数闭包的“语法糖”。它的捕获列表捕获住的任何外部变量,最终均会变为闭包类型的成员变量。按照C++标准,lambda表达式的operator()默认是const的,一个const成员函数是无法修改成员变量的值的。而mutable的作用,就在于取消operator()的const。

1
2
3
int a = 0;
auto f1 = [=] { return a++; }; //Error
auto f2 = [=] () mutable { return a++; }; //OK

4.4 将lambda表达式用作返回值

一个没有指定任何捕获的lambda函数,可以显式转换成一个具有相同声明形式函数指针

1
2
int (*p)(void) = [] {return 1; };
cout << p();

定义在<functional>头文件中的std::function是多态的函数对象包装,类似函数指针。它可以绑定至任何可以被调用的对象(仿函数、成员函数指针、函数指针和lambda表达式),只要参数和返回类型符合包装的类型即可。返回一个double、接受两个整数参数的函数包装定义如下:

1
function<double(int, int)> myWrapper;

通过使用std::function,可从函数中返回lambda表达式,看一下定义:

1
2
3
function<int(void)> multiplyBy2Lambda(int x) {
return [=]()->int{return 2 * x; };
}

在这个例子中,lambda表达式的返回类型和空参数列表可以忽略,可改写为:

1
2
3
function<int(void)> multiplyBy2Lambda(int x) {
return[=] {return 2 * x; };
}

这个函数的主体部分创建了一个lambda表达式,这个lambda表达式通过值捕捉所在作用域的变量,并返回一个整数,这个返回的整数是传给multiplyBy2Lambda()的值的两倍。这个multiplyBy2Lambda()函数的返回值类型为 function<int(void)>,即一个不接受参数并返回一个整数的函数。函数主体中定义的lambda表达式正好匹配这个原型。变量x通过值捕捉,因此,在lambda表达式从函数返回之前,x值的一份副本绑定至lambda表达式中的x。

可以通过以下方式调用上述函数:

1
2
function<int(void)> fn = mutiplyBy2Lambda(5);
cout << fn() << endl;

通过C++11的auto关键字可以简化这个调用:

1
2
auto fn = mutiplyBy2Lambda(5);
cout << fn() << endl;

输出为10。

mutiplyBy2Lambda()示例通过值捕捉了变量x:[=]。假设这个函数重写为通过引用捕捉变量:[&],如下所示。根据代码所示。根据代码后面的解释,下面这段代码不能正常工作:

1
2
3
function<int(void)> mutiplyBy2Lambda(int x) {
return[&] {return 2 * x; };
}

lambda表达式通过引用捕捉变量x。然而,lambda表达式会在程序后面执行,而不会在mutiplyBy2Lambda()函数的作用域中执行,在那里x的引用不再有效。

4.5 将lambda表达式用作参数

可以编写lambda表达式作为参数的函数。例如,可通过这种方式实现回调函数。下面的代码实现了一个testCallback()函数,这个函数接受一个整数vector和一个回调函数作为参数。这个实现迭代给定vector中的所有元素,并对每个元素调用回调函数,回调函数接受vector中每个元素作为int参数,并返回一个布尔值。如果回调函数返回false,那么停止迭代。

1
2
3
4
5
6
7
8
void testCallback(const vector<int>& vec, const function<bool(int)>& callback) {
for (auto i : vec) {
if (!callback(i))
break;
cout << i << " ";
}
cout << endl;
}

可以按照以下方式测试testCallback()函数。

1
2
3
4
5
6
vector<int> vec(10);
int index = 0;
generate(vec.begin(), vec.end(), [&index] {return ++index;});
for_each (vec.begin(), vec.end(), [](int i) {cout << i << " "; });
cout << endl;
testCallback(vec, [](int i){return i < 6; });

输出结果:

1
2
1 2 3 4 5 6 7 8 9 10
1 2 3 4 5

五、移动语义move和右值引用

5.1 左值与右值

C++11中引入的一个非常重要的概念就是右值引用。理解右值引用是学习“移动语义”(move semantics)的基础。而要理解右值引用,就必须先区分左值与右值。

对左值和右值的一个最常见的误解是:等号左边的就是左值,等号右边的就是右值。

左值和右值都是针对表达式而言的,左值是指表达式结束后依然存在的持久对象,右值是指表达式结束时就不再存在的临时对象。

一个区分左值与右值的便捷方法是:看能不能对表达式取地址,如果能,则为左值,否则为右值。下面给出一些例子来进行说明。

1
2
3
4
5
6
7
8
int a = 10;
int b = 20;
int *pFlag = &a;
vector<int> vctTemp;
vctTemp.push_back(1);
string str1 = "hello ";
string str2 = "world";
const int &m = 1;

请问,a,b, a+b, a++, ++a, pFlag, *pFlag, vctTemp[0], 100, string("hello"), str1, str1+str2, m 分别是左值还是右值?

  • a和b都是持久对象(可以对其取地址),是左值;
  • a+b是临时对象(不可以对其取地址),是右值;
  • a++是先取出持久对象a的一份拷贝,再使持久对象a的值加1,最后返回那份拷贝,而那份拷贝是临时对象(不可以对其取地址),故其是右值;
  • ++a则是使持久对象a的值加1,并返回那个持久对象a本身(可以对其取地址),故其是左值;
  • pFlag和*pFlag都是持久对象(可以对其取地址),是左值;
  • vctTemp[0]调用了重载的[]操作符,而[]操作符返回的是一个int &,为持久对象(可以对其取地址),是左值;
  • 100和string("hello")是临时对象(不可以对其取地址),是右值;
  • str1是持久对象(可以对其取地址),是左值;
  • str1+str2是调用了+操作符,而+操作符返回的是一个string(不可以对其取地址),故其为右值;
  • m是一个常量引用,引用到一个右值,但引用本身是一个持久对象(可以对其取地址),为左值。

5.2 左值引用

区分清楚了左值与右值,我们再来看看左值引用。左值引用根据其修饰符的不同,可以分为非常量左值引用和常量左值引用。

非常量左值引用只能绑定到非常量左值,不能绑定到常量左值、非常量右值和常量右值。

  • 如果允许绑定到常量左值和常量右值,则非常量左值引用可以用于修改常量左值和常量右值,这明显违反了其常量的含义。
  • 如果允许绑定到非常量右值,则会导致非常危险的情况出现,因为非常量右值是一个临时对象,非常量左值引用可能会使用一个已经被销毁了的临时对象。

常量左值引用可以绑定到所有类型的值,包括非常量左值、常量左值、非常量右值和常量右值。

可以看出,使用左值引用时,我们无法区分出绑定的是否是非常量右值的情况。那么,为什么要对非常量右值进行区分呢,区分出来了又有什么好处呢?这就牵涉到C++中一个著名的性能问题——拷贝临时对象。考虑下面的代码:

1
2
3
4
5
6
vector<int> GetAllScores() {
vector<int> vctTemp;
vctTemp.push_back(90);
vctTemp.push_back(95);
return vctTemp;
}

当使用vector<int> vctScore = GetAllScores()进行初始化时,实际上调用了三次构造函数。

  • 一次是vecTemp的构造,
  • 一次是return临时对象的构造,
  • 一次是vecScore的复制构造。

尽管有些编译器可以采用RVO(Return Value Optimization来进行优化,但优化工作只在某些特定条件下才能进行。

可以看到,上面很普通的一个函数调用,由于存在临时对象的拷贝,导致了额外的两次拷贝构造函数和析构函数的开销。当然,我们也可以修改函数的形式为void GetAllScores(vector<int> &vctScore),但这并不一定就是我们需要的形式。另外,考虑下面字符串的连接操作:

1
2
string s1("hello");
string s = s1 + "a" + "b" + "c" + "d" + "e";

在对s进行初始化时,会产生大量的临时对象,并涉及到大量字符串的拷贝操作,这显然会影响程序的效率和性能。

怎么解决这个问题呢?如果我们能确定某个值是一个非常量右值(或者是一个以后不会再使用的左值),则我们在进行临时对象的拷贝时,可以不用拷贝实际的数据,而只是“窃取”指向实际数据的指针(类似于STL中的auto_ptr,会转移所有权)。C++ 11中引入的右值引用正好可用于标识一个非常量右值。

C++ 11中用&表示左值引用,用&&表示右值引用,如:

1
int &&a = 10;

5.3 右值引用

右值引用根据其修饰符的不同,也可以分为非常量右值引用和常量右值引用。

非常量右值引用只能绑定到非常量右值,不能绑定到非常量左值、常量左值和常量右值。

  • 如果允许绑定到非常量左值,则可能会错误地窃取一个持久对象的数据,而这是非常危险的;
  • 如果允许绑定到常量左值和常量右值,则非常量右值引用可以用于修改常量左值和常量右值,这明显违反了其常量的含义。

常量右值引用可以绑定到非常量右值和常量右值,不能绑定到非常量左值和常量左值(理由同上)。

5.4 move 语句

有了右值引用的概念,我们就可以用它来实现下面的CMyString类。

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
class CMyString {
public:
// 构造函数
CMyString(const char *pszSrc = NULL) {
cout << "CMyString(const char *pszSrc = NULL)" << endl;
if (pszSrc == NULL) {
m_pData = new char[1];
*m_pData = '\0';
} else {
m_pData = new char[strlen(pszSrc)+1];
strcpy(m_pData, pszSrc);
}
}
// 拷贝构造函数
CMyString(const CMyString &s) {
cout << "CMyString(const CMyString &s)" << endl;
m_pData = new char[strlen(s.m_pData)+1];
strcpy(m_pData, s.m_pData);
}
// move构造函数 ---- 实质上就是·窃取·临时对象,注意参数的形式
CMyString(CMyString &&s) {
cout << "CMyString(CMyString &&s)" << endl;
m_pData = s.m_pData;
s.m_pData = NULL;
}
// 析构函数
~CMyString() {
cout << "~CMyString()" << endl;
delete [] m_pData;
m_pData = NULL;
}
// 拷贝赋值函数
CMyString &operator =(const CMyString &s) {
cout << "CMyString &operator =(const CMyString &s)" << endl;
if (this != &s) {
delete [] m_pData;
m_pData = new char[strlen(s.m_pData)+1];
strcpy(m_pData, s.m_pData);
}
return *this;
}
// move赋值函数
CMyString &operator =(CMyString &&s) {
cout << "CMyString &operator =(CMyString &&s)" << endl;
if (this != &s) {
delete [] m_pData;
m_pData = s.m_pData;
s.m_pData = NULL;
}
return *this;
}
private:
char *m_pData;
};

可以看到,上面我们添加了move版本的构造函数和赋值函数。那么,添加了move版本后,对类的自动生成规则有什么影响呢?唯一的影响就是,如果提供了move版本的构造函数,则不会生成默认的构造函数。另外,编译器永远不会自动生成move版本的构造函数和赋值函数,它们需要你手动显式地添加。

当添加了move版本的构造函数和赋值函数的重载形式后,某一个函数调用应当使用哪一个重载版本呢?下面是按照判决的优先级列出的3条规则:

  1. 常量值只能绑定到常量引用上,不能绑定到非常量引用上。

  2. 左值优先绑定到左值引用上,右值优先绑定到右值引用上。

  3. 非常量值优先绑定到非常量引用上。

当给构造函数或赋值函数传入一个非常量右值时,依据上面给出的判决规则,可以得出会调用move版本的构造函数或赋值函数。而在move版本的构造函数或赋值函数内部,都是直接“移动”了其内部数据的指针(因为它是非常量右值,是一个临时对象,移动了其内部数据的指针不会导致任何问题,它马上就要被销毁了,我们只是重复利用了其内存),这样就省去了拷贝数据的大量开销。

一个需要注意的地方是,拷贝构造函数可以通过直接调用*this = s来实现,但move构造函数却不能。这是因为在move构造函数中,s虽然是一个非常量右值引用,但其本身却是一个左值(是持久对象,可以对其取地址),因此调用*this = s时,会使用拷贝赋值函数而不是move赋值函数,而这已与move构造函数的语义不相符。要使语义正确,我们需要将左值绑定到非常量右值引用上,C++ 11提供了move函数来实现这种转换,因此我们可以修改为*this = move(s),这样move构造函数就会调用move赋值函数。

排序算法

Posted on 2017-12-26 | In 数据结构与算法 |

冒泡排序

1
2
3
4
5
6
7
8
9
void Bubble_sort (int a[], int n) {
for (int i = 0; i < n; i ++) {
for (int j = i+1; j < n; j++) {
if (a[i] > a[j]) {
Swap(a[i], a[j]);
}
}
}
}

Swap 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Swap(int &a, int & b) {
int tmp = 0;
tmp = a;
a = b;
b = tmp;
}
// or:
void Swap(int &a, int & b) {
if (a != b) {
a ^= b;
b ^= a;
a ^= b;
}
}

选择排序

1
2
3
4
5
6
7
8
9
10
void Select_sort(int a[], int n) {
for (int i = 0 ; i < n ; i++) {
min_index = i;
for (int j = i+1; j < n; j++) {
if (a[j] > a[min_index])
min_index = j;
}
Swap (a[i], a[min_index]);
}
}

直接插入排序

插入排序的思想有点像打扑克抓牌的时候,我们插入扑克牌的做法。想象一下,抓牌时,我们都是把抓到的牌按顺序放在手中。因此每抓一张新牌,我们都将其插入到已有的排好序的手牌当中,

当前抓到第i张牌key = a[i],让其与排好顺序的i-1张牌,从后往前比较,大于key的话就往后移,直到遇到一个不大于key的牌,将key插入到这个位置后面。

1
2
3
4
5
6
7
8
9
10
11
void Insert_sort(int a[], int n) {
for (int i = 1; i < n; i ++) {
int key = a[i];
int j = i -1;
while (j >=0 && a[j]> key ) {
a[j+1] = a[j];
j--;
}
a[j+1] = key;
}
}

希尔排序

先将整个待排元素序列分割成若干个子序列(由相隔某个“增量”的元素组成的)分别进行直接插入排序,然后依次缩减增量再进行排序,待整个序列中的元素基本有序(增量足够小)时,再对全体元素进行一次直接插入排序。

其实希尔排序就是对相隔若干距离的数据进行直接插入排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Shell_sort(int a[], int n, int step) {
for (int gap = n/step; gap > 0; gap /= step )
for (int k = 0; k < gap; k++) {
for (int i = gap; i < n; i += gap) {
int key = a[i];
int j = i - gap;
While (j >= 0 && a[j]> key ) {
a[j+gap] = a[j];
j -= gap;
}
a[j+gap] = key;
}
}
}
}

归并排序

归并排序的主要思想是分治法(Divide and Conquer)。

首先考虑下如何将将二个有序数列合并。这个非常简单,只要从比较二个数列的第一个数,谁小就先取谁,取了后就在对应数列中删除这个数。然后再进行比较,如果有数列为空,那直接将另一个数列的数据依次取出即可。

然后分治递归实现合并排序。

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
void mergearray(int a[], int first, int mid, int last , int temp) {
int i = first, j = mid +1;
int m = mid, n = last;
int k = 0;
while (i <= m && j <= n) {
If (a[i] < a[j]) {
temp[k++] = a[i++];
} else {
temp[k++] = a[j++];
}
}
while (j <= n)
temp[k++] = a[j++];
while (i <= m)
temp[k++] = a[i++];
for (int i = 0; i<k; i++) {
a[first+i] = temp[i];
}
}
void Merge_sort(int a[], int first, int last, int temp[]) {
if (first < last) {
int mid = (first + last) / 2;
Merge_sort(a, first, mid, temp );
Merge_sort(a, mid+1, last, temp );
mergearray(a, first, mid, last, temp);
}
}

快速排序

即挖坑填数+分治。

挖坑填数:

  1. i = l; j = r; 将基准数key挖出形成第一个坑 a[i]。
  2. j--由后向前找比它小的数,找到后挖出此数填前一个坑 a[i]中。
  3. i++由前向后找比它大的数,找到后也挖出此数填到前一个坑 a[j]中。
  4. 再重复执行2,3二步,直到i == j,将基准数key填入a[i]中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void Quick_sort(int a[], int l, int r) {
if (l < r) {
int i = l, j = r, key = a[l];
while (i < j) {
while(i < j && a[j] >= key) // 从右向左找第一个小于key的数
j--;
if(i < j)
a[i++] = a[j];
while(i < j && a[i] < key) // 从左向右找第一个大于等于key的数
i++;
if(i < j)
a[j--] = a[i];
}
a[i] = key;
Quick_sort(a, l, i - 1); // 递归调用
Quick_sort(a, i + 1, r);
}
}

堆排序

堆的定义

二叉堆是完全二叉树或者是近似完全二叉树。

二叉堆满足两个特性:

  1. 父结点的键值总是大于或等于(小于或等于)任何一个子节点的键值。
  2. 每个结点的左子树和右子树都是一个二叉堆(都是最大堆或最小堆)。

当父结点的键值总是大于或等于任何一个子节点的键值时为最大堆。当父结点的键值总是小于或等于任何一个子节点的键值时为最小堆。下图展示一个最小堆:

堆的储存

一般都用数组来表示堆,i结点的父结点下标就为(i–1)/2。它的左右子结点下标分别为2*i+1和2*i+2。如第0个结点左右子结点下标分别为1和2。上图堆的储存结构为:

10 15 30 40

堆的操作

  1. 建立堆:数组具有对应的树的表示形式。一般情况下,树并不能满足堆的条件,通过重新排列元素,可以建立堆化树。

    初始表: 40 10 30, 堆化树:10 40 30

  2. 插入一个元素:新元素被加入到最底层,随后树被更新恢复堆,如下面将15加入表中。

  3. 删除一个元素:删除总发生在根节点,最后一个元素用来填补空缺位置,结果树更新恢复堆。

由以上分析我们可以得知,插入操作其实就是一个从下往上调整的过程:
将元素插入到最后,从下往上与其父节点比较,小于父节点则交换,重复这个过程直到根节点。这是一个“上浮”的过程。

1
2
3
4
5
6
7
8
9
10
void Insert(int a[], int n, int num) {
a[n] = num;
FixUp(a, n);
}
void FixUp(int a[], int i) {
while (int j = (i-1)/2; j>=0 && a[j] > a[i]; i = j, j = (i-1)/2) {
Swap(a[i], a[j]);
}
}

而删除操作则是一个从上往下调整的过程:
先删除根节点,将最后一个元素插入到根节点,从上往下与其左右节点比较,大于左右节点中最小的一个则交换,重复这个过程直到比左右节点都小,这时就不用调整了。这是一个“下沉”的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Delete(int a[], int n) {
Swap(a[0], a[n-1]);
FixDown(a, 0, n-1);
}
void FixDown(int a[], int i, int n) {
int j = 2*i+1;
while(j < n) {
if (j+1 < n && a[j+1] < a[j]) j++;
if (a[j] < a[i]) Swap(a[i], a[j]);
else break;
i = j;
j = 2*i+1;
}
}

讲完FixUp和FixDown操作之后,给定一个无序数组,我们可以很方便的建立起一个堆。

从最后一个元素(n-1)的父节点开始,到根节点进行FixDown操作,就能建立起一个最小堆了。

1
2
3
4
5
void MakeMinHeap(int a[], int n) {
for (int i = n/2-1; i >=0 ; i--) { //最后一个元素的父节点为((n-1)-1)/2, 即n/2-1
FixDown(a, i, n);
}
}

堆排序

首先可以看到堆建好之后堆中第0个数据是堆中最小的数据。取出这个数据再执行下堆的删除操作。这样堆中第0个数据又是堆中最小的数据,重复上述步骤直至堆中只有一个数据时就直接取出这个数据。

由于堆也是用数组模拟的,故堆化数组后,第一次将a[0]与a[n-1]交换,再对a[0...n-2]重新恢复堆。第二次将a[0]与a[n–2]交换,再对a[0...n-3]重新恢复堆,重复这样的操作直到a[0]与a[1]交换。由于每次都是将最小的数据并入到后面的有序区间,故操作完成后整个数组就有序了。有点类似于直接选择排序。

1
2
3
4
5
6
7
void Heap_sort(int a[], int n) {
MakeMinHeap(a, n);
for (int i = n - 1; i >= 1; i--) {
Swap(a[i], a[0]);
FixDown(a, 0, i);
}
}

注意使用最小堆排序后是递减数组,要得到递增数组,可以使用最大堆。

由于每次重新恢复堆的时间复杂度为O(logN),共N-1次重新恢复堆操作,再加上前面建立堆时N/2次向下调整,每次调整时间复杂度也为O(logN)。二次操作时间相加还是O(N*logN)。故堆排序的时间复杂度为O(N*logN)。

排序总结

C++ Section4 内存管理

Posted on 2017-12-25 | In cpp |

new与malloc的区别,delet和free的区别及其内部实现

new 与 malloc的区别:

  1. new 是运算符,malloc是库函数
  2. new会调用构造函数,malloc只申请内存
  3. new返回指定类型的指针,malloc返回void指针
  4. new自动计算所需的内存大小,malloc需要手动设置空间
  5. new可以被重载

内部实现:

delete 和 free 的区别:

  1. delete 是运算符,free是库函数
  2. delete会调用析构函数,free是会释放内存
  3. 使用free之前要检查指针是否为空指针,delete不需要,对空指针delete没有问题
  4. free 和 delete 不能混用,也就是说new 分配的内存空间最好不要使用使用free 来释放,malloc 分配的空间也不要使用 delete来释放

内部实现:

malloc, calloc, realloc, 和 alloca 申请内存的区别

  1. calloc 是申请N个大小为S的空间,且会初始化空间值为0;malloc不会初始化,是随机的垃圾数据(在VS Debug模式下,会是0xcccccc这种特殊值,为了调试方便)
  2. malloc 是在堆上申请大小为S的一个空间,但不会初始化
  3. realloc 是将原本分配的内存扩充到新的大小,要求新的大小必须大于原大小
  4. alloca 是在栈上申请空间,不需要(不能)使用free,运行到作用域以外的时候释放申请的空间

C++内存模型(堆、栈、静态区)

堆 heap :
由new分配的内存块,其释放编译器不去管,由我们程序自己控制(一个new对应一个delete)。如果程序员没有释放掉,在程序结束时OS会自动回收。涉及的问题:“缓冲区溢出”、“内存泄露”

栈 stack :
是那些编译器在需要时分配,在不需要时自动清除的存储区。存放局部变量、函数参数。存放在栈中的数据只在当前函数及下一层函数中有效,一旦函数返回了,这些数据也就自动释放了。函数栈内的变量地址总是连续的,从高地址向低地址生长。

全局/静态存储区 (.bss段和.data段) :
全局和静态变量被分配到同一块内存中。在C语言中,未初始化的静态变量放在.bss段中,初始化的放在.data段中;在C++里则不区分了。

常量存储区 (.rodata段) :
存放常量,不允许修改(通过非正当手段也可以修改)

代码区 (.text段) :
存放代码(如函数),不允许修改(类似常量存储区),但可以执行(不同于常量存储区)
根据c/c++对象生命周期不同,c/c++的内存模型有三种不同的内存区域,即

自由存储区(栈区):局部非静态变量的存储区域,即平常所说的栈
动态存储区(堆区): 用operator new ,malloc分配的内存,即平常所说的堆
静态存储区:全局变量 静态变量 字符串常量存在位置

注意:
栈区变量要注意析构函数的调用次序,由于是先进后出,则先创建的对象,最后被析构。

堆与栈的区别

  1. 堆是先进先出,栈是先进后出。
  2. 栈的大小固定,受限于系统中有效的虚拟内存,可能会发生栈溢出;堆可以动态生长
  3. 栈的空间有系统释放,堆内存由程序员释放
  4. 堆容易产生碎片
  5. 申请方式上,栈是系统自动分配,堆是由程序员申请

如何实现只能动态分配类对象,不能定义类对象

即只能将对象创建于堆上,不能创建于栈上。需要把构造函数和析构函数设为protected,派生类可以访问,外部无法访问。同时创建create和destroy函数,在内部调用构造和析构,用于创建和删除对象。其中create设为static,使用类名访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A {
protected:
A(){};
~A(){};
public:
static A* creat(){
return new A();
}
void destroy(){
delete this;
}
};
int main() {
A* a = A::creat();
a->destroy();
}

如何实现只能在栈上创建对象, 不能在堆上创建对像

在堆上创建对象的唯一方法是使用new关键字,所以,只需要禁用new关键字就可以了。将operator new 设为私有的, 外部不可访问。

1
2
3
4
5
6
7
8
class A {
private:
void* operator new(size_t t){} // 注意函数的第一个参数和返回值都是固定的
void operator delete(void* ptr){} // 重载了new就需要重载delete
public:
A(){}
~A(){}
};

析构函数什么时候声明为私有?什么时候不能声明为私有?

私有析构函数可以使得对象只在堆上构造。在栈上创建的对象要求构造函数和析构函数必须都是公有的,否则编译器报错“析构函数不可访问”;而堆对象由程序员创建和删除,可以把析构函数声明为私有的。由于delete会调用析构函数,而私有的析构无法被访问,编译器报错,此时通过增加一个destroy()方法,在方法内调用析构函数来释放对象:

1
2
3
void destroy() {
delete this;
}

析构函数不能声明为私有的情况:基类的析构函数不能声明为私有,因为要在派生类的析构函数中被隐式调用。

构造函数什么时候声明为私有?什么时候不能声明为私有?

单例模式时构造函数声明为私有。

基类的构造函数不能声明为私有,因为要在派生类的构造函数中被隐式调用。如果在派生类的构造函数中没有显式调用基类的构造,则会调用基类的默认构造函数。

C++ Section3 泛型编程

Posted on 2017-12-25 | In cpp |

模板通式

函数模板通式

1
2
3
4
template <class 形参名,class 形参名,...>
返回类型 函数名(参数列表) {
函数体
}

类模板通式

1
2
3
template <class 形参名,class 形参名, ...>
class 类名
{ ... };

模板的非类型形参

  1. 非类型模板形参:模板的非类型形参也就是内置类型形参,如template class B{};其中int a就是非类型的模板形参。

  2. 非类型形参在模板定义的内部是常量值,也就是说非类型形参在模板的内部是常量。

  3. 非类型模板的形参只能是整型,指针和引用,像double,String, String **这样的类型是不允许的。但是double &,double *,对象的引用或指针是正确的。

  4. 调用非类型模板形参的实参必须是一个常量表达式,即他必须能在编译时计算出结果。

  5. 注意:任何局部对象,局部变量,局部对象的地址,局部变量的地址都不是一个常量表达式,都不能用作非类型模板形参的实参。全局指针类型,全局变量,全局对象也不是一个常量表达式,不能用作非类型模板形参的实参。

  6. 全局变量的地址或引用,全局对象的地址或引用const类型变量是常量表达式,可以用作非类型模板形参的实参。

  7. sizeof表达式的结果是一个常量表达式,也能用作非类型模板形参的实参。

  8. 当模板的形参是整型时调用该模板时的实参必须是整型的,且在编译期间是常量,比如template class A{};如果有int b,这时A m;将出错,因为b不是常量,如果const int b,这时A m;就是正确的,因为这时b是常量。

  9. 非类型形参一般不应用于函数模板中,比如有函数模板template void h(T b){},若使用h(2)调用会出现无法为非类型形参a推演出参数的错误,对这种模板函数可以用显示模板实参来解决,如用h(2)这样就把非类型形参a设置为整数3。显示模板实参在后面介绍。

  10. 非类型模板形参的形参和实参间所允许的转换

    • 允许从数组到指针,从函数到指针的转换。如:template class A{}; int b[1]; A\ m;即数组到指针的转换
    • const修饰符的转换。如:template class A{}; int b; A\<&b> m; 即从int *到const int *的转换。
    • 提升转换。如:template class A{}; const short b=2; A\ m; 即从short到int的提升转换
    • 整值转换。如:template class A{}; A<3> m; 即从int 到unsigned int的转换。
    • 常规转换。

非类型模板的应用:Stack类

stack.h

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
#ifndef STACK_H
#define STACK_H
template <class T,int MAXSIZE>
class Stack {
private:
T elems[MAXSIZE];
int numElems;
public:
Stack();
void push(T const&);
void pop();
T top() const;
bool empty() const{
return numElems == 0;
}
bool full() const{
return numElems == MAXSIZE;
}
};
template <class T,int MAXSIZE>
Stack<T,MAXSIZE>::Stack():numElems(0){
}
template <class T,int MAXSIZE>
void Stack<T, MAXSIZE>::push(T const& elem){
if(numElems == MAXSIZE){
throw std::out_of_range("Stack<>::push(): stack is full");
}
elems[numElems] = elem;
++numElems;
}
template<class T,int MAXSIZE>
void Stack<T,MAXSIZE>::pop(){
if (numElems <= 0) {
throw std::out_of_range("Stack<>::pop(): empty stack");
}
--numElems;
}
template <class T,int MAXSIZE>
T Stack<T,MAXSIZE>::top() const{
if (numElems <= 0) {
throw std::out_of_range("Stack<>::top(): empty stack");
}
return elems[numElems-1];
}
#endif

stack.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <string>
#include "stack.h"
int main(){
Stack<int,20> stack_int; // 可以存储20个int元素的栈
Stack<std::string,40> stack_str; // 可存储40个string元素的栈
// 使用可存储20个int元素的栈
stack_int.push(7);
std::cout << stack_int.top() << std::endl; //7
stack_int.pop();
// 使用可存储40个string的栈
stack_str.push("hello");
std::cout << stack_str.top() << std::endl; //hello
stack_str.pop();
stack_str.pop(); //Exception: Stack<>::pop<>: empty stack
return 0;
}

模板的全特化和偏特化

所谓特化,就是将泛型的东西搞得具体化一些,从字面上来解释,就是为已有的模板参数进行一些使其特殊化的指定,使得以前不受任何约束的模板参数,或受到特定的修饰(例如const或者摇身一变成为了指针之类的东东,甚至是经过别的模板类包装之后的模板类型)或完全被指定了下来。

模板有两种特化,全特化和偏特化(局部特化)

  • 模板函数只能全特化,没有偏特化(以后可能有)。
  • 模板类是可以全特化和偏特化的。

全特化,就是模板中模板参数全被指定为确定的类型。全特化也就是定义了一个全新的类型,全特化的类中的函数可以与模板类不一样。

偏特化,就是模板中的模板参数没有被全部确定,需要编译器在编译时进行确定。 在类型上加上const、&、*( cosnt int、int&、int*、等等)并没有产生新的类型。只是类型被修饰了。模板在编译时,可以得到这些修饰信息。

模板为什么要特化,因为编译器认为,对于特定的类型,如果你能对某一功能更好的实现,那么就该听你的。
模板分为类模板与函数模板,特化分为全特化与偏特化。全特化就是限定死模板实现的具体类型,偏特化就是如果这个模板有多个类型,那么只限定其中的一部分。

先看类模板:

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
template<typename T1, typename T2>
class Test
{
public:
Test(T1 i,T2 j):a(i),b(j){cout<<"模板类"<<endl;}
private:
T1 a;
T2 b;
};
template<>
class Test<int , char>
{
public:
Test(int i, char j):a(i),b(j){cout<<"全特化"<<endl;}
private:
int a;
char b;
};
template <typename T2>
class Test<char, T2>
{
public:
Test(char i, T2 j):a(i),b(j){cout<<"偏特化"<<endl;}
private:
char a;
T2 b;
};

那么下面3句依次调用类模板、全特化与偏特化:

1
2
3
Test<double , double> t1(0.1,0.2);
Test<int , char> t2(1,'A');
Test<char, bool> t3('A',true);

而对于函数模板,却只有全特化,不能偏特化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//模板函数
template<typename T1, typename T2>
void fun(T1 a , T2 b)
{
cout<<"模板函数"<<endl;
}
//全特化
template<>
void fun<int ,char >(int a, char b)
{
cout<<"全特化"<<endl;
}
//函数不存在偏特化:下面的代码是错误的
/*
template<typename T2>
void fun<char,T2>(char a, T2 b)
{
cout<<"偏特化"<<endl;
}
*/

至于为什么函数不能偏特化,似乎不是因为语言实现不了,而是因为偏特化的功能可以通过函数的重载完成。

函数模版的全特化不参与函数重载, 并且优先级低于函数基础模版参与匹配,也就是说,匹配的顺序是:

  1. 非模板函数
  2. 某个没有进行全特化的template function
  3. 如果这个没有进行全特化的template function有全特化版本,并且类型也比较匹配,则选择这个全特化版本

C++ Section2 面向对象(2) 多态与虚函数

Posted on 2017-12-25 | In cpp |

多态性

多态指当不同的对象收到相同的消息时,产生不同的动作

  • 编译时多态(静态绑定),函数重载,运算符重载,模板。
  • 运行时多态(动态绑定),虚函数机制。

运行时多态(动态绑定)

  • 定义:“一个接口,多种方法”,程序在运行时才决定调用的函数。
  • 实现:C++多态性主要是通过虚函数实现的,虚函数允许子类重写override(注意和overload的区别,overload是重载,是允许同名函数的表现,这些函数参数列表/类型不同)。
  • 目的:接口重用。封装可以使得代码模块化,继承可以扩展已存在的代码,他们的目的都是为了代码重用。而多态的目的则是为了接口重用。
  • 用法:声明基类的指针,利用该指针指向任意一个子类对象,调用相应的虚函数,可以根据指向的子类的不同而实现不同的方法。

重载、覆盖、重写的区别

  • Overload(重载):

    在C++程序中,可以将语义、功能相似的几个函数用同一个名字表示,但参数或返回值不同(包括类型、顺序不同),即函数重载。

    (1)相同的范围(在同一个类中);
    (2)函数名字相同;
    (3)参数不同;
    (4)virtual 关键字可有可无。

  • Override(覆盖):

    是指派生类函数覆盖基类函数,特征是:

    (1)不同的范围(分别位于派生类与基类);
    (2)函数名字相同;
    (3)参数相同;
    (4)基类函数必须有virtual 关键字。

  • Overwrite(重写):

    即隐藏,是指派生类的函数屏蔽了与其同名的基类函数,规则如下:

    (1)如果派生类的函数与基类的函数同名,但是参数不同。此时,不论有无virtual关键字,基类的函数将被隐藏(注意别与重载混淆)。
    (2)如果派生类的函数与基类的函数同名,并且参数也相同,但是基类函数没有virtual关键字。此时,基类的函数被重写(隐藏)(注意别与覆盖混淆)。

注:重写基类虚函数的时候,会自动转换这个函数为virtual函数,不管有没有加virtual,因此重写的时候不加virtual也是可以的,不过为了易读性,还是加上比较好。

虚函数与虚继承

转自:虚函数与虚继承寻踪

基本对象模型

首先,我们定义一个简单的类,它含有一个数据成员和一个虚函数。

1
2
3
4
5
6
class MyClass {
int var;
public:
virtual void fun()
{}
};

编译出的MyClass对象结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
1> class MyClass size(8):
1> +---
1> 0 | {vfptr}
1> 4 | var
1> +---
1>
1> MyClass::$vftable@:
1> | &MyClass_meta
1> | 0
1> 0 | &MyClass::fun
1>
1> MyClass::fun this adjustor: 0

从这段信息中我们看出,MyClass对象大小是8个字节。前四个字节存储的是虚函数表的指针vfptr,后四个字节存储对象成员var的值。虚函数表的大小为4字节,就一条函数地址,即虚函数fun的地址,它在虚函数表vftable的偏移是0。因此,MyClass对象模型的结果如图1所示。

图1 Myclass对象模型
图1 MyClass 对象模型

MyClass的虚函数表虽然只有一条函数记录,但是它的结尾处是由4字节的0作为结束标记的。
adjust表示虚函数机制执行时,this指针的调整量,假如fun被多态调用的话,那么它的形式如下:

*(this+0)[0]()

总结虚函数调用形式,应该是:

*(this指针+调整量)[虚函数在vftable内的偏移]()

单重继承对象模型

我们定义一个继承于MyClass类的子类MyClassA,它重写了fun函数,并且提供了一个新的虚函数funA。

1
2
3
4
5
6
7
8
class MyClassA : public MyClass {
int varA;
public:
virtual void fun()
{}
virtual void funA()
{}
};

它的对象模型为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1> class MyClassA size(12):
1> +---
1> | +--- (base class MyClass)
1> 0 | | {vfptr}
1> 4 | | var
1> | +---
1> 8 | varA
1> +---
1>
1> MyClassA::$vftable@:
1> | &MyClassA_meta
1> | 0
1> 0 | &MyClassA::fun
1> 1 | &MyClassA::funA
1>
1> MyClassA::fun this adjustor: 0
1> MyClassA::funA this adjustor: 0

可以看出,MyClassA将基类MyClass完全包含在自己内部,包括vfptr和var。并且虚函数表内的记录多了一条——MyClassA自己定义的虚函数funA。它的对象模型如图2所示。

图2 MyclassA对象模型
图2 MyClassA 对象模型

我们可以得出结论:

  • 在单继承形式下,子类完全获得父类的虚函数表和数据。
  • 子类如果重写了父类的虚函数(如fun),就会把虚函数表原本fun对应的记录(内容MyClass::fun)覆盖为新的函数地址(内容MyClassA::fun),否则继续保持原本的函数地址记录。
  • 如果子类定义了新的虚函数,虚函数表内会追加一条记录,记录该函数的地址(如MyClassA::funA)。

另外类的非虚成员排列顺序是由基类到派生类,先var再varA。

使用这种方式,就可以实现多态的特性。假设我们使用如下语句:

1
2
MyClass*pc = new MyClassA;
pc->fun();

编译器在处理第二条语句时,发现这是一个多态的调用,那么就会按照上边我们对虚函数的多态访问机制调用函数fun。

*(pc+0)[0]()

因为虚函数表内的函数地址已经被子类重写的fun函数地址覆盖了,因此该处调用的函数正是MyClassA::fun,而不是基类的MyClass::fun。

如果使用MyClassA对象直接访问fun,则不会出发多态机制,因为这个函数调用在编译时期是可以确定的,编译器只需要直接调用MyClassA::fun即可。

多重继承对象模型

和前边MyClassA类似,我们也定义一个类MyClassB。

1
2
3
4
5
6
7
8
class MyClassB : public MyClass {
int varB;
public:
virtual void fun()
{}
virtual void funB()
{}
};

它的对象模型和MyClassA完全类似,这里就不再赘述了。

为了实现多重继承,我们再定义一个类MyClassC。

1
2
3
4
5
6
7
8
class MyClassC : public MyClassA, public MyClassB {
int varC;
public:
virtual void funB()
{}
virtual void funC()
{}
};

为了简化,我们让MyClassC只重写父类MyClassB的虚函数funB,它的对象模型如下:

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
1> class MyClassC size(28):
1> +---
1> | +--- (base class MyClassA)
1> | | +--- (base class MyClass)
1> 0 | | | {vfptr}
1> 4 | | | var
1> | | +---
1> 8 | | varA
1> | +---
1> | +--- (base class MyClassB)
1> | | +--- (base class MyClass)
1> 12 | | | {vfptr}
1> 16 | | | var
1> | | +---
1> 20 | | varB
1> | +---
1> 24 | varC
1> +---
1>
1> MyClassC::$vftable@MyClassA@:
1> | &MyClassC_meta
1> | 0
1> 0 | &MyClassA::fun
1> 1 | &MyClassA::funA
1> 2 | &MyClassC::funC
1>
1> MyClassC::$vftable@MyClassB@:
1> | -12
1> 0 | &MyClassB::fun
1> 1 | &MyClassC::funB
1>
1> MyClassC::funB this adjustor: 12
1> MyClassC::funC this adjustor: 0

和单重继承类似,多重继承时MyClassC会把所有的父类全部按序包含在自身内部。而且每一个父类都对应一个单独的虚函数表。MyClassC的对象模型如图3所示。

图3 MyclassC对象模型
图3 MyClassC 对象模型

多重继承下,子类不再具有自身的虚函数表,它的虚函数表与第一个父类的虚函数表合并了。

同样的,如果子类重写了任意父类的虚函数,都会覆盖对应的函数地址记录。如果MyClassC重写了fun函数(两个父类都有该函数),那么两个虚函数表的记录都需要被覆盖!

在这里我们发现MyClassC::funB的函数对应的adjust值是12,按照我们前边的规则,可以发现该函数的多态调用形式为:

*(this+12)[1]()

此处的调整量12正好是MyClassB的vfptr在MyClassC对象内的偏移量。

虚拟继承对象模型

虚拟继承是为了解决多重继承下公共基类的多份拷贝问题。比如上边的例子中MyClassC的对象内包含MyClassA和MyClassB子对象,但是MyClassA和MyClassB内含有共同的基类MyClass。为了消除MyClass子对象的多份存在,我们需要让MyClassA和MyClassB都虚拟继承于MyClass,然后再让MyClassC多重继承于这两个父类。相对于上边的例子,类内的设计不做任何改动,先修改MyClassA和MyClassB的继承方式:

1
2
3
class MyClassA : virtual public MyClass
class MyClassB : virtual public MyClass
class MyClassC : public MyClassA, public MyClassB

由于虚继承的本身语义,MyClassC内必须重写fun函数,否则由于同时存在MyClassA::fun和MyClassB::fun,会有二义性。因此我们需要再重写fun函数。这种情况下,MyClassC的对象模型如下:

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
1> class MyClassC size(36):
1> +---
1> | +--- (base class MyClassA)
1> 0 | | {vfptr}
1> 4 | | {vbptr}
1> 8 | | varA
1> | +---
1> | +--- (base class MyClassB)
1> 12 | | {vfptr}
1> 16 | | {vbptr}
1> 20 | | varB
1> | +---
1> 24 | varC
1> +---
1> +--- (virtual base MyClass)
1> 28 | {vfptr}
1> 32 | var
1> +---
1>
1> MyClassC::$vftable@MyClassA@:
1> | &MyClassC_meta
1> | 0
1> 0 | &MyClassA::funA
1> 1 | &MyClassC::funC
1>
1> MyClassC::$vftable@MyClassB@:
1> | -12
1> 0 | &MyClassC::funB
1>
1> MyClassC::$vbtable@MyClassA@:
1> 0 | -4
1> 1 | 24 (MyClassCd(MyClassA+4)MyClass)
1>
1> MyClassC::$vbtable@MyClassB@:
1> 0 | -4
1> 1 | 12 (MyClassCd(MyClassB+4)MyClass)
1>
1> MyClassC::$vftable@MyClass@:
1> | -28
1> 0 | &MyClassC::fun
1>
1> MyClassC::fun this adjustor: 28
1> MyClassC::funB this adjustor: 12
1> MyClassC::funC this adjustor: 0
1>
1> vbi: class offset o.vbptr o.vbte fVtorDisp
1> MyClass 28 4 4 0

虚继承的引入把对象的模型变得十分复杂,除了每个基类(MyClassA和MyClassB)和公共基类(MyClass)的虚函数表指针需要记录外,每个虚拟继承了MyClass的父类还需要记录一个虚基类表vbtable的指针vbptr。MyClassC的对象模型如图4所示。

图4 MyclassC虚继承对象模型
图4 MyClassC 虚继承对象模型]

虚基类表的第一项记录着当前子对象(当前虚表指针,vfptr_A或者vfptr_B)相对与当前虚基类表指针(vbptr_A或者vbptr_B)的偏移。

MyClassA和MyClassB子对象内的虚表指针都是存储在相对于自身的4字节偏移处,因此该值是-4。假定MyClassA和MyClassC或者MyClassB内没有定义新的虚函数,即不会产生虚函数表,那么虚基类表第一项字段的值应该是0。

虚基类表的第二项记录着公共基类虚表指针vfptr相对于当前虚基类表指针(vbptr_A或者vbptr_B)的偏移量。

比如MyClassA的虚基类表第二项记录值为24,正是MyClass::vfptr相对于MyClassA::vbptr的偏移量,同理MyClassB的虚基类表第二项记录值12也正是MyClass::vfptr相对于MyClassA::vbptr的偏移量。

通过以上的对象组织形式,编译器解决了公共虚基类的多份拷贝的问题。通过每个父类的虚基类表指针,都能找到被公共使用的虚基类的子对象的位置,并依次访问虚基类子对象的数据。至于虚基类定义的虚函数,它和其他的虚函数的访问形式相同,本例中,如果使用虚基类指针MyClass*pc访问MyClassC对象的fun,将会被转化为如下形式:

*(pc+28)[0]()

总结

虚函数机制涉及的指针和表有:

  • 虚函数表指针vfptr和虚函数表vftable
  • 虚继承下还涉及 虚基类表指针vbptr和虚基类表vbtable

虚函数的实现过程:

  1. 编译器为每个含有虚函数的类或者从此类派生的类创建一个虚函数表vftable, 保存此类所有虚函数的地址,并增加一个隐藏成员虚函数表指针vfptr放在所有数据成员之前。在创建类的对象时,在构造函数内部对虚函数表指针进行初始化,指向之前创建的虚函数表。
  2. 单继承情况下,派生类会继承基类所有的数据成员和虚函数表指针,并由编译器生成虚函数表,在创建派生类实例时,将虚函数表指针指向新的,属于派生类的虚函数表。
  3. 多重继承情况下,会有多个虚函数表,几重继承,就会有几个虚函数表。这些表按照派生的顺序依次排列,如果派生类改写了基类的虚函数,那么就会用派生类自己的虚函数覆盖虚函数表的相应的位置,如果派生类有新的虚函数,那么就添加到第一个虚函数表的末尾。
  4. 虚继承情况下,会再创建一个虚基类表和一个虚基类表指针,也就是说,编译器会增加两个指针,
    • 一个是虚基类表指针,指向虚基类表,保存了所有继承过来的虚基类在内存中的地址(偏移量);
    • 另一个是从公共基类(MyClass)继承过来的虚函数表指针,保存了公共基类虚函数的地址。
  5. 虚基类部分会在C++继承层次中只有一份。所有由虚基类派生的类都持有一个虚基类表指针,指向一个虚基类表,表里面保存了所有它继承的虚基类部分的地址。虚基类部分有一个虚函数表指针,指向虚函数表。

基类的析构函数为什么要声明为虚函数

为了能在多态情况下准确调用派生类的析构函数。

如果基类的析构函数非虚函数,则用基类指针或引用引用派生类进行析构时,只会调用基类的析构函数;如果是虚析构函数,则会依次调用派生类的析构和基类的析构。(基类的析构是一定会调用的,无论是否为虚)。

构造函数为什么不可以是虚函数

虚函数在运行期决定函数调用,而在构造一个对象时,由于对象还未构造成功,编译器无法确定对象的实际类型,继而无法决定调用哪一个构造函数。

虚函数的执行依赖于虚函数表,而虚函数表在构造函数中进行初始化工作,即初始化 vptr,让它指向正确的虚函数表,而在构造期间,虚函数表还没有初始化,所以无法决定调用哪个构造函数。

所以,非纯虚的虚方法也就是普通的虚方法必须写定义,哪怕是空的,因为要生成虚函数表,没有方法定义就没有方法地址。纯虚方法和非虚方法可以不用写定义。

不能声明为虚函数的成员函数

构造函数:

首先明确一点,在编译期间编译器完成了虚表的创建,而虚指针在构造函数期间被初始化。
如果构造函数是虚函数,那必然需要通过虚指针来找到虚构造函数的入口地址,但是这个时候我们还没有把虚指针初始化。因此,构造函数不能是虚函数。

內联函数:

编译期內联函数在调用处被展开,而虚函数在运行时才能被确定具体调用哪个类的虚函数。內联函数体现的是编译期机制,而虚函数体现的是运行期机制。

静态成员函数:

静态成员函数和类有关,即使没有生成一个实例对象,也可以调用类的静态成员函数。而虚函数的调用和虚指针有关,虚指针存在于一个类的实例对象中,如果静态成员函数被声明成虚函数,那么调用成员静态函数时又如何访问虚指针呢。总之可以这么理解,静态成员函数与类有关,而虚函数与类的实例对象有关。

非成员函数:

虚函数的目的是为了实现多态,多态和继承有关。所以声明一个非成员函数为虚函数没有任何意义。

C++ Section2 面向对象(1)

Posted on 2017-12-25 | In cpp |

面向对象的三大特性

三大特性:封装,继承,多态。

  1. 封装:封装是实现面向对象程序设计的第一步,封装就是将数据或函数等集合在一个个的单元中(我们称之为类)。封装的意义在于保护或者防止代码(数据)被我们无意中破坏。
  2. 继承:继承主要实现重用代码,节省开发时间。子类可以继承父类的一些东西。
  3. 多态:同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。分为编译时多态和运行时多态。

可被继承及不可被继承

无法被继承的有:

  • 构造函数
  • 析构函数
  • 赋值运算符
  • 友元函数

可被继承的有:

  • 静态成员
  • 静态方法
  • 非静态成员
  • 非静态方法
  • 虚表指针

定义默认构造函数的两种方法

  • 给已有的构造函数中的一个的所有参数加上默认值
  • 通过方法重载定义一个无参数构造函数

注意:隐式调用默认构造函数不要加括号(), 会被编译器解释为函数声明。

调用非默认构造函数的三种方法

  1. Foo f(…); // 隐式调用
  2. Foo f = Foo(…) ;// 显式调用
  3. Foo* f = new Foo(); // 显式调用

由编译器生成的六个成员函数

  1. 默认构造函数
  2. 析构函数
  3. 复制构造函数
  4. 赋值运算符
  5. 取地址运算符
  6. 取地址运算符 const版本
1
2
3
4
5
6
7
8
9
class Foo {
public:
Foo(); // 默认构造函数
~Foo(); // 析构函数
Foo(const Foo &); // 复制构造函数
const Foo& operator=(const Foo &); // 赋值构造函数
Foo* operator&(); // 取地址运算符
const Foo* operator&() const; // 取地址运算符const重载
};

必须在构造函数初始化式里进行初始化的数据成员

  1. 常量成员,因为常量只能初始化不能赋值,所以必须放在初始化列表里面
  2. 引用类型,引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里面
  3. 没有默认构造函数的类类型,因为使用初始化列表可以不必调用默认构造函数来初始化,而是直接调用拷贝构造函数初始化

如果赋值构造函数参数不是传引用而是传值会有什么问题?

如果不是传引用,会造成栈溢出。因为如果是Foo(Foo f)的形式,实参初始化形参的时候也会调用复制构造函数,造成死循环。所以,复制构造函数一定要传引用:

Foo(Foo& f);

三种继承方式(public, private, protected)的区别

  1. 公有继承(public):

    • 基类成员对其对象的可见性: 与一般类及其对象的可见性相同,public成员可见,protected和private成员不可见。
    • 基类成员对派生类的可见性: 对派生类来说,基类的 public 和 protected 成员可见的。
      • 基类的 public 成员和 protected 成员作为派生类的成员时,它们都保持原有状态;
      • 基类的 private 成员依旧是private,派生类不可访问基类中的private成员。
    • 基类成员对派生类对象的可见性: 对派生类对象来说,基类的public成员是可见的,其他成员是不可见的。所以,在公有继承时,派生类的对象可以访问基类中的public成员,派生类的成员方法可以访问基类中的public成员和protected成员。
  2. 私有继承(private):

    • 基类成员对其对象的可见性: 与一般类及其对象的可见性相同,public成员可见,其他成员不可见。
    • 基类成员对派生类的可见性: 对派生类来说,基类的public和protected成员可见:
      • 基类的 public 成员和 protected 成员都作为派生类的 private 成员,并且不能被这个派生类的子类所访问;
      • 基类的 private 成员依旧是private,派生类不可访问基类中的private成员。
    • 基类成员对派生类对象的可见性: 对派生类对象来说,基类的所有成员都是不可见的,所以在私有继承时,基类的成员只能由直接派生类访问,无法再往下继承。
  3. 保护继承(protected):

    • 保护继承与私有继承相似,基类成员对其对象的可见性与一般类及其对象的可见性相同,public成员可见,其他成员不可见。
    • 基类成员对派生类的可见性: 对派生类来说,基类的public和protected成员是可见的:
      • 基类的 public 成员和 protected 成员都作为派生类的 protected 成员,并且不能被这个派生类的子类所访问;
      • 基类的 private 成员依旧是private,派生类不可访问基类中的private成员。
    • 基类成员对派生类对象的可见性: 对派生类对象来说,基类的所有成员都是不可见的。所以,在保护继承时,基类的成员也只能由直接派生类访问,而无法再向下继承。

C++支持多重继承。多重继承是一个类从多个基类派生而来的能力。派生类实际上获取了所有基类的特性。当一个类 是两个或多个基类的派生类时,派生类的构造函数必须激活所有基类的构造函数,并把相应的参数传递给它们 。

class 与 struct的区别

  • class默认的继承方式为private, struct 默认继承方式为public
  • class的成员访问默认为private, struct默认为public

C++ Section1 关键字及其用法

Posted on 2017-12-25 | In cpp |

const

定义常量

限定符声明变量只能被读

1
2
3
4
5
const int i=5;
int j=0;
...
i=j; //非法,导致编译错误
j=i; //合法

必须初始化

1
2
const int i=5; //合法
const int j; //非法,导致编译错误

在另一连接文件中引用const常量

1
2
extern const int i; //合法
extern const int j=10; //非法,常量不可以被再次赋值

便于进行类型检查

用const方法可以使编译器对处理内容有更多了解。

1
2
3
4
5
6
7
8
#define I=10
const long &i=10; /*:由于编译器的优化,使
得在const long i=10; 时i不被分配内存,而是已10直接代入
以后的引用中,以致在以后的代码中没有错误,为达到说教效
果,特别地用&i明确地给出了i的内存分配。不过一旦你关闭所
有优化措施,即使const long i=10;也会引起后面的编译错误。*/
char h=I; //没有错
char h=i; //编译警告,可能由于数的截短带来错误赋值。

可以避免不必要的内存分配

1
2
3
4
5
6
7
8
#define STRING "abcdefghijklmn\n"
const char string[]="abcdefghijklm\n";
...
printf(STRING); //为STRING分配了第一次内存
printf(string); //为string一次分配了内存,以后不再分配
...
printf(STRING); //为STRING分配了第二次内存
printf(string);

由于const定义常量从汇编的角度来看,只是给出了对应的内存地址,而不是象#define一样给出的是立即数,所以,const定义的常量在程序运行过程中只有一份拷贝,而#define定义的常量在内存中有若干个拷贝。

可以通过函数对常量进行初始化

1
2
int value();
const int i = value();

假定对ROM编写程序时,由于目标代码的不可改写,本语句将会无效,不过可以变通一下:

1
const int &i=value();

只要令i的地址处于ROM之外,即可实现:i通过函数初始化,而其值有不会被修改。

const的常量值可以被修改

观察以下一段代码:

1
2
3
const int i = 0;
int *p = (int*)&i;
p = 100;

通过强制类型转换,将地址赋给变量,再作修改即可以改变const常量值。

const与指针

const char *p 表示 指向的内容不能改变。

char * const p,就是将P声明为常指针,它的地址不能改变,是固定的,但是它的内容可以改变。

1
2
3
4
5
6
7
int ii=0;
const int i=0; //i是常量,i的值不会被修改
const int *p1i=&i; //指针p1i所指内容是常量,可以不初始化
int * const p2i=&ii; //指针p2i是常量,所指内容可修改
const int * const p3i=&i; //指针p3i是常量,所指内容也是常量
p1i=&ii; //合法
*p2i=100; //合法

修饰函数参数

const只能修饰输入参数:

如果参数作输出用,不论它是什么数据类型,也不论它采用“指针传递”还是“引用传递”,都不能加const修饰,否则该参数将失去输出功能。

如果输入参数采用“指针传递”,那么加const修饰可以防止意外地改动该指针,起到保护作用

例如StringCopy函数:

1
void StringCopy(char *strDestination, const char *strSource);

其中strSource是输入参数,strDestination是输出参数。给strSource加上const修饰后,如果函数体内的语句试图改动strSource的内容,编译器将指出错误。

“值传递”无需const修饰

如果输入参数采用“值传递”,由于函数将自动产生临时变量用于复制该参数,该输入参数本来就无需保护,所以不要加const修饰。

例如不要将函数voidFunc1(int x) 写成voidFunc1(const int x)。同理不要将函数voidFunc2(A a) 写成voidFunc2(const A a)。其中A为用户自定义的数据类型。

非内部类型输入参数,采取const引用传递

对于非内部数据类型的参数而言,像voidFunc(A a) 这样声明的函数注定效率比较底。因为函数体内将产生A类型的临时对象用于复制参数a,而临时对象的构造、复制、析构过程都将消耗时间。

为了提高效率,可以将函数声明改为voidFunc(A &a),因为“引用传递”仅借用一下参数的别名而已,不需要产生临时对象。但是函数voidFunc(A &a) 存在一个缺点:

“引用传递”有可能改变参数a,这是我们不期望的。解决这个问题很容易,加const修饰即可,因此函数最终成为voidFunc(const A &a)。

内部类型输入参数,不必采取const引用传递

以此类推,是否应将voidFunc(int x) 改写为voidFunc(const int&x),以便提高效率?完全没有必要,因为内部数据类型的参数不存在构造、析构的过程,而复制也非常快,“值传递”和“引用传递”的效率几乎相当。

因此,

对于非内部数据类型的输入参数,应该将“值传递”的方式改为“const引用传递”,目的是提高效率。例如将voidFunc(A a) 改为voidFunc(const A &a)。

对于内部数据类型的输入参数,不要将“值传递”的方式改为“const引用传递”。否则既达不到提高效率的目的,又降低了函数的可理解性。例如voidFunc(int x) 不应该改为voidFunc(const int &x)。

修饰函数返回值

“指针传递”方式返回值

如果给以“指针传递”方式的函数返回值加const修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加const修饰的同类型指针。

1
2
3
4
5
constchar *GetString(void);
如下语句将出现编译错误:
char *str = GetString();
正确的用法是
const char *str = GetString();

“值传递”方式返回值

如果函数返回值采用“值传递方式”,由于函数会把返回值复制到外部临时的存储单元中,加const修饰没有任何价值。

例如不要把函数int GetInt(void) 写成const int GetInt(void)。

同理不要把函数A GetA(void) 写成const A GetA(void),其中A为用户自定义的数据类型。

“引用传递”方式返回

如果返回值不是内部数据类型,将函数A GetA(void) 改写为const A &GetA(void)的确能提高效率。但此时千万千万要小心,一定要搞清楚函数究竟是想返回一个对象的“拷贝”还是仅返回“别名”就可以了,否则程序会出错。若返回对象的“拷贝”就应该采用“值传递”,仅返回“别名”可以用“引用传递”。

函数返回值采用“引用传递”的场合并不多,这种方式一般只出现在类的赋值函数中,目的是为了实现链式表达。

1
2
3
4
5
6
7
8
classA {
A &operate = (const A &other); // 赋值函数
};
int main {
A a, b, c; // a, b, c 为A的对象
a = b = c; // 正常的链式赋值
(a = b) = c; // 不正常的链式赋值,但合法
}

如果将赋值函数的返回值加const修饰,即

const A &operate = (const A &other);

那么该返回值的内容不允许被改动。上例中,语句a = b = c 仍然正确,但是语句(a = b) = c 则是非法的。

修饰类的数据成员

不能在类声明中初始化const数据成员。以下用法是错误的,因为类的对象未被创建时,编译器不知道SIZE的值是什么。

1
2
3
4
class test {
const int SIZE = 100; // 错误,企图在类声明中初始化const数据成员
int array[SIZE]; // 错误,未知的SIZE
};

正确的使用const实现方法为:const数据成员的初始化只能在类构造函数的初始化表中进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class A {
A(int size); // 构造函数
const int SIZE ;
};
A::A(int size) : SIZE(size) // 构造函数的初始化表
{
...
}
// error 赋值的方式是不行的
A::A(int size)
{
SIZE=size;
}
void main(){
A a(100); // 对象 a 的SIZE值为100
A b(200); // 对象 b 的SIZE值为200
}

static和const也以同时修饰数据成员。 然而,它们不能同时修饰成员函数,这两点要注意区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Year {
private:
int y;
public:
static int const Inity;
public:
Year() {
y = Inity;
}
};
int const Year::Inity = 1997; //静态变量的赋值方法,注意必须放在类外定义
void main() {
cout<<Year.Inity<<endl; //注意调用方式,这里是用类名调用的。
}

修饰类的成员函数

任何不会修改数据成员的函数都应该声明为const类型。如果在编写const成员函数时,不慎修改了数据成员,或者调用了其它非const成员函数,编译器将指出错误,这无疑会提高程序的健壮性。

const放在函数末尾修饰const成员函数,因为const关键字是左结合。

以下程序中,类stack的成员函数GetCount仅用于计数,从逻辑上讲GetCount应当为const函数。编译器将指出GetCount函数中的错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Stack {
public:
void Push(int elem);
int Pop(void);
int GetCount(void) const; // const 成员函数
private:
int m_num;
int m_data[100];
};
int Stack::GetCount(void) const {
++ m_num; // 编译错误,企图修改数据成员m_num
Pop(); // 编译错误,企图调用非const函数
return m_num;
}

如果有个成员函数想修改对象中的某一个成员怎么办?这时我们可以使用mutable关键字修饰这个成员,mutable的意思也是易变的,容易改变的意思,被mutable关键字修饰的成员可以处于不断变化中,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Stack {
public:
void Push(int elem);
int Pop(void);
int GetCount(void) const; // const 成员函数
private:
mutable int m_num; // mutable 数据成员
int m_data[100];
};
int Stack::GetCount(void) const {
++ m_num; // 编译通过,在const函数中修改mutable数据成员m_num
return m_num;
}

注意: 不可以同时用const和static修饰成员函数。

C++编译器在实现const的成员函数的时候为了确保该函数不能修改类的实例的状态,会在函数中添加一个隐式的参数const this*。但当一个成员为static的时候,该函数是没有this指针的。也就是说此时const的用法和static是冲突的。

我们也可以这样理解:两者的语意是矛盾的。static的作用是表示该函数只作用在类型的静态变量上,与类的实例没有关系;而const的作用是确保函数不能修改类的实例的状态,与类型的静态变量没有关系。因此不能同时用它们。

static

局部变量

在局部变量之前加上关键字static,局部变量就被定义成为一个局部静态变量。

  1. 内存中的位置:静态存储区
  2. 初始化:未经初始化的全局静态变量会被程序自动初始化为0(自动对象的值是任意的,除非他被显示初始化)
  3. 作用域:作用域仍为局部作用域,当定义它的函数或者语句块结束的时候,作用域随之结束。
  4. 生命周期:直到程序结束。

注:当static用来修饰局部变量的时候,它就改变了局部变量的存储位置(从原来的栈中存放改为静态存储区)及其生命周期(局部静态变量在离开作用域之后,并没有被销毁,而是仍然驻留在内存当中,直到程序结束,只不过我们不能再对他进行访问),但未改变其作用域。

全局变量

在全局变量之前加上关键字static,全局变量就被定义成为一个全局静态变量。

  1. 内存中的位置:静态存储区(静态存储区在整个程序运行期间都存在)
  2. 初始化:未经初始化的全局静态变量会被程序自动初始化为0(自动对象的值是任意的,除非他被显示初始化)
  3. 作用域:全局静态变量在声明他的文件之外是不可见的。准确地讲从定义之处开始到文件结尾。
  4. 生命周期:直到程序结束。

注:static修饰全局变量,并未改变其存储位置及生命周期,而是改变了其作用域,使当前文件外的源文件无法访问该变量,好处如下:(1)不会被其他文件所访问,修改。(2)其他文件中可以使用相同名字的变量,不会发生冲突。对全局函数也是有隐藏作用。

类中的成员变量

用static修饰类的数据成员实际使其成为类的全局变量,会被类的所有对象共享,包括派生类的对象。因此,static成员必须在类外进行初始化(初始化格式: int base::var=10;),而不能在构造函数内进行初始化,不过也可以用const修饰static数据成员在类内初始化。但是定义必须在类的外部,见上文。在类的内部const static类型即使赋字面值常量也只能算是声明,定义必须在外部进行。

不要试图在头文件中定义(初始化)静态数据成员。在大多数的情况下,这样做会引起重复定义这样的错误。即使加上#ifndef #define #endif或者#pragma once也不行。

静态数据成员可以成为成员函数的可选参数,而普通数据成员则不可以。

静态数据成员的类型可以是所属类的类型,而普通数据成员则不可以。普通数据成员的只能声明为所属类类型的指针或引用。

类中的成员函数

用static修饰成员函数,使这个类只存在这一份函数,所有对象共享该函数,不含this指针。

静态成员是可以独立访问的,也就是说,无须创建任何对象实例就可以访问。base::func(5,3);当static成员函数在类外定义时不需要加static修饰符。

在静态成员函数的实现中不能直接引用类中说明的非静态成员,可以引用类中说明的静态成员。因为静态成员函数不含this指针。

struct / union

定义

结构体struct:把不同类型的数据组合成一个整体,自定义类型。

共同体union:使几个不同类型的变量共同占用一段内存

相同点

struct和union都有内存对齐,结构体的内存布局依赖于CPU、操作系统、编译器及编译时的对齐选项。

关于内存对齐,先让我们看四个重要的基本概念:

  1. 数据类型自身的对齐值:对于char型数据,其自身对齐值为1,对于short型为2,对于int,float,double类型,其自身对齐值为4,单位字节。
  2. 结构体或者类的自身对齐值:其成员中自身对齐值最大的那个值。
  3. 指定对齐值:#pragma pack(n),n=1,2,4,8,16改变系统的对齐系数
  4. 数据成员、结构体和类的有效对齐值:自身对齐值和指定对齐值中小的那个值。

首先根据结构体内部成员的自身对齐值得到结构体的自身对齐值(内部成员最大的长度),如果没有修改系统设定的默认补齐长度4的话,取较小的进行内存补齐。

不同点

结构体struct:不同之处,stuct里每个成员都有自己独立的地址。sizeof(struct)是内存对齐后所有成员长度的加和。

共同体union:当共同体中存入新的数据后,原有的成员就失去了作用,新的数据被写到union的地址中。sizeof(union)是最长的数据成员的长度。

总结

struct和union都是由多个不同的数据类型成员组成, 但在任何同一时刻, union中只存放了一个被选中的成员, 而struct的所有成员都存在。在struct中,各成员都占有自己的内存空间,它们是同时存在的。一个struct变量的总长度等于所有成员长度之和。在Union中,所有成员不能同时占用它的内存空间,它们不能同时存在。Union变量的长度等于最长的成员的长度。对于union的不同成员赋值, 将会对其它成员重写, 原来成员的值就不存在了, 而对于struct的不同成员赋值是互不影响的。

inline

inline用来向编译器请求声明为内联函数,编译器有权拒绝。

与宏函数的对比

  • 内联函数在运行时可调试,而宏定义不可以;
  • 编译器会对内联函数的参数类型做安全检查或自动类型转换(同普通函数),而宏定义则不会;
  • 内联函数可以访问类的成员变量,宏定义则不能;
  • 在类中声明同时定义的成员函数,自动转化为内联函数
  • 宏只是预定义的函数,在编译阶段不进行类型安全性检查,在编译的时候将对应函数用宏命令替换。对程序性能无影响。

不能声明为inline的函数

  • 包含了递归、循环等结构的函数一般不会被内联。
  • 虚拟函数一般不会内联,但是如果编译器能在编译时确定具体的调用函数,那么仍然会就地展开该函数。
  • 如果通过函数指针调用内联函数,那么该函数将不会内联而是通过call进行调用。
  • 构造和析构函数一般会生成大量代码,因此一般也不适合内联。
  • 如果内联函数调用了其他函数也不会被内联。

typedef / using

二者功能都是定义新类型,using 为c++11新特性。下面语句功能一致:

1
2
3
typedef int MyInt;
using MyInt = int;

explicit

explicit禁止了隐式转换类型,用来修饰构造函数。原则上应该在所有的构造函数前加explicit关键字,当你有心利用隐式转换的时候再去解除explicit,这样可以大大减少错误的发生。如果一个构造函数

Foo(int) ;则下面的语句是合法的:

1
2
Foo f;
f = 12; // 发生了隐式转换,先调用Foo(int)用12构建了一个临时对象,然后调用赋值运算符复制到f中

如果给构造函数加了explicit,即 explicit Foo(int);就只能进行显示转换,无法进行隐式转换了:

1
2
3
f = 12; // 非法,隐式转换
f = Foo(12); // 合法,显示转换
f = (Foo) 12; // 合法,显示转换,C风格

指针 / 引用

本质上的区别是,指针是一个新的变量,只是这个变量存储的是另一个变量的地址,我们通过访问这个地址来修改变量。

而引用只是一个别名,还是变量本身。对引用进行的任何操作就是对变量本身进行操作,因此以达到修改变量的目的。

区别如下:

  1. 指针:指针是一个变量,只不过这个变量存储的是一个地址,指向内存的一个存储单元;而引用跟原来的变量实质上是同一个东西,只不过是原变量的一个别名而已。如:

    1
    2
    int a=1; int *p=&a;
    int a=1; int &b=a;

    上面定义了一个整形变量和一个指针变量p,该指针变量指向a的存储单元,即p的值是a存储单元的地址。

    而下面2句定义了一个整形变量a和这个整形a的引用b,事实上a和b是同一个东西,在内存占有同一个存储单元。

  2. 可以有const指针,但是没有const引用(一般说的const引用其实是指向const对象的引用);

  3. 指针可以有多级,但是引用只能是一级(int **p;合法 而 int &&a是不合法的)
  4. 指针的值可以为空,但是引用的值不能为NULL,并且引用在定义的时候必须初始化;
  5. 指针的值在初始化后可以改变,即指向其它的存储单元,而引用在进行初始化后就不会再改变了。
  6. “sizeof引用”得到的是所指向的变量(对象)的大小,而”sizeof指针”得到的是指针本身的大小;
  7. 指针和引用的自增(++)运算意义不一样。
  8. 指针传参的时候,还是值传递,试图修改传进来的指针的值是不可以的。只能修改地址所保存变量的值。引用传参的时候,传进来的就是变量本身,因此可以被修改。

注意:const引用只是表明,保证不会通过此引用间接的改变被引用的对象! 详见:C++引用与const引用

C# 中的委托和事件

Posted on 2017-12-25 | In csharp |

转自:http://www.tracefact.net/CSharp-Programming/Delegates-and-Events-in-CSharp.aspx
作者:张子阳

引言

委托(Delegates)和 事件(Events)在 .Net Framework中的应用非常广泛,然而,较好地理解委托和事件对很多接触C#时间不长的人来说并不容易。它们就像是一道槛儿,过了这个槛的人,觉得真是太容易了,而没有过去的人每次见到委托和事件就觉得心里别(biè)得慌,混身不自在。

本文中,我将通过两个范例由浅入深地讲述什么是委托、为什么要使用委托、事件的由来、.Net Framework中的委托和事件、委托和事件对Observer设计模式的意义,对它们的中间代码也做了讨论。

将方法作为方法的参数

我们先不管这个标题如何的绕口,也不管委托究竟是个什么东西,来看下面这两个最简单的方法,它们不过是在屏幕上输出一句问候的话语:

1
2
3
4
5
6
7
void GreetPeople(string name) {
// 做某些额外的事情,比如初始化之类,此处略
EnglishGreeting(name);
}
public void EnglishGreeting(string name) {
Console.WriteLine("Morning, " + name);
}

暂且不管这两个方法有没有什么实际意义。GreetPeople用于向某人问好,当我们传递代表某人姓名的name参数,比如说“Jimmy”,进去的时候,在这个方法中,将调用EnglishGreeting方法,再次传递name参数,EnglishGreeting则用于向屏幕输出 “Morning, Jimmy”。

现在假设这个程序需要进行全球化,哎呀,不好了,我是中国人,我不明白“Morning”是什么意思,怎么办呢?好吧,我们再加个中文版的问候方法:

1
2
3
4
public void ChineseGreeting(string name){
Console.WriteLine("早上好, " + name);
}

这时候,GreetPeople也需要改一改了,不然如何判断到底用哪个版本的Greeting问候方法合适呢?在进行这个之前,我们最好再定义一个枚举作为判断的依据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public enum Language{
English, Chinese
}
public void GreetPeople(string name, Language lang){
//做某些额外的事情,比如初始化之类,此处略
swith(lang){
case Language.English:
EnglishGreeting(name);
break;
case Language.Chinese:
ChineseGreeting(name);
reak;
}
}

OK,尽管这样解决了问题,但我不说大家也很容易想到,这个解决方案的可扩展性很差,如果日后我们需要再添加韩文版、日文版,就不得不反复修改枚举和GreetPeople()方法,以适应新的需求。
在考虑新的解决方案之前,我们先看看 GreetPeople的方法签名:

1
public void GreetPeople(string name, Language lang)

我们仅看 string name,在这里,string 是参数类型,name 是参数变量,当我们赋给name字符串“jimmy”时,它就代表“jimmy”这个值;当我们赋给它“张子阳”时,它又代表着“张子阳”这个值。然后,我们可以在方法体内对这个name进行其他操作。哎,这简直是废话么,刚学程序就知道了。

如果你再仔细想想,假如GreetPeople()方法可以接受一个参数变量,这个变量可以代表另一个方法,当我们给这个变量赋值 EnglishGreeting的时候,它代表着 EnglsihGreeting() 这个方法;当我们给它赋值ChineseGreeting 的时候,它又代表着ChineseGreeting()方法。

我们将这个参数变量命名为 MakeGreeting,那么不是可以如同给name赋值时一样,在调用 GreetPeople()方法的时候,给这个MakeGreeting 参数也赋上值么(ChineseGreeting或者EnglsihGreeting等)?

然后,我们在方法体内,也可以像使用别的参数一样使用MakeGreeting。但是,由于MakeGreeting代表着一个方法,它的使用方式应该和它被赋的方法(比如ChineseGreeting)是一样的,比如:

1
MakeGreeting(name);

好了,有了思路了,我们现在就来改改GreetPeople()方法,那么它应该是这个样子了:

1
2
3
public void GreetPeople(string name, xxx MakeGreeting){
MakeGreeting(name);
}

注意到 xxx ,这个位置通常放置的应该是参数的类型,但到目前为止,我们仅仅是想到应该有个可以代表方法的参数,并按这个思路去改写GreetPeople方法,现在就出现了一个大问题:这个代表着方法的MakeGreeting参数应该是什么类型的?

NOTE:这里已不再需要枚举了,因为在给MakeGreeting赋值的时候动态地决定使用哪个方法,是ChineseGreeting还是 EnglishGreeting,而在这个两个方法内部,已经对使用“morning”还是“早上好”作了区分。

聪明的你应该已经想到了,现在是委托该出场的时候了,但讲述委托之前,我们再看看MakeGreeting参数所能代表的 ChineseGreeting()和EnglishGreeting()方法的签名:

1
2
public void EnglishGreeting(string name)
public void ChineseGreeting(string name)

如同name可以接受String类型的“true”和“1”,但不能接受bool类型的true和int类型的1一样。MakeGreeting的 参数类型定义 应该能够确定 MakeGreeting可以代表的方法种类,再进一步讲,就是MakeGreeting可以代表的方法 的 参数类型和返回类型。

于是,委托出现了:它定义了MakeGreeting参数所能代表的方法的种类,也就是MakeGreeting参数的类型。

NOTE:如果上面这句话比较绕口,我把它翻译成这样:string 定义了name参数所能代表的值的种类,也就是name参数的类型。

本例中委托的定义:

1
public delegate void GreetingDelegate(string name);

可以与上面EnglishGreeting()方法的签名对比一下,除了加入了delegate关键字以外,其余的是不是完全一样?

现在,让我们再次改动GreetPeople()方法,如下所示:

1
2
3
public void GreetPeople(string name, GreetingDelegate MakeGreeting){
MakeGreeting(name);
}

如你所见,委托GreetingDelegate出现的位置与 string相同,string是一个类型,那么GreetingDelegate应该也是一个类型,或者叫类(Class)。但是委托的声明方式和类却完全不同,这是怎么一回事?实际上,委托在编译的时候确实会编译成类。因为Delegate是一个类,所以在任何可以声明类的地方都可以声明委托。更多的内容将在下面讲述,现在,请看看这个范例的完整代码:

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
using System;
using System.Collections.Generic;
using System.Text;
namespace Delegate {
//定义委托,它定义了可以代表的方法的类型
public delegate void GreetingDelegate(string name);
class Program {
private static void EnglishGreeting(string name) {
Console.WriteLine("Morning, " + name);
}
private static void ChineseGreeting(string name) {
Console.WriteLine("早上好, " + name);
}
//注意此方法,它接受一个GreetingDelegate类型的方法作为参数
private static void GreetPeople(string name, GreetingDelegate MakeGreeting) {
MakeGreeting(name);
}
static void Main(string[] args) {
GreetPeople("Jimmy Zhang", EnglishGreeting);
GreetPeople("张子阳", ChineseGreeting);
Console.ReadKey();
}
}
}

输出如下:

1
2
Morning, Jimmy Zhang
早上好, 张子阳

我们现在对委托做一个总结:
委托是一个类,它定义了方法的类型,使得可以将方法当作另一个方法的参数来进行传递,这种将方法动态地赋给参数的做法,可以避免在程序中大量使用If-Else(Switch)语句,同时使得程序具有更好的可扩展性。

将方法绑定到委托

看到这里,是不是有那么点如梦初醒的感觉?于是,你是不是在想:在上面的例子中,我不一定要直接在GreetPeople()方法中给 name参数赋值,我可以像这样使用变量:

1
2
3
4
5
6
7
8
9
static void Main(string[] args) {
string name1, name2;
name1 = "Jimmy Zhang";
name2 = "张子阳";
GreetPeople(name1, EnglishGreeting);
GreetPeople(name2, ChineseGreeting);
Console.ReadKey();
}

而既然委托GreetingDelegate 和 类型 string 的地位一样,都是定义了一种参数类型,那么,我是不是也可以这么使用委托?

1
2
3
4
5
6
7
8
9
static void Main(string[] args) {
GreetingDelegate delegate1, delegate2;
delegate1 = EnglishGreeting;
delegate2 = ChineseGreeting;
GreetPeople("Jimmy Zhang", delegate1);
GreetPeople("张子阳", delegate2);
Console.ReadKey();
}

如你所料,这样是没有问题的,程序一如预料的那样输出。这里,我想说的是委托不同于string的一个特性:可以将多个方法赋给同一个委托,或者叫将多个方法绑定到同一个委托,当调用这个委托的时候,将依次调用其所绑定的方法。在这个例子中,语法如下:

1
2
3
4
5
6
7
8
9
static void Main(string[] args) {
GreetingDelegate delegate1;
delegate1 = EnglishGreeting; // 先给委托类型的变量赋值
delegate1 += ChineseGreeting; // 给此委托变量再绑定一个方法
// 将先后调用 EnglishGreeting 与 ChineseGreeting 方法
GreetPeople("Jimmy Zhang", delegate1);
Console.ReadKey();
}

输出为:

1
2
Morning, Jimmy Zhang
早上好, Jimmy Zhang

实际上,我们可以也可以绕过GreetPeople方法,通过委托来直接调用EnglishGreeting和ChineseGreeting:

1
2
3
4
5
6
7
8
9
static void Main(string[] args) {
GreetingDelegate delegate1;
delegate1 = EnglishGreeting; // 先给委托类型的变量赋值
delegate1 += ChineseGreeting; // 给此委托变量再绑定一个方法
// 将先后调用 EnglishGreeting 与 ChineseGreeting 方法
delegate1 ("Jimmy Zhang");
Console.ReadKey();
}

NOTE:这在本例中是没有问题的,但回头看下上面GreetPeople()的定义,在它之中可以做一些对于EnglshihGreeting和ChineseGreeting来说都需要进行的工作,为了简便我做了省略。
注意这里,第一次用的“=”,是赋值的语法;第二次,用的是“+=”,是绑定的语法。如果第一次就使用“+=”,将出现“使用了未赋值的局部变量”的编译错误。

我们也可以使用下面的代码来这样简化这一过程:

1
2
GreetingDelegate delegate1 = new GreetingDelegate(EnglishGreeting);
delegate1 += ChineseGreeting; // 给此委托变量再绑定一个方法

看到这里,应该注意到,这段代码第一条语句与实例化一个类是何其的相似,你不禁想到:上面第一次绑定委托时不可以使用“+=”的编译错误,或许可以用这样的方法来避免:

1
2
3
GreetingDelegate delegate1 = new GreetingDelegate();
delegate1 += EnglishGreeting; // 这次用的是 “+=”,绑定语法。
delegate1 += ChineseGreeting; // 给此委托变量再绑定一个方法

但实际上,这样会出现编译错误: “GreetingDelegate”方法没有采用“0”个参数的重载。尽管这样的结果让我们觉得有点沮丧,但是编译的提示:“没有0个参数的重载”再次让我们联想到了类的构造函数。我知道你一定按捺不住想探个究竟,但再此之前,我们需要先把基础知识和应用介绍完。

既然给委托可以绑定一个方法,那么也应该有办法取消对方法的绑定,很容易想到,这个语法是“-=”:

1
2
3
4
5
6
7
8
9
10
11
12
13
static void Main(string[] args) {
GreetingDelegate delegate1 = new GreetingDelegate(EnglishGreeting);
delegate1 += ChineseGreeting; // 给此委托变量再绑定一个方法
// 将先后调用 EnglishGreeting 与 ChineseGreeting 方法
GreetPeople("Jimmy Zhang", delegate1);
Console.WriteLine();
delegate1 -= EnglishGreeting; //取消对EnglishGreeting方法的绑定
// 将仅调用 ChineseGreeting
GreetPeople("张子阳", delegate1);
Console.ReadKey();
}

输出为:

1
2
3
Morning, Jimmy Zhang
早上好, Jimmy Zhang
早上好, 张子阳

让我们再次对委托作个总结:
使用委托可以将多个方法绑定到同一个委托变量,当调用此变量时(这里用“调用”这个词,是因为此变量代表一个方法),可以依次调用所有绑定的方法。

事件的由来

我们继续思考上面的程序:上面的三个方法都定义在Programe类中,这样做是为了理解的方便,实际应用中,通常都是 GreetPeople 在一个类中,ChineseGreeting和 EnglishGreeting 在另外的类中。现在你已经对委托有了初步了解,是时候对上面的例子做个改进了。假设我们将GreetingPeople()放在一个叫GreetingManager的类中,那么新程序应该是这个样子的:

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
namespace Delegate {
//定义委托,它定义了可以代表的方法的类型
public delegate void GreetingDelegate(string name);
//新建的GreetingManager类
public class GreetingManager{
public void GreetPeople(string name, GreetingDelegate MakeGreeting) {
MakeGreeting(name);
}
}
class Program {
private static void EnglishGreeting(string name) {
Console.WriteLine("Morning, " + name);
}
private static void ChineseGreeting(string name) {
Console.WriteLine("早上好, " + name);
}
static void Main(string[] args) {
// ... ...
}
}
}

这个时候,如果要实现前面演示的输出效果,Main方法我想应该是这样的:

1
2
3
4
5
static void Main(string[] args) {
GreetingManager gm = new GreetingManager();
gm.GreetPeople("Jimmy Zhang", EnglishGreeting);
gm.GreetPeople("张子阳", ChineseGreeting);
}

我们运行这段代码,嗯,没有任何问题。程序一如预料地那样输出了:

1
2
3
Morning, Jimmy Zhang
早上好, 张子阳

现在,假设我们需要使用上一节学到的知识,将多个方法绑定到同一个委托变量,该如何做呢?让我们再次改写代码:

1
2
3
4
5
6
7
8
static void Main(string[] args) {
GreetingManager gm = new GreetingManager();
GreetingDelegate delegate1;
delegate1 = EnglishGreeting;
delegate1 += ChineseGreeting;
gm.GreetPeople("Jimmy Zhang", delegate1);
}

输出:

1
2
Morning, Jimmy Zhang
早上好, Jimmy Zhang

到了这里,我们不禁想到:面向对象设计,讲究的是对象的封装,既然可以声明委托类型的变量(在上例中是delegate1),我们何不将这个变量封装到 GreetManager类中?在这个类的客户端中使用不是更方便么?于是,我们改写GreetManager类,像这样:

1
2
3
4
5
6
7
8
public class GreetingManager{
//在GreetingManager类的内部声明delegate1变量
public GreetingDelegate delegate1;
public void GreetPeople(string name, GreetingDelegate MakeGreeting) {
MakeGreeting(name);
}
}

现在,我们可以这样使用这个委托变量:

1
2
3
4
5
6
7
static void Main(string[] args) {
GreetingManager gm = new GreetingManager();
gm.delegate1 = EnglishGreeting;
gm.delegate1 += ChineseGreeting;
gm.GreetPeople("Jimmy Zhang", gm.delegate1);
}

输出为:

1
2
Morning, Jimmy Zhang
早上好, Jimmy Zhang

尽管这样做没有任何问题,但我们发现这条语句很奇怪。在调用gm.GreetPeople方法的时候,再次传递了gm的delegate1字段:

1
gm.GreetPeople("Jimmy Zhang", gm.delegate1);

既然如此,我们何不修改 GreetingManager 类成这样:

1
2
3
4
5
6
7
8
9
10
11
public class GreetingManager{
//在GreetingManager类的内部声明delegate1变量
public GreetingDelegate delegate1;
public void GreetPeople(string name) {
if(delegate1!=null){ //如果有方法注册委托变量
delegate1(name); //通过委托调用方法
}
}
}
`

在客户端,调用看上去更简洁一些:

1
2
3
4
5
6
7
static void Main(string[] args) {
GreetingManager gm = new GreetingManager();
gm.delegate1 = EnglishGreeting;
gm.delegate1 += ChineseGreeting;
gm.GreetPeople("Jimmy Zhang"); //注意,这次不需要再传递 delegate1变量
}

输出为:

1
2
Morning, Jimmy Zhang
早上好, Jimmy Zhang

尽管这样达到了我们要的效果,但是还是存在着问题:

在这里,delegate1和我们平时用的string类型的变量没有什么分别,而我们知道,并不是所有的字段都应该声明成public,合适的做法是应该public的时候public,应该private的时候private。
我们先看看如果把 delegate1 声明为 private会怎样?

结果就是:这简直就是在搞笑。因为声明委托的目的就是为了把它暴露在类的客户端进行方法的注册,你把它声明为private了,客户端对它根本就不可见,那它还有什么用?

再看看把delegate1 声明为 public 会怎样?结果就是:在客户端可以对它进行随意的赋值等操作,严重破坏对象的封装性。

最后,第一个方法注册用“=”,是赋值语法,因为要进行实例化,第二个方法注册则用的是“+=”。但是,不管是赋值还是注册,都是将方法绑定到委托上,除了调用时先后顺序不同,再没有任何的分别,这样不是让人觉得很别扭么?

现在我们想想,如果delegate1不是一个委托类型,而是一个string类型,你会怎么做?答案是使用属性对字段进行封装。

于是,Event出场了,它封装了委托类型的变量,使得:在类的内部,不管你声明它是public还是protected,它总是private的。在类的外部,注册“+=”和注销“-=”的访问限定符与你在声明事件时使用的访问符相同。

我们改写GreetingManager类,它变成了这个样子:

1
2
3
4
5
6
7
8
public class GreetingManager{
//这一次我们在这里声明一个事件
public event GreetingDelegate MakeGreet;
public void GreetPeople(string name) {
MakeGreet(name);
}
}

很容易注意到:MakeGreet 事件的声明与之前委托变量delegate1的声明唯一的区别是多了一个event关键字。看到这里,在结合上面的讲解,你应该明白到:事件其实没什么不好理解的,声明一个事件不过类似于声明一个进行了封装的委托类型的变量而已。

为了证明上面的推论,如果我们像下面这样改写Main方法:

1
2
3
4
5
6
7
static void Main(string[] args) {
GreetingManager gm = new GreetingManager();
gm.MakeGreet = EnglishGreeting; // 编译错误1
gm.MakeGreet += ChineseGreeting;
gm.GreetPeople("Jimmy Zhang");
}

会得到编译错误:事件“Delegate.GreetingManager.MakeGreet”只能出现在 += 或 -= 的左边(从类型“Delegate.GreetingManager”中使用时除外)。

事件和委托的编译代码

这时候,我们注释掉编译错误的行,然后重新进行编译,再借助Reflactor来对 event的声明语句做一探究,看看为什么会发生这样的错误:

1
public event GreetingDelegate MakeGreet;

可以看到,实际上尽管我们在GreetingManager里将 MakeGreet 声明为public,但是,实际上MakeGreet会被编译成 私有字段,难怪会发生上面的编译错误了,因为它根本就不允许在GreetingManager类的外面以赋值的方式访问,从而验证了我们上面所做的推论。

我们再进一步看下MakeGreet所产生的代码:

1
2
3
4
5
6
7
8
9
10
11
private GreetingDelegate MakeGreet; //对事件的声明 实际是 声明一个私有的委托变量
[MethodImpl(MethodImplOptions.Synchronized)]
public void add_MakeGreet(GreetingDelegate value){
this.MakeGreet = (GreetingDelegate) Delegate.Combine(this.MakeGreet, value);
}
[MethodImpl(MethodImplOptions.Synchronized)]
public void remove_MakeGreet(GreetingDelegate value){
this.MakeGreet = (GreetingDelegate) Delegate.Remove(this.MakeGreet, value);
}

现在已经很明确了:MakeGreet事件确实是一个GreetingDelegate类型的委托,只不过不管是不是声明为public,它总是被声明为private。另外,它还有两个方法,分别是add_MakeGreet和remove_MakeGreet,这两个方法分别用于注册委托类型的方法和取消注册。实际上也就是: “+= ”对应 add_MakeGreet,“-=”对应remove_MakeGreet。而这两个方法的访问限制取决于声明事件时的访问限制符。

在add_MakeGreet()方法内部,实际上调用了System.Delegate的Combine()静态方法,这个方法用于将当前的变量添加到委托链表中。我们前面提到过两次,说委托实际上是一个类,在我们定义委托的时候:

1
public delegate void GreetingDelegate(string name);

当编译器遇到这段代码的时候,会生成下面这样一个完整的类:

1
2
3
4
5
6
public sealed class GreetingDelegate:System.MulticastDelegate{
public GreetingDelegate(object @object, IntPtr method);
public virtual IAsyncResult BeginInvoke(string name, AsyncCallback callback, object @object);
public virtual void EndInvoke(IAsyncResult result);
public virtual void Invoke(string name);
}

关于这个类的更深入内容,可以参阅《CLR Via C#》等相关书籍,这里就不再讨论了。

委托、事件与Observer设计模式

范例说明

上面的例子已不足以再进行下面的讲解了,我们来看一个新的范例,因为之前已经介绍了很多的内容,所以本节的进度会稍微快一些:

假设我们有个高档的热水器,我们给它通上电,当水温超过95度的时候:1、扬声器会开始发出语音,告诉你水的温度;2、液晶屏也会改变水温的显示,来提示水已经快烧开了。

现在我们需要写个程序来模拟这个烧水的过程,我们将定义一个类来代表热水器,我们管它叫:Heater,它有代表水温的字段,叫做temperature;当然,还有必不可少的给水加热方法BoilWater(),一个发出语音警报的方法MakeAlert(),一个显示水温的方法,ShowMsg()。

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
namespace Delegate {
class Heater {
private int temperature; // 水温
// 烧水
public void BoilWater() {
for (int i = 0; i <= 100; i++) {
temperature = i;
if (temperature > 95) {
MakeAlert(temperature);
ShowMsg(temperature);
}
}
}
// 发出语音警报
private void MakeAlert(int param) {
Console.WriteLine("Alarm:嘀嘀嘀,水已经 {0} 度了:" , param);
}
// 显示水温
private void ShowMsg(int param) {
Console.WriteLine("Display:水快开了,当前温度:{0}度。" , param);
}
}
class Program {
static void Main() {
Heater ht = new Heater();
ht.BoilWater();
}
}
}

Observer设计模式简介

上面的例子显然能完成我们之前描述的工作,但是却并不够好。现在假设热水器由三部分组成:热水器、警报器、显示器,它们来自于不同厂商并进行了组装。那么,应该是热水器仅仅负责烧水,它不能发出警报也不能显示水温;在水烧开时由警报器发出警报、显示器显示提示和水温。

这时候,上面的例子就应该变成这个样子:

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
// 热水器
public class Heater {
private int temperature;
// 烧水
private void BoilWater() {
for (int i = 0; i <= 100; i++) {
temperature = i;
}
}
}
// 警报器
public class Alarm{
private void MakeAlert(int param) {
Console.WriteLine("Alarm:嘀嘀嘀,水已经 {0} 度了:" , param);
}
}
// 显示器
public class Display{
private void ShowMsg(int param) {
Console.WriteLine("Display:水已烧开,当前温度:{0}度。" , param);
}
}

这里就出现了一个问题:如何在水烧开的时候通知报警器和显示器?在继续进行之前,我们先了解一下Observer设计模式,Observer设计模式中主要包括如下两类对象:

  1. Subject:监视对象,它往往包含着其他对象所感兴趣的内容。在本范例中,热水器就是一个监视对象,它包含的其他对象所感兴趣的内容,就是temprature字段,当这个字段的值快到100时,会不断把数据发给监视它的对象。
  2. Observer:监视者,它监视Subject,当Subject中的某件事发生的时候,会告知Observer,而Observer则会采取相应的行动。在本范例中,Observer有警报器和显示器,它们采取的行动分别是发出警报和显示水温。

在本例中,事情发生的顺序应该是这样的:

  1. 警报器和显示器告诉热水器,它对它的温度比较感兴趣(注册)。
  2. 热水器知道后保留对警报器和显示器的引用。
  3. 热水器进行烧水这一动作,当水温超过95度时,通过对警报器和显示器的引用,自动调用警报器的MakeAlert()方法、显示器的ShowMsg()方法。

类似这样的例子是很多的,GOF对它进行了抽象,称为Observer设计模式:Observer设计模式是为了定义对象间的一种一对多的依赖关系,以便于当一个对象的状态改变时,其他依赖于它的对象会被自动告知并更新。Observer模式是一种松耦合的设计模式。

实现范例的Observer设计模式

我们之前已经对委托和事件介绍很多了,现在写代码应该很容易了,现在在这里直接给出代码,并在注释中加以说明。

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
using System;
using System.Collections.Generic;
using System.Text;
namespace Delegate {
// 热水器
public class Heater {
private int temperature;
public delegate void BoilHandler(int param); //声明委托
public event BoilHandler BoilEvent; //声明事件
// 烧水
public void BoilWater() {
for (int i = 0; i <= 100; i++) {
temperature = i;
if (temperature > 95) {
if (BoilEvent != null) { //如果有对象注册
BoilEvent(temperature); //调用所有注册对象的方法
}
}
}
}
}
// 警报器
public class Alarm {
public void MakeAlert(int param) {
Console.WriteLine("Alarm:嘀嘀嘀,水已经 {0} 度了:", param);
}
}
// 显示器
public class Display {
public static void ShowMsg(int param) { //静态方法
Console.WriteLine("Display:水快烧开了,当前温度:{0}度。", param);
}
}
class Program {
static void Main() {
Heater heater = new Heater();
Alarm alarm = new Alarm();
heater.BoilEvent += alarm.MakeAlert; //注册方法
heater.BoilEvent += (new Alarm()).MakeAlert; //给匿名对象注册方法
heater.BoilEvent += Display.ShowMsg; //注册静态方法
heater.BoilWater(); //烧水,会自动调用注册过对象的方法
}
}
}

输出为:

1
2
3
4
Alarm:嘀嘀嘀,水已经 96 度了:
Alarm:嘀嘀嘀,水已经 96 度了:
Display:水快烧开了,当前温度:96度。
// 省略...

.Net Framework中的委托与事件

尽管上面的范例很好地完成了我们想要完成的工作,但是我们不仅疑惑:为什么.Net Framework 中的事件模型和上面的不同?为什么有很多的EventArgs参数?

在回答上面的问题之前,我们先搞懂 .Net Framework的编码规范:

  • 委托类型的名称都应该以EventHandler结束。
  • 委托的原型定义:有一个void返回值,并接受两个输入参数:一个Object 类型,一个 EventArgs类型(或继承自EventArgs)。
  • 事件的命名为 委托去掉 EventHandler之后剩余的部分。
  • 继承自EventArgs的类型应该以EventArgs结尾。

再做一下说明:

  1. 委托声明原型中的Object类型的参数代表了Subject,也就是监视对象,在本例中是 Heater(热水器)。回调函数(比如Alarm的MakeAlert)可以通过它访问触发事件的对象(Heater)。
  2. EventArgs 对象包含了Observer所感兴趣的数据,在本例中是temperature。

上面这些其实不仅仅是为了编码规范而已,这样也使得程序有更大的灵活性。比如说,如果我们不光想获得热水器的温度,还想在Observer端(警报器或者显示器)方法中获得它的生产日期、型号、价格,那么委托和方法的声明都会变得很麻烦,而如果我们将热水器的引用传给警报器的方法,就可以在方法中直接访问热水器了。

现在我们改写之前的范例,让它符合 .Net Framework 的规范:

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
using System;
using System.Collections.Generic;
using System.Text;
namespace Delegate {
// 热水器
public class Heater {
private int temperature;
public string type = "RealFire 001"; // 添加型号作为演示
public string area = "China Xian"; // 添加产地作为演示
//声明委托
public delegate void BoiledEventHandler(Object sender, BoiledEventArgs e);
public event BoiledEventHandler Boiled; //声明事件
// 定义BoiledEventArgs类,传递给Observer所感兴趣的信息
public class BoiledEventArgs : EventArgs {
public readonly int temperature;
public BoiledEventArgs(int temperature) {
this.temperature = temperature;
}
}
// 可以供继承自 Heater 的类重写,以便继承类拒绝其他对象对它的监视
protected virtual void OnBoiled(BoiledEventArgs e) {
if (Boiled != null) { // 如果有对象注册
Boiled(this, e); // 调用所有注册对象的方法
}
}
// 烧水。
public void BoilWater() {
for (int i = 0; i <= 100; i++) {
temperature = i;
if (temperature > 95) {
//建立BoiledEventArgs 对象。
BoiledEventArgs e = new BoiledEventArgs(temperature);
OnBoiled(e); // 调用 OnBolied方法
}
}
}
}
// 警报器
public class Alarm {
public void MakeAlert(Object sender, Heater.BoiledEventArgs e) {
Heater heater = (Heater)sender; //这里是不是很熟悉呢?
//访问 sender 中的公共字段
Console.WriteLine("Alarm:{0} - {1}: ", heater.area, heater.type);
Console.WriteLine("Alarm: 嘀嘀嘀,水已经 {0} 度了:", e.temperature);
Console.WriteLine();
}
}
// 显示器
public class Display {
public static void ShowMsg(Object sender, Heater.BoiledEventArgs e) { //静态方法
Heater heater = (Heater)sender;
Console.WriteLine("Display:{0} - {1}: ", heater.area, heater.type);
Console.WriteLine("Display:水快烧开了,当前温度:{0}度。", e.temperature);
Console.WriteLine();
}
}
class Program {
static void Main() {
Heater heater = new Heater();
Alarm alarm = new Alarm();
heater.Boiled += alarm.MakeAlert; //注册方法
heater.Boiled += (new Alarm()).MakeAlert; //给匿名对象注册方法
heater.Boiled += new Heater.BoiledEventHandler(alarm.MakeAlert); //也可以这么注册
heater.Boiled += Display.ShowMsg; //注册静态方法
heater.BoilWater(); //烧水,会自动调用注册过对象的方法
}
}
}

输出为:

1
2
3
4
5
6
7
8
9
Alarm:China Xian - RealFire 001:
Alarm: 嘀嘀嘀,水已经 96 度了:
Alarm:China Xian - RealFire 001:
Alarm: 嘀嘀嘀,水已经 96 度了:
Alarm:China Xian - RealFire 001:
Alarm: 嘀嘀嘀,水已经 96 度了:
Display:China Xian - RealFire 001:
Display:水快烧开了,当前温度:96度。
// 省略 ...

总结

在本文中我首先通过一个GreetingPeople的小程序向大家介绍了委托的概念、委托用来做什么,随后又引出了事件,接着对委托与事件所产生的中间代码做了粗略的讲述。

在第二个稍微复杂点的热水器的范例中,我向大家简要介绍了 Observer设计模式,并通过实现这个范例完成了该模式,随后讲述了.Net Framework中委托、事件的实现方式。

希望这篇文章能给你带来帮助。

Markdown 简明教程

Posted on 2017-12-17 | In Tools |

1. 斜体和粗体

语法说明:

*斜体* 或者 _斜体_
**粗体**
***加粗斜体

显示效果:

  • 这是斜体
  • 这是粗体
  • 这是加粗斜体

2. 标题

语法说明:

# 一级标题
## 二级标题
### 三级标题
#### 四级标题
... ...

3. 段落与换行

语法说明:

无间隔换行: 在段落末加2个空格[Space][Space]
有间隔换行: 在段落后加2个换行[tr][tr]

显示效果:

第一行
第二行

第一行

第二行

4. 超链接

4.1. 自动链接

语法说明:

<http://o1zys.github.io/>

显示效果:

http://o1zys.github.io/

4.2. 行内式

语法说明:

[ ]里写链接文字,( )里写链接地址, ( )中的” “中可以为链接指定title属性,title属性可加可不加。title属性的效果是鼠标悬停在链接上会出现指定的 title文字。[链接文字](链接地址 “链接标题”)’这样的形式。链接地址与链接标题前有一个空格。

[Oizys's Blog](http://o1zys.github.io/ "Oizys's Blog")

显示效果:

Oizys’s Blog

4.3. 参考式

语法说明:

参考式超链接一般用在学术论文上面,或者另一种情况,如果某一个链接在文章中多处使用,那么使用引用 的方式创建链接将非常好,它可以让你对链接进行统一的管理。

参考式链接分为两部分,文中的写法 [链接文字][链接标记],在文本的任意位置添加[链接标记]:链接地址 “链接标题”,链接地址与链接标题前有一个空格。

经常浏览[Google][1]以及[自己的博客][2]。
[Google][1]是很好的搜索网站。
[1]:http://www.google.com "Google"
[2]:http://o1zys.github.com "Oizys's Blog"

显示效果:

经常浏览Google以及自己的博客。
Google是很好的搜索网站。

5. 列表

5.1. 无序列表

语法说明:

使用 *,+,- 表示无序列表。

- 无序列表项 一
+ 无序列表项 二
* 无序列表项 三

显示效果:

  • 无序列表项 一
  • 无序列表项 二
  • 无序列表项 三

5.2. 有序列表

语法说明:

使用数字表示有序列表。

1. 有序列表项 一
2. 有序列表项 二
3. 有序列表项 三

显示效果:

  1. 有序列表项 一
  2. 有序列表项 二
  3. 有序列表项 三

5.3. 特殊情况

在特殊情况下,项目列表很可能会不小心产生,像是下面这样的写法:

1986. What a great season.

会显示成

  1. What a great season.

所以应该要改成

1986\. What a great season.

结果才会正确

1986. What a great season.

6. 引用

6.1 两种引用方式

语法说明:

> 这是一个有两段文字的引用,  
> 段落1句1.  
> 段落1句2.  
> 
> 段落2句3.  
> 段落2句4.  

显示效果:

这是一个有两段文字的引用,
段落1句1.
段落1句2.

段落2句3.
段落2句4.

Markdown 也允许你偷懒只在整个段落的第一行最前面加上 > :

语法说明:

> 这是一个有两段文字的引用,  
段落1句1.  
段落1句2.  

> 段落2句3.  
段落2句4.  

显示效果:

这是一个有两段文字的引用,
段落1句1.
段落1句2.

段落2句3.
段落2句4.

6.2. 引用的多层嵌套

语法说明:

> 第一级
> > 第二级
> > > 第三级

> > 二级

> 一级

显示效果:

第一级

第二级

第三级

二级

一级

7. 图像

7.1. 行内式

语法说明:

![图片Alt](图片地址 “图片Title”)

![头像](https://avatars.githubusercontent.com/o1zys
 "Oizys")

显示效果:

头像

7.2. 参考式

语法说明:

在文档要插入图片的地方写![图片Alt][标记]
在文档的最后写上[标记]:图片地址 “Title”

![头像][prof_pic]
[prof_pic]:https://avatars.githubusercontent.com/o1zys
 "Oizys"

显示效果:

头像

8. 代码

8.1. 行内式

语法说明:

学习一门新语言的开始是`HelloWorld()`。

显示效果:

学习一门新语言的开始是HelloWorld()。

8.2. 缩进式多行代码

语法说明:

缩进 4 个空格或是 1 个制表符。
一个代码区块会一直持续到没有缩进的那一行(或是文件结尾)。

#include <iostream>
int main() {
    cout << "Hello World!" << endl;
}

显示效果:

#include <iostream>
int main() {
    cout << "Hello World!" << endl;
}

8.3. ``` 块

语法说明:

这种方式的代码块可以支持不同语言的语法高亮,要在 ``` 之后加上语言类型。

``` c++
#include
int main() {
cout << “Hello World!” << endl;
}
```

显示效果:

1
2
3
4
#include <iostream>
int main() {
cout << "Hello World!" << endl;
}

8.4. HTML原始码

语法说明:

在代码区块里面, & 、 < 和 > 会自动转成 HTML 实体,这样的方式让你非常容易使用 Markdown 插入范例用的 HTML 原始码,只需要复制贴上,剩下的 Markdown 都会帮你处理,例如:

<table>
       <tr>
           <th rowspan="2">值班人员</th>
            <th>星期一</th>
            <th>星期二</th>
            <th>星期三</th>
        </tr>
    <tr>
        <td>张三</td>
        <td>李四</td>
        <td>王五</td>
    </tr>
</table>

显示效果:













值班人员 星期一 星期二 星期三
张三 李四 王五

9. 表格

语法说明:

  1. 不管是哪种方式,第一行为表头,第二行分隔表头和主体部分,第三行开始每一行为一个表格行。
  2. 列于列之间用管道符|隔开。原生方式的表格每一行的两边也要有管道符。
  3. 第二行还可以为不同的列指定对齐方向。默认为左对齐,在-右边加上:就右对齐。

简单方式:

姓名|性别|总分
-|-|-
小明|男|80
小红|女|130
小刚|男|9

原生方式:

|姓名|姓别|总分|
|-|-|-|
|小明|男|80|
|小红|女|130|
|小刚|男|9|

为第三列指定右对齐:

姓名|性别|总分
-|-|-:
小明|男|80
小红|女|130
小刚|男|9

显示效果:

简单方式:

姓名 性别 总分
小明 男 80
小红 女 130
小刚 男 9

原生方式:

姓名 姓别 总分
小明 男 80
小红 女 130
小刚 男 9

为第三列指定右对齐:

姓名 性别 总分
小明 男 80
小红 女 130
小刚 男 9

10. 内容目录

在段落中填写[TOC]以显示全文内容的目录结构。

11. 注脚

语法说明:

在需要添加注脚的文字后加上脚注名字[^注脚名字],称为加注。 然后在文本的任意位置(一般在最后)添加脚注,脚注前必须有对应的脚注名字。

注意:经测试注脚与注脚之间必须空一行,不然会失效。成功后会发现,即使你没有把注脚写在文末,经Markdown转换后,也会自动归类到文章的最后。

使用 Markdown[^1]可以效率的书写文档, 直接转换成 HTML[^2]

[^1]:Markdown是一种纯文本标记语言

[^2]:HyperText Markup Language 超文本标记语言

显示效果:

使用 Markdown^1可以效率的书写文档, 直接转换成 HTML[^2]。

[^2]:HyperText Markup Language 超文本标记语言

注:脚注自动被搬运到最后面,请到文章末尾查看,并且脚注后方的链接可以直接跳转回到加注的地方。

12. LaTex公式

这里采用MathJax引擎,所以在Markdown文件中必须添加

<script type="text/javascript" src="http://cdn.mathjax.org/mathjax/latest/MathJax.js?config=default"></script>

12.1. $表示行内公式

语法说明:

质能守恒方程可以用一个很简洁的方程式 $E=mc^2$ 来表达。

显示效果:
质能守恒方程可以用一个很简洁的方程式 $E=mc^2$ 来表达。

12.2. $$表示整行公式

语法说明:

$$\sum_{i=1}^n a_i=0$$  

$$f(x_1,x_x,\ldots,x_n) = x_1^2 + x_2^2 + \cdots + x_n^2$$  

显示效果:

$$\sum_{i=1}^n a_i=0$$

$$f(x_1,x_x,\ldots,x_n) = x_1^2 + x_2^2 + \cdots + x_n^2$$

访问 MathJax 参考更多使用方法。

13. 分隔线

语法说明:

你可以在一行中用三个以上的星号、减号、底线来建立一个分隔线,行内不能有其他东西。你也可以在星号或是减号中间插入空格。下面每种写法都可以建立分隔线:

* * *

***

*****

- - -

---------------------------------------

显示效果:






12
Oizys

Oizys

Programmer

11 posts
7 categories
12 tags
© 2018 Oizys
Powered by Hexo
Theme - NexT.Pisces