Misaki`s blog

学习是一种态度


  • 首页

  • 关于

  • 标签

  • 分类

  • 归档

【Cpp】第十三章-异常

发表于 2019-09-24 | 分类于 Cpp
字数统计: 2.6k

异常

传统C中处理错误的方式

  以往我们在写C语言程序时,当用户的错误输入或者非期望结果发生时我们的程序有可能就会开始不正确的走向,此时我们为了程序的可靠性和健壮性往往需要对结果进行判断,并且防止一些非预期行为的发生。在C语言中我们程序在发生错误时我们往往会通过以下几种方式来阻止程序继续向错误方向执行,并且向外返回错误。

返回错误码

  返回错误码就是我们常说的返回值,每个函数都有一个返回值,我们可以返回一个整形数据来告知外部执行的函数发生了什么问题。可是这样的返回错误码的方式不便于使用,我们为了搞懂错误是什么还不得不去查询手册,而且如果在主函数中返回的话程序会直接返回错误码终止程序。

终止程序

  在C中有类似assert的断言函数供我们判断状态,保证错误的发生,可是当不符合assert的要求时,它会直接终止程序,并且给我们一个终止位置让我们自己去找错误,这不仅不便于查找错误甚至还让用户难以接收,谁都不想因为一点小错程序自己就会挂掉。

非本地跳转

  在C语言的标准库中有这么一组函数setjmp和longjmp用于实现非本地跳转,setjmp用于设置跳转点,longjmp用于进行跳转(这里的跳转已经类似于Cpp的异常处理,异常处理也是在他们的基础上进行完善实现),但是他们两个并不常用。

Cpp异常处理

  Cpp的异常处理更加人性化,更加方便,它可以帮助用户更加直观的理解错误,并且还能保证程序可以不会退出继续按照我们的要求继续执行

关键字

  在Cpp中关于异常处理一共有三个关键字try,catch, throw。
  try用于将有可能抛出异常的代码包裹,这些代码被称为保护代码。try后面必须至少跟一个catch。
  throw用于抛出异常,throw必须在try包裹的保护代码中使用才能起到正常的效果。
  catch用于捕获异常以及处理异常,跟在try的后面。
  使用格式

1
2
3
4
5
6
7
8
9
10
11
12
try
{
throw ...;
}
catch(...)
{

}
catch(...)
{

}

异常匹配规则

  异常处理由使用throw抛出一个对象而引发,抛出对象的类型决定了该执行哪个catch语句中的内容。抛出的异常对象会以传值的方式传递给catch语句,这里十分类似于函数的副本传参调用方式,因此传递给catch的是抛出的异常对象的拷贝,之所以要拷贝是因为异常对象可能是个临时对象。传递给catch的拷贝对象会在执行完catch后释放。一般来说catch的类型和抛出的对象类型并不需要完全相同的,比如我们可以用基类捕获派生类异常对象,这点十分实用。catch(...)可以捕获任意类型异常对象,一般最后加上这个捕获来保证所有异常都能被捕获。
  throw语句后面的所有语句都不会再执行,会直接跳到try后面最近的匹配的catch语句,执行完catch后会从最后一个catch后开始执行。

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
#include <iostream>
#include <string>
using namespace std;
double Div(double num1, double num2)
{
if (num2 == 0)
{
throw string("Division by zero condition");
cout << "Here" << endl;//不会执行了
}
return num1 / num2;
}
int main()
{
try
{
Div(1, 0);//故意引发异常
}
catch(const string errmsg)
{
cout << errmsg << endl;
}
catch(...)
{
cout << "unknow error" << endl;
}
cout << "Here Start" << endl;//执行完异常处理后从这里开始继续执行
}


Division by zero condition
Here Start

栈展开

  在函数中使用异常处理时,会按照以下流程去处理异常。
  1、检查throw是否在try中。
  2、在当前函数栈中寻找是否有匹配的catch。
  3、如果当前函数栈中有匹配的catch则处理,处理完继续执行最后一个catch后的语句。没有则跳出当前函数栈,前往上层调用函数栈继续寻找是否有匹配的catch,如果某一层匹配,则处理异常,之后继续从当前栈中最后一个catch后继续执行。
  4、如果直到main搜索完都没有匹配的catch则终止程序。
  以上这个过程称为栈展开。

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 <string>
using namespace std;
double Div(double num1, double num2)
{
if (num2 == 0)
{
throw string("Division by zero condition");
//cout << "Here" << endl;//不会执行了
}
//这里无法处理异常前往上层
return num1 / num2;
}
void Func()
{
try
{
Div(1, 0);
}//跳到这里处理异常
catch(const string errmsg)
{
cout << errmsg << endl;
}
catch(...)
{
cout << "unknow error" << endl;
}
cout << "Start Here" << endl;
}
int main()
{
Func();
}


Division by zero condition
Start Here

异常重新抛出

  有时候一个异常一个catch并无法成功处理,我们希望在本层catch做简单处理后再抛出给上层进行处理,于是我们可以在catch中重新抛出异常。

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
#include <iostream>
#include <string>
using namespace std;
double Div(double num1, double num2)
{
if (num2 == 0)
{
throw string("Division by zero condition");
//cout << "Here" << endl;//不会执行了
}
//这里无法处理异常前往上层
return num1 / num2;
}
void Func()
{
int* arr = new int[10];
try
{
Div(1, 0);
}//跳到这里处理异常
catch(const string errmsg)
{
//为了内存不泄露在这里先释放内存
cout << "delete[]" << endl;
delete[] arr;
//抛给上层处理
throw;
}
cout << "delete[]" << endl;
delete[] arr;
}
int main()
{
try
{
Func();
}
catch(const string errmsg)
{
cout << errmsg << endl;
}
}


delete[]
Division by zero condition

异常的安全与规范

  在Cpp中抛出异常是不安全的,因为在throw后的代码都不会再执行下去会打乱程序的执行顺序,因此强烈不建议在构造函数和析构函数中抛出异常,可能会导致对象无法完全初始化或者无法完全释放空间导致内存泄露的情况发生。
  为了保证规范性,我们应该提前告诉用户这个函数会抛出哪些类型的异常,方便使用者调用这个函数及处理异常。为此我们可以在函数声明后面加上throw(异常类型...)。

1
2
3
4
5
6
//这里表示这个函数会抛出A/B/C/D中的某种类型的异常 
void fun() throw(A,B,C,D);
//这里表示这个函数只会抛出bad_alloc的异常
void* operator new (std::size_t size) throw (std::bad_alloc);
//这里表示这个函数不会抛出异常
void* operator new (std::size_t size, void* ptr) throw();

  如果没有声明throw则表示这个函数可以抛出任意类型的异常。

自定义异常体系

  实际使用中很多公司都会自定义自己的异常体系进行规范的异常管理,因为一个项目中如果大家随意抛异 常,那么外层的调用者基本就没办法玩了,所以实际中都会定义一套继承的规范体系。这样大家抛出的都是 继承的派生类对象,捕获一个基类就可以了。

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
#include <iostream>
#include <string>
//服务器开发中通常使用的异常继承体系
class Exception
{
protected:
std::string _errmsg;
int _id;
//list<StackInfo> _traceStack;
// ...
};
class SqlException : public Exception
{
};

class CacheException : public Exception
{
};

class HttpServerException : public Exception
{
};

int main()
{
try
{
// server.Start();
// 抛出对象都是派生类对象
}
catch (const Exception &e) // 这里捕获父类对象就可以
{
}
catch (...)
{
std::cout << "Unkown Exception" << std::endl;
}
return 0;
}
}

标准库中的异常体系

  标准库中有一套官方的一场体系供我们使用,其中一共有以下几个类,并且继承关系如下。

异常类

  异常类的介绍。
异常类

  我们也可以通过类来继承exception类来自定义异常类。但是实际中我们很少使用库中的异常类,因为它实在不够好用。

异常的优缺点

优点

  1、可以更好的知道错误信息,包括调用栈信息等,可以更好的定位bug。
  2、可以更好的从调用栈深处返回错误信息方便在外层处理,而错误码就必须得层层返回错误码才可以进行处理。
  3、很多第三发库都包括异常,通过异常我们可以更好的使用它们。
  4、很多测试框架都在使用异常,方便进行软件测试。
  5、部分函数使用异常更好处理,比如返回值不是整形的函数,或者是模板的函数,都无法通过错误码返回异常。

缺点

  1、异常会导致程序执行流来回变动,不便于调试和跟踪程序。
  2、会增加一些性能开销,虽然不大但还是提出来。
  3、Cpp内存需要自己回收,因此抛出异常如果不注意处理会造成内存泄露,死锁等情况的发生,不过我们可以通过RAII解决,学习成本高(RAII在之后介绍)。
  4、Cpp标准库中的异常体系并不好用,导致大家各用各的异常体系,十分混乱。
  5、异常抛出必须要规范化使用,否则外层捕获将带来很大困难,可见Cpp的异常处理必须十分谨慎。

【Cpp】第十二章-多态

发表于 2019-09-23 | 分类于 Cpp
字数统计: 6.6k

多态

  几乎所有面向对象语言我们总能在其中听到这么几个特点,封装,继承,多态,对于Cpp也不例外,那么什么是多态,Cpp又是如何实现多态的呢?

多态的概念

  什么是多态?多态就是当不同的对象去完成某种相同的事务时却展现出完全不同的行为状态。在生活中也存在着各种各样的多态,例如当我们要买票时不同的人有着不同的票,学生票,成人票等等;又比如说当别人家的孩子到你家来做客时,你父母对待你和对待ta之间的差距也体现了多态。具体点说,多态可以让我们定义的对象根据对象的不同调用不同的函数,尽管这些函数名、参数、返回值都是相同的。这一点是不是听上去类似于重载?或者说类似于之前继承中的重定义?但接下来你会发现这其中有着很大的差别。

如何实现多态

构成多态的条件

  以下是一个简单的实现了多态的例子,我们看看这些类和哦我们往常所写的类有哪些不同。

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
#include <iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "full ticket!" << endl;
}
};
class Student: public Person
{
virtual void BuyTicket()
{
cout << "student ticket!" << endl;
}
};
int main()
{
Person* p = new Person();
p->BuyTicket();
Person* p2 = new Student();//切割,将子类对象指针赋值给父类指针
p2->BuyTicket();
delete p;
delete p2;
}


full ticket!
student ticket!

  这段代码中我们用相同类型的指针通过指向不同类型的对象从而调用了不同的函数,实现了多态。要构成多态,必须满足两个条件:
  1、必须通过基类的指针或者引用调用构成多态的函数。
  2、被调用的构成多态的函数必定是虚函数,并且在子类中完成了重写/覆盖。
  那么什么是虚函数呢?

虚函数

  被virtual关键字修饰的成员函数就是虚函数,在上面的例子中基类和派生类中的BuyTicket()就是虚函数,并且如果基类中的函数为虚函数,派生类中的重写函数不用加virtual也会默认被视为虚函数。但是这里又出现了一个概念,重写。

重写(覆盖)

  之前我们有讲过重载,重定义,这里又提出了重写的概念,那么什么是重写,又如何构成重写呢?
  重写是构成多态的前提,而构成重写要求必须是派生类中有一个虚函数与基类中的虚函数函数名、参数、返回值完全相同,才能构成重写。

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
#include <iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "full ticket!" << endl;
}
};
class Student: public Person
{
//参数不同无法构成重写
virtual void BuyTicket(int)
{
cout << "student ticket!" << endl;
}
};
int main()
{
Person* p = new Person();
p->BuyTicket();
Person* p2 = new Student();//切割,将子类对象指针赋值给父类指针
p2->BuyTicket();
delete p;
delete p2;
}


full ticket!
full ticket!

重写的两个例外

  重写中要求派生类虚函数必须和基类虚函数函数名参数返回值都完全相同才能构成重写,但是也有例外,而且是两个。

协变

  第一个例外是协变,它可以让返回值不同的两个虚函数也构成重写,但是要求基类虚函数必须返回基类对象的指针或者引用,派生类虚函数必须返回派生类对象的指针或者引用。

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
#include <iostream>
using namespace std;
class Person
{
public:
virtual Person* BuyTicket()
{
cout << "full ticket!" << endl;
return new Person;
}
};
class Student: public Person
{
//参数不同无法构成重写
virtual Student* BuyTicket()
{
cout << "student ticket!" << endl;
return new Student;
}
};
int main()
{
//这里肯定会造成内存泄露,这里为了演示暂时不考虑内存泄露的问题
Person* p = new Person();
p->BuyTicket();
Person* p2 = new Student();//切割,将子类对象指针赋值给父类指针
p2->BuyTicket();
delete p;
delete p2;
}


full ticket!
student ticket!

  协变的原理也是跟切割有关系,因为切割才使得派生类对象、指针、引用可以隐式转换为基类对象、指针、和引用。

析构函数的重写

  如果我们用基类指针去指向一个派生类对象,当我们要释放这个对象的内存空间,势必会调用析构函数,但是如果调用的是基类的析构函数那么必然会导致派生类中一些成员变量无法释放空间,所以最好一个派生类的析构函数是可以和基类构成多态的,这样才能使基类指针可以根据对象的不同调用对应的析构函数不会导致内存泄露。但是派生类析构函数名和基类的析构函数名不同啊,如果构成重写呢?在这里编译器做了一些处理,只要基类的析构函数是虚函数,派生类的析构函数无论是否有virtual修饰都会成为析构函数。从表面上看函数名不同违背了重写的规则,但是在底层实现上编译器会将析构函数名统一处理为destructor。

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
#include <iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "full ticket!" << endl;
}
virtual ~Person()
{
cout << "~Person()" << endl;
}

};
class Student: public Person
{
//参数不同无法构成重写
virtual void BuyTicket()
{
cout << "student ticket!" << endl;
}
virtual ~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
Person* p = new Person();
p->BuyTicket();
Person* p2 = new Student();//切割,将子类对象指针赋值给父类指针
p2->BuyTicket();
delete p;//调用Person析构函数
delete p2;//调用Student析构函数,由于Student继承于Person所以还会再调用基类析构函数
}


full ticket!
student ticket!
~Person()
~Student()
~Person()

override和final

  这两个关键字在Cpp11中出现,他们用于帮助我们判断我们的虚函数是否还需要重写或者虚函数必须完成重写,可以帮助我们检查错误,规范代码。

override

  凡是被override修饰的虚函数表示其必然重写了基类的某个虚函数,如果没有重写则报错,因此override是用在派生类的虚函数中的。

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
#include <iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "full ticket!" << endl;
}
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
class Student: public Person
{
//参数不同无法构成重写
virtual void BuyTicket()
{
cout << "student ticket!" << endl;
}
virtual ~Student()
{
cout << "~Student()" << endl;
}
virtual void BuySomething() override
{
cout << "buy something" << endl;
}
};
int main()
{
Person* p = new Person();
p->BuyTicket();
Person* p2 = new Student();//切割,将子类对象指针赋值给父类指针
p2->BuyTicket();
delete p;//调用Person析构函数
delete p2;//调用Student析构函数,由于Student继承于Person所以还会再调用基类析构函数
}


.\mian.cpp:27:18: error: 'virtual void Student::BuySomething()' marked 'override', but does not override
virtual void BuySomething() override

final

  被final关键字修饰的函数表示其不能再被重写,如果派生类重写则报错,所以这个关键字用在基类虚函数中。

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
#include <iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "full ticket!" << endl;
}
virtual ~Person()
{
cout << "~Person()" << endl;
}
virtual void Buysomething() final
{
cout << "buy something" << endl;
}
};
class Student: public Person
{
virtual void BuyTicket()
{
cout << "student ticket!" << endl;
}
virtual ~Student()
{
cout << "~Student()" << endl;
}
virtual void Buysomething()
{
cout << "buy something too" << endl;
}
};
int main()
{
Person* p = new Person();
p->BuyTicket();
Person* p2 = new Student();//切割,将子类对象指针赋值给父类指针
p2->BuyTicket();
delete p;//调用Person析构函数
delete p2;//调用Student析构函数,由于Student继承于Person所以还会再调用基类析构函数
}


.\mian.cpp:30:18: error: virtual function 'virtual void Student::Buysomething()'
virtual void Buysomething()
^~~~~~~~~~~~
.\mian.cpp:14:18: error: overriding final function 'virtual void Person::Buysomething()'
virtual void Buysomething() final

重写、重载、重定义的对比

  重载是同一作用域内,多个同名函数通过参数列表的不同编译器经过底层命名规则的处理来构成重载。重定义是派生类与基类两个作用域内,派生类成员与基类成员重名构成重定义(隐藏)。重写是派生类与基类两个作用域内,两个虚函数函数名、参数、返回值完全相同才构成重写(覆盖)。因此我们可以总结如下。
  重载:同一作用域内,函数名相同,参数不同。
  重写:在派生类和基类两个作用域内,函数为虚函数,且函数名参数返回值都完全相同。
  重定义:在派生类和基类两个作用域内,函数名相同,只要不构成重写的同名函数即构成重定义。

抽象类

  在一个虚函数的声明后面加上=0这个虚函数就会变成纯虚函数,带有纯虚函数的类被称为抽象类,抽象类也叫接口类,这个类不能实例化出对象,只有继承它的子类将其类内所有的纯虚函数全部重写后,其子类才可以实例化对象。

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 <iostream>
using namespace std;
//抽象类
class Abstract
{
public:
virtual void BuyTicket() = 0;
virtual void Ticket()
{

}
};
class Person : public Abstract
{
public:
//重写纯虚函数
virtual void BuyTicket()
{
cout << "full ticket!" << endl;
}
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
class Student: public Abstract
{
virtual void BuyTicket()
{
cout << "student ticket!" << endl;
}
virtual ~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
//Abstract* pt = new Abstract();//报错,抽象类不能实例化对象
//之后所有的继承自抽象类的派生类就可以使用抽象类作为统一接口
Abstract* p = new Person();
p->BuyTicket();
Abstract* p2 = new Student();//切割,将子类对象指针赋值给父类指针
p2->BuyTicket();
delete p;//调用Person析构函数
delete p2;//调用Student析构函数,由于Student继承于Person所以还会再调用基类析构函数
}

  使用抽象要注意派生类要重写抽象类中的所有纯虚函数,否则会报错,抽象类中的非纯虚函数,不会强制重写。

多态的原理

  如今大多数Cpp的书中关于多态也仅仅局限在如何使用上,但是这是肯定不够的,于是通过一些资料和实验在这里总结出Cpp实现多态的原理,这对于多态的使用有极强的帮助。

虚函数表

  我们首先看一个小例子,体会一下在存在虚函数的类中所存在的一个神秘的成员。

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
#include <iostream>
using namespace std;
class Base
{
virtual void Func()
{
cout << "Func1" << endl;
}
private:
int _a;
};
class Deliver: public Base
{
virtual void Func()
{
cout << "Func1" << endl;
}
private:
int _b;
};
int main()
{
cout << sizeof(Base) << endl;;
cout << sizeof(Deliver) << endl;;
}


8
12

  这里可能就会产生疑惑,在基类中命名只有一个成员_a应该只有四个字节啊哪里来的八个字节空间,难道虚函数也占空间么?派生类中继承了从基类来的成员_a自己还有一个成员_b应该只有八个字节哪里来的12,为什么他们都多出来了四个字节的空间?
  接下来我们继续对其进行改进,多新增几个虚函数和普通函数。

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
#include <iostream>
using namespace std;
class Base
{
virtual void Func()
{
cout << "Func1" << endl;
}
virtual void Func2()
{
cout << "Func2" << endl;
}
void Func3()
{
cout << "Func3" << endl;
}
private:
int _a;
};
class Deliver: public Base
{
virtual void Func()
{
cout << "Func1" << endl;
}
void Fun3()
{
cout << "Func3" << endl;
}
private:
int _b;
};
int main()
{
cout << sizeof(Base) << endl;;
cout << sizeof(Deliver) << endl;;
}


8
12

  我们发现我们增加了虚函数后类的大小并没有改变,永远只是增加了四个字节。这又是为什么呢?
  通过一些高级ide对内存的查看及反复实验得出结论,在每一个有虚函数的类中会多生成一个成员虚函数表指针,这个指针指向一张表称其为虚函数表也叫虚表,虚表可以看作是一个数组,它的元素类型为虚函数指针,也就是说这个数组是一个虚函数指针数组,其中的每一个元素指向一个类内的虚函数,最后一个元素为nullptr作为结尾的标记。
  那么为什么编译器要生成这么一个虚函数表,它又是怎么生成的呢?
  每个含有虚函数的类都会自动生成一个虚函数表,如果是没有发生继承的类,则虚函数表中所存储的都是自己类中的所有虚函数的指针,并且类内会自动生成一个成员虚函数表指针去指向这个虚函数表。如果一个派生类类继承了一个拥有虚函数的类,则相当于自己本身这个类也拥有虚函数,因此也会生成一个虚函数表,但是在最开始生产时会将基类的虚函数表一模一样拷贝一份作为自己的虚函数表,然后类内生成虚函数表指针指向这个虚函数表,此时它的虚函数表中的每个指针都指向了在基类中定义的虚函数,一旦这个派生类重写了基类中的某个虚函数,则会用重写后的虚函数的指针去覆盖对应的虚函数表中父类虚函数的指针,并且派生类中新添加的虚函数会放在虚函数表的最后,非虚函数不会放入虚函数表,以此完成虚表的构建,虚表是Cpp中完成多态的关键。
  关于虚表可能会疑惑它存存放在哪里,虚函数又存放在哪里,虚表中存放的是虚函数么?
  注意:类中所存放的不过是虚表指针,所以32位操作系统只有4个字节,用于找到虚表位置。虚表中存放的只是虚函数的指针,虚函数和普通函数一样是存放在代码段的,只是它的指针存放在虚表中方便我们找到他。而虚表在vs环境下是存放在代码段的,根据编译器的不同处理方式也有所不同。

多态的原理

  知道了虚表的存在后就要明白为什么要有虚表,它又是如何在多态的实现中起到重要作用的。

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
#include <iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "Full ticket" << endl;
}
};
class Student : public Person
{
public:
//发生重写/覆盖
virtual void BuyTicket()
{
cout << "Student ticket" << endl;
}
};
int main()
{
Person* person = new Person();
Person* student = new Student();
person->BuyTicket();
student->BuyTicket();
}


Full ticket
Student ticket

  以上这个例子是一个再普通不过了的多态的例子,我们结合虚表解析一下编译器实现多态的过程。

多态原理

  student和person虽然都是Person类型的指针,但是却指向了类型不同的对象,而不同类型的对象在初始化时虚表指针也会根据自己的类型随之生成因此虽然student类型为Person但是其指向的Student类型对象中的虚表指针是指向Student类的虚表。而编译器在调用虚函数前都会通过对象中的虚表指针去虚表中寻找对应函数的地址,Student类对函数进行了重写,重写后的函数地址覆盖了原来的函数地址,因此student对象在调用函数时虚表中找到的就是Student::BuyTicket(),同理person对象也会根据虚表找到自己应该调用的Person::BuyTicket(),这样即可完成多态。
  类中的非虚函数并不会写入虚函数表,因此无法完成重写更别说多态,所以实现多态的前提重写中就以要求两个函数必须是虚函数。

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
#include <iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout << "Full ticket" << endl;
}
void fun()
{
cout << "fun1" << endl;
}
};
class Student : public Person
{
//发生重写/覆盖
public:
virtual void BuyTicket()
{
cout << "Student ticket" << endl;
}
void fun()
{
cout << "fun2" << endl;
}
};
int main()
{
Person* person = new Person();
Person* student = new Student();
person->BuyTicket();
student->BuyTicket();
person->fun();
student->fun();
}


Full ticket
Student ticket
fun1
fun1

  以上的fun因为不是虚函数并不会被写入虚表,更无法完成重写,所以构不成多态,这解释了为什么说虚表才是实现多态的关键。

静态绑定和动态绑定

  在程序编译期间就已经确定了程序的行为的方式称为静态多态,函数的重载也是一种多态,但是函数重载的多态是利用了命名规则,我们调用不同参数的函数等于调用了不同函数的函数,与调用普通函数没有差别,其调用规则在程序编译期间就已经决定,这种绑定方式也称为早绑定。
  在程序运行期间根据具体类型动态决定程序执行行为的方式称为动态绑定,例如利用虚函数重写的方式达成的多态,就是在程序执行期间根据类型去不同的虚表中调用不同的函数,以此达成多态功能,这种绑定方式也成为晚绑定。

为什么必须是指针或引用

  在我们知道了多态的原理后已经明白了为什么要构成重写才能完成多态,那么对于第一条规则,为什么必须通过基类的指针或者引用才可以使用多态?
  首先为什么是基类我觉得不用多说了,因为切割的存在派生类类对象、指针和引用可以赋值给基类的对象、指针和引用从而根据指针和引用指向不同的对象类型调用不同的函数。那么不用指针和引用,对象能不能完成多态呢?毕竟派生类类对象也是可以给基类对象赋值的。
  答案是肯定不行的。在原理的介绍中有提到过虚表指针是在对象初始化时根据对象类型创建的,建立什么类型对象就有指向什么类虚表的虚表指针。而指针和引用所完成的切割与对象完成的切割有所不同,基类指针指向派生类对象是可以完成的,但是此时在内存中派生类对象依然是派生类对象,独属于派生类的成员依然存在着,只是无法通过基类指针对其访问(引用同理,引用底层实现也是利用指针)。当通过指向派生类的基类指针访问派生类对象的虚函数时,所利用的虚表指针也是在派生类对象初始化时创建的指向派生类虚表的指针,因此才可以找到派生类的虚表从而访问我们想要的函数。这一切都得益于指针之间的赋值不过是对同一块内存的不同理解罢了,并没有改变内存中的数据,才能找到属于派生类的虚函数表。而对象之间的赋值必然会通过赋值运算符重载或者拷贝构造,这其中不过是把派生类成员变量的值拷贝给了基类中存在的成员变量,并没有完成虚表指针的拷贝,虚表指针在对象初始化时已经根据自身类型确定死了,因此通过对象的赋值基类对象无法获得派生类对象的虚表指针,无法访问到派生类虚表也就无法调用我们动态绑定的函数,也就无法完成多态。

单继承与多继承中的虚函数表

单继承

  单继承是我们之前之前一直讨论的模型,派生类的虚函数表如果是未发生重写状态则会完全复制一份基类的虚函数表,并将其指针作为成员放在类中,并且会放在开头,我们以以下这个类作为例子分别画出派生类内成员继承的模型和基类与派生类虚函数表内的数据。
  我们首先用一种非常规的方式打印基类和派生类中虚函数表的内容

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
#include <iostream>
using namespace std;
class Base1
{
public:
virtual void func1()
{
cout << "Base1::func1()" << endl;
}
virtual void func2()
{
cout << "Base1::func2()" << endl;
}
private:
int _b1;
};
class Derive : public Base1
{
//发生重写/覆盖
public:
virtual void func1()
{
cout << "Derive::func1()" << endl;
}
virtual void func3()
{
cout << "Derive::func3()" << endl;
}
virtual void func4()
{
cout << "Derive::func4()" << endl;
}
private:
int _d;
};
typedef void (*VFPtr)();//虚函数指针
//打印虚函数表内容
void PrintVTable(VFPtr vTable[])//虚表本质上是一个虚函数指针数据
{
cout << "vTable address:" << vTable << endl;
for(int i = 0; vTable[i] != nullptr; i++)
{
printf("%dst vfptr of the vTable:0X%x -> ", i, vTable[i]);
VFPtr func = vTable[i];
func();
}
}
int main()
{
//取出虚函数表指针
//对于单继承虚函数表指针就是对象的前4个字节,取出前四个字节再强转为VFPtr*类型即可
Base1 b;
Derive d;
VFPtr* vTable1 = (VFPtr*)(*(int*)&b);
PrintVTable(vTable1);
VFPtr* vTable2 = (VFPtr*)(*(int*)&d);
PrintVTable(vTable2);
}


vTable address:0x4051e4
0st vfptr of the vTable:0X403cf0 -> Base1::func1()
1st vfptr of the vTable:0X403d24 -> Base1::func2()
vTable address:0x4051f4
0st vfptr of the vTable:0X403d88 -> Derive::func1()
1st vfptr of the vTable:0X403d24 -> Base1::func2()
2st vfptr of the vTable:0X403dbc -> Derive::func3()
3st vfptr of the vTable:0X403df0 -> Derive::func4()

  打印的第一个虚表是基类的虚函数表,我们对Func1进行了重写因此可以看到派生类虚表中函数地址与基类的不一样,而Func2我们并没有重写因此还是拷贝的基类的呢一份并没有变,Func3和Func4是派生类新加上去的,因此放在派生类虚表的最后。
  我们将派生类中成员与虚表的对应关系表示出来。

多态原理

多继承

  对于多继承来说,派生类每继承一个基类都会从基类中拷贝一份虚表,因此一个派生类在多继承中可以拥有多个虚表,也就拥有多个虚表指针。我们将以上的例子稍微变更一下,再打印基类与派生类中虚表的内容。

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
#include <iostream>
using namespace std;
class Base1
{
public:
virtual void func1()
{
cout << "Base1::func1()" << endl;
}
virtual void func2()
{
cout << "Base1::func2()" << endl;
}
private:
int _b1;
};
class Base2
{
public:
virtual void func1()
{
cout << "Base2::func1()" << endl;
}
virtual void func2()
{
cout << "Base2::func2()" << endl;
}
private:
int _b2;
};
class Derive : public Base1, public Base2
{
//发生重写/覆盖
public:
virtual void func1()
{
cout << "Derive::func1()" << endl;
}
virtual void func3()
{
cout << "Derive::func3()" << endl;
}
private:
int _d;
};
typedef void (*VFPtr)();//虚函数指针
//打印虚函数表内容
void PrintVTable(VFPtr vTable[])//虚表本质上是一个虚函数指针数据
{
cout << "vTable address:" << vTable << endl;
for(int i = 0; vTable[i] != nullptr; i++)
{
printf("%dst vfptr of the vTable:0X%x -> ", i, vTable[i]);
VFPtr func = vTable[i];
func();
}
}
int main()
{
//取出虚函数表指针
//对于单继承虚函数表指针就是对象的前4个字节,取出前四个字节再强转为VFPtr*类型即可
Base1 b1;
Base2 b2;
Derive d;
VFPtr* vTable1 = (VFPtr*)(*(int*)&b1);
PrintVTable(vTable1);
VFPtr* vTable2 = (VFPtr*)(*(int*)&b2);
PrintVTable(vTable2);
VFPtr* vTable3 = (VFPtr*)(*(int*)&d);
PrintVTable(vTable3);
//这里的多继承的派生类中的第二个虚表指针紧跟在第一个派生类成员之后
VFPtr* vTable4 = (VFPtr*)(*(int*)((char*)&d + sizeof(Base1)));
PrintVTable(vTable4);
}


vTable address:0x405218
0st vfptr of the vTable:0X403d30 -> Base1::func1()
1st vfptr of the vTable:0X403d64 -> Base1::func2()
vTable address:0x405228
0st vfptr of the vTable:0X403dc8 -> Base2::func1()
1st vfptr of the vTable:0X403dfc -> Base2::func2()
vTable address:0x405238
0st vfptr of the vTable:0X403e60 -> Derive::func1()
1st vfptr of the vTable:0X403d64 -> Base1::func2()
2st vfptr of the vTable:0X403e94 -> Derive::func3()
vTable address:0x40524c
0st vfptr of the vTable:0X403f00 -> Derive::func1()
1st vfptr of the vTable:0X403dfc -> Base2::func2()

  这里打印了两个基类的虚表和派生类中的两个虚表,可见派生类如果多继承确实是会有多个虚表存在的,并且如果重写则会对类内所有的虚表中的对应函数都会进行重写,并且会将自己新增的虚函数放进第一个虚表中,也就是最先继承的类所的来的那个虚表。
多态原理

【网络】第五章-网络层协议

发表于 2019-09-22 | 分类于 网络
字数统计: 2.1k

网络层协议

  理解网络层功能,常见协议。

网络层功能

  网络层主要负责地址管理以及路由选择。在网络中我们从一个ip到另一个ip有很多条路可以走,而网络层则是帮我们规划我们传输数据的最佳路线。

ip协议

协议字段

  1、4位版本:当前的协议是IPV4还是IPV6,由socket创建时传入的地址域决定。
  2、4位首部长度:决定了头部有多长,以4个字节为单位。
  3、8位服务类型:3位优先权字段(已弃用),1位保留字段必须置0,4位TOS字段分别表示最小延时,最大吞吐量,最高可靠性,最小成本。这四者互相冲突,只能选择一个,根据场景选用合理的服务类型。
  4、16位总长度:是数据长度,传输层协议头和ip头的总长度,最大报文不超过64k,由此可见UDP报文数据的最大大小并没有64k-8,还应该减去ip头部大小。对于UDP来说如果数据报文长度小于最大报文长度则可以进行传输,但是在传输时还有一个限制——MTU,最大传输单元,这个限制是链路层的限制,限制了传输时最大的数据帧大小,如果UDP报文大于MTU大小则无法通过链路层的传输,此时为了能够传输则会进行数据分片。
  5、16位标识:用于标识当前数据属于哪一个udp数据包。
  6、3位标志:第一位保留,第二位标识是否禁止分片,1表示禁止,第三位表示更多分片,即如果分片了的话,最后一个分片置为1,其它为0,类似于结束标记。
  7、13位片偏移:用于标识当前报文在原始udp数据包中的偏移量,单位为8个字节。
  8、8位生存时间:TTL,之前有讲过MSL,但是在网络中真正的报文生存时间是按照TTL和MSL共同计算的。TTL是所能经过的路由器跳数,8位最大255跳,发送出去之后开始计算,每经过一个主机则-1,当到0时则认为通信失败,丢弃数据。这个属性主要是为了防备路由环路的情况
  9、8位协议:标识上层协议类型,TCP/UDP等。
  10、16位首部校验和:用域校验数据一致性。
  11、32位源ip地址:从哪个主机来。
  12、32位目的ip地址:到哪个主机去。
  13、40字节选项数据:不一定有。

数据分片

  当mtu < IP头和IP数据大小 < 64k则会在网络层进行数据分片,TCP协议不会进行数据分片,因为其在传输层会协商MSS。将一个大包分为多个小包,在应用层叫分包,在传输层叫分段,在网络层叫分片,数据分片主要针对UDP。
  ip协议在进行分片时会一次取出MTU-ip头大小的数据然后分别封装一个ip头,这样就会封装成多个ip报文然后分别进行发送。
  但是此时可能会有问题,分片后并不能保证按照顺序被对端接收,对端对分片进行重组的时候也无从知道重组的顺序,不过好在ip头信息中有几条信息*来帮助我们确定其属于哪一个数据包以及分片顺序。
  标识字段帮助我们找到当前ip数据包原本属于哪个udp数据包,片偏移字段帮助我们找到当前ip数据包在原udp报文中的相对位置,并且其单位为8个字节,完全足够标识64k数据的偏移量,这也说明了ip对udp报文的每个分片必然都是8的整数倍。

地址管理

ip地址的构成

  ip地址由网络号 + 主机号组成。
  1、网络号:每一个路由器向自己子网中的主机分配地址的时候,这些地址中都应该包含这个网络的标记,这个标记用于区分每一个网络。每一个网络的网络号只要不一样则分配的ip地址就不会冲突。
  2、主机号:每一个主机的ip地址不但要具备网络的标识,还需要能够再局域网中唯一标识,这个唯一标识称之为主机号。

网段的划分

  ip地址就是一个拥有4个字节的无符号整形,不过我们经常用点分十进制进行标识,早期对网段根据网络号和主机号所占位数的不同划分为五类地址:

1
2
3
4
5
A类:高8位中高一位:0,低7位是网络号;剩下的都是主机号。(0.0.0.0 ~ 127.255.255.255)
B类:高16位中高两位:10,低14位网络号;剩下主机号。(128.0.0.0 ~ 191.255.255.255)
C类:高24位中高三位:110,低21位网络号;剩下主机号。(192.0.0.0 ~ 223.255.255.255)
D类:高4位固定:1110。(224.0.0.0 ~ 239.255.255.255)
E类:高5位固定:11110。(240.0.0.0 ~ 247.255.255.255)

  如今这种划分方式已经被淘汰,新的网段划分使用CIDR的方式进行划分,对网络的划分借助了一个字段叫子网掩码。
  子网掩码是一个uint32_t类型的数据,由连续的二进制1组成。它如何帮助我们划分网络呢?
  子网掩码取反可以得到局域网中的最大主机号。例如255.255.255.0 取反-> 0.0.0.255,则这个网段中的最大主机号为255。
  

特殊的ip地址

  主机号全为0的地址–网络号。
  主机号全为1–udp的局域网广播地址。
  127.0.0.1–虚拟回环网卡,用于本机网络测试。
  0.0.0.0–本机任意地址。

局域网组建规则

  并不是所有网络号都能用于组建局域网,因为如果所有网络都能组件局域网则仍然有可能造成重复,因此规定能够组件私网的网络号只有以下几种:

1
2
3
10.*.*.* 
172.16.*.* ~ 172.31.*.*
192.168.*.*

  局域网时相邻的网络不能使用相同的网络号,会造成地址分配冲突。这里的相邻网络指的是同一路由器不同网卡所属的网络,一台路由器可以身处多个网络中,记录在同一路由器的路由表上的网络都是相邻网络。

路由选择

  每个路由器上都有一张路由表,记录当前路由器所相连的网络,当路由器接收一个数据,则取出目的ip地址,通过路由表判断当前数据的目的主机属于哪一个相邻的网络(使用相邻网络的地址与子网掩码进行相与,然后与目的网络的网络号进行比较)。若属于某个相邻的网络则使用相对应的网卡将数据发送出去,若路由表匹配失败,则将数据发送到下一层网络设备。

路由选择

  上图大概描述了一个运营商与各个独立用户之间搭建局域网进行通信的简易模型,但实际上原要比这复杂得多。
  路由选择并不困难,最为困难的是路由表生成算法。例如距离向量算法,LS算法等。关于路由选择有着很多的算法这里不再深入讨论。
  关于路由选择和网络搭建十分复杂,是一门独立的学科,还需要进一步研究讨论。

【网络】第四章-传输层协议

发表于 2019-09-22 | 分类于 网络
字数统计: 5.8k

传输层协议

  传输层负责端与端之间的数据传输,其中典型协议为TCP协议和UDP协议。
  TCP协议是TCP/IP协议栈中的传输层的典型协议,叫传输控制协议,面向连接,可靠传输,提供字节流服务。
  UDP协议是TCP/IP协议栈中的传输层的典型协议,叫用户数据报协议,无连接,不可靠,提供数据报传输服务。

UDP协议

协议字段

  协议字段都包含在UDP协议数据报的报头中,每次发送数据都会将这些信息和数据一起发出。
  1、16位源端口:标识数据从哪个进程来。
  2、16位目的端口:描述数据到哪个进程去。
  3、16位校验和:对数据的二进制反码求和,用于校验接收到的数据是否和发出的数据一致。
  4、16位数据报长度:由于数据报长度一共只有16位因此udp一个数据报的最大大小为64k,加上udp数据报头部信息一共64位还要占8个字节,因此数据大小不能超过64k-8。并且sendto这个接口会将我们传入的数据直接封装报头进行传输,所以我们在使用sendto接口的时候buf的长度最大不能超过64k-8。
  如果我们要传输的数据大于64-8。则需要用户在应用层进行分包传输。并且udp协议不保证安全也不保证包序,因此在接收数据后我们还需要在应用层进行整理。
  根据udp报文中的数据报长度大小,我们的udp协议能且仅能一次收发一条完整的udp报文,不会将报文拆分,报文的长度可以从报头中获得。

应用

  因为udp无连接不可靠,因此udp不能应用于有安全性要求的传输,但是因为报头短,并且不需要保证安全性因此传输速度快,因此多应用于实时性要求高的场景。
  udp在协议栈层面实现了广播机制,通过向一个地址发送信息可以向局域网内所有主机发送信息。
  在应用层基于udp协议实现的协议:DNS协议,DHCP协议。

TCP协议

协议字段

  1、16位源端口:标识数据从哪个进程来。
  2、16位目的端口:标识数据到哪个进程去。
  3、32位序号:用于进行包序管理。
  4、32位确认序号:用于进行包序管理。
  5、4位首部长度:用于描述头部信息长度,单位是4个字节。tcp协议头部长度最小为20字节,最大为当4位首部长度最大时即为15 * 4 = 60字节。
  6、6位保留:用于保留下来存储新的属性和数据。
  7、6位标志位:用于标志当前tcp信息的属性和类型,常见的有URG/ACK/PSH/RST/SYN/FIN。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CWR(Congestion Window Reduce):拥塞窗口减少标志被发送主机设置,用来表明它接收到了设置ECE标志的TCP包,发送端通过降低发送窗口的大小来降低发送速率

ECE(ECN Echo):ECN响应标志被用来在TCP3次握手时表明一个TCP端是具备ECN功能的,并且表明接收到的TCP包的IP头部的ECN被设置为11。更多信息请参考RFC793。

URG(Urgent):该标志位置位表示紧急(The urgent pointer) 标志有效。该标志位目前已经很少使用参考后面流量控制和窗口管理部分的介绍。

ACK(Acknowledgment):取值1代表Acknowledgment Number字段有效,这是一个确认的TCP包,取值0则不是确认包。后续文章介绍中当ACK标志位有效的时候我们称呼这个包为ACK包,使用大写的ACK称呼。

PSH(Push):该标志置位时,一般是表示发送端缓存中已经没有待发送的数据,接收端不将该数据进行队列处理,而是尽可能快将数据转由应用处理。在处理 telnet 或 rlogin 等交互模式的连接时,该标志总是置位的。

RST(Reset):用于复位相应的TCP连接。通常在发生异常或者错误的时候会触发复位TCP连接。

SYN(Synchronize):同步序列编号(Synchronize Sequence Numbers)有效。该标志仅在三次握手建立TCP连接时有效。它提示TCP连接的服务端检查序列编号,该序列编号为TCP连接初始端(一般是客户端)的初始序列编号。在这里,可以把TCP序列编号看作是一个范围从0到4294967295的32位计数器。通过TCP连接交换的数据中每一个字节都经过序列编号。在TCP报头中的序列编号栏包括了TCP分段中第一个字节的序列编号。类似的后续文章介绍中当这个SYN标志位有效的时候我们称呼这个包为SYN包。

FIN(Finish):带有该标志置位的数据包用来结束一个TCP会话,但对应端口仍处于开放状态,准备接收后续数据。当FIN标志有效的时候我们称呼这个包为FIN包。

  8、16位窗口大小:标志传输窗口的大小,这块牵扯到tcp协议的传输特性和机制,在后续进一步讲解。
  9、16位校验和:校验数据一致性。
  10、16位紧急指针:带外数据。
  11、40字节选项数据:有时没有有时有,但长度必须是4的整数倍字节。
  除选项外其他头信息一共20字节,这是最小头信息长度。

TCP连接管理机制

三次握手建立连接

  首先客户端处于SYN_SENT状态服务端处于LISTEN状态,客户端会向服务端发送一个SYN包,服务端收到后会转变状态为SYN_REVD状态,成功后向客户端发送SYN+ACK报文表示收到连接请求,之后客户端会再次确认,转换状态为ESTABLISHED并返回ACK包表示连接成功,服务端收到ACK后叶转换状态为ESTABLISHED。

三次握手

四次挥手断开连接

  首先客户端(假设客户端先发起断开连接请求)处于FIN_WAIT1状态会发送FIN包请求关闭连接,服务端收到后切换状态为CLOSE_WAIT状态,并且发送ACK包确认收到请求,客户端收到数据包后会会转变状态为FIN_WIAT2状态。随后服务端切换状态为LAST_ACK并且主动发送FIN包确认是否断开连接,客户端收到后切换状态为TIME_WAIT并发送ACK包给服务端,服务端收到后切换状态为CLOSED正式断开连接,但是客户端此时并不会真正断开连接,处在TIME_WAIT状态的客户端会在此继续等待一段时间,之后才会改变状态为CLOSED。
四次挥手

为什么?

  在tcp连接和断开过程中有很多疑问,为什么要三次握手四次挥手?为什么客户端断开连接还要等待?
  为什么握手要三次才能建立连接?两次行不行?四次呢?
  我们都注意到握手和挥手都需要客户端和服务端双方各发送一个SYN和收到一个ACK包才能算成功,这是为了防止网络传输过程中由于延迟,丢包等问题对数据造成丢失或延迟,因此客户端和服务端此时都要确认对方此时是在线的是可以连接的。我们可以想象这样的场景,在挥手过程中如果只有两次的话,客户端发送一个连接请求,服务端收到后就表示已经建立连接,这样是十分不安全的,如果这个连接请求因为延迟被耽误了很久,服务端接收到报文的时候客户端此时已经关闭了,那么这个连接就是失败的,但是服务端却表示成功了,那着肯定是不合理不安全的。因此服务端也必须要发送一个SYN包确认此时客户端在线,需要服务端回复后才能确认连接建立成功,因此两次握手是不安全的,但是四次连接也是没有必要的,服务端在建立连接时确认客户端在线的SYN报文是可以和回复报文一起发送的,因此三次即可。
  为什么挥手要四次,三次可以么?
  和建立连接同理,服务端和客户端需要各发送一次SYN进行一次确认,但是这里为什么不可以将服务端的SYN和ACK放在一起发送?因为在连接关闭后,连同socket及其相应的缓冲区都会关闭,清空数据,那么如果此时缓冲区中还有信息的话信息也会一起丢失,因此客户端发送FIN包后服务端不能将回应ACK和确认FIN一起发送,服务端会在用户接收缓冲区中所有数据后才会发送SYN确认关闭请求,然后关闭套接字。
  TIME_WAIT有什么用,为什么客户端关闭连接后要等待?要等待多久?
  如果没有TIME_WAIT,客户端在发送最后一次ACK后就直接关闭的情况下,如果此时最后一次ACK包文丢失,服务端很久并没有收到包文,它会再次重发最后一次FIN包,如果此时客户端又重启了一个同端口的客户端准备再次建立连接,此时却接收到服务端发来的FIN包就会陷入混乱,因此客户端在此时需要等待。至于等待多久此时需要2个MSL的时间,一个MSL时间表示数据包在网络中的生命周期,它最久只能在网络中存活这么久,如果还没有成功则会销毁这个包,也就是丢包,等待两个MSL时间是为了让客户端自己发送的包以及以及服务端如果重发FIN包都能够要么接收到,要么都消亡在网络中不对后续连接造成影响,等待2个MSL时间已经足够保守极大减少之前连接会影响到之后连接情况。

保活机制

  默认情况下,通信双方7200s没有数据往来,每隔75s向对方发送一个保活探测数据包,要求对方进行相应,若是得到相应则认为连接正常,若是连续9次没有得到相应,则认为连接断开,将socket状态置为CLOSE_WAIT。

确认应答机制

  TCP给发送的所有数据中的每一字节都进行了编号,假如说客户端首先给服务端发送了1-1000号数据,服务端如果接收到所有数据会返回1001ACK表示前1000字节的数据都已经收到了,但是如果返回的不是1001或者压根没有返回,客户端就会知道中间有丢包,于是为了可靠传输则会重传,于是有了超时重传机制。

超时重传机制

  如果在客户端发送了1-1000号数据后服务端返回了1001ACK,此时客户端发送了1001-2000号数据,但是服务端给我们迟迟不见发送ACK确认,于是客户端就会认为1001-2000号数据丢包则会重新发送1001-2000号数据。
  当然客户端收不到ACK也有可能是ACK丢包,因此客户端会依然重发数据,此时服务端可能会存在大量重复数据,服务端会根据编号来辨别重复数据,并且如果客户端之后发送了2001-3000号数据,并且收到了3001号ACK则客户端虽然没有收到2001号ACK也会认为之前所有的数据都已经收到。
  至于多久才算超时,Linux是以500ms作为一个单位以整数倍作为超时时间,以指数形式增长,当重传到一定次数后还收不到ACK则认为网络出现异常,强制关闭连接。

确认数据有序和无误

  超时重传机制和确认应答机制只能保证所有的包都不丢包,但是并不能保证包有序到达并且数据无误,这就要用到TCP头信息中的序号/确认序号来对包文进行排序,如果服务端先收到了2001-3000号包但没有收到1001-2000号数据服务端则暂时不会向用户表示自己收到了2001-3000号数据,会在此等待收到1001-2000号数据为止,并且收到数据后对包文进行排序和整理,以及通过校验和对包文数据进行校验。

滑动窗口机制

  我们之前讨论都是在一次传输一次应答的情况下讨论的,但是这种通信方式过于缓慢,于是TCP是可以同时发送多个数据传输而不用非要每一条传输都接收ACK才会发送下一条数据,这样大大可以提高性能。但是如果发送过快,而网络状况不好,全部丢包,服务端一条都收不到或者只能零零散散接收到一些片段,则会导致客户端大量的重传包文,十分影响效率,因此引入滑动窗口机制。
  在介绍滑动窗口之前,我们还要知道一个概念即MSS,最大数据长度,这个大小往往是在三次握手期间就已经确定下来的,标识一个包文最大只能传输这么大的数据。知道这个概念后,我们就要开始思考一个问题,TCP允许同时发送多个数据包,但是为了防止大量丢包该怎么做呢?没错,呢就是限制能够同时发送的数据包的数量,这就是滑动窗口成立的基础。
  滑动窗口大小决定了能够无需等待确认应答的情况下可以继续发送数据的最大值。假如说我们在三次握手期间确定MSS=1024,滑动窗口win=4096,则表示客户端向服务端可以暂时不等待确认应答最多可以发送4096/1024=4个数据包,假设一开始客户端窗口框住的数据区域为1-4096,并且同时发送4个数据包完毕后,客户端就必须停下来等待确认应答。如果我们的第一个包1-1024号数据已经成功发送并且收到了响应ACK1025则表示一号包中数据发送成功,则客户端的窗口会向后滑动一个MSS大小框住接下来的数据1025-5120(假设滑动窗口大小并没有改变),然后继续向服务端发送窗口滑动后出现的新的一个数据包,以此类推,直到发出所有数据包并接受所有响应,此时滑动窗口应该已经滑倒了数据末尾。窗口大小越大,网络吞吐量越高。

快速重传机制

  因为滑动窗口机制我们知道了TCP允许同时发送多个数据包,但是如果其中发生丢包会怎样呢?假设窗口还是win=4096,MSS=1024我们的第一个包1-1024已经成功接收到了ACK,说明服务端接收到了1-1024的数据,但是服务端此时迟迟收不到1025-2048的数据,尽管后面的数据都已经收到,此时服务端会连续不断发送1025ACK提醒客户端我想要的是1025-2048数据。一旦客户端连续三次收到相同的ACK则会知道对应的数据包丢失重新发送对应的数据包,这个机制叫做快速重传机制。

流量控制

  同时得益于滑动窗口,我们可以控制传输数据的速度和大小,控制网络吞吐量。但是滑动窗口的大小并不是一成不变的,服务端在接收数据过程中如果缓冲区满了无法继续接收数据则会造成大量丢包情况,于是此时服务端可以通过改变窗口大小控制数据传输大小。服务端可以将自己缓冲区的大小放进TCP头部信息的窗口大小字段中,告诉客户端自己缓冲区大小,当缓冲区逐渐变满的过程中服务端每次发送ACK都会重新调整滑动窗口大小,告诉客户端你传满点我收不下了,当大小为0时表示缓冲区已满,只有等用户从缓冲区提取信息后服务端才会加大窗口大小继续接收数据,由此做到流量控制。

拥塞控制

  滑动窗口的大小并不是一开始就是最大的,因为要考虑到网络状况,一开始滑动窗口的大小只有1,用于试探网络状况,当确认网络状况没有问题的时候才会逐渐以指数级别增长速度继续增长,但是到达一个阈值后停止继续增长。如果传输过程中出现丢包需要重传情况则会将阈值缩小为原先的一半,并将滑动窗口置回1。这个过程用一句话形容就是慢启动,快增长,客户端想尽快将数据发送给对方,但是又要避免网络造成太大压力的折中方案。

延迟应答机制

  客户端将数据发送给服务端后,服务端收到数据后并不会立刻回复ACK,因为刚接收完数据存储在缓冲区中用户还没有取走数据,因为流量控制机制此时必然要调小窗口大小,这样会影响传输速度,影响网络吞吐量,于是服务端会稍微在此等待片刻再做应答,这个时间一般是200ms左右,操作系统不同时间也不同,而操作系统也许处理数据速度很快10ms就取走数据,此时缓冲区数据被取走缓冲区又恢复到无数据状态,也就不用调小窗口大小控制传输速度了,这样可以加大网络吞吐量,提高传输效率。

捎带应答机制

  不光客户端可以给服务端发送数据,服务端也会给客户端发送数据,假如客户端发送数据你好呀,服务端想要回复我还行,服务端为了传输效率则会将客户端数据的ACK和要发送的我还行数据一起发送给客户端,这就是捎带应答机制。

TCP面向字节流

提供字节流服务

  一个TCP套接字在被创建后,同时在内核中创建一个发送缓冲区和一个接收缓冲区,既可以读数据也可以写数据,这样的模式被称为全双工。在调用send发送数据后会将数据先读取到缓冲区中,操作系统会将数据积累到合适的长度在合适的时机进行发送。在接收数据时也是从接收缓冲区中读取数据。正因为缓冲区的存在,使得TCP的数据传输十分自由,写一个数据可以一次写完也可以分段多次写,读也一样,正因为字节流这样灵活的服务导致了接下来的问题——粘包问题。

TCP粘包问题

  首先要明确一点UDP不会粘包,因为在UDP头信息中就已经存储了数据大小信息,根据这个信息可以分辨出这个包的边界,因此不会出现取数据时取出下一个报文的数据的情况。但是由于TCP面向字节流传输灵活因此无法明确两个包之间的边界。
  如何解决呢?明确两个包之间的边界。
  要想解决粘包问题我们只能通过在应用层对数据的处理来明确数据边界了。有以下几种做法。
  1、如果数据是定长的,例如传输的是结构或者类这样的块状结构,只需要每次都保证读取定长的数据即可。
  2、如果是边长的数据,可以约定在数据头部添加一个字段,表示这个包数据的长度,获取这个包的话只读取这么长的数据即可。
  3、也可以在不影响的数据下在数据末尾添加分割符,标识这个包数据的结束。
  以上这些方法都可以区分包与包之间数据的边界,从而解决粘包问题。

TCP总结

  以上讲了TCP这么多的机制,我们可以将其分类总结,TCP之所以复杂的原因是它为了保证可靠性但又尽可能的在提升效率。

可靠性

  1、校验和。
  2、序列号。
  3、确认应答机制。
  4、超时重传机制。
  5、流量控制。
  6、拥塞控制。
  7、连接管理。

提高性能

  1、快速重传。
  2、滑动窗口。
  3、延迟应答机制。
  4、捎带应答机制。

TCP应用

  基于TCP的应用层协议:HTTP/HTTPS/SSH/FTP/SMTP。

TCP与UDP的对比

  通过对TCP与UDP的介绍,我们现在已经很清楚TCP与UDP各自的特点及各自的优势。
  对于TCP协议,适用于要求安全性高,传输可靠的情况下,例如文件传输,重要状态更新等情况。
  对于UDP协议,适用于实时性高,速度要求快的情况下,例如视频传输,通信领域等。

如何用UDP实现可靠传输

  UDP本身是不可靠的,可能会有大量丢包的情况发生,因此我们想要UDP实现可靠传输可以参考TCP在应用层实现TCP的一些可靠机制,例如在应用层利用序号确定包序,引入确认应答和超时重传保证不丢包等。

【网络】第三章-应用层协议

发表于 2019-09-21 | 分类于 网络
字数统计: 5.1k

应用层协议

  在这个章节中将会进一步详细讨论应用层协议及其知名协议HTTP协议。

协议

  应用层负责程序之间的数据沟通,其中协议大概分为两类,自定制协议和知名协议。

自定制协议

  自定制协议就是程序员自己定义的协议,用来对应用程序发送的数据进行整理或者加密,对端只有了解这种协议才能对数据进行解析。
  这里利用自定制简单实现一个网络版计算器。客户端将两个数字和一个运算符传输给服务端,服务端对接收到的信息进行解析,得到数字和运算符运算出结果后将结果返回给客户端。
  在开始之前我们要先自定制一个协议方便我们的客户端与服务端之间进行数据通信。
  我们可以将一个表达式解析成如下形式再发送给服务端:1 + 1 -> 1; 2; +。这样的协议形式我们只需要让服务端对数据进行相应切割就可以找到两个操作数和运算符,但是解析过程略微繁琐,所以我们还有第二种方式。
  我们利用一个整形数据四个字节一个运算符一个字节的特点,一共分配九个字节大小的内存,约定前八个字节存放两个操作数,最后一个字节存放运算符,一起发送到服务端服务端再对发送的数据分块解析也可以得到数据。
  第一种方式易于实现,只用发送一个字符串即可,第二种方式可以用内存拷贝的方式实现,不过最方便的还是利用一个结构体。

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
tcp_socket.hpp
/**
* 封装一个tcpsocket类,向外提供简单接口能够实现客户端服务端编程流程
* 1、创建套接字
* 2、绑定地址信息
* 3、开始监听/发起连接请求
* 4、获取已完成连接
* 5、发送数据
* 6、接收数据
* 7、关闭套接字
**/

#include <iostream>
#include <string>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#define CHECK_RET(q) if(q == false) {return -1;}
struct calc_t
{
int num1;
int num2;
char op;
};
class TcpSocket
{
public:
TcpSocket()
{

}
~TcpSocket()
{
Close();
}
//创建套接字
bool Socket()
{
//这里首先创建的时皮条套接字
_sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if(_sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return false;
}
return true;
}
//绑定地址信息
bool Bind(const std::string& ip, uint16_t port)
{
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(&ip[0]);
socklen_t len = sizeof(struct sockaddr_in);
int ret = bind(_sockfd, (struct sockaddr*)&addr, len);
if(ret < 0)
{
std::cerr << "bind error" << std::endl;
return false;
}
return true;
}
//服务端开始监听
bool Listen(int backlog = 5)
{
int ret = listen(_sockfd, backlog);
if(ret < 0)
{
std::cerr << "listen error" << std::endl;
return false;
}
return true;
}
//连接服务端
bool Connect(const std::string& ip, uint16_t port)
{
int ret;
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(&ip[0]);
socklen_t len = sizeof(struct sockaddr_in);
ret = connect(_sockfd, (struct sockaddr*)&addr, len);
if(ret < 0)
{
std::cerr << "connet error" << std::endl;
return false;
}
return true;
}
//设置套接字
void SetFd(int fd)
{
_sockfd = fd;
}
//获取新的套接字
bool Accept(TcpSocket& newsock)
{
struct sockaddr_in addr;
socklen_t len = sizeof(struct sockaddr_in);
//这里fd是皮条套接字新创建出来的连接套接字
int fd = accept(_sockfd, (struct sockaddr*)&addr, &len);
if(fd < 0)
{
std::cerr << "accept error" << std::endl;
return false;
}
//newsock._sockfd = fd;
newsock.SetFd(fd);
return true;
}
//发送数据
bool Send(void* buf, int len)
{

int ret = send(_sockfd, buf, len, 0);
if(ret < 0)
{
std::cerr << "send error" << std::endl;
return false;
}
return true;
}
bool Send(const std::string& buf)
{
int ret = send(_sockfd, &buf[0], buf.size(), 0);
if(ret < 0)
{
std::cerr << "send error" << std::endl;
return false;
}
return true;
}
//接收数据
bool Recv(void* buf, int len)
{
int ret = recv(_sockfd, buf, len, 0);
if(ret < 0)
{
std::cerr << "recv error" << std::endl;
return false;
}
else if(ret == 0)
{
std::cerr << "peer shutdown" << std::endl;
return false;
}
return true;
}
bool Recv(std::string& buf)
{
char tmp[4096] = {0};
int ret = recv(_sockfd, &tmp[0], 4096, 0);
if(ret < 0)
{
std::cerr << "recv error" << std::endl;
return false;
}
else if(ret == 0)
{
std::cerr << "peer shutdown" << std::endl;
return false;
}
buf = tmp;
return true;
}
//关闭
bool Close()
{
if(_sockfd >= 0)
{
close(_sockfd);
}
}
private:
int _sockfd;
};

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
tcp_cli.cpp
#include "tcp_socket.hpp"
#include <stdlib.h>
/**
* 实现客户端
* 1、创建套接字
* 2、绑定地址信息(客户端不需要手动绑定)
* 3、向服务端发起连接请求
* 4、发送数据
* 5、接收数据
* 6、关闭套接字
**/
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cerr << "./tcp_cli srvip srvport" << std::endl;
return -1;
}
TcpSocket sock;
CHECK_RET(sock.Socket());
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
CHECK_RET(sock.Connect(ip, port));
while(1)
{
calc_t tmp;
tmp.num1 = 11;
tmp.num2 = 22;
tmp.op = '+';
sock.Send((void*)&tmp, sizeof(calc_t));
}
sock.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
38
39
40
41
42
43
44
45
46
tcp_srv.cpp
/**
* 服务端实现
* 1、创建套接字
* 2、绑定地址信息
* 3、开始监听
* 4、获取新连接
* 5、接收数据
* 6、发送数据
* 7、关闭套接字
**/
#include "tcp_socket.hpp"
#include <stdlib.h>
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cerr << "./tcp_srv 192.169.11.128 9000" << std::endl;
return -1;
}
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
TcpSocket sock;
CHECK_RET(sock.Socket());
CHECK_RET(sock.Bind(ip, port));
CHECK_RET(sock.Listen());
//这个新的套接字要放在循环外部,否则一次循环结束变量销毁会关闭套接字连接就会断开
TcpSocket newsock;
while(1)
{
bool ret = sock.Accept(newsock);
if(ret == false)
{
continue;
}
calc_t buf;
ret = newsock.Recv((void*)&buf, sizeof(calc_t));
if(ret == false)
{
std::cerr << "Recv error" << std::endl;
return -1;
}
std::cout << buf.num1 << " " << buf.num2 << " " << buf.op << std::endl;
}
sock.Close();
}

  使用:

1
2
[misaki@localhost netbase]$ ./tcp_srv 192.168.11.128 9000
11 22 +

  我们的服务端就收到了指定的数据信息。
  在对于自定制协议的使用中有两条重要概念,序列化与反序列化。
  序列化:将数据对象按照指定协议在内存中进行排布成为可持久化存储。
  反序列化:将数据传按照指定的协议进行解析得到各个数据对象。
  对于序列化与反序列化有几种常用工具。json序列化,protobuf序列化,二进制序列化。

知名协议(HTTP协议)

  应用层知名协议有很多不过最常用的就是HTTP协议。在HTTP协议中包含一个重要信息统一资源定位符(URL),URL中又包含哪些信息呢?

URL

  其实URL就是我们常说的网址。
  在URL中有登录信息,服务器地址端口号,文件地址信息,查询字符串和片段标识符。
  这里给出一篇博客对URL的组成形式进行了讲解。

  https://www.jianshu.com/p/406d19dfabd3

  在URL中要注意的是文件地址信息以?结束往后的是查询字符串及片段标识符这两个信息。
  查询字符串是客户端提交给服务器的数据。查询字符串是一个个key=val的键值对,并且以&作为间隔,以#作为结尾。并且提交的数据中不能出现特殊字符,因为会和URL中的分隔符造成二义,造成URL解析失败,因此若提交的数据中有特殊字符就必须进行转义。在URL中如果字符前出现了%则认为这个字符经过了URL的转码。URL转码规则为将需要转码的字符转为16进制,然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%,编码成%XY 格式。片段标识符是对资源的补充,可以理解为书签。

HTTP协议格式

  HTTP协议分为以下几个大的组成部分。

1、首行

  首行又分为请求首行和响应首行。
  请求首行格式:请求方法 URL 协议版本\r\n。
  请求方法有很多,其中常用的有GET/POST方法。GET方法多用于向服务器请求资源,不过也可以提交数据,只不过提交的数据在查询字符串中以明文方式传输,十分不安全并且长度有限。POST方法是专门用于向服务器提交表单数据的,提交的数据在正文中。除此之外还有很多种请求方法,HEAD/PUT/DELETE/CONNECT/OPTIONS/TRACE/PATCH,这里简单介绍几个。
  HEAD请求方法和GET一样是向服务器请求资源,不过HEAD请求只会接收服务器的回复中的首行及头部信息,而不要正文信息。
  PUT请求会用正文信息替代服务端指定文件中的数据。
  DELETE是删除服务器指定文件。
  URL已经介绍过。关于协议版本目前常见的有http/0.9 http/1.0 http/1.1 http/2。0.9版本是HTTP最早期的协议,只有一个GET方法,用于向服务器获取数据展示HTML文件。并且0.9版本的连接是短连接,客户端向服务端建立连接发送请求,服务端应答完毕就会关闭套接字。与之对应的是长连接,即一轮信息交互后不会关闭客户端,可以继续通信,这样就不用频繁建立套接字,这个长连接在1.0版本中得以实现,但是这个长连接还是一来一回的连接方式,并且在1.0版本中默认关闭长连接,需要在头部信息中开启,在这个版本中还加入了POST和HEAD方法。之后便是1.1版本,并且是现在最常用的版本,这个版本中长连接默认开启,并且使用了管线化连接方式,即客户端可以连续发送多个请求,服务端一一进行回复,更加灵活方便,在这个版本中还将请求方法增加到现在的9个方法。
  响应首行格式:协议版本 响应状态码 响应状态码描述\r\n
  协议版本与请求首行中的协议版本是一样的。这里着重介绍响应状态码。
  响应状态码是服务端对请求处理结果的表述,一共分为五大类:

1
2
3
4
5
6
7
1xx:表示描述性信息
2xx:处理正确响应,如200表示请求已经正确处理完毕
3xx:表示重定向,如301永久重定向,302临时重定向。
永久重定向指下次如果还请求此路径则直接访问重定向之后的网址,不再请求原网址
临时重定向表示知识临时重定向到新网址,下次访问还是先访问原网址
4xx:客户端错误,如404请求资源未找到,400请求格式有误
5xx:服务端错误,如502网关错误,500服务器内部错误。

  响应状态码描述就是对响应状态码响应的文字描述信息。

2、头部

  头部是以一个一个键值对的形式存在的,格式key: value,标识着连接的属性和一些信息。一对键值对独占有一行,以\r\n作为结尾。这里简单介绍几种头信息,但其实头信息非常非常的多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//请求头部:
Connection:开启/关闭长连接
Content-Length:正文长度
Content-Type:正文数据类型,有很多类型,包括图片,HTML超文本等
User-Agent:浏览器版本信息及系统版本信息
Accept:告诉服务端自己可以接收哪些数据
Accept-Encoding:能接收的数据编码
Cookie:向服务端发送当前用户的Cookie信息即sessionid
//响应头部:
Server:服务端版本信息
Date:事件
Content-Type:正文数据类型
Transfer-Encoding:传输格式,如果为chunked表示为分块传输,此时每个分块的大小写在正文第一行,最后一个分块大小为0
Location:重定向后的新位置
Set-Coolie:向客户端发送属于当前用户的Cookie信息即sessionid,还有一些其他信息,如Cookie超时失效日期
Referer:从哪个页面跳转到当前页面

  在请求头部和响应头部中都有关于Cookie的属性信息,那么什么是Cookie?这里举个例子,假如说我们在网上购物,此时我们看中一件商品要将其加入购物车,因此为了区分用户需要进行登录,那么本次请求就是加入购物车请求我们需要登录一次,然后请求成功了,随后我们想要购买购物车中的商品,但是HTTP协议是无状态协议,本次请求与上次请求无任何关系,于是为了购买我们不得不再登陆一次,才能确认我们用户的身份,我们每次进行用户操作都要进行登录,十分麻烦。
  之后大佬们想要让HTTP协议能够进行状态维持,于是加入了Cookie帮助我们临时保存一些验证信息,于是情况就改变了。在我们第一次登录完毕后,服务端会在服务器内为客户端建立一个会话,生成一个会话id(sessionid),并将会话id和用户信息保存起来,此时服务端会给客户端一个响应,响应信息的头部中就会有Set-Cookie信息,其中存储着sessionid和一些其他相关信息例如超时失效信息。客户端在收到服务端的sessionid信息后会将信息保存在浏览器自己的Cookie文件中,在下次再向服务器发信息时会先将与这个服务器对应的Cookie文件中的信息全部读出放入请求信息中,这个信息就存放在请求头部的Cookie中,其中主要就是sessionid。在服务端获取sessionid后就能通过这个id找到对应用户的用户信息,从而避免需要重复登录的情况发生。
  这里还要关注一个问题Cookie与session的区别是什么?Cookie是保存在客户端的,每次发起请求时会发送给服务端去寻找属于当前用户的session;而session是保存在服务端的,是为每个用户创建的一个会话,其中保存着对应的sessionid和用户信息。

3、正文

  在头部与正文之间用一个空行作为间隔,\r\n\r\n即表示一个空行,当遇到两个连续的\r\n时则认为头部结束了。正文则是一些数据信息。

实现HTTP协议服务器

  不管什么信息发送至服务端,服务端同一回复学习是一种态度。
  HTTP服务器实际上是一个TCP服务器,接收到数据后打印出接收的数据,然后统一按照HTTP协议格式回复信息即可。

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
/**
* 实现一个简单的http服务器
* 这个代码用到了我们之前封装额tcp头文件
**/
#include "tcp_socket.hpp"
#include <iostream>
#include <sstream>
int main()
{
TcpSocket sock;
CHECK_RET(sock.Socket());
CHECK_RET(sock.Bind("0.0.0.0", 9000));
CHECK_RET(sock.Listen());
while(1)
{
TcpSocket cliSock;
if(sock.Accept(cliSock) == false)
{
continue;
}
std::string buf;
cliSock.Recv(buf);
std::cout << "req:[" << buf << "]" << std::endl;;
std::string body = "<html><body><h1>学习是一种态度</h1></body></html>";
body += "<meta http-equiv='content-type' content='text/html;charset=utf-8'>";
std::string first = "HTTP/1.1 200 OK";
std::stringstream ss;
ss << "Content-Length: " << body.size() << "\r\n";
ss << "Content-Type: " << "text/html" << "\r\n";
std::string head = ss.str();
std::string blank = "\r\n";
cliSock.Send(first);
cliSock.Send(head);
cliSock.Send(blank);
cliSock.Send(body);
cliSock.Close();
}
sock.Close();
}

  我们用浏览器访问我们的服务器,服务端会打出以下数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[misaki@localhost httpserver]$ ./httpserver 
req:[GET / HTTP/1.1
Host: 192.168.11.128:9000
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
Accept-Encoding: gzip, deflate
Accept-Language: zh,zh-TW;q=0.9,en-US;q=0.8,en;q=0.7

]
req:[GET /favicon.ico HTTP/1.1
Host: 192.168.11.128:9000
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36
Accept: image/webp,image/apng,image/*,*/*;q=0.8
Referer: http://192.168.11.128:9000/
Accept-Encoding: gzip, deflate
Accept-Language: zh,zh-TW;q=0.9,en-US;q=0.8,en;q=0.7

  由于我们打印了客户端发来的数据所以我们可以看到很多http请求数据。浏览器也会显示以下页面。

http

  之后我们更改一下代码,用一下重定向。

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
/**                                                                                   
* 实现一个简单的http服务器
**/
#include "tcp_socket.hpp"
#include <iostream>
#include <sstream>
int main()
{
TcpSocket sock;
CHECK_RET(sock.Socket());
CHECK_RET(sock.Bind("0.0.0.0", 9000));
CHECK_RET(sock.Listen());
while(1)
{
TcpSocket cliSock;
if(sock.Accept(cliSock) == false)
{
continue;
}
std::string buf;
cliSock.Recv(buf);
std::cout << "req:[" << buf << "]" << std::endl;;
std::string body = "<html><body><h1>学习是一种态度</h1></body></html>";
body += "<meta http-equiv='content-type' content='text/html;charset=utf-8'>";
//std::string first = "HTTP/1.1 200 OK";
//这类改为重定向
std::string first = "HTTP/1.1 302 OK";
std::stringstream ss;
ss << "Content-Length: " << body.size() << "\r\n";
ss << "Content-Type: " << "text/html" << "\r\n";
ss << "Location: http://www.taobao.com/\r\n";
std::string head = ss.str();
std::string blank = "\r\n";
cliSock.Send(first);
cliSock.Send(head);
cliSock.Send(blank);
cliSock.Send(body);
cliSock.Close();
}
sock.Close();
}

  再次使用浏览器访问,则会跳转到淘宝页面。

http

  之后我们再次更改代码,这次使用404状态码。

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
/**
* 实现一个简单的http服务器
**/
#include "tcp_socket.hpp"
#include <iostream>
#include <sstream>
int main()
{
TcpSocket sock;
CHECK_RET(sock.Socket());
CHECK_RET(sock.Bind("0.0.0.0", 9000));
CHECK_RET(sock.Listen());
while(1)
{
TcpSocket cliSock;
if(sock.Accept(cliSock) == false)
{
continue;
}
std::string buf;
cliSock.Recv(buf);
std::cout << "req:[" << buf << "]" << std::endl;;
std::string body = "<html><body><h1>学习是一种态度</h1></body></html>";
body += "<meta http-equiv='content-type' content='text/html;charset=utf-8'>";
//std::string first = "HTTP/1.1 200 OK";
//这类改为重定向
//std::string first = "HTTP/1.1 302 OK";
//这次改为客户端错误
std::string first = "HTTP/1.1 404 OK";
std::stringstream ss;
ss << "Content-Length: " << body.size() << "\r\n";
ss << "Content-Type: " << "text/html" << "\r\n";
ss << "Location: http://www.taobao.com/\r\n";
std::string head = ss.str();
std::string blank = "\r\n";
cliSock.Send(first);
cliSock.Send(head);
cliSock.Send(blank);
cliSock.Send(body);
cliSock.Close();
}
sock.Close();
}

  还是使用浏览器访问显示以下画面。
http

  我们的返回状态码是404为什么也可以显示页面?浏览器默认错误页面是可以自定制的,我们这里返回的相当于是一个错误页面,于是浏览器也帮我们显示了出来。
  其他状态码这里就不一一演示了。

【网络】第二章-套接字编程

发表于 2019-09-16 | 分类于 网络
字数统计: 6.3k

套接字编程

  套接字编程也叫Socket编程。这个章节将总结和归纳Linux操作系统下如何利用系统接口进行网络编程。

网络字节序

  之前有讲过字节序这个概念,不同的主机往往有着不同的数据存储协议,分为大端以及小端两种,但既然是网络通信,不同主机之间就必须要统一规定一个字节序来规定数据传输方式,这个就被称为网络字节序。好在系统中有一些系统接口ntohs和htons来帮助我们完成网络字节序和本机字节序之间的相互转换。

传输层协议

  网络通信是两端通信,客户端与服务端。主动发起请求的是客户端,被动接受请求的一段是服务端。永远是客户端先向服务端发送数据。通信中数据需要经过层层封装,每一层都有典型协议,但是传输层有两个协议,TCP/UDP协议。

协议特点

  TCP协议特点:传输控制协议,面向连接,可靠传输,提供字节流传输服务。
  UFP协议特点:用户数据报协议,无连接,不可靠,面向数据报。
  TCP为了保证可靠传输牺牲了性能,因此适用于文件/压缩包/程序的传输;UDP速度快但是不够安全可靠,因此多应用于视频在线观看的传输。

UDP网络通信编程

流程

  1、创建套接字,是进程与网卡直接建立关联。在内核中会创建一个socket结构体。在这个结构体中会包含很多与网络通信有关的信息。
  2、为套接字绑定地址信息(ip/port)。为了告诉操作系统哪些数据应该由这个进程处理。在操作系统内核中每一个套接字都会有一块缓冲区,上面存放着这个套接字绑定的地址信息所属的进程应该接收的数据。网络通信过程中操作系统会把本机上所有进程需要接收数据统一放进套接字缓冲区后再发送给绑定的进程,同样的发送数据也是一样的原理,因此才需要跟操作系统内核中的套接字绑定地址信息来认领属于自己的那一块缓冲区。
  3、客户端首先向服务端发送数据。服务端指定对端的地址,这时候socket就会将数据从绑定的地址发送出去。通常服务端必须固定一个地址信息,不能随意改变,保证客户端能够连上固定的服务器。但是客户端的地址可以随意,因为数据先由客户端发送,发送给服务端,服务端就能获知客户端的地址。
  4、服务端接收数据。客户端发送的数据道道服务端主机后,服务端操作系统根据这个数据的地址信息决定将这个数据放到哪一个套接字的缓冲区中。服务端通过创建套接字返回的描述符,在内核中找到套接字结构体,进而从缓冲区中取出数据。
  5、关闭套接字,释放内核中套接字占用的资源。

接口

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
//创建套接字
int socket(int domain, int type, int protocol);
//domain:地址域,一般用AF_INET表示IPV4
//type:SOCK_STREAM-流式套接字,tcp可用;SOCK_DGRAM-数据报套接字,udp可用
//protocol:0-套接字类型默认协议;IPPROTO_TCP -6;IPPROTO_TCP -17
//返回值:套接字文件描述符,操作句柄
//绑定地址信息
int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
//这其中sockaddr是一个结构体,其中存放着地址信息,但是在操作系统中有很多不同的地址信息结构体,ipv4和ipv6都不同,但是为了方便使用只有这一个接口,
//于是为了方便使用单独设计了一个地址信息结构体就是这个sockaddr,如果使用其他地址信息结构体可以用类型强转转过来再传入
//addrlen就是这个结构体的大小,常用的结构体由sockaddr_in供ipv4使用
//返回值:成功返回0,失败返回-1
//发送数据
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
//sockfd:套接字描述符
//buf:发送数据缓冲区指针
//len:发送数据长度
//flags:标志位,默认为0,缓冲区无数据则阻塞
//dest_addr:目的地址信息
//addrlen:目的地址信息结构体大小
//返回值:实际发送的数据长度,出错返回-1
//接收数据
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);
//sockfd:套接字文件描述符
//buf:接受数据的缓冲区指针
//len:接受数据长度
//flags:标志位,默认为0,缓冲区无数据则阻塞
//src_addr:对端地址信息结构体
//addrlen:输入输出型参数,要接收的对端地址信息结构体的大小,如果超过对端结构体大小会自动改为实际接收的大小
//返回值:实际接收的数据长度,出错返回-1。
//关闭套接字
int close(int fd);
//fd:套接字描述符

实现

  这里首先完全使用C来完成服务端的功能,虽然没有经过封装流程过于复杂但是可以帮助我们更好的理解udp通讯的流程。

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
/**                                                           
* 传输层基于UDP协议的服务端程序
* 1、创建套接字
* 2、为套接字绑定地址信息
* 3、接收数据
* 4、发送数据
* 5、关闭套接字
**/
#include <stdio.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main(int argc, char* argv[])
{
if(argc != 3)
{
printf("Usage: ./main 192.168.122.132 9000\n");
return -1;
}
int sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if(sockfd < 0)
{
perror("socket error\n");
return -1;
}
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(atoi(argv[2]));
//inet_addr将点分十进制ip地址转换为网络字节序ip地址
addr.sin_addr.s_addr = inet_addr(argv[1]);
socklen_t len = sizeof(struct sockaddr_in);
int ret = bind(sockfd, (struct sockaddr*)&addr, len);
if(ret < 0)
{
perror("bind error");
return -1;
}
while(1)
{
char buf[1024] = {0};
struct sockaddr_in cliaddr;
socklen_t len = sizeof(struct sockaddr_in);
int ret = recvfrom(sockfd, buf, 1023, 0, (struct sockaddr*)&cliaddr, &len);
if(ret < 0)
{
perror("recvfrom error");
close(sockfd);
return -1;
}
printf("client say: %s\n", buf);
memset(buf, 0, 1024);
scanf("%s", buf);
len = sizeof(struct sockaddr_in);
ret = sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr*)&cliaddr, len);
if(ret < 0)
{
perror("sendto error");
close(sockfd);
return -1;
}
}
close(sockfd);
}

  接下来使用Cpp对udp通讯再进行一次封装,这样可以方便我们之后的使用,使用也会更有模块化,之后用其实现客户端,这里可以选择把客户端地址信息写死,或者不绑定系统自己分配都可以。

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
/**                                                                
* 封装实现一个udpsocjet类,向外提供更加容易使用的udp接口
**/
#include <iostream>
#include <string>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdlib.h>
#define CHECK_RET(q) if((q) == false){return -1;}
using std::string;
class UdpSocket
{
public:
UdpSocket()
:_sockfd(-1)
{

}
~UdpSocket()
{
Close();
}
//创建套接字
bool Socket()
{
_sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if(_sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return false;
}
return true;
}
//绑定本机ip地址及端口信息
bool Bind(const string& ip, uint16_t port)
{
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(ip.c_str());
socklen_t len = sizeof(struct sockaddr_in);
int ret = bind(_sockfd, (struct sockaddr*)&addr, len);
if(ret < 0)
{
std::cerr << "bind error" << std::endl;
return false;
}
return true;
}
//接收数据并接收对端的ip地址及端口信息
bool Recv(string& buf, string& ip, uint16_t& port)
{
char tmp[4096];
struct sockaddr_in peeraddr;
socklen_t len = sizeof(peeraddr);
int ret = recvfrom(_sockfd, tmp, 4096, 0, (struct sockaddr*)&peeraddr, &len);
if(ret < 0)
{
std::cerr << "recvfrom error" << std::endl;
return false;
}
buf.assign(tmp, ret);
port = ntohs(peeraddr.sin_port);
ip = inet_ntoa(peeraddr.sin_addr);
return true;
}
//发送数据
bool Send(const string& data, const string& ip, const uint16_t& port)
{
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(ip.c_str());
socklen_t len = sizeof(struct sockaddr_in);
int ret = sendto(_sockfd, &data[0], data.size(), 0, (struct sockaddr*)&addr, len);
if(ret < 0)
{
std::cerr << "send error" << std::endl;
return false;
}
return true;
}
bool Close()
{
if(_sockfd >= 0)
{
close(_sockfd);
_sockfd = -1;
}
return true;
}
private:
int _sockfd;
};
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cerr << "./udp_cli serverip serverport" << std::endl;
return -1;
}
UdpSocket sock;
CHECK_RET(sock.Socket());
//CHECK_RET(sock.Bind("192.168.11.128", 8000));
while(1)
{
string buf;
std::cin >> buf;
CHECK_RET(sock.Send(buf, argv[1], atoi(argv[2])));
buf.clear();
string ip;
uint16_t port;
CHECK_RET(sock.Recv(buf, ip, port));
std::cout << "server say: " << buf << std::endl;
}
}

  然后我们把他们都跑起来完成通讯。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(客户端发送数据)
[misaki@localhost Net]$ ./client 192.168.11.128 9000
nihao
(服务端接收数据)
[misaki@localhost Net]$ ./server 192.168.11.128 9000
client say: nihao
(服务端回复)
[misaki@localhost Net]$ ./server 192.168.11.128 9000
client say: nihao
nihao
(客户端收到回复)
[misaki@localhost Net]$ ./client 192.168.11.128 9000
nihao
server say: nihao

TCP网络通信编程

流程

  TCP建立连接比UDP更为复杂一些,因为为了保证安全必须点对点一对一进行建立连接,于是TCP建立连接就产生了所谓的三次握手建立连接。
  关于三次握手建立连接的解读这里给上一篇文章,这篇文章解读清晰易懂,有理有据,提供参考。
https://baijiahao.baidu.com/s?id=1614404084382122793&wfr=spider&for=pc

  大概来说只有三次握手才能避免丢包延迟等情况造成的连接无效,才能完全确认连接已经建立。为了安全建立连接这是udp所没有的。
  站在应用层面,我们服务端为了接收客户端的连接请求,需要有以下步骤:
  1、建立套接字。
  2、绑定地址信息。
  3、服务端开始监听。
  4、关闭每一个连接套接字,以及皮条套接字。
  但是这里要注意tcp的套接字与udp不同一个套接字只能与一台客户端建立连接,而不是一个套接字即可接收所有发往本机的所有数据。一但一个套接字与一个主机已经建立连接,它的状态就会改变为已建立连接状态将无法再监听其他客户端的连接请求。那么此时其他客户端想要通讯我们的套接字还在与上一个主机通信呢其他客户端就都会无法连接到服务端,这该如何处理呢?
  这里tcp在处理时利用了一种特殊的机制,这种机制十分类似于拉皮条。没错就是拉皮条,我们最开始建立的套接字只是一个监听套接字,这个套接字就是拉皮条的,我们称之为皮条套接字,开始监听后,只要有客户端想要与这个服务端建立连接,我们的皮条套接字就会自己创建一个新的套接字与客户端进行绑定,并将新产生的套接字返回给我们,我们就可以利用这个新的套接字与指定的客户端进行通信。意思是说我们的皮条套接字并不实质与服务端通信,它只负责创建新的套接字为客户端提供一对一服务。
  当我们的服务端开始监听后,我们皮条套接字就开始工作了,表示客户端此时可以进来进行通信了,并且还是一对一服务哦,但是建立连接也是需要时间的,三次握手嘛,并且一对一通信也需要时间,如果此时客户端不断向服务端发送连接请求,每一个请求都会创建新的套接字,这会消耗大量资源,在高峰期可能资源就会耗尽。为了防止这样的情况发生,tcp在监听时会创建一个队列,我们称之为未完成连接队列,我们的皮条套接字会为这个队列依次创建套接字进行连接,如果这个队列满了,客户端此时就不能再连接服务端了,皮条套接字也不用再创建新的套接字了,着手完成眼下的套接字连接以及通信,以此来控制资源。至于这个队列有多大,我们可以在监听时来指定这个队列的大小。
  4、获取新创建的套接字描述符进行通信。在创建连接后,我们得从皮条套接字那里获取新的建立连接的套接字才能进行通信。
  对于客户端来说,为了和服务端建立连接,也要有以下这些步骤:
  1、创建套接字。
  2、绑定地址信息。
  3、向服务端发起三次握手建立连接,这里需要给入服务端的地址信息。
  4、接收发送数据。这里的接收和发送数据不需要再想udp呢样每次都必须给如详细的对端地址信息了,这里已经有套接字建立了稳定连接,只需要传入指定的套接字描述符即可。
  5、关闭套接字。
  从流程可以看出,udp和tcp在建立套接字和绑定地址信息上没有太大区别,区别主要在tcp在进行数据传输前要先建立一次连接,连接建立完成后使用套接字描述符即可进行数据传输。

接口

  创建套接字与绑定地址信息的接口以及关闭套接字都与udp一致。

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
//服务端监听
int listen(int sockfd, int backlog);
//sockfd:皮条套接字
//backlog:未完成连接队列大小,并发连接数
//返回值:成功返回0,失败返回-1
//客户端连接服务端
int connect(int sockfd, sockaddr* srvaddr, socklen_t addrlen);
//sockfd:服务端套接字描述符
//srvaddr:服务端地址信息
//addrlen:服务端地址信息大小
//返回值:成功返回0,失败返回-1
//服务端接收客户端连接
int accept(int sockfd, sockaddr* srcaddr, socklen_t* len);
//sockfd:皮条套接字描述符
//srcaddr:客户端地址信息
//len:客户端地址信息长度
//返回值:返回皮条套接字新建立的套接字描述符,失败返回-1
//接收数据
int recv(int sockfd, void* buf, int len, int flags);
//sockfd:套接字描述符
//buf:接收缓冲区
//len:接收数据长度
//flags:默认0阻塞接收
//返回值:实际接收字节数,失败返回-1, 连接断开返回0
//发送数据
int send(int sockfd, void* buf, int len, int flags);
//sockfd:套接字描述符
//buf:发送缓冲区
//len:发送数据长度
//flags:默认0阻塞发送
//返回值:实际发送的字节数,失败返回-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
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
/**                                                                        
* 封装一个tcpsocket类,向外提供简单接口能够实现客户端服务端编程流程
* 1、创建套接字
* 2、绑定地址信息
* 3、开始监听/发起连接请求
* 4、获取已完成连接
* 5、发送数据
* 6、接收数据
* 7、关闭套接字
**/

#include <iostream>
#include <string>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#define CHECK_RET(q) if(q == false) {return -1;}
class TcpSocket
{
public:
TcpSocket()
{

}
~TcpSocket()
{
Close();
}
//创建套接字
bool Socket()
{
//这里首先创建的时皮条套接字
_sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if(_sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return false;
}
return true;
}
//绑定地址信息
bool Bind(const std::string& ip, uint16_t port)
{
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(&ip[0]);
socklen_t len = sizeof(struct sockaddr_in);
int ret = bind(_sockfd, (struct sockaddr*)&addr, len);
if(ret < 0)
{
std::cerr << "bind error" << std::endl;
return false;
}
return true;
}
//服务端开始监听
bool Listen(int backlog = 5)
{
int ret = listen(_sockfd, backlog);
if(ret < 0)
{
std::cerr << "listen error" << std::endl;
return false;
}
return true;
}
//连接服务端
bool Connect(const std::string& ip, uint16_t port)
{
int ret;
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(&ip[0]);
socklen_t len = sizeof(struct sockaddr_in);
ret = connect(_sockfd, (struct sockaddr*)&addr, len);
if(ret < 0)
{
std::cerr << "connet error" << std::endl;
return false;
}
return true;
}
//设置套接字
void SetFd(int fd)
{
_sockfd = fd;
}
//获取新的套接字
bool Accept(TcpSocket& newsock)
{
struct sockaddr_in addr;
socklen_t len = sizeof(struct sockaddr_in);
//这里fd是皮条套接字新创建出来的连接套接字
int fd = accept(_sockfd, (struct sockaddr*)&addr, &len);
if(fd < 0)
{
std::cerr << "accept error" << std::endl;
return false;
}
//newsock._sockfd = fd;
newsock.SetFd(fd);
return true;
}
//发送数据
bool Send(const std::string& buf)
{
int ret = send(_sockfd, &buf[0], buf.size(), 0);
if(ret < 0)
{
std::cerr << "send error" << std::endl;
return false;
}
return true;
}
//接收数据
bool Recv(std::string& buf)
{
char tmp[4096] = {0};
int ret = recv(_sockfd, &tmp[0], 4096, 0);
if(ret < 0)
{
std::cerr << "recv error" << std::endl;
return false;
}
else if(ret == 0)
{
std::cerr << "peer shutdown" << std::endl;
return false;
}
buf = tmp;
return true;
}
//关闭
bool Close()
{
if(_sockfd >= 0)
{
close(_sockfd);
}
}
private:
int _sockfd;
};

  这里是对tcp的接口进行一次封装,接下来实现客户端与服务端。
  客户端:

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
#include "tcp_socket.hpp"
#include <stdlib.h>
/**
* 实现客户端
* 1、创建套接字
* 2、绑定地址信息(客户端不需要手动绑定)
* 3、向服务端发起连接请求
* 4、发送数据
* 5、接收数据
* 6、关闭套接字
**/
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cerr << "./tcp_cli srvip srvport" << std::endl;
return -1;
}
TcpSocket sock;
CHECK_RET(sock.Socket());
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
CHECK_RET(sock.Connect(ip, port));
while(1)
{
std::string buf;
std::cin >> buf;
bool ret = sock.Send(buf);
if(ret == false)
{
sock.Close();
return -1;
}
buf.clear();
ret = sock.Recv(buf);
if(ret == false)
{
sock.Close();
return -1;
}
std::cout << "server say: " << buf << std::endl;
}
sock.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
38
39
40
41
42
43
44
45
46
47
48
49
/**
* 服务端实现
* 1、创建套接字
* 2、绑定地址信息
* 3、开始监听
* 4、获取新连接
* 5、接收数据
* 6、发送数据
* 7、关闭套接字
**/
#include "tcp_socket.hpp"
#include <stdlib.h>
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cerr << "./tcp_srv 192.169.11.128 9000" << std::endl;
return -1;
}
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
TcpSocket sock;
CHECK_RET(sock.Socket());
CHECK_RET(sock.Bind(ip, port));
CHECK_RET(sock.Listen());
//这个新的套接字要放在循环外部,否则一次循环结束变量销毁会关闭套接字连接就会断开
TcpSocket newsock;
while(1)
{
bool ret = sock.Accept(newsock);
if(ret == false)
{
continue;
}
std::string buf;
ret = newsock.Recv(buf);
if(ret == false)
{
std::cerr << "recv error" << std::endl;
newsock.Close();
continue;
}
std::cout << "client say: " << buf << std::endl;
buf.clear();
std::cin >> buf;
newsock.Send(buf);
}
sock.Close();
}

  使用:

1
2
3
4
5
6
7
8
[misaki@localhost Net]$ ./tcpserver 192.168.11.128 9000
client say: nihao
wobuhao

[misaki@localhost Net]$ ./tcpclient 192.168.11.128 9000
nihao
server say: wobuhao
haha

  但是在这里发现进行完一轮通信后,服务端无法再接收到客户端新的数据,这是因为此时监听套接字(我们还是叫的好听点)和通信套接字是在同一个进程中共同工作,此时监听套接字阻塞在了监听新的客户端,已经建立好的套接字就无法继续通信。为了解决这个问题我们必须使监听套接字和通信套接字共同同时工作,因此就牵扯到了并行的问题,这里有两种解决方案,利用多进程,或者多线程。
  客户端代码是不用改变的,此时要改的只有服务端的代码。
  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
86
87
88
89
90
/**                                                                                   
* 服务端实现
* 1、创建套接字
* 2、绑定地址信息
* 3、开始监听
* 4、获取新连接
* 5、接收数据
* 6、发送数据
* 7、关闭套接字
**/
#include "tcp_socket.hpp"
#include <stdlib.h>
#include <sys/wait.h>
#include <signal.h>
void sigcb(int signo)
{
//等待任意一个进程退出
//SIGCHLD信号是一个非可靠信号
//多个进程同时退出有可能会造成事件丢失,导致有可能有僵尸进程没有被处理
//因此在一次事件回调中,将能够处理的僵尸进程全都处理掉
while(waitpid(-1, NULL, WNOHANG) > 0);
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cerr << "./tcp_srv 192.169.11.128 9000" << std::endl;
return -1;
}
//在这里进行信号改写
signal(SIGCHLD, sigcb);
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
TcpSocket sock;
CHECK_RET(sock.Socket());
CHECK_RET(sock.Bind(ip, port));
CHECK_RET(sock.Listen());
//这个新的套接字要放在循环外部,否则一次循环结束变量销毁会关闭套接字连接就会断开
TcpSocket newsock;
//这里要并行执行,这里使用多进程
//让主进程继续获得新连接获取
//子进程负责与客户端通信
//并且这种处理方式更加稳定,子进程出现问题主进程并不会出现问题
while(1)
{
bool ret = sock.Accept(newsock);
if(ret == false)
{
continue;
}
//子进程处理与客户端的数据通信
if(fork() == 0)
{
//这里让子继承再创建一个子进程,本身直接退出,结束父进程的等待
/*
if(fork() > 0)
{
exit(0);
}
*/
//这里处理数据通信的实际上是子进程的子进程,但是子进程已经推出了
//这个孙子进程会变成孤儿进程,归init进程管理,并且退出不会变成僵尸进程
while(1)
{
std::string buf;
ret = newsock.Recv(buf);
if(ret == false)
{
std::cerr << "recv error" << std::endl;
newsock.Close();
exit(0);
}
std::cout << "client say: " << buf << std::endl;
buf.clear();
std::cin >> buf;
newsock.Send(buf);
}
newsock.Close();
exit(0);
}
//父进程直接关闭新连接的套接字
newsock.Close();
//父进程要等待子进程,防止变成僵尸进程,但是子进程如果一直不结束,父进程会造成阻塞
//因此这里有两种方式处理
//1、让子进程再创建孙子进程处理数据通信,子进程直接退出,孙子进程会变成孤儿进程不会变成僵尸进程
//wait(NULL);
//2、在子进程退出后会向父进程发送信号,信号会一直等着我们进行处理,因此我们可以通过改写这个信号来回收所有子进程
}
sock.Close();
}

  使用:

1
2
3
4
5
6
7
8
9
10
11
[misaki@localhost Net]$ ./tcpserver 192.168.11.128 9000
client say: nihao
wohenhao
client say: haode
heihei

[misaki@localhost Net]$ ./tcpclient 192.168.11.128 9000
nihao
server say: wohenhao
haode
server say: heihei

  这样就能进行多轮通信。
  2、多线程解决。

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
/**
* 服务端实现
* 1、创建套接字
* 2、绑定地址信息
* 3、开始监听
* 4、获取新连接
* 5、接收数据
* 6、发送数据
* 7、关闭套接字
**/
#include "tcp_socket.hpp"
#include <stdlib.h>
#include <pthread.h>
//线程入口函数,创建线程进行数据通信
void* thr_start(void* arg)
{
TcpSocket* newsock = (TcpSocket*)arg;
while(1)
{
std::string buf;
bool ret = newsock->Recv(buf);
if(ret == false)
{
std::cerr << "recv error" << std::endl;
newsock->Close();
return NULL;
}
std::cout << "client say: " << buf << std::endl;
buf.clear();
std::cin >> buf;
newsock->Send(buf);
}
newsock->Close();
delete newsock;
return NULL;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cerr << "./tcp_srv 192.169.11.128 9000" << std::endl;
return -1;
}
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
TcpSocket sock;
CHECK_RET(sock.Socket());
CHECK_RET(sock.Bind(ip, port));
CHECK_RET(sock.Listen());
while(1)
{
//这个要分配内存在堆区才能达到数据共享
TcpSocket* newsock = new TcpSocket();
bool ret = sock.Accept(*newsock);
if(ret == false)
{
//连接失败别忘了也要把空间销毁,以免内存泄露
delete newsock;
continue;
}
pthread_t tid;
//创建线程
pthread_create(&tid, NULL, thr_start, (void*)newsock);
pthread_detach(tid);
}
sock.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
38
39
40
41
42
43
[misaki@localhost Net]$ ./tcpserver 192.168.11.128 9000
client say: nihao
haode
client say: heihei
wowotou
client say: yikuaiqian
sige
client say: heihei
client say: nihao
heihei
client say: haha
houhu^Hou
client say: haha
heihei
peer shutdown
recv error
chuwentile
client say: dengdeng
en
client say: shia
en

[misaki@localhost Net]$ ./tcpclient 192.168.11.128 9000
nihao
server say: haode
heihei
server say: wowotou
yikuaiqian
server say: sige

heihei
server say: heihei
haha

[misaki@localhost Net]$ ./tcpclient 192.168.11.128 9000
nihao
server say: houhou
haha
dengdeng
server say: chuwentile
shia
server say: en
server say: en

  这里起了两个客户端都是可以直接进行通信的,但是要注意一点有时候线程分离线程没有及时关闭情况下我们的数据有可能还会给已经关闭了的服务端发过去。

【项目】P2P下载帮

发表于 2019-09-14 | 分类于 项目
字数统计: 1.7k

P2P下载帮

开发环境

  本项目开发完全在Centos7.2版本下使用C/Cpp进行开发,gcc版本5.3.1,用到的库有httplib及boost,工具有gcc,gdb,makefile,git。

项目介绍

功能简介

  这个下载工具可以帮助用户在局域网内搜索所有正在使用本软件的用户并且进行两台主机间的文件传输。

功能模块

  项目共分为两大功能模块,客户端与服务端。

服务端

  服务端模块主要负责利用HTTP协议将服务主机上的信息进行组织并且返回给客户端,达到响应客户端请求的作用,主要有以下功能:
  1、配对请求的确认功能。客户端发送配对请求给服务端时,服务端如果在线并且接收到了请求信息则会返回成功配对响应给客户端让客户端确认服务端此时在线可以发送后续请求。
  2、文件列表信息响应功能。客户端可以向服务端发送获取共享文件信息的请求,服务端此时则负责拉取共享文件夹中的文件名并且将信息组织反馈给客户端。这里利用了boost库中的接口将文件名组织进http协议的body中然后将数据返回给客户端。
  3、文件数据信息响应功能。客户端确认要下载的文件后向服务端发起下载请求,服务端此时要做的就是将目标文件内容读取进缓冲区中组织进http的body中响应给客户端,这里同样使用boost中的接口进行实现。但在这里设计时意识到要将文件内容全部读入内存再发送,会占用大量内存,在文件极大时将会无法应付,因此这里将接口设计成根据http头中的range信息进行分块对文件读取并且组织再响应的方式配合客户端对文件数据进行分块传输。

客户端

  客户端主要负责在前端打印信息,并且在后端响应用户操作向服务端发送请求,主要有以下功能:
  1、控制台界面,利用控制台打印选项完成与用户之间的交互。
  2、主机广播配对功能。客户端必须先向局域网广播,即向所有主机发送配对请求,获取在线主机列表,才能发送后续指令。然而HTTP协议基于TCP协议因此并没有UDP协议才有的广播功能,因此这里我模拟实现了一个广播功能,即获取网络号和最大主机号,逐个向他们发送配对请求。但是串行化发送配对请求要消耗极大的时间,因此这里选择创建若干个线程并行化进行广播配对,压缩配对时间。
  3、获取服务端文件列表功能。客户端选择在线主机,并且向该主机发送获取共享文件列表的请求,服务端响应后将共享文件列表存储以供后续使用。
  4、获取服务端文件数据功能。客户端选择目标下载文件向服务端发起下载请求,并且将文件数据分块存入本地下载文件夹中。出于同样的原因,缓冲区大小受限所以选择分块传输。但是这里为了更高的效率给出两种情况,一种是并行化下载,一种是串行化下载。当文件小于2G时,内存完全足够申请缓冲区,为了更快的速度选择并行化下载;当文件大于2G时,硬件受限所以进行串行化下载,虽然速度有所下降,但是可以减少内存占用。

项目中遇到的问题

大文件下载

  httplib库将文件所有数据读入内存中再进行响应,因此大文件受限于内存会无法进行传输,这里选择进行分块下载,并且为了提高下载效率采取小文件并行下载,大文件串行下载来解决问题。

TCP局域网广播

  TCP协议并没有提供广播功能,因此我只能模拟实现广播功能完成广播配对。这里利用系统接口获取本机所有网卡的ip地址及子网掩码,ip地址与子网掩码想与得到网络号,子网掩码取反得到最大主机号,由此获得局域网内所有主机的ip地址,并且向其逐个发送配对请求来完成广播配对。

创建多参数入口函数的线程

  在客户端利用线程分块向服务端发送获取文件数据的请求时,需要向线程入口函数传入多个参数,但是Cpp库中以及系统函数都无法传入多个参数,在晚上查阅资料、文档并且通过个人实验下发现boost库中的线程函数是可以传入多个参数的,因此选择使用boost库中的线程函数。

客户端文件写入函数

  客户端需要在线程中多次打开下载文件。我最初选择使用Cpp中的文件函数打开文件并且写入文件,但是发现每个线程打开文件都会将文件中之前的数据进行清空,根本无法分块写入。在查阅文档后决定改用系统调用函数来完成文件分块写入,在参数中只要不加入截断文件的参数即可累积写入。

项目效果预览

  使用界面。
P2P

  搜索主机(为了方便演示,这里更改只搜索了本机主机)

P2P

  选择主机。

P2P

  选择下载文件。

P2P

  下载成功。

P2P

  4G大文件下载。

P2P

P2P

P2P

  经验证,文件md5值一致。

项目扩展方向

  目前项目仅支持局域网内的文件传输,但是项目可以扩展为可以在外网进行P2P文件传输的工具,这里需要用到内网穿透技术。
  项目还可以在用户服务上进行进一步改进,比如可以计入文件预览,视频文件在线点播等功能。

项目发布

  GitHub:https://github.com/MisakiFx/P2PDownload

【网络】第一章-网络基础

发表于 2019-09-09 | 分类于 网络
字数统计: 1.8k

网络基础

  在这个章节先对计算机网络进行大概简单的介绍,之后的章节会逐个进行深入的讨论。

网络发展背景

网络的构成

  网络一开始先利用交换机进行两台计算机之间的互联及数据交换,一台主机将数据发给交换机,交换机再发送给另一台,不会起任何冲突。

交换机

  但是假如又要加入一台主机,交换机就会起矛盾,它不知道该将数据发给哪台主机。为此又出现了新的数据交换工具,路由器,路由器可以自己选择将数据发送给合适的主机。
路由器

  由此便构成了一个小型局域网。
局域网

  假如此时又有一个这样的局域网,他们之间又通过路由器进行数据交换,于是它们又构成了一个更大一些的局域网。一旦局域网变得更大,就会变成城域网,之后是广域网,由此构成网络环境。局域网城域网与广域网之间的区别由网络的覆盖范围而决定。
局域网

IP地址

  IP地址在网络中唯一的标识了一个主机,数据从哪里来要到哪里去都要用到ip地址。IP地址由一个4个字节的无符号整数组成,因此一共可以标识42亿个主机,这在计算机发明之初大家可能都认为这是一个天文数字了,肯定够用了,但在现在来看这明显不够用啊,怎么办呢?

DHCP/NAT

  伟大的人类想到两种缓兵之计,DHCP/NAT技术。DHCP技术使路由器可以动态给连接到自己的主机分配ip地址,也就是内网IP地址(这个IP地址外网是连接不到的),谁上网给谁分配,这样可以减少ip地址的使用情况。但是还是不够啊,怎么办?呢就让大家共用同一个ip地址吧,于是路由器建立映射,将内网所有主机的内网ip建立映射,收集所有数据统一使用自己的ip地址进行数据发送到外网,等收取数据时再根据映射发给内网中的主机,这样就可以极大减少IP地址的使用。但是目前来看ip地址依然十分紧张,因此现在国家也在强力发展ipv6,这是一个16个字节的整数,这个足够用了,不过困难的是ipv6不兼容ipv4因此目前发展并不是很好。

端口

  端口(port),在一台主机上唯一标识一个进程,在网络通讯中用来表示数据从主机上的哪一个进程发出,并发送给另一台主机上的哪一个进程。网络通信每一条数据中,都会包含两条信息:源端口,目的端口。
  端口的是一个uint16_t类型的,因此端口范围从0-65535。并且一个端口只能被一个进程占用,但一个进程可以使用多个端口。

网络协议

  网络通信中,为了保证字符编码,数据格式,传输方式的一致性,需要通信双方使用数据格式的约定,若要实现网络互联,就需要定理网络通信协议标准,统一网络通信数据格式。
  在网络通信中每条数据都会包含一个五元组(源ip地址/源端口/目的IP地址/目的端口/协议)。

协议分层

  按照提供的服务,协议,接口对环境进行分层,从底层来说就是一种封装,上层并不需要关心下层的一个实现,直接使用就可以,使用的更加灵活方便。协议分层之后,通信环境层次清晰,并且每一次的功能具体实现会变得简单化,更容易形成标准。
  在网络通信中环境也是十分复杂的,因此为了更加容易的去实现网络通信功能因此对整个通信环境进行分层。

OSI七层模型

  OSI七层模型(由上至下):应用层->表示层->会话层->传输层->网络层->链路层->物理层。
  但是这种模型并不实用,因为表示层,会话层和应用层功能相近分开进行实现反倒十分麻烦,因此现在并不常用这套模型,因为有更好的模型进行替代。

tcp/ip五层模型

  tcp/ip五层模型(由上至下):应用层->传输层->网络层->链路层->物理层。
  tcp/ip协议是一个协议簇,其中有着很多的协议,而tcp/ip是其中的代表协议,因此以此命名。其中还有很多常用的协议。
  五层模型每一层都有属于自己的协议,组织不同的数据形式,以下由高至低讨论。
  应用层:负责应用程序之间的数据沟通。这个层中的协议一部分是软件开发者为了组织数据的同时进行数据加密自己定制的协议,被称为自定制协议;当然还有一部分是由于协议定制的十分方便,广为流传大家都在使用,这类协议被称为知名协议,例如HTTP协议。
  传输层:负责端与端之间的数据沟通。封装端口信息,用来标识数据由哪个进程发出,目标到哪个进程中去。常见协议有TCP/UDP协议。
  网络层:负责地址管理与路由选择。负责选择让数据如何通过最合适的路径传达到目标路由器上。常见协议:IP协议,常用设备有路由器。
  链路层:相邻设备之间的数据传输。在实际中要将数据实际发送到与自己相邻的主机,一般来说是通过广电信号进行传输,但是为了能够找到目标主机,在底层网卡往往有着自己的一个地址用来标识自己,这个地址叫做MAC地址,链路层就是通过MAC地址找到实际要传输数据的网卡并将数据交给它。常见协议以太网协议Ethernt,常见设备交换机。
  物理层:负责光电信号的传输。这一侧就是物理的信号传输了。常见协议以太网协议,常见设备集线器。

网络通信数据的传输流程

交换机

  以上标识的就是数据在网络通信中传输的流程,发送数据时每一层都会在信息中加入类似结构体的东西对数据进行封装,接受数据时也会一层一层解开响应的结构体获取数据。

【Cpp】《Effective C++》第一章-让自己习惯C++

发表于 2019-08-31 | 分类于 Cpp
字数统计: 7.3k

  这是我在学习《Effective C++》中总结得出的心得与体会,完全是以我自己的理解所作的笔记,是对个人经验的积累。基于第三版我以每个章节进行总结,全书一共九个章节。

第一章-让自己习惯C++

条款01:视C++为一个语言联邦

  View C++ as a federation of languages.
  在初期Cpp被开发之初,Cpp并没有如今这样丰富的功能,它更应该被称为仅是一个带有面向对象特性的C,即C with Classes。但如今的Cpp已经在其基础纸上新增了诸多特性例如模板更好的支持了泛型编程,STL给我们提供了更多的工具,加上其完全兼容C使其既可以面向对象编程也可以面向过程编程,这就使Cpp成为了一个历史上独一无二绝无仅有的强大存在,其也可以被理解为多个语言的组合,我们这些语言称为次语言(sublanguage)。
  也正因Cpp可以被看为多个次语言的组合,使它的语法更为复杂,标准更加难以捉摸,但是当我们在使用某个次语言的时候,守则与通例都会变得更加简单易懂。然而当我们从一个次语言切换到另一个时,守则往往也会改变。
  在《Effective C++》中将Cpp分为了四种次语言,是我们可以更加直观的正视这门复杂的语言。
  4种次语言:
  1、C:Cpp是以C为基础,Cpp的编程是更为高级的C编程,其可以是为是对C的封装及升级,并且Cpp中的诸多语法都是来自于C,例如指针、数组、预处理等等。但当我们仅仅使用C这一次语言的时候的我们则要考虑守则与规范在这一次语言中的极限,因为在这里没有模板,没有异常,没有重载。因此当我们在Cpp内使用纯C进行编程时要注意让自己遵守的规范从C的角度出发,考虑更为底层更为细节的方面。
  2、Object-Oriented C++:这一部分可以看作是Cpp开发之初C with Classes所实现的部分,是带面向对象版本的C。在这一次语言中凸显了Cpp面向对象的特点例如封装、继承、多态、动态绑定等。在使用这部分的次语言时我们则要遵守面向对象在Cpp上的守则。
  3、Template C++:这是Cpp的泛型编程部分,在这一部分有着模板这一概念,而模板的书写往往是在给高级编程搭轮子。模板编程弥漫了整个Cpp,并且带来了崭新的编程范型模板元编程(template meteprogramming/TMP)。唯独模板才适用的规范也并不在少数,但是这些与Cpp主流编程并不冲突。
  4、STL:STL是Cpp中标准的模板库,也是最常用最常见的工具库,其中六大组件互相配合,当然如果你使用STL那么也要遵守STL独有的规范。
  在使用不同的次语言时就有着不同的规范守则,当你从一个次语言切换到另一个时高效编程守则有可能需要你改变策略。例如在对内置类型传参时用值传参(pass-by-value)往往比传引用(pass-by-reference)更加高效,但当你如果从C part of C++这一次语言移往Object-Oriented C++,对于用户自定义的类型在传参时往往是传引用(pass-by-reference)更加高效,因为省略掉了一次拷贝构造,避免了不必要的开销,对于Template C++ 来说也是这样。
  由以上可以看出当我们在使用Cpp进行编程时,使用不同部分的次语言为了更加高效的进行编程往往就需要遵守不同的守则,这些守则有可能在不同的次语言间是相互违背的,所以在不同的次语言见进行转换时我们的策略可能也需要进行改变。这也是为什么要将Cpp分为这些次语言的原因这点很重要。
  牢记一点:Cpp的高效编程视情况而变化,取决于使用Cpp的哪一部分。

尽量以const,enum,inline替换#define

  Prefer consts,enums,and inlines to #defines.
  在这一条款中,要求程序员最好可以用编译器来代替预编译器,因为预编译器并不可以视为语言的一部分。

用const,enum替代宏定义常量

  预编译器中你定义的宏的名称并不会计入符号表内,从而使得调试及排错十分困难。因此我们尽量使用常量定义来代替宏定义,首当其冲就是为了避免宏定义使得其名称无法计入符号表。
  但是对于定义常量来代替宏有两点特殊情况需要讨论:
  1、定义常量指针(constant pointers)。关于常量指针和指向常量的指针在C语言章节就有解释。在本书中这里指的是一个完全是常量的指针,即是一个既是本身无法改变并且所指内容也无法改变的指针,因此我们为了定义这样一个常量需要将const写两遍才行。例:const char* const authorName = "Misaki";,但这种情况下利用Cpp中的STL中的string类反倒更好定义。
  2、类的成员专属常量。这种常量为了让其只有一份实体,因此要让其成为static的。一般情况下我们在定义一个静态的成员变量时都需要在类外进行定义,然而如果这个静态成员变量具有常性,即是const的,并且它为整数类型(integral type),再并且只要不取它们的地址则它无需在类外进行定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;
class GamePlayer
{
public:
static const int NumTurns = 5;
int scores[NumTurns];
};
//const int GamePlayer::NumTurns; //不给予数值的定义式
int main()
{
cout << GamePlayer::NumTurns << endl;
}


5

  当然如果你要取他们的地址或者你的编译器坚持要看到定义式则也是可以加上定义式的,但是 不可以再次给予数值,因为其载声明时已经获得初值了。
  但是我们无法使用宏定义的方式来定义一个成员常量,宏并不在乎作用域,受#define和#undef等宏的控制,因此宏并不具备封装性。
  但是有的编译器也许不支持在声明中就获得初值的in-class初值设定,呢么我们只好在定义中加入初值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;
class GamePlayer
{
public:
static const int NumTurns;
//int scores[NumTurns];//编译器要求在编译时就能确定数组的大小
};
const int GamePlayer::NumTurns = 5;
int main()
{
cout << GamePlayer::NumTurns << endl;
}


5

  但这种情况下我们并无法在编译期间获得一个常量值,因此无法用其定义数组,但是也有其他解决方法,改用the enum hack的补偿做法。一个枚举类型的数值可权充int被使用。

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;
class GamePlayer
{
public:
//static const int NumTurns;
enum
{
NumTurns = 5
};
int scores[NumTurns];//编译器要求在编译时就能确定数组的大小
};
//const int GamePlayer::NumTurns = 5;
int main()
{
cout << GamePlayer::NumTurns << endl;
}


5

  这样也可以在编译时就确定常量的值。通过这个enum hack我们也可以直接在编译期间使用常量,然而enum hack在某些方面的表现更接近于#define宏定义而不是const常量,例如取一个const常量的地址是合法的,然而取#define和enum的地址是不合法的。enum hack在很多方面都很实用,并且是模板元编程的而基础技术。

用inline替代宏函数

  另一个#define很坑的地方就是用它去实现宏函数。因为宏只是单纯的文本替换因此我们经常要给所有实参都加上括号来防止意外发生,但这样会大大降低代码可读性。因此书中极大程度建议使用inline函数来代替宏函数,它同样可以节省函数调用的额外开销,但是有着更好的可读性。并且在类中还可以定义专属于类内的private inline函数,而宏定义做不到此事。
  我们总结一下宏的缺点:
  1、定义常量不会计入记号表(symbol table),因此不便于调试。
  2、无法定义成员常量,宏并不重视作用域。
  3、宏的使用,尤其是宏函数会使代码可读性变差,不便于理解。
  因此对于常量,最好以const或enum替代#define;对于宏函数最好用inline函数替代#define。在用const定义常量的时候也有两点特殊情况需要注意,一个是常量指针一个是类专属常量。
  我们基本可以用enum,const,inline替代#define所有使用场景,降低了对预处理器的使用,但是也并非完全消除,预处理器依然有举足轻重的作用,我们只能尽可能减少对其的使用。比如说当我们在写日志检错时就需要用到宏,需要知道具体是哪一行发生了错误,而如果使用函数则会跳转到函数中。

总结

  1、对于单纯常量,最好以const对象或enums替换#define。
  2、对于宏函数,最好以inline函数替代。

尽可能使用const

  Use const whenever possible.
  const多才多艺,可以用来修饰相当多的变量或函数,一旦被修饰则该对象会被编译器强制约束来保持它自身不会被改变。

const对于指针和迭代器

  对于指针而言 const出现在*左边表示被指示物是常量,如果出现在*右边则表示指针本身是常量,当然其也可以两边都出现表示被指示物和指针本身都是常量。
  通过对STL中容器的模拟实现我们也可以很清楚的知道迭代器底层的实现其实就是一个指针,因此迭代器对const来说也有着和指针一样的应用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <vector>
using namespace std;
int main()
{
vector<int> vec = {1, 2, 3, 4};
//我们给接下来这个迭代器加上const
//其类似于T* const指针,注意不是const T*虽然const写在前面,但这里const强调的是迭代器自身的不可变
//迭代器也不过是一个typedef类型而已
const vector<int>::iterator iter = vec.begin();
*iter = 5;//合法,迭代器所指对象可变
//iter++;//不合法,迭代器自身不可变
cout << vec[0] << endl;
//这里相当于一个const T*指针,指向内容不可改,自身可改
vector<int>::const_iterator const_iter = vec.begin();
//*const_iter = 1;//不合法,指向内容不可改
const_iter++;//合法,自身可变
cout << *const_iter << endl;
}


5
2

const对于函数的应用

  const可以与一个函数的返回值、参数、自身产生关联。在这里我们先讨论对返回值以及参数的关联。
  对于函数的返回值,我们用const进行修饰来避免产生一些意外情况。这里我用书中举得例子,来重载一个有理数类的operator*运算符。

1
2
class Rational{ ... };
const Rational operator*(const Rational& lhs, const Rational& rhs);

  这里参数设置为引用加const修饰的原因是为了减少拷贝构造加快效率同时避免对操作数的修改,很好理解,但是返回值加const或许比较难以想到,这是因为总可能有人会写出这样违法的代码。

1
2
Rational a, b, c;
(a * b) = c;//在a * b结果上调用operator=

  这段代码如果我们对operator*重载的返回值并未加上const编译器是允许编的过这样违法的操作的,但这明显是不合常理的是我们想命令禁止的,因此对于用const来修饰函数的返回值也有着莫大的必要。
  对于const修饰参数已经再熟悉不过了,只要你不想修改它都建议将其定义为const,这是很好的习惯。

const成员函数

  接下来我们着重讨论如何const对于函数自身的修饰,而这些修饰都与类成员函数有关。
  const修饰成员函数自身就是将其修饰为常成员函数,在这种函数中无法修改调用对象自身的数据,因为传入的是一个const的this指针。同时我们还要注意一点很重要的语法,常成员函数与普通成员函数间会构成重载,即使两个成员函数的参数列表完全相同,但只要他们在同一个类中并且函数名相同,仅由常量性constness不同也是可以构成重载的。但是注意此时其构成重载的原因并非是this指针这个参数被const修饰,因为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
#include <iostream>
#include <string>
using namespace std;
class TextBook
{
public:
//重载operator[]
//这里返回值加const是为了让const对象自身不被修改不造成错误
const char& operator[](size_t position) const
{
return text[position];
}
char& operator[](size_t position)
{
return text[position];
}
private:
string text = "123456";
};
void Print(const TextBook& book1, TextBook& book2)
{
cout << book1[0] << endl;//合法
cout << book2[0] << endl;//合法
book2[0] = '5';//合法,普通对象调用普通成员函数,返回值可改
cout << static_cast<const TextBook&>(book2)[0] << endl;
//static_cast<const TextBook&>(book2)[0] = '1';//非法这里我们将其转换为const类型对象发现可以主动调用常成员函数
//book1[0] = '5';//非法,常量对象调用常成员函数而其返回值是const修饰的因此不可改
}
int main()
{
TextBook book;
Print(book, book);
}


1
1
5

  并且通过以上的代码我还发现虽然普通对象可以调用常成员函数,但是当其与普通成员函数构成重载时我们想要显示调用常成员函数必须将对象转换为const的,这完全没有必要,属于脱裤子放屁的一种手段。

bitwise constness和logical constness

  如果一个成员函数是常成员函数,则就会引申出这样两个流行概念。
  bitwise const阵营的人相信只有成员函数不改变成员的任何变量的时候才可以说其为const的,这也是Cpp中对常量性的定义,const成员函数不可以更改对象内任何的non-static成员变量。
  但是不幸的是在Cpp中很容易对这一观点进行反驳,很多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
#include <iostream>
#include <string.h>
using namespace std;
class CTextBook
{
friend ostream& operator<<(ostream &cout, const CTextBook& text);
public:
CTextBook(const char* str)
:_text(nullptr)
{
int capacity = strlen(str) + 1;
_text = new char[capacity];
strcpy(_text, str);
}
~CTextBook()
{
if(_text != nullptr)
{
delete[] _text;
}
}
char& operator[](size_t position) const
{
return _text[position];
}
private:
char* _text;
};
ostream& operator<<(ostream &cout, const CTextBook& text)
{
cout << text._text << endl;
return cout;
}
int main()
{
CTextBook text("Hello");
text[0] = 'J';
cout << text;
}


Jello

  以上这个例子中我们在const成员函数中并没有改变成员变量,因此编译器并没有报错,以此欺骗了编译器,但是并不代表我们不能通过const成员函数改变成员内的对象。由此即产生了第二个阵营对const成员函数的理解即logical constness。
  这个阵营中的人主张const成员函数可以改变其所处理的对象中的某些成员变量,但是只有在客户端侦测不出的情况下才可以这样。但是还有一些情况下我们要修改一些本身可以修改的成员变量,他们的修改从逻辑角度考虑是合理的,是必须的,但是此时编译器由于语法的关系判定其不符合bitwise constness,因此判定其报错不通过编译。例如以下这种情况。

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
#include <iostream>
#include <string.h>
using namespace std;
class CTextBook
{
friend ostream& operator<<(ostream &cout, const CTextBook& text);
public:
CTextBook(const char* str)
:_text(nullptr)
{
int capacity = strlen(str) + 1;
_text = new char[capacity];
strcpy(_text, str);
}
~CTextBook()
{
if(_text != nullptr)
{
delete[] _text;
}
}
char& operator[](size_t position) const
{
return _text[position];
}
size_t length() const
{
if(!_lengthIsValid)
{
//这里明显是编译不过的,因为其不是bitwiss constness的
_len = strlen(_text);
_lengthIsValid = true;
}
return _len;
}
private:
char* _text;
int _len;
bool _lengthIsValid;
};
ostream& operator<<(ostream &cout, CTextBook& text)
{
cout << text._text << endl;
return cout;
}
int main()
{
CTextBook text("Hello");
text[0] = 'J';
cout << text;
}

  以上这个例子肯定是编译不过的,其不符合编译器的编译标准,但是从逻辑角度来看这些赋值和改变我们是可以接受了,但是编译器不同意怎么办呢?解决这个问题需要用到mutable释放掉non-static成员变量的bitwiss constness约束。

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
#include <iostream>
#include <string.h>
using namespace std;
class CTextBook
{
friend ostream& operator<<(ostream &cout, const CTextBook& text);
public:
CTextBook(const char* str)
:_text(nullptr)
,_len(0)
,_lengthIsValid(false)
{
int capacity = strlen(str) + 1;
_text = new char[capacity];
strcpy(_text, str);
}
~CTextBook()
{
if(_text != nullptr)
{
delete[] _text;
}
}
char& operator[](size_t position) const
{
return _text[position];
}
size_t length() const
{
if(!_lengthIsValid)
{
//这里明显是编译不过的,因为其不是bitwiss constness的
_len = strlen(_text);
_lengthIsValid = true;
}
return _len;
}
private:
char* _text;
mutable int _len;
mutable bool _lengthIsValid;
};
ostream& operator<<(ostream &cout, const CTextBook& text)
{
cout << text._text << endl;
return cout;
}
int main()
{
CTextBook text("Hello");
text[0] = 'J';
cout << text;
cout << text.length() << endl;
}


Jello
5

  mutable虽然可以解决bitwise constness而不是logical constness的问题。但是不能解决所有的问题,比如const和non-const成员函数中代码冗余的问题,我们要实现两个功能完全相同的函数只是为了让常量性不同的对象调用,这样就会造成代码冗余。最好的解决方法就是在non-const函数中调用const函数。注意以上这段话不能反过来,我们不能够在const函数中调用non-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
65
66
67
68
69
70
71
72
73
74
#include <iostream>
#include <string.h>
#include <assert.h>
using namespace std;
class CTextBook
{
friend ostream& operator<<(ostream &cout, const CTextBook& text);
public:
CTextBook(const char* str)
:_text(nullptr)
,_len(0)
,_lengthIsValid(false)
{
int capacity = strlen(str) + 1;
_text = new char[capacity];
strcpy(_text, str);
}
~CTextBook()
{
if(_text != nullptr)
{
delete[] _text;
}
}
//const函数
const char& operator[](size_t position) const
{
//在这里加上检查,我们先少些点代码,假设这之前有更多的代码
//......
assert(position < length());
return _text[position];
}
//non-const函数
char& operator[](size_t position)
{
//这里只需要调用const版本的函数即可,但是为了调用要先进行类型转换
//这里用到了将本身的对象转换为常对象来调用常成员函数
const char& a = static_cast<const CTextBook&>(*this)[position];
//这里将返回值的const限定去掉
return const_cast<char&>(a);
}
size_t length() const
{
if(!_lengthIsValid)
{
//这里明显是编译不过的,因为其不是bitwiss constness的
_len = strlen(_text);
_lengthIsValid = true;
}
return _len;
}
private:
char* _text;
mutable int _len;
mutable bool _lengthIsValid;
};
ostream& operator<<(ostream &cout, const CTextBook& text)
{
cout << text._text << endl;
return cout;
}
int main()
{
CTextBook text("Hello");
const CTextBook con_text("Hello");
//con_text[0] = 'J';//常量成员调用常成员函数,无法更改
text[0] = 'J';
cout << text;
cout << text.length() << endl;
}


Jello
5

  但是要注意这其中进行了两次转型,这是十分不安全的尽量还是少使用,但是尽管如此我们还是达成了我们避免数据冗余的问题。

总结

  1、将某些东西声明为const可帮助编译器侦测出错误用法。const可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。
  2、扁你其前置实施bitwise constness,但你编写程序时应该使用logical constness。
  3、const与non-const成员函数有着实质等价时,令non-const版本调用const版本可避免代码重复。

确定对象被使用前已先被初始化

  Make sure that objects are initialized before they`re used.

