指针

参数传递

下列程序是否正确:

1
2
3
4
5
6
7
8
9
10
11
void GetMemory(char *p)
{
p = new char[100];
}
void Test(void)
{
char *str = NULL;
GetMemory(str);
strcpy(str, "hello world");
printf(str);
}

错误,程序崩溃。因为GetMemory 并不能传递动态内存, Test 函数中的 str 一直都是 NULL。strcpy(str, "hello world");将使程序崩溃。

1
2
3
4
5
6
7
8
9
10
11
char* GetMemory(void)
{
char p[] = "hello world";
return p;
}
void Test(void)
{
char *str = NULL;
str = GetMemory();
printf(str);
}

可能返回乱码。因为GetMemory 返回的是指向“栈内存”的指针,该指针的地址不是 NULL,但其原现的内容已经被清除,新内容不可知。

1
2
3
4
5
6
7
8
9
10
11
void GetMemory2(char **p, int num)
{
*p = new char[num];
}
void Test(void)
{
char *str = NULL;
GetMemory2(&str, 100);
strcpy(str, "hello");
printf(str);
}

能正常运行,但是内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
oid Test(void)
{
char *str = new char[100];
strcpy(str, "hello");
delete[ ] str;
if (str != NULL)
{
strcpy(str,"world");
printf(str);
}
}

篡改动态内存区的内容,后果难以预料,非常危险。因为 delete[ ]str;之后,str成为野指针(需要str = NULL;)if(str != NULL)语句不起作用。

编译

编译选项MT和MD的区别

  在C++(特别是Windows平台下的MSVC编译器)中,/MT 和 /MD 是两种不同的运行时库链接方式,主要区别在于如何链接C/C++标准库(如 msvcrt.lib、libcmt.lib 等)。

  • /MT:静态链接运行时库

  全称是Multi-Threaded(静态多线程库)。特点是将C/C++标准库(如libcmt.lib)静态编译到你的程序中,生成的可执行文件不依赖外部的运行时库DLL,不过因为库代码被直接嵌入导致生成的文件体积较大。适用的场景有需要独立分发的程序(需要避免系统依赖),或兼容性要求高(避免用户缺少运行时库)。

  • /MD:动态链接运行时库

  全称是Multi-Threaded Dll(动态多线程库)。特点是程序动态链接到微软的运行时库DLL(如msvcrXX.DLL),生成的文件体积较小,但运行时需要系统中有对应的DLL,通常需要程序分发对应的vcredist(微软VisualC++运行时库)。适用的场景有需要多个程序共享同一份运行库(减少磁盘和内存占用),或依赖其他动态库时,保持运行时版本库一致。

需要注意的问题:

  1. 如果项目中的库(如第三方的DLL)和主程序使用不同的选项(一个用/MT,一个用/MD),会导致链接冲突或运行时崩溃。需要保证所有模块(包括exe和dll)使用相同版本的运行时库选项。
  2. /MTd和/MDd是对应的调试版本,分别链接到静态(如libcmtd.lib)或动态(如msvcrXXd.dll)的调试运行库。
  3. 现代大多数情况下推荐使用/MD动态链接,便于更新运行时库,只有特殊需求(如嵌入式环境)才使用/MT。

编程

不用C++库函数,自行实现strcpy

1
2
3
4
5
6
7
8
char *strcpy(char *strDest, const char *strSrc);
{
assert((strDest!=NULL) && (strSrc !=NULL));
char *address = strDest;
while( (*strDest++ = * strSrc++) != ‘\0’ )
;
return address ;
}

  泛型编程就是以独立于任何特定类型的方式编写代码。使用泛型程序时,我们需要提供具体程序实例操作的类型或只值。

  泛型编程和面向对象编程一样,都依赖于某种形式的多态性。

  面向对象编程中的多态性在运行时依赖存在继承关系的类。我们能够编写使用这些类的代码,忽略基类与派生类之间类型上的差异。只要使用基类的引用或指针,基类类型或派生类类型的对象就可以使用相同的代码。

  如果说面向对象是一种通过间接层来调用函数以换取一种抽象(创建一个接口类),那么泛型编程则是更直接的抽象,它不会因为间接层而损失效率。不同于面向对象的动态期多态,泛型编程是一种静态期多态,通过编译器生成最直接的代码。泛型编程可以将算法与特定类型、结构剥离,尽可能复用代码。标准库中的容器、迭代器和算法是很好的泛型编程的例子,几乎可以在任意类型上使用标准库的类和函数。

  C++中,模板是泛型编程的基础。模板是创建类或函数的蓝图或公式。

函数模板

  模板定义以关键字template开始,后接模板形参表。模板形参表是用尖括号括住的一个或多个模板形参的列表,形参之间以逗号分隔。模板形参表不能为空。

  模板形参表很像函数形参表,函数形参表定义了特定类型的局部变量但并不初始化那些变量,在运行时再提供实参来初始化形参。同样,模板形参表示可以在类或函数的定义中使用的类型或值。

1
2
3
4
5
6
7
//声明一个名为T的类型形参
//在callWithMax内部,可以使用名字T引用一个类型,具体表示哪个类型由编译器根据所用函数参数而确定
template<typename T>
void callWithMax(const T &a, const T &b)
{
f(a > b ? a : b);
}

  类型形参T跟在关键字class或者typename之后定义,这里class和typename没有区别。模板形参可以是表示类型的类型形参,也可以是表示常量表达式的非类型形参。模板形参选择的名字没有本质含义。可以给模板形参赋予的唯一含义是区别形参是类型形参还是非类型形参。如果是类型形参,我们就知道该形参表示的是未知类型;如果是非类型形参,我们就知道他是一个未知值。模板非类型形参是模板定义内部的常量值

  上面那个模板函数若再加上inline(不要放在template之前,要放在模板形参之后,返回类型之前),使其成为模板内联函数。这个模板内联函数可以用下列宏定义替换:

1
2
3
4
5
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

int a = 5, b = 0;
CALL_WITH_MAX(++a, b); //这里a被累加两次
CALL+WITH_MAX(++a, b + 10); //这里a被累加一次

  由此可见,用模板内联函数可以避免宏定义中犯人的括号。而且还可以避免莫名其妙的情况,上面例子可见第一次运算的时候a的递增次数竟然依赖于它被拿来和谁比较。因此我们应该尽量少使用宏!!

  (宏定义和内联的区别:宏定义是在预处理阶段进行代码替换,内联函数是在编译阶段插入代码;其次宏没有类型检查,函数由类型检查)

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
/*
泛型编程的主要工作不是程序员,而是编译器
编译器会在编译期间根据代码自动生成方法
由于大量工作是编译器操作,可能通用的泛型函数并不能满足我们的需求
所以我们通常还需要“特化”处理
*/


//特化
//如果是字符串的话特殊处理
//其余的输入可以让输入类型不相同,返回值统一为int
template<>
char* max(char* a, char* b)
{
return (strcmp(a, b) > 0 ? (a) : (b));
}
template<class T1, class T2>
int max(T1 a, T2 b)
{
return static_cast<int>(a > b ? a : b);
}

#include <iostream>
using namespace std;
int main()
{
//调用函数
char* s1 = "hello";
char* s2 = "world";
cout << max(s1, s2) << endl;
cout << max(2, 4.5) << endl;
}

  泛型编程是把算法和具体的数据结构分开了,我们不需要考虑类型本身是什么,直接用一套逻辑把所有的类型都涵盖了,如果需要针对某些特殊类型做处理,我们就进行单独的“特化”。这里比较复杂的操作是编译器的推理过程,程序员所做的工作无非是把该定义好的类型通知编译器,让编译器帮助我们做处理。

类模板

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class Type>
class Queue
{
public:
Queue();
Type &front();
const Type &front() const;
void push(const Type&);
void pop();
bool empty() const;
};

Queue<int> q1; //必须显式指定实参

  使用类模板时,必须为模板形参显式指定实参,编译器使用实参来实例化这个类的特定类型版本,重新编写Queue类。

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+2+3...+100 ==> n*(n+1)/2

//这里用泛型不是用泛型编程可以传递广泛类型的属性,这里直接定义了int
//这里是借用泛型编程的另一个特征:编译期推理,让程序自动生成代码
template<int n>
struct Sum
{
// 递推的思路,求前n个数的和可以表示为前(n-1)个数的和+n
// Sum(n) = Sum(n-1)+n
// 这里声明一个enum的成员,内部的枚举值是N,N的计算方法是递归求和
// 利用泛型编程的自动推理完成编译期计算
enum Value {
N = Sum<n-1>::N+n
};
};
//下面这个就是递推的基准点,n=1的特化
// 如果不设置,那么就会无穷递归
template<>
struct Sum<1>
{
enum Value {N = 1}; // n=1
};

int main()
{
//这里只需要输出结果就行,运算过程中在编译期就完成了
cout << Sum<100>::N << endl; //计算1+2+……+99+100
return 0;
}

  模板编程的难点很大程度上在于对编译器的理解,我们需要直到怎么帮助编译器提供需要生成代码的信息。

设计模式

  一个模式描述了一个不断发生问题及这个问题的解决方案;模式是前人的设计经验上总结出来的对于一些普遍存在的问题提供的通用解决方案,比如单例模式、观察者模式等;

  软件工程中有很多模式,面向对象常见的有23种设计模式,可以分为创建型,结构型和行为型(观察者)模型。创建型模型是处理对象创建的设计模式,试图根据实际情况使用合适的方式创建对象,又两个主导思想:一是将系统使用的具体类型封装起来,二是隐藏这些具体类的实例创建和结合的方式。创建型模式包括单例模式、抽象工厂模式、建造者模式、工厂模式,原型模式。结构型模式有适配器模式、桥接模式、装饰模式、组合模式、外观模式、享元模式代理模式。行为型模式有模板方法模式、命令模式、迭代器模式、观察者模式、中介者模式、备忘录模式、解释器模式、状态模式、职责链模式、访问者模式。

  设计模式不是万能的,它建立在系统变化点上,哪里有变化哪里就可以用。设计模式是为了解耦合,为了扩展,它通常是演变过来的,需要演变才能准确定位。设计模式是一种软件设计方法,不是标准,面目前大部分框架中都包含了大量的设计模式思想。

  

单例模式

  有些时候,我们需要整个程序中有且只有一个实例,比如系统日志、Windows资源管理器窗口,数据库分配主键等操作。单例的实现思路:

  1. Singleton拥有一个私有的构造函数,确保用户无法通过new直接实例它;
  2. 包含一个静态私有成员变量instance与静态公有方法Instance(),让外部通过Instance()来获取实例;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//Singleton.h

class Singleton
{
public:
//只有静态的方法才能访问静态的变量
static const Singleton* getInstance();
static void DoSomething()
{
cout << "Do Something" << endl;
}
// 将构造和析构函数私有化,防止外部访问
private:
Singleton();
~Singleton();

// 使用静态变量帮助解决资源的分配和释放
// 肯定不能使用栈上的对象,不然会销毁
// 如果使用堆上的对象,在getInstance()方法中new出来,那就涉及到资源释放问题
// 析构函数是private的,外部是无法直接释放的
// 静态变量在程序中属于全局区,但声明了private后可见区域只在类内
// 所以可以让这个实例随着程序的产生而产生,随着程序的灭亡而灭亡
static Singleton* This;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//Singleton.cpp

Singleton* Singleton::This = nullptr; //静态变量需要显式声明


const Singleton* Singleton::getInstance()
{
//这里只是简化了,还需要考虑多线程的情况
if (!This)
{
This = new Singleton;
}
return This;
}

Singleton::Singleton()
{
}

Singleton::~Singleton()
{
}
1
2
3
4
5
6
7
8
int main()
{
Singleton::getInstance()->DoSomething();

Singleton::getInstance()->DoSomething();

return 0;
}

  

  上面的单例模式实现,程序开始时全局实例Singleton* Singleton::This为空,只有我们主动调用Singleton::getInstance()->DoSomething()方法时才会产生这个对象的实例。我们也可以采用饿汉的方式,程序启动时就创建实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//Singleton.cpp

Singleton* Singleton::This = new Singleton(); //这里直接new

const Singleton* Singleton::getInstance()
{
//这里只是简化了,还需要考虑多线程的情况
if (!This)
{
This = new Singleton;
}
return This;
}

Singleton::Singleton()
{
}

Singleton::~Singleton()
{
}

  

观察者模式

  在观察者模式中,观察者需要直接订阅目标事件;在目标发出内容改变的事件后,直接接收事件并作出相应;对象通常是一对多关系。常见于各种MVC的框架中,Model的变化通知各种类型的View时几乎都存在这种模式。

  观察者模式的实现思路:把问题的职责解耦合,将Observable和Observer抽象开,分清抽象和实体。

1
2
3
4
5
6
7
8
9
10
11
//观察者类

class Observer
{
public:
Observer() { ; }
virtual ~Observer() { ; }

// 当被观察对象发生变化时,通知被观察者调用这个方法
virtual void Update(void* pArg) = 0;
};

用list列表来存储被观察清单。

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
95
96
97
98
//被观察对象

class Observer;

class Observerable
{
public:
Observerable();
virtual ~Observerable();

// 注册观察者(加入被观察列表)
void Attach(Observer* pOb);
// 反注册观察者
void Detach(Observer* pOb);

int GetObseverCount() const
{
return _Obs.size();
}

void DetachAll() //清理所有订阅者
{
_Obs.clear();
}

virtual void GetSomeNews(string str)
{
SetChange(str);
}
protected:
void SetChange(string news); // 有变化,需要通知

private:
// 通知观察者
void Notify(void* pArg);

private:
bool _bChange;
list<Observer*> _Obs; //观察对象列表
};


// 注册观察者
void Observerable::Attach(Observer* pOb)
{
if (pOb == NULL)
{
return;
}

// 看看当前列表中是否有这个观察者
auto it = _Obs.begin();
for (; it != _Obs.end(); it++)
{
if (*it == pOb)
{
return;
}
}

_Obs.push_back(pOb);
}

// 反注册观察者
void Observerable::Detach(Observer* pOb)
{
if ((pOb == NULL) || (_Obs.empty() == true))
{
return;
}

_Obs.remove(pOb);
}

void Observerable::SetChange(string news)
{
_bChange = true;

Notify( ( (void*)news.c_str() ));
}


void Observerable::Notify(void* pArg)
{
if (_bChange == false)
{
return;
}

// 看看当前列表中是否有这个观察者
auto it = _Obs.begin();
for (; it != _Obs.end(); it++)
{
(*it)->Update(pArg);
}

_bChange = false;
}
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
//main

//被观察对象
class News : public Observerable
{
public:
virtual void GetSomeNews(string str)
{
SetChange("News: " + str);
}
};

//两个观察者
class User1:public Observer
{
public:
virtual void Update(void* pArg)
{
cout << "User1 Got News: " << reinterpret_cast<char*>(pArg) <<endl;
}
};
class User2 :public Observer
{
public:
virtual void Update(void* pArg)
{
cout << "User2 Got News: " << reinterpret_cast<char*>(pArg) <<endl;
}
};

int main()
{
User1 u1;
User2 u2;

News n1;
n1.GetSomeNews("t0"); //此时没人订阅,什么事情都不会发生

//订阅
n1.Attach(&u1);
n1.Attach(&u2);
n1.GetSomeNews("t1");

n1.Detach(&u2);
n1.GetSomeNews("t2");

return 0;
}

  观察者模式帮助我们把职责关系梳理清晰了,我们如果要增加一个新的观察者,直接继承一个Observer类,实现一下消息更新的虚方法即可。

适配器(Adapter)模式

  适配器模式可以理解为一个插口转换器,去不同国家旅游时可以适配不同的插座接口。适配器将类接口转换为客户端期望的另一个接口,让接口更兼容。适配器模式的动机是:如果可以更改接口,则可以重用现有的软件。

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
95
96
97
//适配器模式
//场景:
// 已经有一个矩形处理类LegacyRectangle,可以画一个矩形
// 此时收到需求,在画矩形之前需要额外输入一些别的东西,原有的LegacyRectangle类不能满足需求
//


//原始的矩形处理类
class LegacyRectangle
{
public:
LegacyRectangle(double x1, double y1, double x2, double y2)
{
_x1 = x1;
_y1 = y1;
_x2 = x2;
_y2 = y2;
}

void LegacyDraw()
{
cout << "LegacyRectangle:: LegacyDraw()" << _x1 << " " << _y1 << " " << _x2 << " " << _y2 << endl;
}

private:
double _x1;
double _y1;
double _x2;
double _y2;
};



// 创建一个抽象类和外部对接,让外部去调用这个接口
class Rectangle
{
public:
virtual void Draw(string str) = 0;
};


//创建内部接口,来实现对原始LegacyRectangle代码的复用
//有两种方式:

// 第一种适配的方式:使用多重继承,把原始的类一并继承
class RectangleAdapter: public Rectangle, public LegacyRectangle
{
public:
RectangleAdapter(double x, double y, double w, double h) :
LegacyRectangle(x, y, x + w, y + h)
{
cout << "RectangleAdapter(int x, int y, int w, int h)" << endl;
}

virtual void Draw(string str)
{
cout << "RectangleAdapter::Draw()" << endl;
LegacyDraw(); //实际使用的还是原始的方法
}
};

// 第二种适配的方式:组合方式的Adapter,把原始的类当作成员变量
// 这种方式更常见,因为可以避免多重继承方式的强耦合性
class RectangleAdapter2 :public Rectangle
{
public:
RectangleAdapter2(double x, double y, double w, double h) :
_lRect(x, y, x + w, y + h)
{
cout << "RectangleAdapter2(int x, int y, int w, int h)" << endl;
}

virtual void Draw(string str)
{
cout << "RectangleAdapter2::Draw()" << endl;
_lRect.LegacyDraw();
}
private:
LegacyRectangle _lRect;
};

int main()
{
double x = 20.0, y = 50.0, w = 300.0, h = 200.0;
//实际创建的是适配器对象,但接口调用用的是Rectangle接口类
RectangleAdapter ra(x, y, w, h);
Rectangle* pR = &ra;
pR->Draw("Testing Adapter");

cout << endl;

RectangleAdapter2 ra2(x, y, w, h);
Rectangle* pR2 = &ra2;
pR2->Draw("Testing2 Adapter");

return 0;
}

  计算机程序的输入流起点和输出流的终点都可以是磁盘文件。C++把每个文件都看成是一个有序的字节序列,每个文件都以文件结束标志结束。

  按照文件中数据的组织形式可以把文件分为:

  1. 文本文件:文件中信息形式为ASCII码文件,每个字符占用一个字节;
  2. 二进制文件:文件中信息的形式与其在内存中的形式相同;

  

文件操作步骤:

  1. 打开文件open;
  2. 检查打开是否成功;
  3. 读或写read\write;
  4. 检查是否读完EOF(end of file);
  5. 使用完文件后关闭文件close;
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
//把输入的字符信息写入文件

#include <iostream>
#include <fstream>

using namespace std;

int main()
{
int a;
int index = 0;

fstream fout;
fout.open("test.txt", ios::app);

/*
//可以直接这么写,程序会默认打开
fstream fout("test.txt");
*/

if(fout.fail()) //也可以判断fout是否非空:if(!fout)
{
cout << "open fail" << endl;
}

while(cin >> a)
{
fout << "The numbers are:" << a << endl; //用定义的fout输出到文件
index++;
if(index == 5)
{
break;
}
}

fout.close(); //关闭文件
}

  

文件打开方式 行为
ios::in ifstream的默认模式,打开文件进行读操作
ios::out ofstream的默认方式,打开文件进行写操作
ios::ate 打开一个已经有输入或输出文件并查找到文件尾
ios::app 打开文件以便在文件的尾部添加数据
ios::nocreate 如果文件不存在,则打开操作失败
ios::trunc 如果文件存在,清除文件原有内容(默认)
ios::binary 以二进制方式打开

  

  文件的默认打开方式是ASCII,如果需要以二进制方式打开,需要设置ios::binary:

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
//二进制的方式拷贝一个文件:

#include <string>
#include <fstream>
#include <iostream>
using namespace std;

static const int bufferLen = 2048;
bool CopyFile(const string& src, const string& dst)
{
// 打开源文件和目标文件
// 源文件以二进制读的方式打开
// 目标文件以二进制写的方式打开
ifstream in(src.c_str(), ios::in | ios::binary);
ofstream out(dst.c_str(), ios::out | ios::binary | ios::trunc);

// 判断文件打开是否成功,失败返回false
if (!in || !out)
{
return false;
}

// 从源文件中读取数据,写到目标文件中
// 通过读取源文件的EOF来判断读写是否结束
// 分块读取,不要一下全读进来,防止缓冲区不够用
char temp[bufferLen];
while (!in.eof())
{
in.read(temp, bufferLen);
streamsize count = in.gcount(); //实际读的大小
out.write(temp, count);
}

// 关闭源文件和目标文件
// 如果不关闭会导致资源泄露
in.close();
out.close();

return true;
}

int main()
{
CopyFile("AA.mp3", "BB.mp3");

return 0;
}

I/O流

  传统的C语言中I/O处理有printf,scanf,getch,gets等函数,他们的问题是不可编程,仅仅能识别内置的数据类型,无法识别自定义的数据类型;而且代码移植性差,有很多坑。

  C++中有I/O流istream,ostream等处理方式,可编程,对于类库的设计者来说很有用;而且还能简化编程,使I/O的风格保持一致。

IO缓存区

  计算机发展过程中,需要实现外部的设备可以用统一的标准和计算机程序进行交互。最早的UNIX系统是以文件的方式进行交互。这个思想发展到现在就是IO缓存区。因为外部设备和内存的速度是不一样的,缓存区的存在可以让我们的信息读取更加高效。

标准的IO提供三种类型的缓存模式:

  1. 按块缓存:如文件系统,一次性加载到内存中
  2. 按行缓存:以''区分行
  3. 不缓存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main()
{
int a;
int index = 0;

while(cin >> a)
{
cout << "The numbers are:" << a << endl;
index++;
if(index == 5)
{
break;
}
}

char ch;
cin >> ch;
cout << "the last char is:" << ch << endl;

return 0;
}

  上面代码一连输入6个int,内部接收完5个数字后循环结束,但可以看到6还是读入了,并且以char的形式输出,后面的ch还没来得及输入程序就退出了。这是程序中面临的一个问题,输入的6被暂存到缓冲区中成为了脏数据,在我们不之情的情况下把后面的输入给覆盖掉了。

我们可以使用cin::ignore(int count,type metadelim)方法来清空缓冲区,第一个参数是要清空多少缓冲区信息,第二个参数表达以什么符号作为结尾

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
//清空缓冲区
int main()
{
int a;
int index = 0;

while(cin >> a)
{
cout << "The numbers are:" << a << endl;
index++;
if(index == 5)
{
break;
}
}

//清空缓冲区脏数据
//numeric_limits<std::streamsize>::max()表示缓冲区的最大范围
cin.ignore(numeric_limits<std::streamsize>::max(), '\n');

char ch;
cin >> ch;
cout << "the last char is:" << ch << endl;

return 0;
}

  C++使用struct、class来定义一个类:struct的默认成员权限是public,class的默认成员权限是private;除此之外,二者基本无差别。类的成员函数以及友元函数可以访问类中的所有成员,类外通过类的对象只能访问公有成员。

1
2
3
4
5
6
7
8
9
class Student{
private:
string name;
double score;
public:
double GetSorce(){
return score;
}
};

上面学生的类并不是真实世界中的学生,只是一个抽象的概念,并不包含真实世界中学生的所有属性,只是把一些属性抽象出来。

成员函数

  类的内部,声明成员函数是必须的,但定义成员函数是可选的。类体内部定义的函数默认为inline。

  调用成员函数时,实际上是使用对象来调用的。每个成员函数(除了static成员函数)都有一个额外的、隐含的形参this。在调用成员函数时,形参this初始化为调用函数的对象地址。程序员在成员函数中处理非静态数据成员时,隐式的类对象就会发生:

1
2
3
4
5
6
7
8
9
10
void rectangle::SetLength(double a)
{
len += a;
}

//实际上len的存取是经过this完成的,这个函数的参数可以理解为:
void rectangle::SetLength(rectangle* const this, double a)
{
this -> len += a;
}

构造函数

  构造函数是特殊的成员函数,和类同名且没有返回类型。一个类可以有多个构造函数,每个构造函数必须有与其他构造函数不同数目或类型的形参。

  若没有为一个类显式定义任何构造函数,编译器将自动为这个类生成默认的构造函数,默认构造函数不带参数。若编译器自动生成默认构造,或者程序员自己定义了一个未进行任何操作的默认构造,则类中的每个成员使用与初始化变量相同的规则来进行初始化:1)类成员运行该类的默认构造函数来初始化;2)内置类型或者符合类型的成员初始值依赖作用域,局部作用域中这些成员不被初始化、全局作用域中它们被初始化为0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class student{
public:
Student();
void show();
private:
string name;
int number;
int score;
};

Student a;

int main()
{
student b;
}

//name是string类型的,所以a对象和b对象都调用string的默认构造
//score和number是内置类型,依赖作用域:
//a是全局对象,score和number初始化为0
//b是局部对象,number和score为垃圾值

  某些情况下,默认构造函数是被编译器隐式应用的,如果程序员定义了其他构造函数,编译器将不合成默认的构造函数,如果我们不定义默认的构造函数,该类的使用将受限。假设一个NoDefault的类定义了一个有string实参的构造函数,却不定义默认构造,那么:

  1. 所有包含NoDefault对象成员的类的构造函数,必须在成员初始化列表中通过传递一个初始的string给Nodefault构造函数来显式的初始化Nodefault成员;
  2. 编译器不再为含有NoDefault类型成员的类合成默认构造函数,程序员必须显式定义默认构造,且默认构造中必须显式的初始化其NoDefault成员;
  3. NoDefault类型不能用作动态分配数组的元素类型;
  4. NoDefault类型的静态分配数组必须为每一个元素提供一个显式的初始化式;
  5. 如果一个容器比如vector保存NoDefault对象,那么就不能使用接受容器大小而没有同时提供一个元素初始化式的构造函数;