C part of C++

  在Cpp中是存在不会被初始化的变量的,无论这些变量是成员变量来自类中,还是内置类型的变量都有可能编译器不会帮助我们初始化,这是十分危险的,因为会导致不明确的行为。如果我们在使用C part of C++时变量往往并不会进行初始化例如数组往往需要我们手动给一个初值,而我们在使用Cpp中的vector时编译器却可以保证即使我们不给初值这个数组也会被初始化,如果熟悉STL库的话,你会知道vecotr此时会将内部的三个成员变量全部初始化为空,长度和容量都为0。所以在使用C part of C++时我们最好手动为每一个变量赋予初值,这是很好的编程习惯。

自定义类型

  对于自定义类型以外的所有类型,他们的初始化都落在了构造函数的身上,我们所要作的就是确保每一个构造函数都将对象的每一个成员初始化。
  但是这里要搞清楚赋值和初始化的概念。这里就牵扯到了构造函数的使用。我们都知道我们可以在构造函数中对成员赋值,而是用初始化列表才可以对成员进行初始化。所以经常可以看到有些对Cpp不是很了解的程序员写出这样的构造函数。

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 <iostream>
using namespace std;
class PhoneNumber
{
public:
PhoneNumber()
{
cout << "PhoneNumber()" << endl;
}
PhoneNumber(string theNumber)
{
cout << "PhoneNumber(string theNumber)" << endl;
string _theNumber;
}
PhoneNumber operator=(const PhoneNumber& phoneNumber)
{
_theNumber = phoneNumber._theNumber;
cout << "PhoneNumber operator=(const PhoneNumber& phoneNumber))" << endl;
}
private:
string _theNumber;
};
class ABEntry
{
public:
ABEntry(const string& theName, const string& theAddress, const PhoneNumber& theNumber)
{
//这里的构造函数写法并不是直接对成员进行初始化,更应该说是在初始化后进行了一次赋值
_theName = theName;
_theAddress = theAddress;
//所以这里会调用赋值函数
_theNumber = theNumber;
}
private:
string _theName;
string _theAddress;
PhoneNumber _theNumber;
};
int main()
{
ABEntry abEntry("Misaki", "China", PhoneNumber("181********"));
}


PhoneNumber(string theNumber)
PhoneNumber()
PhoneNumber operator=(const PhoneNumber& phoneNumber))

  这种构造函数的写法并不能为成员变量进行初始化,而是将变量先初始化后再进行赋值,在过程中产生了一次无参构造,一次有参构造,以及一次赋值,这会消耗更多的性能。而最佳的处理手段是将成员变量的初始化放进初始化列表中。

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
#include <iostream>
using namespace std;
class PhoneNumber
{
public:
PhoneNumber()
{
cout << "PhoneNumber()" << endl;
}
PhoneNumber(string theNumber)
{
cout << "PhoneNumber(string theNumber)" << endl;
string _theNumber;
}
PhoneNumber(const PhoneNumber& phoneNumber)
{
_theNumber = phoneNumber._theNumber;
cout << "PhoneNumber(const PhoneNumber& phoneNumber)" << endl;
}
PhoneNumber operator=(const PhoneNumber& phoneNumber)
{
_theNumber = phoneNumber._theNumber;
cout << "PhoneNumber operator=(const PhoneNumber& phoneNumber))" << endl;
}
private:
string _theNumber;
};
class ABEntry
{
public:
//这样才能对成员进行初始化,从结果也可以看出这里少了一次无参的默认构造
ABEntry(const string& theName, const string& theAddress, const PhoneNumber& theNumber)
:_theName(theName)
,_theAddress(theAddress)
,_theNumber(theNumber)
{
}
private:
string _theName;
string _theAddress;
PhoneNumber _theNumber;
};
int main()
{
ABEntry abEntry("Misaki", "China", PhoneNumber("181********"));
}