成员初始化列表

  构造函数后面可以接初始化列表,为类的一个或多个数据成员指定初值。需要注意,C++中成员变量的初始化顺序与变量在类型中声明的顺序相同,而和它们在初始化列表中的顺序无关!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class A
{
private:
int i;
int j;
public:
A():j(0),i(j+2){};
void print()
{
//声明顺序是先i后j
//所以哪怕初始化列表中先写j的值,也是先初始化i
//所以现在i中是个垃圾值,这里没办法输出
cout << i << j << endl;
}
};

  可以不使用初始化列表,而是在构造函数中为成员赋值,不过这样做效率会有点低。从概念上讲,构造函数的执行阶段分为两步:第一步是初始化阶段;第二步是计算赋值阶段。普通的内置类型成员由于不进行隐式初始化,所以无论使用初始化列表还是在构造函数中赋值都无关紧要,但如果是一个类成员,不在初始化列表中进行显式初始化,而是在构造函数函数体里赋值,相当于先调用这个类成员自身的构造函数进行初始化,然后再走赋值赋值操作,多了一个步骤。

  没有默认构造函数的类类型的成员变量、const类型的成员变量、引用类型的成员变量必须在构造函数初始化列表中进行初始化。

复制(拷贝)构造函数

  复制构造函数、赋值操作符和析构函数总称为复制控制,编译器会自动实现操作,不过程序员可以定义自己的版本。一个有用的经验:如果一个类需要析构函数,那么它也同时需要赋值操作符和复制构造函数。通常编译器自己合成的复制构造函数非常精炼,只做必需的工作。

  只有单个形参,而且该形参是对本类类型对象的引用(常用const修饰),这样的构造函数称为复制构造函数或拷贝构造函数。复制构造函数的作用:

  1. 根据另一个同类型的对象初始化一个对象;
  2. 复制一个对象,将它作为实参传给一个函数或从函数返回时复制一个对象;
  3. 初始化顺序容器中的元素;
  4. 根据元素初始化式列表初始化数组元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//1. 根据另一个同类型的对象初始化一个对象:
//C++有两种初始化方式,复制初始化使用"="符号,直接初始化将初始化式放在圆括号中
string dots(10, '.'); //直接初始化
string dots2 = "."; //复制初始化,不过很多编译器会优化
string dots3(dots); //也是复制初始化,用另一个本对象初始化自己
string dot4 = dots; //也是复制初始化

//复制初始化的步骤:首先使用指定的构造函数创建一个临时对象,
//然后把临时对象复制到正在创建的对象中

//需要注意区分"="什么时候是复制初始化,什么时候是赋值操作符:
string null_book = "9-999-9999-9";
string null_book2; //语句1
null_book2 = null_book; //语句2
//这里语句1是调用string的默认构造函数创建一个空字符串对象
//语句2是利用赋值运算符将null_book赋值给null_book2,不是复制构造
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
//2. 复制一个对象,将它作为实参传给一个函数或从函数返回时复制一个对象:
//当形参或返回值是自定义类类型时,将由复制构造函数进行复制

class MyClass{
public:
MyClass(int n) { number = n; }
MyClass(const MyClass& other){
//拷贝构造中打印
number = other.number;
cout << "a ";
}
private:
int number;
};

void fun(MyClass p)
{
MyClass temp(p); //第三次拷贝构造
}

int main()
{
MyClass obj1(10), obj2(0); //默认构造
MyClass obj3(obj1); //第一次拷贝构造
fun(obj3); //实参obj3到形参p,第二次拷贝构造
return 0;
}

//最终结果输出“a a a”,调用3次拷贝构造
1
2
3
4
5
//3. 初始化顺序容器中的元素:
vector<string> svec(5);

//编译器首先使用string的默认构造函数来创建一个临时值初始化svec
//然后使用复制构造函数将临时值复制到svec中的每一个元素
1
2
3
4
5
6
7
8
9
10
//4. 根据元素初始化式列表初始化数组元素:

//类类型数组如果没有初始化式,则将用默认构造函数初始化每个元素;
//但如果使用花括号初始化列表来显式初始化数组,则使用复制初始化构造来初始化每个元素

Sales_item_primer_eds[] = {
string("0-201-16487-6"),
string("0-201-54848-7"),
string("0-201-82470-1")
}

  复制构造函数参数必须是引用,如果不用引用,那就变成了值传递。值传递方式会调用该类的拷贝构造函数,从而造成无穷递归的调用拷贝构造。

深拷贝、浅拷贝

  • 浅拷贝:只拷贝指针地址,对所有对原对象的引用仍然指向原对象,C++默认拷贝构造函数与赋值运算符重载都是浅拷贝;节省空间,但容易引发多次释放;
  • 深拷贝:重新分配堆内存,拷贝指针指向内容,且引用原对象的变量将指向被复制的新对象;浪费空间,但是不会导致多次释放;

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Test{
char* ptr;
}

void shallow_copy(Test& src, Test& dest)
{
dest.ptr = src.ptr;
}

void deep_copy(Test& src, Test& dest){
dest.ptr = malloc(strlen(src.ptr) + 1);
memcpy(dest.ptr, src.ptr);
}

  深拷贝的思想比较常见,比如C++的一个优化策略叫写时复制。有个信息存放在内存空间中,如果大家都去读取,那么内存中保留一份即可,但如果有地方需要写数据,那么会复制出一个新的地址空间存放相同数据,写操作作用在新地址空间上。

  深拷贝和浅拷贝各有优劣,如果想兼有二者的优点,有两种可用方案:第一是使用引用计数,用shared_ptr的思路,每有一个指针指向对象,引用计数+1,直到引用计数清零时再清理内存;第二种是C++11的新标准移动语义move,把资源让渡,既可以避免重新创建空间,也防止空间释放导致新问题。

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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
//自定义字符串类String操作

class String
{
public:
String(const char *str = NULL); // 普通构造函数
String(const String &other); // 拷贝构造函数(深拷贝)
String(String&& other); // 移动构造函数,两个&&代表右值引用
~String(void); // 析构函数
String& operator= (const String& other); // 赋值函数
String& operator=(String&& rhs)noexcept; // 移动赋值运算符

friend ostream& operator<<(ostream& os, const String &c); // cout输出

private:
char *m_data; // 用于保存字符串
};


// String 的普通构造函数
String::String(const char *str)
{
if (str == NULL)
{
m_data = new char[1];
if (m_data != NULL)
{
*m_data = '\0';
}
else
{
exit(-1);
}
}
else
{
int len = strlen(str);
m_data = new char[len + 1]; //把'\0'的空间留出来
if (m_data != NULL)
{
strcpy(m_data, str);
}
else
{
exit(-1);
}
}
}

// 拷贝构造函数
// 开辟新的内存空间了,属于深拷贝
String::String(const String &other)
{
int len = strlen(other.m_data);
m_data = new char[len + 1];
if (m_data != NULL)
{
strcpy(m_data, other.m_data);
}
else
{
exit(-1);
}
}

// 移动构造函数
String::String(String&& other)
{
if (other.m_data != NULL)
{
// 资源让渡
m_data = other.m_data;
// 之前的联接断开
other.m_data = NULL;
}
}

// 赋值函数
String& String::operator= (const String &other)
{
if (this == &other)
{
return *this;
}
// 释放原有的内容
delete[] m_data;
// 重新分配资源并赋值
int len = strlen(other.m_data);
m_data = new char[len + 1];
if (m_data != NULL)
{
strcpy(m_data, other.m_data);
}
else
{
exit(-1);
}

return *this;
}

// 移动赋值运算符
String& String::operator=(String&& rhs)noexcept
{
if(this != &rhs)
{
// 清空原有的
delete[] m_data;

//赋值新的
m_data = rhs.m_data;

// 我们接管了内存,原有内存不需要释放,把链接断开即可
rhs.m_data = NULL;
}
return *this;
}

// String 的析构函数
String::~String(void)
{
if (m_data != NULL)
{
delete[] m_data;
}
}

ostream& operator<<(ostream& os, const String &c)
{
os << c.m_data;
return os;
}

int main()
{
String s1("Hello"); // 构造函数
cout << s1 << endl;
//String s2 = s1; // 调用拷贝构造函数
String s2(s1); // 调用拷贝构造函数
cout << s2 << endl;
String s2A(std::move(s1)); // 移动构造函数
cout << s2A << endl;
String s3; // 无参构造函数
cout << s3 << endl;
s3 = s2; // 调用赋值函数
cout << s3 << endl;
String s3A; // 无参构造函数
s3A = std::move(s2A); // 移动赋值运算符
cout << s3A << endl;

return 0;
}

析构函数

  构造函数可以分配一个缓冲区或打开一个文件,在构造函数中分配资源后,就需要一个对应的操作来自动回收或释放资源。析构函数就是这样的特殊函数,作为构造函数的补充来完成所需的资源回收。当对象超出作用域或动态分配的对象被删除时,将自动应用析构函数。

  不管类是否定义了自己的析构函数,编译器都将自动执行类中的非static数据成员的析构函数。

  构造函数不能被定义为虚函数,但析构函数可以被定义为虚函数。一般来说,如果类中定义了虚函数,那么析构函数也应被定义为虚析构,尤其是类内有申请的动态内存需要被清理和释放。

  假设类B是从类A继承的,如果类A的析构不是虚函数,那么如果基类指针pA指向new出来的B对象时,delete基类指针,只会运行基类的析构函数,而不会运行派生类的析构函数,造成资源释放不彻底。

  无论程序员是否定义了自己的析构函数,都会创建和运行合成析构函数。如果程序员自己定义了析构,那么再执行完自定义析构后会运行合成析构函数。合成析构函数按照对象创建时的逆序撤销每个非static成员,即声明次序的逆序。对于类类型的数据成员,合成析构函数调用该数据成员的析构函数来撤销对象。

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
class A{
public:
A() { cout << "A"; }
~A() { cout << "~A"; }
};

class B{
public:
B(A &a):_a(a) //_a(a)调用了拷贝构造
{
cout << "B";
}
private:
A _a;
};

int main()
{
A a; //对象定义时自动调用构造,产生输出A
B b(a); //输出B之前还调用了A的拷贝构造

return 0;
} //对象声明周期结束后,按照构造函数的逆序依次调用析构


//输出结果“AB~B~A~A”
//先构造A,再构造B,符合对象先构造被包含的对象,再构造主对象
//析构的顺序是构造的逆序,复合对象先析构主对象,再析构包含的对象
//这里之所以走了两边A的析构,是因为这直接有一步拷贝构造,由于没有自定义所以没输出

构造和析构的调用顺序

单继承

  派生时,构造和析构不能被继承,为了对基类成员初始化,必须对派生类重新定义构造和析构,并在构造函数的初始化列表中调用基类的构造函数。派生类创建对象时,系统首先通过派生类的构造函数来调用基类的构造函数,完成基类成员的初始化,然后对派生类中新增的成员进行初始化。

1
2
3
4
派生类名(总参数表) : 基类构造函数(参数表)
{
//函数体
}/

  必须将基类的构造函数放在派生类的初始化列表中以调用基类构造函数,派生类构造函数调用顺序为:

  1. 完成对象所占整块内存的开辟,由系统在调用构造函数时自动完成;
  2. 调用基类的构造函数完成基类成员的初始化;
  3. 若派生类中含对象成员、const成员或引用成员,则必须在初始化列表中完成对其初始化;
  4. 派生类构造函数体执行;

  当对象被删除时,派生类的析构函数被执行。析构函数不能继承,因此,在执行派生类析构函数时,基类析构函数会被自动调用。执行顺序先是执行派生类的析构函数,再执行基类的析构函数,这和执行构造函数的孙顺序正好相反。

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
class A{
public:
A() { cout << "A"; }
~A() { cout << "~A"; }
};

class B : public A
{
public:
B(A &a):_a(a) //_a(a)调用了拷贝构造
{
cout << "B";
}
~B()
{
cout << "~B" << endl;
}
private:
A _a;
};

int main()
{
A a; //对象定义时自动调用构造,产生输出A
B b(a); //B继承A,先调用了父类的构造函数,除此之外还有个拷贝构造,最后走B的构造

return 0;
} //对象声明周期结束后,按照构造函数的逆序依次调用析构


//输出结果“AAB~B~A~A~A”
//先构造A,再构造B,符合对象先构造被包含的对象,再构造主对象
//析构的顺序是构造的逆序
//这里之所以走了两边A的析构,是因为这直接有一步拷贝构造,由于没有自定义所以没输出

多继承

  多继承时,派生类的构造函数初始化列表需要调用需要调用各个基类的构造函数。此时构造函数初始化列表只能控制用于初始化基类的值,并不能控制基类构造次序。基类构造函数按照基类构造函数在类派生列表中的出现次序调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class ZooAnimal{

};

class Bear : public ZooAnimal{

};

class Endangered{

};

class Panda : public Bear, public Endangered{

}

//Bear是Panda的第一个直接基类,基类初始化顺序为:
//1. ZooAnimal,从Panda的直接基类Bear沿层次向上的最终基类;
//2. Bear,第一个直接基类
//3. Endangered, 第二个直接基类,它本身没有基类
//4. Pande,初始化Panda本身的成员,然后运行它的构造函数的函数体