PhoneNumber(string theNumber)
PhoneNumber(const PhoneNumber& phoneNumber)

  建议再初始化列表中对成员变量进行初始化,并且建议在初始化列表中对所有的成员包括基类都能进行手动初始化,无论是自定义类型还是内置类型,这样就不用考虑哪些成员是可以不初始化的成员,这样的编程习惯也是极好的。
  对于成员变量的初始化顺序,其顺序并不由初始化列表而决定,基类总是优先于派生类进行初始化,而成员变量是以其声明次序进行初始化的,因此如果你如果定义了一个变量并决定把它作为数组的大小来初始化数组,那么你要保证在声明数组前那个代表大小的变量应该现有值。因此为了方便理解,增强可读性我们在构造函数中书写初始化列表时最好是按照声明顺序对其进行初始化。

不同编译单元内定义之non-local static对象的初始化

  这一句话很长,很难理解,我们大概可以理解为在不同文件中的静态(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
30
31
32
33
34
35
36
37
38
39
40
FileSystem.h

#include <iostream>
using namespace std;
class FileSystem
{
public:
size_t numDisks() const
{
return _disks;
}
private:
size_t _disks = 5;
};

第一个.cpp
#include <iostream>
#include "FileSystem.h"
using namespace std;
FileSystem tfs;//其中定义一个全局变量

第二个.cpp
#include <iostream>
#include "FileSystem.h"
using namespace std;
extern FileSystem tfs;
class Directory
{
public:
Directory()
{
_disks = tfs.numDisks();
}
size_t _disks;
};
int main()
{
Directory dir;
cout << dir._disks << endl;
}

  以上这段代码中我们在一个源码文件中定义了一个成员变量,而在另一个源文件中声明外部变量并且使用了这个变量对我们的成员变量进行了初始化,但是我们并无法确定这个外部变量编译器在处理时是否会先对其进行初始化,如果没有初始化而我们又用它对我们的成员进行初始化此时就会无法进行指定的初始化。因此我们要使用一种间接的方式设计它,来保证外部成员一定会先进行初始化。

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
FileSystem.h

#include <iostream>
using namespace std;
class FileSystem
{
public:
size_t numDisks() const
{
return _disks;
}
private:
size_t _disks = 5;
};
FileSystem& tfs();

第一个.cpp
#include <iostream>
#include "FileSystem.h"
using namespace std;
//这里的设计模式十分类似于单例模式
//在函数内部定义静态变量,这样保证在调用函数时一定会先对变量进行初始化
//保证初始化顺序
FileSystem& tfs()
{
static FileSystem fs;
return fs;
}

第二个.cpp
#include <iostream>
#include "FileSystem.h"
using namespace std;
class Directory
{
public:
Directory()
{
_disks = tfs().numDisks();
}
size_t _disks;
};
int main()
{
Directory dir;
cout << dir._disks << endl;
}

  这样的设计模式更加安全可靠。

总结

  1、为内置类型进行手动初始化,Cpp并不保证会对其进行初始化。
  2、构造函数最好使用初始化列表对成员进行初始化,而不是使用赋值操作,并且初始化时最好按照声明次序进行初始化,增强可读性。
  3、为免除“跨编译单元之初始化次序”问题,最好书写接口,利用local static对象代替non-local static对象。

【Cpp】第十一章-继承

发表于 2019-08-22 | 分类于 Cpp
字数统计: 7.8k

继承

什么是继承

  继承是为了更好的使代码得以复用而产生的,同时呈现了面向对象程序设计中的层次结构,继承会使得我们写好的类可以得到扩展。简单来说继承可以增强我们的代码复用,包括可以复用类的层次结构,同时使程序复用层次和条理。

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
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
//如果我们设计一个类考虑到它会被继承那么最好用protected成员取代private成员
//protected成员在类外部仍然是无法使用的,但是在派生类中它是可见的也就是可以使用的
Person()
{
cout << "im a Person" << endl;
}
void Print()
{
cout << "age = " << _age << endl;
cout << "name = " << _name << endl;
}
protected:
int _age = 20;
string _name = "Misaki";
};
//Programer类,这里让其公有继承Person类
//这时继承产生的新类被称为子类,也叫派生类
//被继承的旧类被称为父类,也叫基类
//继承方式为公有(public),其会让基类中的public成员成为派生类中的public成员
//基类中的protected成员成为派生类中的protected成员,基类中的private成员在派生类中不可见
//关于继承方式也在下文讲解
class Programer: public Person
{
//这里我们不再重写几个默认生成的成员函数,他们默认会完成他们应有的功能
//具体派生类的默认成员函数会在下文讲解
protected:
int _workyear;
};
class Teacher: public Person
{
protected:
int _teachyear;
};
int main()
{
Person person;//基类对象
Programer programer;//派生类对象
Teacher teacher;//派生类对象
person.Print();
programer.Print();
teacher.Print();
}