虚继承

  首先调用虚基类的构造函数,虚基类如果有多个,则虚基类构造函数的调用顺序是此虚基类在当前派生表中出现的顺序而不是它们在成员初始化表中的顺序。

  所有虚基类的构造函数调用完毕后,按照“多继承”中的规则调用其他构造函数。

同名成员函数

  对类层次中的同名函数来说,有三种关系:重载(overload)、覆盖(override)和隐藏(hide、oversee)。理清三种关系有助于写出高质量代码。

成员函数的重载

  重载的概念很简单,只有在同一类定义中的同名成员函数才存在重载关系,主要特点是函数的参数类型和数目有所不同,但不能出现函数参数的个数和类型均相同,仅仅依靠返回值类型的不同来区分。这和普通函数的重载是完全一致的。

  重载和成员函数是否是虚函数无关。

1
2
3
4
5
6
7
//四个重载函数:
class A{
virtual int fun();
void fun(int);
void fun(double, double);
static int fun(char);
};

成员函数的覆盖

  覆盖是指:在派生类中覆盖基类中的同名函数,要求基类函数必须是虚函数,且:

  1. 与基类的虚函数具有相同的参数个数;
  2. 与基类的虚函数有相同的参数类型;
  3. 与基类的虚函数有相同的返回类型:如果返回的是指针(或引用),派生类的覆盖函数可以返回相同的指针(或引用),也可以返回基类返回的类型的子类型的指针(或引用);
1
2
3
4
5
6
7
8
9
10
11
class A{
public:
virtual void fun1(int, int){

}
};

class B :public A{
public:
void fun1(int, int){ }
};

  重载和覆盖的区别:

  1. 覆盖是子类和父类之间的关系,是垂直的;重载是同一类中不同方法之间的关系,是水平的;
  2. 覆盖要求参数列表相同,返回类型相同;重载要求参数列表不同,返回类型不要求;
  3. 覆盖关系中,调用方法体是根据对象的类型来决定的,重载关系是根据调用时的实参表与形参表来选择方法体的;

成员函数的隐藏

  隐藏是指在某些特殊情况下,派生类中的函数屏蔽了基类中的同名函数,这些情况有:

  1. 两个函数参数相同,但基类函数不是虚函数;
  2. 两个函数参数不同,无论基类函数是否是虚函数,基类函数都会被屏蔽;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A{
public:
void fun(int xp){
cout << xp << endl;
}
};

class B : public A{
public:
void fun(int xp){} //隐藏父类的fun函数
};

B b ;
b.fun(); //调用B类的fun函数
b.A::fun(2); //调用基类A中的fun函数
1
2
3
4
5
6
7
8
9
10
11
12
13
class A{
public:
virtual void fun(int xp){ //是否为virtual不重要
cout << xp << endl;
}
};

class B : public A{
public:
void fun (char* xp) {} //隐藏父类的fun函数
};


继承

  通过继承机制,可以利用已有的数据类型来定义新的数据类型。所定义的新的数据类型不仅拥有新定义的成员,而且还同时拥有旧的成员。已存在的用来派生新类的类为基类,也称父类;新产生的类为派生类,也称子类。

  一个派生类可以从一个基类派生(单继承),也可以从多个基类派生(多继承)。

1
2
3
class <派生类名>:<继承方式1> <基类名1><继承方式2><基类名2>,...{
<派生类新成员>
};

  派生类对象由多个部分组成:派生类本身定义的(非static)成员加上由基类(非static)成员组成的子对象。如果一个类有多个直接基类,而这些直接基类又有一个共同的基类,则在最底层的派生类中会保留这个间接的共同基类数据成员的多份同名成员。

基类成员访问属性

派生类对基类成员的访问形式有以下两种:

  1. 内部访问:由派生类中新增的成员函数对基类继承来的成员的访问;
  2. 对象访问:在派生类外部,通过派生类的对象从基类继承来的成员的访问;

  • 公有继承public

  共有继承的特点是基类的公有成员和保护成员作为派生类的成员时,它们都保持原有的状态,而基类的私有成员仍然是私有的,不能被这个派生类的子类所访问。一定要区分公有继承派生类的对象和派生类的成员函数对基类的访问是不同的:

  1. 父类的public成员成为子类的public成员,可以被该子类中的函数(内部访问)及其友元函数访问,也可以由该子类的对象(外部访问)访问;
  2. 父类的private成员仍旧是父类的private成员,子类成员包括友元函数和子类对象都不允许访问;
  3. 父类的protected成员成为子类的protected成员,可以被该子类中的函数(内部访问)及其友元函数访问,但不可以由该子类的对象访问

  • 私有继承private:

  私有继承的特点是基类的所有成员都作为派生类的私有成员,且不能被这个派生类的子类访问。

  • 保护继承protected:

  保护继承的特点是基类所有的公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元函数访问,基类的私有成员仍然私有。

继承导致的二义性

类间的转换

  C++基本类型的指针之间不含有隐式转换(void*除外),需要显式转换。但用户自定义类型有特殊的转换规则:

  1. 私有继承和保护继承时不允许隐式转换;
  2. 公有继承下,派生类的对象、对象指针、对象引用可以赋值给基类的对象、对象指针、对象引用,此时发生了隐式转换。但反过来不允许:基类的对象、对象指针、对象引用不能赋值给派生类的对象、对象指针、对象引用,因为派生类中包含了基类的所有信息,但基类缺乏派生类新增的信息;
  3. 虽然基类的对象指针、对象引用不允许隐式转换为派生类的对象指针、对象引用,但可以通过强制转换(显式)成为派生类的对象指针、对象引用。注意只限于基类对象的指针和引用,基类对象本身不允许;
  4. 一个指向基类的指针可以用来指向该基类的公有派生类的任何对象,这是C++程序运行时多态性的关键;

  无论是通过创建成员对象还是通过继承,当我们把一个类的子对象嵌入一个新类中时,编译器会把每一个子对象置于新对象中。当引入多重继承时,由于对象往上转换期间出现多个类,因而对象存在多个this指针:

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
class base1
{
char c[16];
public:
void printthis1() {
cout << "base1 this=" << this << endl;
}
};

class base2 {
char c[16];
public:
void printthis2() {
cout << "base2 this=" << this << endl;
}
};

class member1 {
char c[16];
public:
void printthism1() {
cout << "member1 this=" << this << endl;
}
};

class member2 {
char c[16];
public:
void printthism2() {
cout << "member2 this=" << this << endl;
}
};

class mi :public base1, public base2 {
member1 m1;
member2 m2;
public:
void printthis() {
cout << "m1 this=" << this << endl;
printthis1(); //调用继承自base1的printthis1()
printthis2(); //调用继承自base2的printthis2()
m1.printthism1();
m2.printthism2();
}
};

int main()
{
mi MI;
cout << "sizeof(mi)=" << sizeof(mi) << endl;
MI.printthis();

cout << endl;

base1* b1 = &MI;
base2* b2 = &MI;
cout << "base 1 pointer=" << b1 << endl;
cout << "base 2 pointer=" << b2 << endl;

cout << endl;

mi* b3 = &MI;
if (b1 == b3) {
cout << "b1==b3" << endl;
}
if (b2 == b3) {
cout << "b2==b3" << endl;
}

return 0;
}

多基继承

  当继承基类时,在派生类中就获得了基类所有数据成员的副本,该副本称为子对象。

  一般来说,在派生类中队基类成员的访问应当具有唯一性,但在多基继承时,如果多个基类中存在同名成员的情况,造成编译器无从判断具体要访问哪个基类中的成员,则称为对基类成员访问的二义性问题。

  若两个基类中具有同名的数据成员或成员函数,应使用成员名限定来消除二义性。更好的办法是在子类中也定义一个同名函数,根据需要调用成员名限定函数,从而实现对基类同名函数的隐藏。

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:
void printf(){
cout << "this is A" << endl;
}
};

class B{
public:
void printf(){
cout << "this is B" << endl;
}
};

class C : public A, public B{
public:
void disp(){
//printf(); //直接使用,编译器无法确定采用哪个版本
A::printf();
B::printf();
}
}

菱形继承

  菱形继承如上图。只要菱形继承一出现,由于新类中存在重叠的子对象base,此时既引入了二义性,同时重叠的子对象也增加了额外的存储空间。

  和多基继承一样,用成员名限定指明使用哪一个即可。

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
class A {
public:
void printf() {
cout << "this is x in A" << endl;
}
};

class B :public A {};
class C :public A {};

class D :public B, public C {};



int main()
{
D d;

/*错误使用
* A * pa = (A*)&d; //上行转换产生二义性
* d.printf(); //系统不知道调用B类的printf还是C类的printf
*/

//d中含有两个基类对象A,次数上行转换时必须要转两次,指明要使用哪一个
A* pa = (A*)(B*)&d;

d.B::printf(); //使用B类的printf
d.C::printf(); //使用C类的printf
/*
* 此处不能写d.A::printf()
* d对象中含有两个A类对象,编译会报错
*/

return 0;
}

  事实上,使用关键字virtual将基类A声明为虚基类,可有效解决上述问题

转换构造函数

  可以用单个实参来调用的构造函数定义从形参类型到该类类型的一个隐式转换。

1
2
3
4
5
6
7
8
class Integral{
public:
Integral(int = 0); //转换构造函数
private:
int real;
}

Integral integ = 1; //调用转换构造函数将1转换为Integral类的对象

  转换构造函数需要满足以下条件之一:

  1. Integral类的定义和实现中给出了仅包括只有一个int类型参数的构造函数;
  2. Integral类的定义和实现中给出了包含一个int类型的参数,且其他参数都有缺省值的构造函数;
  3. Integral类的定义和实现中虽然不包含int类型参数,但包含一个非int类型参数如float类型,此外没有其他参数或者其他参数都有缺省值,且int类型参数可以隐式转换为float类型参数;

  这种隐式的类型转换某些情况下可能导致预期之外的行为,因此可以将转换构造函数声明为explicit来禁止隐式转换。

1
2
3
4
5
6
7
8
9
10
11

class Integral{
public:
explicit Integral(int = 0);
private:
int real;
}

//Integral integ = 1; //编译错误,禁止隐式转换
Integral integ2(1); //显式的调用构造
Integral integ3 = Integral(1); //直接使用构造

类型转换函数

  通过上述的转换构造函数可以将一个指定类型的数据转换为类的对象。但是不能反过来将一个类的对象转换成其他类型的数据。如可以将int型对象转换为上面例子中的Integral,但是不能将Integral转换为int。为此,C++提供了一个称为类型转换函数的函数来解决这个转换问题。类型转换函数的作用是将一个类的对象转换成另一个类型的数据:

1
2
3
4
5
6
7
8
9
10
11
12
class Integral{
public:
Integral(int = 0); //转换构造函数
operator int(){ //类型转换函数
return real;
}
private:
int real;
}

Integral integ = 1; //调用转换构造函数将int转换为Integral类的对象
int i = integ; //调用类型转换函数将integ转换为int类型

  定义类型转换函数必须要注意几点:

  1. 转换函数必须是成员函数,不能是友元形式;
  2. 转换函数不能指定返回类型,但在函数体内必须用return语句以传值的方式返回一个目标类型的变量;
  3. 转换函数不能有参数;

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
/*
总结,非C++内置类型A和B,以下几种情况B可以隐式转化为A:
*/

//1. B公有继承A
class B :public A {};

A a;
B b;
a = b;


//2. B中有类型转换函数
class B{
operator A();
}

A a;
B b;
a = b;


//3. A实现了非explicit的参数为B的构造函数
class A{
A(const B&);
}
A a;
B b;
a = b;

虚函数多态

  多态性是面向对象语言的基本特征。多态性可以简单概况为“一个接口,多种方法”。函数重载就是一种简单的多态,一个函数名(调用接口)对应着几种不同的函数原型(方法)。

  更通俗的说,多态性是指“同一个操作作用于不同的对象就会产生不同的响应”。多态性分为静态多态和动态多态,其中函数重载和运算符重载属于静态多态性,虚函数属于动态多态性。