im a Person
im a Person
im a Person
age = 20
name = Misaki
age = 20
name = Misaki
age = 20
name = Misaki

  以上这个例子就是简单的利用继承进行了类的扩展扩展出了两个派生类,可以看出派生类中拥有积累的成员函数和成员变量,并且还可以添加新的成员函数和变量。

继承方式

三种继承方式

  继承方式一共有三种:public, protected,private,这三种继承方式都可以将基类中的成员继承到派生类中,但是继承方式的不同也决定着在派生类中继承过来的成员的访问权限的不同,具体关于各个继承方式中对访问权限的改变可以参考下图。

继承

  这里提到基类中的private成员无论何种继承方式在派生类中都是不可见的,所谓不可见指成员依然已经继承了过来并且作为了私有成员,但是在派生类中限制这些成员无论在类外还是类内都是无法访问的。
  因此为了让一个成员在派生类中依然可以访问便出现了protected访问权限,所以才说如果一个类为了方便继承最好将其private访问权限用protected来替代,这样即使继承产生派生类也依然可以在派生类中访问该成员。
  如果我们在继承时不写出继承方式,则class默认使用private继承,struct默认使用public继承,不过为了可读性最好还是显示的写出继承方式。
  最为经常使用的继承方式是public继承,它可以让派生类中各个访问权限下的成员在派生类中还为相应的访问权限,是最为方便便于理解的继承方式,并且其他继承方式的扩展和维护性也并不强,因此除非特殊情况也不建议使用除public外的其他继承方式。

切割

  在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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <iostream>
#include <string>
using namespace std;
//基类
class Person
{
public:
Person()
{
cout << "im a Person" << endl;
}
void Print()
{
cout << "age = " << _age << endl;
cout << "name = " << _name << endl;
}
protected:
int _age = 20;
string _name = "Misaki";
};
//派生类
class Programer: public Person
{
protected:
int _workyear;
};
//派生类
class Teacher: public Person
{
protected:
int _teachyear;
};
int main()
{
Person person;
//Programer programer;
Teacher teacher;
//person.Print();
//programer.Print();
//teacher.Print();
//teacher是派生类对象,person是基类对象,这样的隐式类型转换是允许的
person = teacher;
//也可以用派生类对象来拷贝构造基类对象
Person person2 = teacher;
person.Print();
person2.Print();
}


im a Person
im a Person
age = 20
name = Misaki
age = 20
name = Misaki

  以上这段代码我们将派生类对象赋值给了基类对象,并且用派生类对象拷贝构造了基类对象,在这其中得以隐式转换最重要的原因就是编译器进行了切割处理,即将派生类对象中属于基类的那一部分成员保留,而将属于派生类的成员舍弃,由此才能完成类型转换。

切割

  上图过程即为切割的过程,在派生类的对象/指针/引用赋值给父类的对象/指针/引用时都会发生隐式类型转换,过程中都会发生切割。但是基类对象/指针引用不可以赋值给派生类对象/指针/引用。当然也有例外,基类指针可以通过强制类型转换也可以赋值给派生类指针,当然只有在基类指针指向了派生类对象时才是安全的。如果基类构成多态也可以用RTTI(运行时类型识别)的dynamic_cast类型识别后进行类型转换,是最为安全的。