静态联边与动态联编

  程序调用函数时,具体使用哪个代码块是由编译器决定的。以函数重载为例,C++编译器根据传递给函数的参数和函数名决定具体要使用哪一个函数,称为联编或绑定(binding)。

  编译器可以在编译过程中完成这种联编,在编译过程中进行的联编叫静态联编(static binding)或早期联编(early binding)。

  在一些场合下,编译器无法在编译过程中完成联编,必须在程序运行时完成选择,因此编译器必须提供一套称为“动态联编”(dynamic binding)的机制,也叫晚期联编(late binding),C++通过虚函数来实现动态联编。

  只要在类成员函数原型前加一个关键字virtual即可。如果一个基类的成员函数定义为虚函数,那么,它在所有的派生类中也保持为虚函数;即使派生类中省略了virtual,也仍然是虚函数。派生类可以根据需要对虚函数进行重定义,要求与基类虚函数有相同的参数个数、相同的参数类型、相同的返回类型(如果基类返回的是指针,派生类重定义的虚函数可以返回基类返回类型的子类型)。

  虚函数的访问可以通过对象名、指针和引用来访问:

  1. 通过对象名来访问时,编译器采用的是静态联编。通过对象名访问虚函数时,调用哪个类的函数取决于定义对象名的类型。对象类型是基类时,就调用基类的函数;对象类型是子类时,就调用子类的函数;
  2. 通过指针访问非虚函数时,调用哪个函数取决于指针本身的类型;而通过指针访问虚函数时,编译器根据指针所指向对象的类型决定要调用哪个函数(动态联编),而与指针本身的类型无关;
  3. 通过引用访问虚函数与指针访问类似,不同的是,引用一经声明后,引用变量本身无论如何改变,其调用的函数就不会再改变,始终指向其开始定义时的函数。虽然使用上有一定限制,但一定程度上提高了代码安全性,特别是在函数参数传递场合中,可以将引用理解为一种“受限制的指针”;

  构造函数不能为虚函数。假设B继承于A,声明B对象时需要先执行A的构造函数,再执行B的构造函数。虚构函数的性质是如果函数为虚函数,那么将只执行子类的函数,不执行父类的函数。一旦将构造函数声明为虚函数,那么父类将无法构造。此外,虚函数是不同类型的对象产生不同的动作,构造函数执行时还没有对象产生,无法使用virtual来完成想要完成的动作。

  此外,普通函数也不能声明为虚函数,只能被overload(重载),不能被override(覆盖),声明为虚函数毫无意义。

  静态成员函数也不支持成为虚函数。静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,它不归属于具体的对象,所以没有动态绑定的必要性。

  友元函数也不支持成为虚函数,因为C++就不支持友元函数的继承。

  内联函数和赋值运算符可以被声明为虚函数,但是毫无意义。内联函数是为了在代码中直接展开,减少函数调用花费的代价,而虚函数是为了在继承后,对象能够准确地执行自己的动作,这是不可能统一的。即使虚函数被声明为内联函数,编译器也根本不会把这样的函数内联展开;operator =重载函数要求形参与类本身类型相同,所以基类的赋值操作符形参类型为基类类型,子类类型的重载函数形参要求为子类类型,即使声明为虚函数,也不能作为子类的赋值操作符。

  构造函数和虚构函数的函数体内可以调用虚函数,但是应尽量避免这种行为。构造派生类对象时,首先运行基类构造函数初始化对象的基类部分,此时派生类部分是未初始化的;同样的,撤销派生类对象时,首先撤销它的派生类部分,然后按照与构造顺序的逆序撤销它的基类部分。可见这两种情况下,对象都是不完整的。为了适应这种不完整性,编译器将对象的类型视为在构造期间或析构期间发生了变化。在基类构造函数中或析构函数中,将派生类对象当作基类类型对象对待,即在构造或者析构函数中,哪怕使用了"this->虚函数"的形式调用,编译器仍然解释为静态联编的“本类名::虚函数名”。

虚函数表指针(vptr)和虚基类表指针(bptr)

  C++在布局以及存取时间上主要的额外负担是由Virtual引起的,包括:

  • virtual function机制:用以支持一个有效率的“执行器绑定”;
  • virtual base class:用以实现多次出现在继承体系中的基类,有一个单一而被共享的实体。

虚函数表指针

  C++中有两种数据成员:static和nonstatic,以及三种类成员函数:static、nonstatic和virtual。

1
2
3
4
5
6
7
8
9
10
11
class Point{
public:
Point(float xval);
virtual ~Point();
float x() const;
static int PointCount();
protected:
virtual ostream& print(ostream& os) const;
float _x;
static int _point_count;
};

  C++对象模型中,非static数据成员被配置于每一个对象之内,static数据成员则被存放在所有的对象之外,通常被放置在程序的全局(静态)存储区内,故不会影响个别的对象大小。

  static和非static函数也被放在所有的对象之外,通过this指针来访问。

  virtual函数以两个步骤支持:

  1. 每一个类产生出一堆指向virtual functions的指针,放在表格之中,这个表格成为virtual table(vtbl);
  2. 每一个对象被添加了一个指针,指向相关的virtual table。通常这个指针被称为vptr(虚函数表指针)。vptr的设定和重置都由每一个类的构造函数、析构函数和复制构造函数自动完成。每一个类所关联的type_info信息(用以支持runtime type identification,RTTI)也经由virtual table被指出来,通常是放在表格的第一个slot处。

  由此可见,非static对象成员必须和对象绑定,其余的成员可以脱离对象存在:

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
class A{
public:
string a;
void f1(){
printf("Hello world");
}
void f2(){
a = "Hello World";
printf("%s", a.c_str());
}

virtual void f3(){
printf("Hello World");
}
};

int main()
{
A* aptr = NULL;

aptr->f1(); //正确,f1脱离对象存在,且f1中没有任何成员变量

aptr->f2(); //错误,f2中有成员变量a,不能脱离对象存在

aptr->f3(); //错误,f3是虚函数,需要使用虚表指针,存在于具体对象中

return 0;
}

含静态变量、虚函数的类空间

  sizeof应用在类时的处理和struct中的处理是一样的,只需要注意特殊情况:

  1. 类或结构体中的static成员不对类或结构体的大小产生影响;
  2. 函数不占用内部空间;
  3. 如果含有virtual函数,需要额外计算vptr指针的大小(4);
  4. 空类的大小为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
class Base
{
public:
virtual void f() { cout << "Base::f" << endl; }
virtual void g() { cout << "Base::g" << endl; }
virtual void h() { cout << "Base::h" << endl; }
};

int main()
{
typedef void(*Fun)(void);
Base b;
Fun pFun = NULL;
cout << "虚函数表地址:" << (int*)(&b) << endl;
cout << "虚函数表 - 第一个函数地址:" << (int*)*(int*)(&b) << endl;

//Invoke the first virtual function
pFun = (Fun) * ((int*)*(int*)(&b));
pFun();
pFun = (Fun) * ((int*)*(int*)(&b) + 1);
pFun();
pFun = (Fun) * ((int*)*(int*)(&b) + 2);
pFun();

return 0;
}

  这个示例可以看到,我们通过强行把&b转成int*,取得虚函数表地址,然后,再次取值就可以得到第一个虚函数的地址了。

  单基继承时,派生类仅有一个vptr。派生类中的虚函数覆盖了父类中的同名函数。多基继承时,有几个基类就有几个vptr,每个vptr中派生类替换掉父类的同名函数。

虚基类表指针(pbtr)

  C++支持单一继承,也支持多重继承,继承关系也可以指定为虚拟(virtual,也就是共享的意思)

1
2
3
4
class iostream : public istream, public ostream {...};

class istream : virtual public ios {...};
class ostream : virtual public ios {...};

  类似这种iostream的继承方式称为菱形继承。即istream和ostream虚拟继承ios,然后iostream普通继承istream、ostream。

  在虚拟继承的情况下,基类不管在继承串链中被派生多少次,永远只会存在一个实体。

  虚拟继承基类的子类中,子类会增加某种形式的指针,或者指向虚基类子对象,或者指向一个相关的表格;表格中存放的不是虚基类子对象的地址,就是其偏移量。此指针被称为pbtr。在同时存在vptr和bptr时,某些编译器会将其进行优化,合并为一个指针。

虚拟继承时的构造函数

  普通的多层继承,构造函数的调用是嵌套的,如由C1类派生C23类,C2类派生C3类,则各个构造函数结构如下形式:

1
2
C2(总参数表):C1(参数表)
C3(总参数表):C2(参数表)

  但是对于虚基类派生来说,A类虚拟派生B类、C类,D类继承B类,C类,则构造函数写法如下:

1
2
3
4
B(总参数表):A(参数表)
C(总参数表):A(参数表)
//D(总参数表):B(参数表), C(参数表) //错误
D(总参数表):B(参数表), C(参数表), A(参数表)

  根据虚基派生的性质,类D中只有一份虚基类A的拷贝,因此A类的构造函数在D类中只能被调用一次。所以,从A类直接虚拟派生(B和C)和间接派生(D)类的对象中,其构造函数初始化列表里都必须列出虚基类A构造函数的调用。这种机制保证了不管有多少层继承,虚基类构造函数必须且只能被调用一次。

  若在初始化列表中没有显式调用虚基类的构造函数,则将调用虚基类的默认构造函数,若虚基类没有定义默认构造函数,则编译出错。

纯虚函数

  纯虚函数是一种特殊的虚函数,一般格式如下:

1
2
3
class <类名>{
virtual <类型> <函数名>(<参数表>) = 0;
};

  在许多情况下,在基类中不能对虚函数给出有实际意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。

  凡是含有纯虚函数的类称为抽象类。这种类不能声明对象,只能作为基类为派生类服务。除非在派生类中完全实现基类中所有的纯虚函数,否则派生类也是抽象类,不能实例化对象。

  只定义了protected型构造函数的类也是抽象类。对一个类来说,如果只定义了protected型的构造函数而没有提供public构造函数,无论是在外部还是在派生类中都不能创建该类的对象,但可以由其派生出新的类,这种能派生新类,却不能创建自己对象的类是另一种形式的抽象类。

动态运行时类型识别(RTTI)

  通过运行时类型识别(RTTI),程序能够使用基类的指针或者引用来检索这些指针或引用所指对象的实际类型。C++提供了两个操作符提供RTTI:

  1. typeid操作符,返回指针或者引用所指对象的实际类型;
  2. dynamic_cast操作符,将基类类型的指针或引用安全地转换为派生类型的指针或引用;

typedef表达式形如typeid(e),这里e是任意表达式或者类型名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Base *bp;
Derived *dp;
if(typeid(*bp) == typeid(*dp)) //比较pb所指对象与dp所指对象的实际类型
{

}

if(typeid(*bp) == typeid(Derived)) //比判断pb是否指向Derived对象
{

}


// !!!只有当typeid的操作数是带虚函数的类类型的对象时,才返回动态类型信息。!!!
// 测试指针时返回指针的静态的、编译时的类型
if(typeid(bp) == typeid(Derived)) //这个必定失败,bp是指针类型,返回Base*,比较的是Base*类型和Derived类型
{

}

抽象法则

  面向对象的误区:对象是对现实世界中具体物体的反映,继承是对物体分类的反映?这个观念是错误的。举个例子,现实生活中我们往往把正方形看作是长和宽都相等的特殊的长方形,如果把这个思想引入到C++中,可能会这么设计继承关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class rectangle  //长方形
{
virtual void SetLength(double a)
{
……
}
};

class square : public rectangle //正方形,继承长方形类
{
virtual void SetLength(double a)
{
……
}
}

  上面设计了两个类,一个长方形类,一个正方形类,其中正方形类继承了长方形类以及内部的方法SetLength。当我们调用长方形类SetLength的方法时,我们只是修改了长方形的长,长方形的宽不受影响;但是如果我们调用了正方形对象的SetLength方法,不仅长会受到影响,宽也会受到影响。这个从面向对象的继承体系来说就有很大的问题了。所以我们不要把现实世界中的关系代入到面向对象编程中。

具体类型的抽象

  • 让自定义的类像内置类型一样

  一个普通的int型变量,可以完成加、减、乘、除、比较、输出、自增等等一系列操作;如果现在有一个自定义的复数类型,我们自然也希望可以像使用int型变量一样使用它,同时它对我们是一个黑盒,一种抽象,我们不需要关心内部是如何实现的。

  操作符重载函数的名字为operator后跟着所定义的操作符的符号。像任何其他函数一样,操作符重载函数有一个返回值和一个形参表。 形参表必须具有与该操作符数目相同的形参(如果操作符是一个类成员,则包括隐式this形参)。比如赋值是二元运算,所以该操作符函数有两个形参:第一个形参对应着左操作数、第二个形参对应右操作数。

  大多数操作符可以定义为成员函数或非成员函数。当操作符为成员函数时,它的第一个操作数隐式绑定到this指针。有些操作符(包括赋值操作符)必须是类的成员函数,比如赋值就必须是类的成员,所以this绑定到指向左操作数的指针。因此,赋值操作符接受单个形参,且该形参是同一类类型的对象。右操作数一般作为const引用传递。

  并非所有操作符都可以重载,“::”、“.*”、“.”、“?:”不可以重载。可以记忆:带“点”的不允许重载。

  对于赋值操作符来说,和复制构造函数一样,如果类没有定义自己的赋值操作符,则编译器会合成一个。但一定要区分复制构造函数和赋值运算符的区别。复制构造函数只有在对象实例化时才会被调用,也就是说,在复制构造函数调用期间,这个对象处于一个未决状态(直到复制构造函数被成功调用),另外复制构造函数不返回任何值,void都没有。而赋值运算符则在一个现存的对象被赋予新值的时候被调用,并且它有返回值。所以并不是出现了“=”就是调用赋值构造函数,必须要看到底是否产生了新的对象实例。

  一个新创建的类,编译器默认生成:构造函数、复制构造函数、析构函数、赋值运算符重载函数、取址运算符重载函数、const取值运算符重载函数。其中基类的构造函数(包括拷贝构造)、析构函数、赋值操作符重载函数都不能被派生类继承。

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
//complex.h
#pragma once

#include <iostream>
using namespace std;