继承中的作用域

  在继承体系中,基类和派生类都有属于自己的作用域,那么如果在派生类中定义了和基类同名的成员编译器会怎样处理呢?

重定义/隐藏

  派生类和基类有各自独立的作用域,在派生类中如果出现和基类同名成员,则会优先调用派生类的同名成员,即隐藏基类成员,这个过程叫做被称为隐藏也叫做重定义。
  但是我们也可以在派生类中加上域限定符显示调用基类的成员。

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
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
void Print()
{
cout << "Person::Print()" << endl;
}
protected:
int _age = 20;
string _name = "Misaki";
};
//派生类
class Teacher: public Person
{
public:
void Print()
{
cout << "Teacher::Print()" << endl;
}
void PrintAge()
{
cout << "Teacher::_age:" << _age << endl;//重名成员构成隐藏
cout << "Person::_age:" << Person::_age << endl;//显示调用基类成员
}
protected:
int _age = 19;
};
int main()
{
Person person;
person.Print();//Person::Print()
Teacher teacher;
teacher.Print();//Teacher::Print(),派生类重名成员构成隐藏
teacher.PrintAge();
}


Person::Print()
Teacher::Print()
Teacher::_age:19
Person::_age:20

  值得注意的是,关于函数,函数重载指的是在相同作用域内函数名相同参数不同构成重载,而重定义/隐藏是指在基类和派生类的作用域内函数名相同就会构成隐藏。所以我们最好还是不要在派生类中定义和基类同名的成员,避免隐藏和重定义的发生。