class Complex
{
public:
Complex(); // 默认构造函数
Complex(double r, double i); // 构造函数
virtual ~Complex(); // 析构函数
Complex(const Complex& x); // 拷贝构造
Complex& operator=(const Complex &c); // =号运算符


double GetReal( ) const { return _real; } //const修饰函数,函数体内成员变量不允许改变
void SetReal(double d) { _real = d; }
double GetImage() const { return _image; }//const修饰函数,函数体内成员变量不允许改变
void SetImage(double i) { _image = i; }

// 运算符重载
//这里函数参数传递的是引用,因为可能从外面传递另外一个complex,但我们不想调用拷贝构造生成一个副本
//重载等号操作符时返回的是引用,可以让返回值在其他地方使用
//如果返回的不是该类型的引用而是是void,那么三个对象连续赋值操作c1 = c2 = c3将不能通过编译
Complex operator+(const Complex &c) const;
Complex& operator+=(const Complex &c);
Complex operator-(const Complex &c) const;
Complex& operator-=(const Complex &c);
Complex operator*(const Complex &c) const;
Complex& operator*=(const Complex &c);
Complex operator/(const Complex &c) const;
Complex& operator/=(const Complex &c);
Complex& operator=(const Complex &c);

bool operator==(const Complex &c) const;
bool operator!=(const Complex &c) const;
bool operator>(const Complex &c) const;
bool operator>=(const Complex &c) const;
bool operator<(const Complex &c) const;
bool operator<=(const Complex &c) const;


// 前置和后置++
Complex& operator++(); //前置++
Complex operator++(int); //后置++
Complex& operator--(); //前置--
Complex operator--(int); //后置--


//标准输入输出IO重载
//输入输出需要把当前的Complex对象传递进来,也要把外部的ostream或istream传递进来
//ostream和istream的拷贝构造函数不是public,所以不能复制,这里必须传引用
//由于会改变流的状态,这里ostream和istream也不能是const
//但是传递进来的ostream本身不是当前类内的成员变量,不能访问当前类的属性
//所以定义为friend
//返回值就是操作符操作的ostream或istream对象
friend ostream& operator<<(ostream& os, const Complex &x);
friend istream& operator>>(istream& is, Complex &x);

private:
double _real; // 复数的实部
double _image; // 复数的虚部
};
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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
//complex.cpp
#include "stdafx.h"
#include "complex.h"

Complex::Complex()
{
_real = 0.0;
_image = 0.0;
//cout << "Complex::Complex()" << endl;
}

Complex::Complex(double r, double i)
{
_real = r;
_image = i;
//cout << "Complex::Complex(double r, double i)" << endl;
}

Complex::Complex(const Complex& c)
{
_real = c._real;
_image = c._image;
//cout << "Complex::Complex(const Complex& c)" << endl;
}

Complex& Complex::operator= (const Complex& c)
{
if (this != &c) //如果二者相等,就不需要做操作,这里比较的是地址
{
_real = c._real;
_image = c._image;
}
return *this;
}

Complex::~Complex()
{
_real = _image = 0.0;
//cout << "Complex::~Complex()" << endl;
}

Complex Complex::operator+ (const Complex& c) const
{
//Complex tmp;
//tmp._real = _real + c._real;
//tmp._image = _image + c._image;
//return tmp;

//上面的写法,在return的时候会触发拷贝构造
//调试打断点可以发现,return会进入“=”操作符的重载,重载函数入参的地址和tmp这个临时对象的地址不一样
//这个tmp被传递到拷贝构造里了

return Complex(_real + c._real, _image + c._image);
//临时对象优化,上面的写法就不会触发拷贝构造,直接走了自定义的构造函数
}

Complex& Complex::operator+= (const Complex& c)
{
_real += c._real;
_image += c._image;

return *this;
}

Complex Complex::operator-(const Complex &c) const
{
return Complex(_real - c._real, _image - c._image);
}

Complex& Complex::operator-=(const Complex &c)
{
_real -= c._real;
_image -= c._image;

return *this;
}

Complex Complex::operator*(const Complex &c) const
{
return Complex(_real*c._real - _image*c._image, _real*c._image + _image*c._real);
}

Complex& Complex::operator*=(const Complex &c)
{
Complex tmp(*this); //拷贝构造函数
_real = tmp._real*c._real - _image*c._image;
_image = tmp._real*c._image + tmp._image*c._real;
return *this;
}

Complex Complex::operator/(const Complex &c) const
{
double t = c._real*c._real + c._image*c._image;
return Complex((_real*c._real - _image*(-c._image)) / t, (_real*(-c._image) + _image*c._real) / t);
}

Complex& Complex::operator/=(const Complex &c)
{
Complex tmp(*this); //拷贝构造函数
double t = c._real*c._real + c._image*c._image;
_real = (tmp._real*c._real - tmp._image*(-c._image)) / t;
_image = (tmp._real*(-c._image) + tmp._image*c._real) / t;
return *this;
}

bool Complex::operator==(const Complex& c) const
{
return (_real == c._real) && (_image == c._image);
}

bool Complex::operator!=(const Complex& c) const
{
return !( (_real == c._real) && (_image == c._image) );
}

bool Complex::operator>(const Complex &c) const
{
return (_real > c._real) && (_image > c._image);
}

bool Complex::operator>=(const Complex &c) const
{
return (_real >= c._real) && (_image >= c._image);
}

bool Complex::operator<(const Complex &c) const
{
return (_real < c._real) && (_image < c._image);
}

bool Complex::operator<=(const Complex &c) const
{
return (_real <= c._real) && (_image <= c._image);
}

Complex& Complex::operator++ () // 前置++
{
_real++;
_image++;
return *this;
}

Complex Complex::operator++ (int) // 后置++
{
//先用中间变量,然后执行计算,之后把原始值传递出去
//Complex tmp(*this);
//_real++;
//_image++;
//return tmp;

//上面的方法会调用拷贝构造,使用下面方法优化
return Complex(_real++, _image++);
}

Complex& Complex::operator--() //前置--
{
_real--;
_image--;
return *this;
}

Complex Complex::operator--(int) //后置--
{
return Complex(_real--, _image--);
}


//注意输入输出的写法,虽然在类内声明了,但本身并不是类的成员
//所以定义为全局函数,不要加“Complex::”
ostream& operator<<(ostream& os, const Complex &x)
{
os << "real value is " << x._real << " image value is " << x._image;
return os;
}

istream& operator >> (istream& is, Complex &x)
{
is >> x._real >> x._image;
return is;
}
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
int main()
{

Complex a(1.0, 2.0);
cout << a.GetReal() << endl; // 1.0
cout << a.GetImage() << endl; // 2.0
a.SetImage(2.0);
a.SetReal(3.0);
cout << a.GetReal() << endl; // 3.0
cout << a.GetImage() << endl; // 2.0



Complex a(3.0, 2.0);

//系统也会默认有等号运算符的重载,我们自己不实现也可以用
Complex c;
c = a + b;

//注意上面的写法和Complex c = a + b;的含义是不一样的
//上面的行为是先声明后赋值
//Complex c = a + b的行为是定义
//调试时需要区别,如果要调试赋值操作,不要写成定义流程了

//实际工程中尽量写Complex c = a + b;这个,减少一个默认构造函数提高效率


Complex d;
d = c++;
cout << d << endl;
cin >> d;
cout << d << endl;

return 0;
}

  类中有个this指针,指向当前对象本身。

  类创建后会系统默认创建一个构造函数,我们可以自己实现构造函数。但我们如果重写了构造函数,那么原始的默认构造函数就不存在了,如果想使用需要重新声明实现。

  等号运算符也一样,系统会默认帮我们重载。不过我们最好不要过于相信系统默认的重载,在复杂情况下运算的结果可能不是我们想要的。

  程序中的临时对象一定要注意优化,避免产生临时对象,否则会触发拷贝构造。

  new和delete是可以被重载的运算符。但实际上我们不能重定义new和delete表达式的行为,能够被重载的是全局函数operator new和operator delete。

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
class X{
public:
X() { cout << "constructor" << endl; }
~X() { cout << "destructor" << endl; }

//此重载函数覆盖了全局版本,并在函数体内调用全局版本
static void* operator new(size_t size){
cout << "new" << endl;
return ::operaotr new(size);
}

static void operator delete(void* pointee){
cout << "delete" << endl;
::operator delete(pointee);
}
};

int main()
{
X* px = new X();
delete px;
return 0;
}

//输出结果:“new constructor destructor delete”

  "X* px = new X()"中的new为new operator(new运算符),它将调用类X中的operator new,为该类分配空间,然后调用当前实例的构造函数。

  new operator的特点:

  1. 调用operator new分配足够的空间,并调用相关对象的构造函数
  2. 不可以被重载

  operator new的特点:

  1. 只分配所要求的空间,不调用相关对象的构造函数;
  2. 可以被重载;
  3. 重载时,返回类型必须声明为void*;
  4. 重载时,第一个参数类型必须为表达式要求分配的空间的大小(字节),类型为size_t;
  5. 重载时,可以带其他参数;

  “delete px”中的delete为delete operator,它将调用该实例的析构函数,然后调用类X中的operator delete,以释放该实例占用的空间。

  operator new与operator delete和C语言中的malloc和free对应,只负责分配及释放空间。但使用operator new分配空间必须使用operator delete来释放,而不能使用free,因为它们对内存使用的登记方式不同。反过来也一样。

  利用对构造newdelete,可以禁止一个类产生栈对象和堆对象:

  产生堆对象的唯一方法是使用new操作,通过禁止使用new就可以禁止产生堆对象。由于new操作执行时会调用operator new,而operator new是可以重载的。所以使operator new为private属性即可禁止new操作。为了对称,最好将operator delete也重载为private。

  创建栈上的对象时不使用new操作,不需要向堆申请内存,而是直接调整堆栈指针以“挪出”适当大小的指针,将对象压栈,产生栈对象的方式是调用构造函数,当函数返回时调用其析构函数释放这个对象,然后调整栈顶指针回收那块栈内存。所以将构造函数和析构函数设置为private后,系统不能调用构造函数和析构函数,自然不能在栈上产生对象了。

抽象类型的抽象

  数学中有不同的图形,比如长方形、原型、三角形;多种图形计算周长、面积的方法不同,但都需要一个计算方法。我们可以抽象出一个图形类Shape,用Shape类进行公共层面的抽象操作。

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
#include "stdafx.h"
#include <iostream>
using namespace std;

// 抽象类
// 抽象类的特征是虚函数后面都是"=0",意味着不可以有实际的对象
class Shape
{
public:
virtual double Area() const = 0;// 子类方法实现不一致时要加上virtual
virtual void Show() = 0;
void SetColor(int color) { _color = color; }
void Display() //一个共性的方法
{
cout << Area() << endl;
}
private:
int _color;
};

class Square: public Shape
{
public:
Square(double len) :_len(len) { } //用参数列表初始化
void Show() { cout << "Square" << endl; }
double Area() const
{
return _len*_len;
}
private:
double _len; //边长
};


class Circle : public Shape
{
public:
Circle(double radius) :_radius(radius) {} //用参数列表初始化
void Show() { cout << "Circle" << endl; }
double Area() const
{
return 3.1415926*_radius*_radius;
}

private:
double _radius; //半径
};

class Triangle : public Shape
{
public:
Triangle(double len, double height) :_len(len), _height(height){}
void Show() { cout << "Triangle" << endl; }
double Area() const
{
return 0.5*_len*_height;
}

private:
double _len; //底
double _height; //高
};

int main()
{
// 多态的实现,可以让我们面对变化,尽可能少修改原有的逻辑,直接扩充逻辑
const int shapeNum = 3;

Square s1(2.0);
s1.SetColor(1);
Circle c1(2.0);
Triangle t1(2.0, 3.0);

Shape* shapes[shapeNum];
shapes[0] = &s1;
shapes[1] = &c1;
shapes[2] = &t1;

for (unsigned int index = 0; index < shapeNum; index++)
{
shapes[index]->Display();
}

cout << endl;
cout << sizeof(s1) << endl;

return 0;

}

面向对象三大特性

  • 封装性:数据和代码捆绑在一起,避免外界干扰和不确定性访问;封装可以使得代码模块化;
  • 继承性:让某种类型对象获得另一个类型对象的属性和方法,继承可以扩展已经存在的代码;
  • 多态性:同一事物在保有原来特点的情况下表现出不同事物的能力,即不同对象会产生不同的行为;多态的目的是为了接口重用;

  面向对象是软件工程发展到一定阶段为了管理代码和数据提出的一种方法,它没有解决以前解决不了的问题,不是万能的,只是为我们便捷的开发出能适应快速变化的软件提供了可能。面向对象不是对现实世界的映射,但它的封装性可以把问题简化;它的继承性可以减少代码重复,避免重新发明轮子;它的多态可以实现灵活的功能扩充,提升开发效率;

ASCII码表

  ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是基于拉丁字母的一套电脑编码系统,主要用于显示现代英语和其他西欧语言。它是最通用的信息交换标准,并等同于国际标准ISO/IEC 646。

   ASCII码使用指定的7位或8位二进制数组合来表示128或256种可能的字符。美国一开始设计的是7位共128个,包含95个可见字符和33个控制字符,后期扩展到8位,称为扩展ASCII码。

  

国标编码

  中国也有自己的编码,用于表示中文字符。设计编码首先要确定字符集。国标使用分区管理,共计分为94个区,每个区含94位,共8836个码位。19区收录除汉字外的682个字符;1015区为空白区,没有使用;1655区收录3755个一级汉字,按照拼音排序;5687区收录3008个二级汉字,按照部首/笔画排序;剩余的88~94区为空白区,没有使用。这套字符集称为GB2312字符集。按照这个字符集来编码,比如3区的6行7列字符,编码0367。

  ASCII是直接把码位按照二进制存储,但国标编码不是。比如5709编码的汉字(侃),把6709按照前两位和后两位分开,并分别转为16进制:0x39和0x09;然后两个数分别加上0xA0得到0xD9和0xA9,将两数合并,0xD90xA9就是汉字侃的GB2312码。

  GB2312编码之所以要加0xA0,是为了要兼容ASCII码。GB2312是双字节编码,为了与ASCII编码区分,每个单字节的第八位必须是1,所以GB2312编码最少要从0x80(1000 0000)开始。但是根据规定,0x80~0x9F需要留给控制块,所以只能从0xA0开始。

  计算机判断字符是ASCII码还是GB2312编码的方式就是通过字符大小,ASCII码是7个字节,如果小于127就是ASCII码,如果连续碰到两个大于127的8位就把两个组合起来当作GB2312编码。

  GB2312编码只能表示6763个汉字,后期也不够用了,所以对GB2312做了扩展为GBK字符集。GB2312的高位和地位都要求必须大于127,GBK不再规定低位大于127,只保证高位大于127。把之前没用上的码位都用上,扩充了将近20000个汉字和符号。计算机只要碰到一个大于127的字节,就表示一个汉字的开始。

  后期还扩展了GB18030字符集,新增了少数民族的字符。

  

Unicode

  ASCII可表示的字符数太少了,但如果每个国家都设计一套自己的编码就太乱了,为了把世界上的文字都映射到一套字符空间中,诞生了Unicode。Unicode是一个标准,规定了字符集和编码。

  最开始的Unicode字符集称为UCS-2字符集,和ASCII码一样,把用到的字符按顺序罗列并标上对应的码位。存储方式和ASCII一样,直接把码位按照二进制方式存储。一共可以表示即65536个字符。后期发现不够用后出现了UCS-4字符集,可以表示将近43亿个字符,但由于这个编码占用的存储空间太大了,并没有被广泛接受。

  互连网时代后,对Unicode做了优化,目前有3种Unicode的编码方式:

  1. UTF-8:用1byte来表示字符,可以兼容ASCII码;
    1. 特点是存储效率高,可变长(不方便内部随机访问);
    2. 无字节序问题,可以作为外部编码;
  2. UTF-16:用2bytes表示一个字符,可以分为UTF-16BE(big endian)和UTF-16LE(little endian)
    1. 特点是定长的,方便内部随机访问;
    2. 有字节序问题,不可以作为外部编码;
  3. UTF-32:用4bytes表示一个字符,可以分为UTF-32BE(big endian)和UTF-32LE(little endian)
    1. 特点是定长的,方便内部随机访问;
    2. 有字节序问题,不可以作为外部编码;

   注意:如果使用的Windows系统,Windows文件可能有BOM(byte order mark)来表示字节序,如果要在其他平台使用,可以去掉BOM或者忽略掉。BOM在文本文件头部,FEFF(十进制为254 255)表示大端,FFFE(十进制为255 254)表示小端。

  

  UTF-8编码原理:UTF-8将UCS-4字符集的码位划分为4个区间(0x000000000x0000007F;0x000000800x000007FF;0x000008000x0000FFFF;0x000100000x0010FFFF),第一个区间的编码样式为0XXXXXXX,第二个区间的编码样式为110XXXXX 10XXXXXX,第三个区间的编码样式为1110XXXX 10XXXXXX 10XXXXXX,第四个区间的编码样式为11110XXX 10XXXXXX 10XXXXXX 10XXXXXX。

  汉字“王”在UCS-4中的编码为0x0000738B,转换为二进制就是“0000 0000 0000 0000 0111 0011 1000 1011”。0x0000738B属于UCS-4的第三区间,这个区间的编码样式是“1110XXXX 10XXXXXX 10XXXXXX”。此时我们得到了“王”这个字符的二进制形式和编码样式,把二进制形式从高到低依次插入到编码样式中,得到了“王”对应的UTF-8编码:“11100111 10001110 10001011”,十六进制为“0xe7 0x8e 0x8b”。

  

程序中编码错误的根本原因在于编码方式和解码方式的不统一。

C语言字符的语法陷阱

先看C语言中常见的词法、语法问题:

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
char c1 = 'yes';
/*
* 不符合常理,但这样定义没有错误
* 编译器会截断
* 至于是保留第一个还是最后一个,这个和编译器有关
* 虽然没报错,但编译器会有warning
*/


char c2 = "yes";
/*
* 编译器报错
* "yes"是一个字符串,c2只是一个字符变量,不能存储字符串
*/

const char* slash = "/";
/*
字符串的正确定义方法
slash中存放两个字符:'/'、'\0'
这样其实就是把字符串的首地址给了指针变量
*/

const char* slash2 = '/';
/*
编译器报错
字符的类型不能给指针,两个变量类型不匹配
*/

const char* slash3 = &c1;
/*
正确
slash3指针变量存放c1单个字符的地址
*/

  从上面的例子可以看到,C语言是高级语言中的低级语言,优点是小巧、高效、接近底层,比如上面的例子就把字符和字符串区分的很细,但缺点就是细节和陷阱比较多。为了更好的解决这个问题,C++在兼容C语言的同时,推出了既高效又易于大规模开发的机制:string类的使用:

1
2
3
4
5
6
7
8
#include <string>

string s1(1,'yes'); //s
string s2(3,'yes'); //sss
string s3(1,'y'); //y
string s4("/"); // /
string s5(1,'/'); // /
string s6("yes"); //yes

C语言指针和数组的关系问题

c预言数组在作为参数时的退化行为,退化为一个指针。

给定一个数组,计算数组中的数据的平均数,有以下代码:

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
//计算平均数
double average1(int arr[10])
{
double result = 0.0;
int len = sizeof(arr) / sizeof(arr[0]);
std::cout << "In average1 : " << len << std::endl;
for (int i = 0; i < len; i++)
{
result += arr[i];
}

return result / len;
}

int main()
{
int array1[] = { 10,20,30,40,50,60,70,80,90,100 };

//数组长度最好是用变量这样来求,不要写成常量
//这样方便扩展
int len = sizeof(array1) / sizeof(array1[0]);
std::cout << "len : " << len << std::endl;

std::cout << average1(array1) << std::endl;

return 0;
}

  可以看到输出的值并不是平均数,通过输出中间数据可以知道,main函数中的长度是10,而average1中的数组长度是1;

  出现这个的原因就是C预言数组在作为函数参数传递时会退化为一个指针,average1中的入参实际上只是函数的首地址,sizeof(arr)输出的只是单个元素的长度。

  可以进行如下优化,通过外部把数组长度先行计算出来然后传递给函数。需要注意,如果传递的是字符数组的话就不需要这么麻烦了,因为字符数组往往是通过'\0'结尾的,函数内部有办法知道数组的长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//直接把数组长度传递进来
double average2(int arr[10], int len)
{
double result = 0.0;

for (int i = 0; i < len; i++)
{
result += arr[i];
}

return result / len;
}

int main()
{
int array1[] = { 10,20,30,40,50,60,70,80,90,100 };

int len = sizeof(array1) / sizeof(array1[0]);

std::cout << average2(array1,len) << std::endl;

return 0;
}

  

  其实知道数组当作函数参数传递时会发生退化时,就可以不传递数组,而是只传递指针:

1
2
3
4
5
6
7
8
9
10
11
double average2(int* arr, int len)
{
double result = 0.0;

for (int i = 0; i < len; i++)
{
result += arr[i];
}

return result / len;
}

  

  C语言之所以要这么做,是和c语言发展分不开的。c语言早期是伴随着unix操作系统,是非常底层的,对空间要求非常高的语言。如果函数传参时传递了一个非常大的数据容器,空间转移的效率是非常低的。所以C语言设计者就通过传递指针和容器尺寸这样一种传递方式从而达到节省空间的目的。

  

  C++的解决方案就是引入STL容器,实现底层包装,保证效率的同时也保证简单安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <vector>

//这里传递的是引用
//如果传递的是vector本身,c++这里会产生一个副本,如果容器很大会得不偿失
double average3(std::vector<int>& v)
{
double result = 0.0;
std::vector<int>::iterator it = v.begin();
for (;it!=v.end();++it)
{
result += *it;
}

return result / v.size();
}

int main()
{
std::vector<int> vt = { 10,20,30,40,50,60,70,80,90,100 };
std::cout << average3(vt) << std::endl;

return 0;
}

  使用stl容器后,哪怕是二维数组,处理起来也很方便了:

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
double average2DV(vector<vector<int> >& vv)
{
double result = 0.0;
unsigned int size = 0;

for (unsigned int i = 0; i < vv.size(); ++i)
{
for (unsigned int j = 0; j < vv[i].size(); ++j)
{
result += vv[i][j];
size += 1;
cout << vv[i][j] << " ";
}
cout << endl;
}

return result / size;
}

int main()
{
vector<vector<int> > vv2D{8, vector<int>(12,3) }; //8个vector,每个包含12个3
cout << average2DV(vv2D);
return 0;
}

C语言的移位问题

问题一:右移操作:无法区分是逻辑右移还是算术右移。

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
#include<cstdio>

#include <bitset>
#include <iostream>
using namespace std;

int main()
{

char a1 = 0x63; // 0110 0011
a1 = (a1 << 4); // 0011 0000
printf("0x%x\n", a1); //左移操作,末位补0

a1 = 0x63; // 0110 0011
a1 = (a1 >> 4); // 0000 0110 逻辑右移
printf("0x%x\n", a1); // 逻辑右移自动补0


char a2 = 0x95; // 1001 0101
a2 = (a2 << 4); // 0101 0000
printf("0x%x\n", a2); //左移操作,末位补0

a2 = 0x95; // 1001 0101
a2 = (a2 >> 4); // 1111 1001 算术右移
printf("0x%x\n", a2); //这里执行的是算数右移操作,补1了

return 0;
}

  上面可以看到,C语言在执行右移操作时表现不同,而不同的编译器输出的结果可能都不一样,C语言并没有做统一标准。C语言官方的做法是在做右移操作时,把操作数都变为无符号的数,这样可以保证执行的是逻辑右移操作(补0)。原因是无符号数首位都是0,可以保证补位的数也是0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main()
{

unsigned char a3 = 0x63; // 0110 0011
a3 = (a3 << 4); // 0011 0000
printf("0x%x\n", a3);

a3 = 0x63; // 0110 0011
a3 = (a3 >> 4); // 0000 0110 逻辑右移
printf("0x%x\n", a3);


unsigned char a4 = 0x95; // 1001 0101
a4 = (a4 << 4); // 0101 0000
printf("0x%x\n", a4);

a4 = 0x95; // 1001 0101
a4 = (a4 >> 4); // 0000 1001 逻辑右移
printf("0x%x\n", a4);

return 0;
}

  

  

问题二:移位操作位数的限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main()
{
//示例常见与权限控制,每一位代表不同的权限
//0000 0000
const unsigned char priv = 0xFF; //初始化权限
const unsigned char P_BAKCUP = (1<<7); //备份权限
const unsigned char P_ADMIN = (1<<8); //最高权限

printf("0x%x\n", P_BAKCUP);
printf("0x%x\n", P_ADMIN);
if (priv & P_BAKCUP)
{
cout << "BAKUP" << endl;
}
if (priv & P_ADMIN)
{
cout << "ADMIN" << endl;
}
return 0;
}

  由运行结果可以看到,char本身就只有8位,P_ADMIN的移位操作已经超过了8位,这时候所有的8位都被清零了。这是C语言编码常见错误,移位操作一定要注意操作位数上限,移位数大于0,小于位数;

  

  出现上面两个问题的原因就是,C语言设计移位操作时需要考虑操作数表示的上下文环境。C++为了对这个问题做改进,引入了bitset:

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 <bitset>

int main()
{
// bitset
bitset<10> priv = 0xFF; //手动控制,这里就只有10位
bitset<10> P_BAKCUP = (1 << 6);
bitset<10> P_ADMIN = (1 << 7);

//这里可以直接输出
cout << priv << endl;
cout << P_BAKCUP << endl;
cout << P_ADMIN << endl;

if ((priv & P_BAKCUP) == P_BAKCUP)
{
cout << "BAKUP" << endl;
}
if ((priv & P_ADMIN) == P_ADMIN)
{
cout << "ADMIN" << endl;
}

return 0;
}

C语言强制类型转换问题

C语言中强制类型转换隐藏了很多bug和陷阱:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
using namespace std;