派生类的默认成员函数

  派生类也有6个默认成员函数,但是着6个默认成员函数既要兼顾到基类也需要兼顾到派生类,因此在写法上与常规的并不相同。

构造函数

  在派生类的构造函数中我们需要先显示调用基类的构造函数对基类成员进行初始化然后才能对派生类成员进行初始化。

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
#include <iostream>
#include <string>
using namespace std;
//基类
class Person
{
public:
//这里构造函数就不选择传参了方便起见我都初始化为定值
Person()
:_age(20)
,_name("Misaki")
{
cout << "Person()" << endl;
}
void Print()
{
cout << "age = " << _age << endl;
cout << "name = " << _name << endl;
}
protected:
int _age;
string _name;
};
//派生类
class Teacher: public Person
{
public:
//派生类构造函数,先调用基类构造函数,在初始化派生类成员
//默认生成的构造函数也是这样实现的
Teacher()
:Person()
,_teachyear(3)
{
cout << "Teacher()" << endl;
}
void Print()
{
Person::Print();
cout << "teachyear = " << _teachyear << endl;
}
static int _count;
protected:
int _teachyear;
};
int Teacher::_count = 2;
int main()
{
Teacher teacher;
teacher.Print();
}


Person()
Teacher()
age = 20
name = Misaki
teachyear = 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
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
#include <iostream>
#include <string>
using namespace std;
//基类
class Person
{
public:
//这里构造函数就不选择传参了方便起见我都初始化为定值
Person()
:_age(20)
,_name("Misaki")
{
cout << "Person()" << endl;
}
Person(const Person& person)
:_age(person._age)
,_name(person._name)
{
cout << "Person(const Person&)" << endl;
}
void Print()
{
cout << "age = " << _age << endl;
cout << "name = " << _name << endl;
}
protected:
int _age;
string _name;
};
//派生类
class Teacher: public Person
{
public:
//派生类构造函数,先调用基类构造函数,在初始化派生类成员
//默认生成的构造函数也是这样实现的
Teacher()
:Person()
,_teachyear(3)
{
cout << "Teacher()" << endl;
}
//拷贝构造函数,先调用基类构造函数对基类成拷贝构造,再拷贝构造派生类成员
//默认生成的拷贝构造也是这样的实现方法
Teacher(const Teacher& teacher)
:Person(teacher)//这里利用派生类对象可以隐式转换为基类对象来调用基类拷贝构造
,_teachyear(3)
{
cout << "Teacher(const Teacher&)" << endl;
}
void Print()
{
Person::Print();
cout << "teachyear = " << _teachyear << endl;
}
static int _count;
protected:
int _teachyear;
};
int Teacher::_count = 2;
int main()
{
Teacher teacher1;
Teacher teacher2 = teacher1;
teacher2.Print();
}


Person()
Teacher()
Person(const Person&)
Teacher(const Teacher&)
age = 20
name = Misaki
teachyear = 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
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
#include <iostream>
#include <string>
using namespace std;
//基类
class Person
{
public:
//这里构造函数就不选择传参了方便起见我都初始化为定值
Person()
:_age(20)
,_name("Misaki")
{
cout << "Person()" << endl;
}
Person(const Person& person)
:_age(person._age)
,_name(person._name)
{
cout << "Person(const Person&)" << endl;
}
Person& operator=(const Person& person)
{
if(&person != this)
{
_age = person._age;
_name = person._name;
}
cout << "Person::operator=(const Person&)" << endl;
return *this;
}
void Print()
{
cout << "age = " << _age << endl;
cout << "name = " << _name << endl;
}
protected:
int _age;
string _name;
};
//派生类
class Teacher: public Person
{
public:
//派生类构造函数,先调用基类构造函数,在初始化派生类成员
//默认生成的构造函数也是这样实现的
Teacher()
:Person()
,_teachyear(3)
{
cout << "Teacher()" << endl;
}
//拷贝构造函数,先调用基类构造函数对基类成拷贝构造,再拷贝构造派生类成员
//默认生成的拷贝构造也是这样的实现方法
Teacher(const Teacher& teacher)
:Person(teacher)//这里利用派生类对象可以隐式转换为基类对象来调用基类拷贝构造
,_teachyear(3)
{
cout << "Teacher(const Teacher&)" << endl;
}
//赋值运算符重载,先调用基类的赋值运算符重载对基类成员进行赋值,再赋值派生类成员
//默认生成的赋值运算符重载也是这样的实现方法
Teacher& operator=(const Teacher& teacher)
{
if(&teacher != this)
{
Person::operator=(teacher);//调用基类的赋值运算符重载,并且用隐式类型转换传参
_teachyear = teacher._teachyear;
}
cout << "Teacher::operator=(const Teacher&)" << endl;
return *this;
}
void Print()
{
Person::Print();
cout << "teachyear = " << _teachyear << endl;
}
static int _count;
protected:
int _teachyear;
};
int Teacher::_count = 2;
int main()
{
Teacher teacher1;
Teacher teacher2;
teacher2 = teacher1;
teacher2.Print();
}


Person()
Teacher()
Person()
Teacher()
Person::operator=(const Person&)
Teacher::operator=(const Teacher&)
age = 20
name = Misaki
teachyear = 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
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
#include <iostream>
#include <string>
using namespace std;
//基类
class Person
{
public:
//这里构造函数就不选择传参了方便起见我都初始化为定值
Person()
:_age(20)
,_name("Misaki")
{
cout << "Person()" << endl;
}
Person(const Person& person)
:_age(person._age)
,_name(person._name)
{
cout << "Person(const Person&)" << endl;
}
Person& operator=(const Person& person)
{
if(&person != this)
{
_age = person._age;
_name = person._name;
}
cout << "Person::operator=(const Person&)" << endl;
return *this;
}
~Person()
{
cout << "~Person()" << endl;
}
void Print()
{
cout << "age = " << _age << endl;
cout << "name = " << _name << endl;
}
protected:
int _age;
string _name;
};
//派生类
class Teacher: public Person
{
public:
//派生类构造函数,先调用基类构造函数,在初始化派生类成员
//默认生成的构造函数也是这样实现的
Teacher()
:Person()
,_teachyear(3)
{
cout << "Teacher()" << endl;
}
//拷贝构造函数,先调用基类构造函数对基类成拷贝构造,再拷贝构造派生类成员
//默认生成的拷贝构造也是这样的实现方法
Teacher(const Teacher& teacher)
:Person(teacher)//这里利用派生类对象可以隐式转换为基类对象来调用基类拷贝构造
,_teachyear(3)
{
cout << "Teacher(const Teacher&)" << endl;
}
//赋值运算符重载,先调用基类的赋值运算符重载对基类成员进行赋值,再赋值派生类成员
//默认生成的赋值运算符重载也是这样的实现方法
Teacher& operator=(const Teacher& teacher)
{
if(&teacher != this)
{
Person::operator=(teacher);//调用基类的赋值运算符重载,并且用隐式类型转换传参
_teachyear = teacher._teachyear;
}
cout << "Teacher::operator=(const Teacher&)" << endl;
return *this;
}
//要注意派生类析构函数不需要显示调用基类的析构函数,在调用派生类析构函数释放派生类成员后
//会自动调用基类的析构函数,来满足先释放派生类成员再释放基类成员的顺序
~Teacher()
{
cout << "~Teacher()" << endl;
//Person::~Person();这样调用会报错
}
void Print()
{
Person::Print();
cout << "teachyear = " << _teachyear << endl;
}
static int _count;
protected:
int _teachyear;
};
int Teacher::_count = 2;
int main()
{
Teacher teacher;
}


Person()
Teacher()
~Teacher()
~Person()

  派生类析构函数执行时会先释放派生类成员再释放基类成员,并且要注意派生类析构函数会自动调用基类析构函数进行清理,无需手动调用。从执行结果上也可以看出编译器是先释放派生类成员再释放基类成员。

总结

  对派生类默认成员函数进行总结。
  1、派生类构造函数必须首先调用基类构造函数构造基类成员,之后再构造派生类成员才能成立。
  2、派生类拷贝构造函数必须先调用基类拷贝构造函数拷贝构造基类成员,之后再拷贝构造派生类成员才能成立。
  3、派生类赋值运算符重载必须先调用基类赋值运算符重载函数对基类成员进行赋值,再对派生类成员进行赋值才能成立。
  4、派生类析构函数会在调用后自动调用基类析构函数无需显式调用基类析构函数。
  5、派生类对象构造会先构造基类成员再构造派生类成员。
  6、派生类对象析构会先释放派生类成员再释放基类成员。

继承与友元

  在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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <iostream>
#include <string>
using namespace std;
class Test;
//基类
class Person
{
friend class Test;
public:
void Print()
{
cout << "age = " << _age << endl;
cout << "name = " << _name << endl;
}
protected:
int _age = 20;
string _name = "Misaki";
};
//派生类
class Teacher: public Person
{
public:
void Print()
{
Person::Print();
cout << "teachyear = " << _teachyear << endl;
}
protected:
int _teachyear = 3;
};
class Test
{
public:
void Print()
{
cout << "age = " << person._age << endl;
cout << "name = " << person._name << endl;
}
void Print2()
{
cout << "age = " << teacher._age << endl;
cout << "name = " << teacher._name << endl;
//cout << "teachyear = " << teacher._teachyear << endl;//无法访问
}
private:
Person person;
Teacher teacher;
};
int main()
{
Test test;
test.Print2();
}


age = 20
name = Misaki

继承和静态成员

  关于继承和静态成员,我们所要记住的只有一句话,在整个继承体系中,无论发生多少次继承,静态成员在继承体系中只存在一份。

静态成员函数

  关于静态成员函数,这句话将更好理解,在不发生隐藏的情况下我们调用的依然是基类的静态函数。

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 <string>
using namespace std;
//基类
class Person
{
public:
static void Print()
{
cout << "Person::_count = " << _count << endl;
}
protected:
int _age = 20;
string _name = "Misaki";
static int _count;
};
int Person::_count = 1;
//派生类
class Teacher: public Person
{
public:
//static void Print()
//{
// cout << "Teacher::_count = " << _count << endl;
//}
protected:
int _age = 19;
};
int main()
{
Person::Print();
Teacher::Print();
}


Person::_count = 1
Person::_count = 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
#include <iostream>
#include <string>
using namespace std;
//基类
class Person
{
public:
static void Print()
{
cout << "Person::_count = " << _count << endl;
}
protected:
int _age = 20;
string _name = "Misaki";
static int _count;
};
int Person::_count = 1;
//派生类
class Teacher: public Person
{
public:
static void Print()
{
cout << "Teacher::_count = " << _count << endl;
}
protected:
int _age = 19;
};
int main()
{
Person::Print();
Teacher::Print();
}


Person::_count = 1
Teacher::_count = 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
#include <iostream>
#include <string>
using namespace std;
//基类
class Person
{
public:
static void Print()
{
cout << "Person::_count = " << _count << endl;
}
static int _count;
protected:
int _age = 20;
string _name = "Misaki";
};
int Person::_count = 1;
//派生类
class Teacher: public Person
{
public:
static void Print()
{
cout << "Teacher::_count = " << _count << endl;
}
protected:
int _age = 19;
};
int main()
{
Person::_count = 3;
Person::Print();
Teacher::Print();
}


Person::_count = 3
Teacher::_count = 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
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <iostream>
#include <string>
using namespace std;
//基类
class Person
{
public:
static void Print()
{
cout << "Person::_count = " << _count << endl;
}
static int _count;
protected:
int _age = 20;
string _name = "Misaki";
};
int Person::_count = 1;
//派生类
class Teacher: public Person
{
public:
static void Print()
{
cout << "Teacher::_count = " << _count << endl;
cout << "Person::_count = " << Person::_count << endl;
}
protected:
int _age = 19;
static int _count;
};
int Teacher::_count = 5;
int main()
{
Person::_count = 3;
Person::Print();
Teacher::Print();
}


Person::_count = 3
Teacher::_count = 5
Person::_count = 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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <string>
using namespace std;
//基类
class Person
{
public:
static void Print()
{
cout << "Person::_count = " << _count << endl;
}
static int _count;
protected:
int _age = 20;
string _name = "Misaki";
};
int Person::_count = 1;
//派生类
class Teacher: public Person
{
public:
static void Print()
{
//cout << "Teacher::_count = " << _count << endl;//编不过了,因为静态函数中只能调用静态成员,而此时_count隐藏已经不是静态成员
cout << "Person::_count = " << Person::_count << endl;
}
int _count = 5;
protected:
int _age = 19;
};
int main()
{
Person::_count = 3;
Person::Print();
Teacher::Print();
Teacher teacher;
cout << teacher._count << endl;//可以访问了
}


Person::_count = 3
Person::_count = 3
5

  由此我们可以判断一点,如果在派生类中发生隐藏和重定义那么在派生类中该同名成员的生命周期及访问权限将根据在派生类中重定义的情况而决定,因此在基类中即使是静态的成员如果在派生类中重定义为非静态变量进行使用也是可以的,可见重定义和隐藏是十分恐怖的,因此我们最好是尽量防止在派生类中定义和基类重名的成员,避免发生隐藏和重定义。

菱形继承和虚继承

  Cpp语法复杂这一特点可以从继承中体现出来,因为其支持多继承,而多继承就有可能会产生菱形继承的情况。

菱形继承

  什么是菱形继承呢?其产生原因就要“归功于”Cpp支持多继承这一特点,多继承就是允许一个类继承于多个基类,当派生类多继承于多个基类时,这些基类也有可能继承于更上层的同一个基类,由此就会产生菱形继承的情况。

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>
using namespace std;
class A
{
public:
char _a = 'A';
};
//B继承于A
class B: public A
{
public:
char _b = 'B';
};
//C也继承于A
class C: public A
{
public:
char _c = 'C';
};
//D继承于B,C
class D: public B, public C
{
public:
char _d = 'D';
};

  以上这种结构就会产生菱形继承,我们用画图的方式具现化表示一下。

菱形继承

  这种继承体系就是菱形继承,也是非常直观的可以体现出来的。

菱形继承带来的问题

  菱形继承是Cpp多继承所带来的主要问题之一,一旦发生菱形继承,首当其冲的我们就应该考虑到菱形继承带来的数据冗余及数据二义性的问题。
  还用上面的例子,因为D同时继承了B,C类,而B,C类又都分别继承了A类这就意味着D中存在着两份A类,一份是从B那里继承来的,另一份是从C继承来的,这就造成了数据冗余,并且当我们通过D想要直接访问A类成员时编译器会报错,因为编译器不知道你要访问的时B中的A类成员还是C中的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
33
34
35
36
37
38
#include <iostream>
using namespace std;
class A
{
public:
char _a = 'A';
};
//B继承于A
class B: public A
{
public:
char _b = 'B';
};
//C也继承于A
class C: public A
{
public:
char _c = 'C';
};
//D继承于B,C
class D: public B, public C
{
public:
char _d = 'D';
};
int main()
{
D d;
//cout << d._a << endl;//报错
d.B::_a = 'E';
d.C::_a = 'F';
cout << d.B::_a << endl;
cout << d.C::_a << endl;
}


E
F

  从以上结果中可以看出d中有着两份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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <iostream>
using namespace std;
class A
{
public:
char _a = 'A';
};
//使用虚拟继承使B继承于A
class B: virtual public A
{
public:
char _b = 'B';
};
//使用虚拟继承使C继承于A
class C: virtual public A
{
public:
char _c = 'C';
};
//D继承于B,C
class D: public B, public C
{
public:
char _d = 'D';
};
int main()
{
//一旦使用虚拟继承那么D类中就只存在一份A的成员变量
D d;
//无论用什么作用域进行访问都只能访问到同一份
d.B::_a = 'E';
cout << d._a << endl;
cout << d.B::_a << endl;
cout << d.C::_a << endl;
d.C::_a = 'F';
cout << d._a << endl;
cout << d.B::_a << endl;
cout << d.C::_a << endl;
//我们甚至还可以这样访问,一旦使用虚拟继承我们可以视d间接继承了A,因此也有了A的作用域
//这在不使用虚拟继承的情况下是无法完成的
cout << d.A::_a << endl;
}



E
E
E
F
F
F
F

  那么虚拟继承是如何做到的这一切呢?在不适用虚拟继承的情况下类D中可以这样表示。

虚拟继承

  但是引入虚拟继承后,编译器就会生成一张虚基表,这张表中存放着这个类与其虚拟继承的基类之间的地址偏移量,而在这个类中也会多生成一个指针被称为虚基表指针指向这张虚基表。因此此时D类中由于所继承的两个基类都是虚拟继承自A类,因此B,C类都会有属于自己的虚基表及指向这张表的虚基表指针,而他们的虚基表中都会存储与同一个A类之间的地址偏移量,因此就可以做到D类中就有唯一的一份A类成员。
  使用虚拟继承D中可以如下表示。

虚拟继承

  但是要注意虚基表中存放的并不是直接指向类A的指针,而是与A的地址偏移量,由此可以找到A。这样就确保了D中只有唯一的一份A的成员,解决了数据冗余以及数据二义性的问题。

1
2
3
4
5
6
7
int main()
{
cout << sizeof(B) << endl;
}


8

  我们取一个虚拟继承的类的大小也可以发现其大小变成了8,这是因为多了一个虚基表指针占了4个字节,加上基类个派生类的两个字符型成员各占2字节,还有2个字节的补齐成了8。不过这样我们知道了虚基表指针是存储在对象中的,那么虚基表存储在哪里呢?这个根据每个编译器的不同都有不同的处理,vs是存储在寄存器中的。
  这里还有一点要注意的,虽然一个继承体系中虚继承自同一个基类的派生类一共只存储一份基类,这是为了防止数据冗余,但是在sizeof()计算每个派生类大小时编译器也是会将基类大小的计算加入每个派生类中的,尽管他们一共只存储一份基类。例如上面的例子如果我们将B/C类属于他们自身的成员去掉我们会发现他们的大小还是有8,这就是因为编译器在每个派生类中还加入了基类大小的计算,这点在虚继承上也不例外。

总结

  在继承这一章,我们透彻的学习了继承的各种知识点,并且还解析了菱形继承以及虚拟继承底层的实现原理,我们不由得产生了一个问题,我什么时候使用继承什么时候使用组合呢?
  继承:是一种is a的关系。
  组合:是一种has a的关系。
  这一点并不难理解,但是实际开发中,我们尽量优先使用组合,因为组合耦合度低,易于开发和维护,我们可以不用过多的考虑基类的实现,而继承不同。但是有一些情况下确实是继承更为符合情景呢么就是用继承,但要小心多继承。继承的使用也是十分广泛的,比如之后要学习的多态,都多亏于继承的语法,当然如果继承和组合都可以使用的话还是使用组合更好。

1234…9
MisakiFx

MisakiFx

Hard working or giving up!!!

86 日志
10 分类
64 标签
GitHub E-Mail 网易云音乐 CSDN

© 2020 MisakiFx
我的网站的访客数:
|
主题 — NexT.Pisces v5.1.4
博客全站共273.4k字