int main()
{
int array[] = { 1,2,3 };
cout << sizeof(array) / sizeof(array[0]) << endl;
int threshold = -1;

if (sizeof(array) / sizeof(array[0]) > threshold)
{
cout << "positive number array" << endl;
}
else
{
cout << "negative number array" << endl;
}

return 0;
}

  上面的代码当数组长度大于0时,需要输出“positive number array”,否则输出“negative number array”。可以通过编译运行后,长度输出为3是正确的,但判断逻辑里却输出了“negative number array”。

  发生这个问题的原因是sizeof的返回值是unsigned int,是无符号数,但threshold却是一个有符号数,在执行比较判断语句时,C语言的机制把threshold转换为了一个无符号数,然后才进行的比较。这里发生的是隐式类型转换。-1转换为unsigned int时会变为4294967295,是个很大的正整数(这里涉及到了补码转换)。

  C语言在编写时,可以先用一个有符号的数把数据先取出来。今后编码时也需要注意,尽量避免用无符号的数据来进行数据比较:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using namespace std;

int main()
{
int array[] = { 1,2,3 };
cout << sizeof(array) / sizeof(array[0]) << endl;
int threshold = -1;
int len = sizeof(array) / sizeof(array[0]); //用一个有符号的变量先把数据拿出来

if (len > threshold)
{
cout << "positive number array" << endl;
}
else
{
cout << "negative number array" << endl;
}

return 0;
}

  

  

类型转换还可能会发生在以下情况:假设要计算1+1/2+1/3+……+1/n,如果代码是这么写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1+1/2+1/3+1/4+... +1/n
double getSum(int n)
{
double result = 0.0;
for (int i = 1; i < n + 1; i++)
{
result += 1 / i;
}
return result;
}

int main()
{
int n = 0;
cin >> n;
cout << getSum(n) << endl;

return 0;
}

可以看到,计算出的结果是1。这里的问题出在“result += 1/n”这句中,被除数是整形,除数也是整形,那么计算结果也是整型值。result虽然会转换为浮点数,但整形计算中已经丢失了精度。

c语言中的一个解决方法是把被除数先转换为浮点数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1+1/2+1/3+1/4+... +1/n
double getSum(int n)
{
double result = 0.0;
for (int i = 1; i < n + 1; i++)
{
result += 1.0 / i; //把被除数换成浮点数,那么结果会被转换为浮点数
}
return result;
}

int main()
{
int n = 0;
cin >> n;
cout << getSum(n) << endl;

return 0;
}

  

  

  上面两个例子可以看到,有时候我们会忽略C语言的隐式类型转换,导致出现程序bug;但有时候我们又需要这种隐式类型转换来得到我们想要的结果。c语言中滥用类型转换可能导致灾难性的后果,且很难排查。C语言之所以这么设计,是因为类型转换在底层语言中的运用非常广泛,且灵活方便。C++为了方便排查隐藏bug减少复杂性,提供了四种类型转换的方式:static_cast、const_cast、dynamic_cast、reinterpret_cast

  • static_cast:其实就是类似于C语言中的类型转换,C++提供了这么一种标准格式用于显示类型转换,可以方便程序员精准定位程序哪里使用了强制类型转换;
  • const_cast:只针对去除const属性;
  • dynamic_cast:用于类的继承关系转换,比如把子类转换为父类、或者父类转换为子类;
  • reinterpret_cast:用于指针的转换;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1+1/2+1/3+1/4+... +1/n
double getSum(int n)
{
double result = 0.0;
for (int i = 1; i < n + 1; i++)
{
result += static_cast<double>(1) / i;
}
return result;
}

int main()
{
int n = 0;
cin >> n;
cout << getSum(n) << endl;

return 0;
}

C语言的整数溢出问题

  32位系统中,一个整数占用4个字节,共32位。其中第一位是符号位,所以一共有31位可以表示整数范围。如果计算的时候,如果我们算出的数值超出了数据表示范围,那么会数据溢出变为负数。要注意C语言中的整数不能和数学上的整数划等号。

1
2
3
4
5
6
7
8
9
10
11
int main()
{
int i = 2147483640;
for (; i > 0; i++)
{
cout << "adding " << i << endl;
}
cout << "exit " << i << endl;

return 0;
}

  出现这个问题的原因和系统的设计是有关的。数据存储空间是有限的,不能无限增长。C语言的一个解决方案是通过字符串的方式来表达大数的运算,字符串理论上是可以无限长的,C语言是有这个类库的,但并没有直接的解决方案。 C++本身也没有提供好的解决方案,但boost库中提供了cpp_int方法:boost官网

  

C语言字符串的典型缺陷

  C标准字符和字符串的区别是:字符是单引号括起来的,字符串是双引号括起来的,由'\0'结尾。而'\0'作为结束符这个方式,表达能力有天生的缺陷:一旦字符串中间具有'\0'字符,那么c语言的字符串函数就会认为这个字符已经结束了。如果用c语言的方式存储一些图片或者其他二进制的内容,很容易出问题。

  C语言的字符串操作还有另一个问题就是效率低下。C语言的字符处理函数都是通过遍历'\0'来寻找字符串结尾的,这个遍历操作会消耗性能。

  C语言设计这种字符串处理方式主要是为了节省空间,有针对性的设计问题。C++语言为了解决这个问题有以下几种思路:

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
int main()
{
cout << "Testing C++ String: " << endl;
string sstr1 = "string";
cout << sstr1.length() << endl; //字符串内容的长度
cout << sstr1.capacity() << endl; //string的容量长度
cout << sizeof(sstr1) << endl; //实际内存分配长度,不同平台的值可能不一致,但实际大小肯定会大于内容长度

cout << endl;

cout << "sstr2: " << endl;
string sstr2 = "stri\0ng";
cout << sstr2.length() << endl;
cout << sstr2.capacity() << endl;
cout << sizeof(sstr2) << endl;

cout << endl;

cout << "sstr1: " << endl;
sstr1 += sstr2; //字符串直接拼接
cout << sstr1.length() << endl;
cout << sstr1.capacity() << endl;
cout << sizeof(sstr1) << endl;

return 0;
}

  可以看到,string类的实现方案中,内部不仅记录了字符串的内容,还有几个变量记录了字符串内容的长度、容量等,在执行字符串操作时不需要遍历寻找'\0',提高了效率;但是依旧保留了c风格字符串以'\0'结尾的传统,还具有一些缺陷。

机器数和真值

机器数:一个数在计算机中的二进制表示形式,叫做这个数的机器数。

机器数是带符号的,在计算机中用一个数的最高位存放符号,正数为0,负数为1;

比如:十进制数+3,就是00000000000000000000000000000011;十进制数-3,就是10000000000000000000000000000011;(int值占4字节);这个例子只是整型数,浮点数有其他表达方式。

真值:真正的数学意义上的数值。因为机器数第一位是符号位,所以机器数的形式就不等于真正的数值。

补码

按照上面的机器数的表示方法有一个问题,第一位用作符号位的话,这个数的表示范围会变小。所以计算机中存储用的并不是机器数,而是补码。

无符号数的编码

用一个函数(Binary to Unsigned的缩写,长度为w)来表示:

eg:

有符号数的补码

用一个函数(Binary to Two`s-complement的缩写,长度为w)来表示:

eg:

补码数值范围

8位字长 16位字长 32位字长 64位字长
UMax 0xFF
255
0xFFFF
65535
0xFFFFFFFF
4294967295
0xFFFFFFFFFFFFFFFF
18446744073709551615
TMin 0x80
-128
0x8000
-32768
0x80000000
-2147483648
0x8000000000000000
-9223372036854775808
TMax 0x7F
127
0x7FFF
32767
0x7FFFFFFF
2147483647
0x7FFFFFFFFFFFFFFF
9223372036854775807
-1
0
0xFF
0x00
0xFFFF
0x0000
0xFFFFFFFF
0X00000000
0xFFFFFFFFFFFFFFFF
0X0000000000000000

需要注意,无符号数中0xFFFFFFFF是最大值,但有符号数中这个值代表-1;还要注意有符号数中的最小值是0x80000000;

字节序(Byte Ordering)

以32位机器为例,一个字有32bits字长,占用4bytes,在内存中有以下两个存放方式:

  1. 大端法(Big Endian):大多数IBM机器、Internet传输;

  1. 小端法(Little Endian):Inter兼容机

个人机器基本上都是小端表示法。

补码的意义

  我们在设计软件系统时总是希望软件系统尽可能的简单通用。于是人们希望在只有加法运算器的情况下设计一种方法能实现减法运算。

  以时间为例:表盘一圈12个小时,现在是8点,那么3小时前是5点,9小时以后还是5点(8+9-12),这里进行的是模12的操作。所以8-3和8+9的结果是一样的;我们就可以用9来表示-3,如果想计算8-3,那么就用加法器计算8+9;

  当然单纯的这么想是有问题的,以时间为例我们可以得到一个对照表,遇见12就清零:

0 -1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11
0 11 10 9 8 7 6 5 4 3 2 1
  • 5-3 ==> (5+9)%12=2
  • 3-5 ==> (3+7)%12 = 10 ==>-2

  我们计算3-5的时候,得到的值是10,我们可以在对照表中得到10对应的值是-2,但我们计算5-3的时候,得到的值是2,我们却不需要找对应的值。计算机如何区分什么时候要找对应的值,什么时候不需要呢?

  可以对表进行一些修改:

0 -1 -2 -3 -4 -5 -6 5 4 3 2 1
0 11 10 9 8 7 6 5 4 3 2 1
  • 5-3 ==> (5+9)%12=2 ==>2
  • 3-5 ==> (3+7)%12 = 10 ==>-2

  表格修改后,每次计算完都在表格中进行对照,这样操作统一,得到的值也是正确的了。其实在计算机内部,补码的用处就是构造这张映射表的。

git安装

git手册提供了完整的安装步骤。

安装成功后,命令行输入git命令可以检查是否安装成功:

1
2
#查看git版本
git --version

最小配置

使用git之前需要配置user.name和user.email,可以方便绑定每次操作的用户信息。

1
2
$ git config --global user.name 'your_name'
$ git config --global user.email 'your_email@domain.com'

上面的'--global'是git的作用域,git一共有三个作用域:

  • --local :只对某个仓库有效
  • --global :对当前用户的所有仓库有效
  • --system :对系统的所有登录用户有效

显示config的配置,可以加--list:

1
2
3
$ git config --list --local
$ git config --list --global
$ git config --list --system

创建仓库并配置local用户信息

建立git仓库有两种使用场景:

第一种场景:把已经有的项目代码列入git管理

1
2
$ cd 项目代码目录
$ git init

第二种场景:新建的项目直接用git管理:

1
2
3
$ cd 某个目录
$ git init your_project #会在当前路径下创建和项目名称同名的文件夹
$ cd your_project

以第二种方式为例,执行完命令后,会在目录下生成一个'.git'的隐藏目录。

如果此时在这个目录下配置local的用户名和邮箱,且和global配置的不同,那么在这个仓库中执行的操作会以local配置为准生效。

工作区和暂存区

1
2
3
$ git add files  #把工作目录中的内容添加暂存区

$ git commit #把暂存区中的内容添加到正式版本历史

git标准的操作是每次在工作目录中修改的文件,都先添加入暂存区。为了某个功能而做的修改都添加入暂存区后,可以统一把这批文件commit到git版本库中。

1
2
3
4
$ git reset --hard
#这个命令可以重置暂存区,轻易不要使用

$ git status #查看工作目录修改状态

如果想要在git中修改某个文件的文件名,可以有两种方式:

1
2
3
4
5
6
7
8
# 方式1:工作目录中修改文件名,然后重新add、commit
$ mv old new #修改文件名
$ git add new
$ git commit

# 方式2:直接调用git命令
$ git mv old new
$ git commit

查看版本历史和分支

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 查看每次提交的细节
$ git log

# 只查看提交备注
$ git log --oneline

# 只查看最近的某几次提交
$ git log -n2 --oneline #只查看2

# 查看本地有多少分支
$ git branch -v

# 查看所有分支的提交记录
$ git log --all

# 图形化查看所有分支的提交记录
$ git log --all --graph

对于Windows用户来说,可能更习惯图形化操作,可以使用git自带的图形化工具:

1
$ gitk  # 打开图形化界面

".git"目录

新建仓库后,工作目录下会生成一个隐藏的".git"目录,这里面就是git最核心的内容。

常用的文件如下:

  • HEAD文件:记录当前工作在哪个分支上;
  • config文件:当前工作目录的一些配置,比如user.name和user.email;
  • refs/heads目录:各个分支指向的提交hash;
  • refs/tags目录:各个tag指向的提交hash;
  • objects目录:存放文件和更改,内部的对象可以分为commit、tree和blob三种类型;这个是git版本管理的底层目录;

git文件类型(commit、tree和blob)

  数据存储是git的核心技术点;git的目标是项目管理,而项目管理中的文件变更很频繁,如果没有一个好的数据管理技术,git的存储信息会越来越大,性能会越来越差。所以设计一个良好的数据存储机制是很关键的。

  .git的object中就是git存储的核心目录,下面的文件共有三种类型:commit、tree和blob。

  上图是git官网文档的实例,每次执行commit操作都会创建一个commit对象出来;一个commit对应唯一一棵树,是这个commit的快照,存放所有的文件夹和文件;blob就是其中具体的文件;要注意每个blob和文件名毫无关系,在git中只要这个文件的内容相同,那对应的就是一个blob,这样可以大大减少存储代价。