目录
std::bind
定时器 timerfd
时间轮设计
C++11正则库
日志打印宏
通用类型ANY
std::bind
std::bind是C++11提供的一个接口,他的功能:传递一个原始函数对象,对其绑定某些参数,生成一个新的函数对象。
其实简单来说,就是基于原始的函数,我们可以使用 bind ,将其中几个或者所有的参数固定,生成一个新的函数对象。 未来我们调用这个新的函数对象时,其实就相当于在调用原始函数对象,只不过有些参数不需要我们手动传参了,已经在新的函数对象中保存固定了。
官方文档对他的说明如下
他的功能就是参数绑定。
他的用法也很简单:
最简单的,我们可以绑定原始函数对象的所有参数
#include<iostream>
#include<functional>
void f(int a)
{
std::cout << a << std::endl;
}
int main()
{
std::function<void()> f1 = std::bind(f, 3); // 将 函数对象 f 的第一个参数固定,生成一个f1对象
f1();
return 0;
}
我们也可以是多个参数的函数对象的所有参数绑定
void f(int a ,int b ,int c)
{
std::cout << a <<" "<<b<<" "<<c << std::endl;
}
int main()
{
std::function<void()> f1 = std::bind(f, 3 ,4 , 5); // 将 函数对象 f 的三个参数都固定,生成一个f1对象
f1();
return 0;
}
而如果我们对原始函数对象的某一些参数不想绑定,而是在调用的时候在传参,我们就需要使用一个 std::placrholder:: 的成员进行参数的占位。
比如我们在上面的函数中,只想固定第1个和第3个参数,第二个参数想要在调用新的函数对象时再传参,那么我们可以这样写。
std::function<void(int)> f1 = std::bind(f, 3 ,std::placeholders::_1, 5);
理解起来也很简单,新生成的函数对象需要传一个参数,所以他的类型是 function<void(int)>,同时,我们需要使用 std::placeholders:: 内的 _1 ,_2 ,_3 ... 表示后续我们调用 f1 时传递给 f1 的参数,_1表示 第1个参数 , _2 表示第2个参数 ,依次类推,然后 _1 ,_2 在bind中的位置就表示后续这个参数是传给 原函数对象的哪个参数进行调用。
又比如,我们想要自己传递 f 的第一个和第三个参数,而第二个参数固定,那么我们可以这样绑定
std::function<void(int,int)> f1 = std::bind(f,std::placeholders::_1, 666,std::placeholders::_2);
要注意,原函数对象的所有参数都必须要在 bind 中有表现,要不就是直接固定,要不就是使用占位符号,等新函数调用的时候传参,而占位符的位置,就决定了新生成的函数对象的参数的传递,也就是新的函数对象的类型。
那么有了bind之后,我们在项目中进行回调函数的设置时,就不需要过于关心函数的参数的设计了,因为我们可以通过bind来进行绑定,bind在一定程度上降低了代码的耦合度,让不同的代码之间的影响减小了。
定时器 timerfd
在项目中,我们需要用到一个超时连接的释放,这时候我们就需要一些定时任务,可是,我们在代码中怎么设置定时任务呢?
Linux 其实提供了定时器给我们,就是 timerfd , Linux下一切皆文件,所以定时器也是通过文件的方式提供给我们的。 他的原理我们也很好理解,每经过一个我们设置的超时时间,系统会向该文件描述符中写入一个数据,这样就会触发 timerfd 的读事件,就说明时间到了,这就可以辅助我们实现超时管理机制。
同时,Linux为了记录超时的次数,timerfd的底层其实就是保存一个 uint64_t 的数据,如果第一次超时,就写入一个 uint64_t 的1 ,触发读事件, 后续如果我们未读取这个 1 ,而超时事件又到了,那么Linux就会对该数据 进行加1操作,表示超时次数又多了一次。
这个八字节数据我们是需要读取出来,判断超时了几次的,可以非阻塞读取也可以阻塞读取。如果单纯就是为了设置一个定时任务,那么阻塞读取是最好的,因为当没超时时,我们就阻塞在读取上,而是要一超时,文件描述符中就有数据了,我们就结束阻塞了,这时候就可以立马处理这个超时任务。
而Linux提供给我们的接口有以下几个:
首先就是创建一个 timerfd 的接口
int timerfd_create(int clockid, int flags);
返回值毋庸置疑就是创建好的 timerfd ,如果失败,返回 -1.
第一个参数 clockid
clockid 是用来指定我们的定时器使用的时间的基准值的。它可以选择 CLOCK_REALTIME 或者CLOCK_MONOTONIC。
CLOCK_REALTIME:表示使用系统时间来作为计时的基准值。但是我们要注意,系统时间是可以被改变的,一旦系统时间改变,就会影响我们设置的定时任务。不推荐使用
CLOCK_MONOTONIC:表示使用相对时间来计时,这个相对时间就是以系统启动的时间作为基准值。这个时间是不受系统时间的影响的。
我们在实际使用定时器时更推荐使用 CLOCK_MONOTONIC 来作为时间。
而第二个参数 flags ,它可以设置成下面的值
TFD_NONBLOCK:设置文件描述符为非阻塞,未来IO的时候默认采用的就是非阻塞方式了。其实就跟我们给普通文件描述符设置 O_NONBLOCK 是一样的。
TFD_CLOEXEC:设置文件描述符不可被子进程拷贝。正常情况下,我们创建子进程,子进程默认会拷贝父进程的文件描述符,而如果我们给文件描述符设置了 O_CLOEXEC ,那么文件描述符就不会被子进程拷贝了。 而TFD_CLOEXEC就是跟O_CLOEXEC一样的作用,防止timerfd 被子进程拷贝。
一般我们都会设置TFD_CLOEXEC,防止被拷贝, 如果我们写的代码不追求性能,可以忍受读取timerfd时阻塞,我们可以不设置TFD_NONBLOCK,而如果我们要用在高并发场景下,也就是不能容忍阻塞,那么我们就需要同时设置 TFD_CLOEXEC 和 TFD_NONBLOCK。
有了定时器之后,我们还需要设置定时器的超时时间,这时候我们就需要使用
int timerfd_settime(int fd, int flags , const struct itimerspec *new_value,
struct itimerspec *old_value);
第一个参数就是我们要设置的定时器的timerfd ,第二个参数 flags 其实就两个选项 0 和 1 .他其实是和我们创建 timerfd 时指定的时间的基准值有关的。 如果我们使用的是相对时间,也就是CLOCK_MONOTONIC ,那么我们就将flags设置为 0 ,如果我们使用的是绝对时间/系统时间,CLOCK_REALTIME , 那么就将 flags 设置为 1。
在我们的使用中,一般都是使用相对时间,那么flags 一般就是设置为 0 。
后面的两个参数就是跟时间相关的了。 我们看参数名就能看出来,new_value 就是要设置的新的超时时间,是一个输入型参数,而 old_value 则是一个输出型参数,是用于获取旧的超时时间的。
struct itimerspec 结构体中包含两个 timerspec 对象,而 timerspec 对象中就是表示的时间,两个成员,tv_sec 是秒,tv_nsec 是纳秒。
那么这两个结构体都表示什么呢?
it_val 设置的是第一次超时的时间,也就是初次超时时间,而 it_interval 设置的是第二次及之后的超时时间间隔。
这个也很好理解,比如我们设置 it_val 为 3秒, it_interval 为 1秒,那么第一次超时就发生在设置完的 3 秒后,而后每隔一秒,就又会触发一次超时,如果经过了十秒,那么文件描述符中的数据就是 8 。 当然就算我们第一次超时之后将超时次数读走了,后面也还是每隔一秒超时一次。
如果 it_interval 的两个成员都设置为 0 ,那么系统只会在第一次超时之后写入一次数据,后面就不会再进行写入了。我们可以使用这种用法来设置一次超时任务。
在使用这个接口时,我们也可以选择不获取原始的超时时间,那么第三个参数直接传NULL就行了。
基于上面的两个接口,我们可以写一个简单的程序,来检验一下上面讲的知识:
#include<iostream>
#include<sys/timerfd.h>
#include<cassert>
#include<unistd.h>
#include<fcntl.h>
int main()
{
int timerfd = timerfd_create(CLOCK_MONOTONIC,TFD_CLOEXEC|TFD_NONBLOCK);
assert(timerfd!=-1);
struct itimerspec timeout;
//第一次超时时间间隔
timeout.it_value.tv_sec = 2; // 第一次超时为 3 s
timeout.it_value.tv_nsec = 0;
//第二次以及之后的超时时间间隔
timeout.it_interval.tv_sec = 1; // 往后每隔 1s 超时一次
timeout.it_interval.tv_nsec = 0;
int ret = timerfd_settime(timerfd,0,&timeout,NULL); //设置定时通知
assert(ret!=-1); //返回值为-1表示设置失败,但是一般是不会失败的,可以不用关心
sleep(10);
uint64_t times = 0;
//十秒之后读取下超时的次数
ret = read(timerfd,×,sizeof times);
assert(read>=0);
std::cout<<"times:"<<times<<std::endl;
return 0;
}
在这个程序中,我们首先创建了一个定时器,获取到文件描述符,然后设置第一次超时时间为 2s,往后每隔 1s 超时一次。
设置完之后,我们休眠 10 s ,之后读取timerfd中的超时次数。 正常来说,读取到的应该是 9 。
我们也可以每隔一秒非阻塞读取一次,如果读到了数据,那么就打印一下提示信息。 我们可以记录每一次超时距离我们启动超时监控的时间点的秒数。
int sec =0;
uint64_t times = 0;
while(1)
{
sleep(1);
sec++;
ret = read(timerfd,×,sizeof times);
if(ret>=0)
{
std::cout<<"第"<<sec<<"秒触发了一次超时"<<std::endl;
}
}
时间轮设计
有了Linux提供的timerfd之后,我们怎么检验超时任务呢?
其实很简单,我们需要为每一个连接维护好上一次活跃的时间,然后每隔一秒或者每隔几秒,这一点可以通过timerfd的读事件来控制,就遍历所有的连接,只要连接上一次活跃的时间跟当前时间间隔超过了设定的超时时间,那么就表示这是一个超时连接了,我们就需要释放。
但是这就意味着,每次检测超时连接的时候都需要将所有的连接遍历一遍,效率很低。
那么还有一种思路,就是维护一个堆或者说优先级队列,按照上一次活跃的时间来建堆,建小堆,那么每次我们检验超时的时候,就只需要看堆顶的连接,如果堆顶连接超时了,那么释放之后,再看更新之后的堆顶连接是否超时。 但是使用堆来做的话,未来我们刷新超时时间的时候效率就很低了。或者说,维护这个堆的成本本身就不低。
我们可以用一种十分巧妙的方案来完成超时销毁的功能。
其实,我们转换以下思路,所谓的超时销毁,可以理解为是一种定时任务,设置超时销毁,就是在当前时间加上超时时间的时间点上挂上一个任务,当时间到了,就执行该任务。
这种思想我们我们联系现实生活中的钟表。
钟表是有秒针的,秒针每秒移动一格。 我们将其抽象化,将钟表的所有的秒的格子理解为一个数组,数组中的每一个元素就表示一分钟内的某一秒中。
而这份秒针每秒钟向后走一格,其实就是 timer_idx = (timer_idx+1)%60 。
如果我们在某一个时刻,要设置一个 10秒钟 之后执行的定时任务,那么怎么做呢?很简单,把对应的任务放到 (timer_idx+10)%60 的位置上。 那么当我们的 timer_idx 往后移动的过程中,十秒之后,就移动到了该任务的位置上,那么执行这个位置的任务。
那么上面的结构其实就是一个 vector<func_t> timer_wheel(60) ,那么可能有人就要说了,我们在一个时间点内,可能不止一个任务,可能有多个任务,这也很简单,在上面的数组的基础上,我们的每一个元素都是一个数组,那么每次 timer_idx 走到某一个位置的时候,就执行对应位置的一维数组的所有任务。
这也是很好设计的,无非就是使用一个 vector<vector<func_t>> timer_wheel(60) 结构就行了,那么未来我们要添加定时任务的时候,无非就是定时任务 push_back 到对应的一维数组中。
不过如果简单只是一个秒级时间轮的话,他只能应付秒级定时任务,也就是只能设定 60s 之内的任务。
那么如果我们的需求或者使用场景可能会有超过 60 秒的任务,这时候要怎么办呢?
这也很简单,还是仿照钟表,我们可以在设计一个分钟级的时间轮,他的设计也可以参考秒级时间轮的设计,
vector<vector<vector<func_t>>> timer_wheel(60 , vector<vector<fund_t>>(60));
最外层的数组是表示在距离当前事件的第几分钟,而每一分钟内又是一个秒级的时间轮。
那么每一秒我们还是会移动秒针,当秒针的值加到60的时候,移动分针,秒针归0。
而如果我们的任务定时可能超过一小时的话,那么还可以在嵌套一层 vector ,用来表示距离当前时间的时针 之后的多少个小时,每个小时内部又是一个分钟级时间轮,每一分钟内又是一个秒级时间轮。
如果定时任务时间更长的话,那么继续嵌套设计。
而在我们的项目中,实际不会用到分钟及以上的定时任务,超时时间都在 60s 以内,所以只需要一个秒级时间轮就行了。
那么时间问题搞定了,我们就需要考虑如何更方便的执行定时任务了。
如果按照正常思维的话,那么我们的秒针每次移动的时候,都需要遍历一个 vector ,依次执行这一秒钟到期的任务。 但是这样做不够优雅,我们能不能设计出,秒针移动到对应的时间之后,自动执行对应的任务呢?
我们需要知道,当秒针移动到该位置的时候,除了执行对应的任务,还需要将这一秒钟的任务对象移除,既然要移除,那么我们能不能将定时任务封装成一个类,然后将定时任务的执行放在对象的析构函数中,析构函数是在一个对象销毁时自动调用的。那么每次秒针移动到对应的位置,我们只需要执行 timer_wheel [timer_idx].clear(),那么在当前时间的所有的定时任务对象都会被销毁,那么会自动调用析构函数,析构函数中会执行外部传进来的定时任务。
那么说干就干,先来设计一个定时任务类,这个类的设计其实很简单,它只需要保存一个函数对象或者说回调方法,然后我们还需要一个任务 id ,用来作为这个定时任务的唯一标识,方便我们后续对其进行管理。 同样,超时时间也需要外界传递给定时任务,所以我们还需要保存一个超时时间。
class TimerTask
{
using task = std::function<void()>; //无参的回调,如果需要参数,有上层进行使用std::bind() 进行参数的绑定
private:
uint64_t _id;
uint64_t _delay;
task _cb;
public:
TimerTask(uint64_t id,uint64_t delay,task cb):_id(id),_delay(delay),_cb(cb){}
~TimerTask(){_cb();}
};
那么时间轮类该如何设置呢?
首先,时间轮需要有一个vector<vector<>> ,用来存放对应时间需要执行的任务,同时,时间轮还需要一个 unordered_map 来管理我们的定时任务,防止同一个任务 id 被重复设置,以及为后续刷新任务或者取消任务提高查找的效率。 还需要一个秒针一直向后移动。
但是现在的问题是在TimerWheel中如何管理TimerTask对象呢?
这时候我们就需要考虑定时任务刷新的逻辑了。
定时任务刷新,如果按照常规思维,那么我们就是需要拿着要刷新的任务的 id ,然后去时间轮数组中遍历,找到对应的任务,然后将其重新放到一个新的位置上。
但是如果要遍历这个二维数组,那么效率就很低了。 我们在设计的时候,不是已经有了一个 map 用来存放 id 和 TimerTask 的映射关系了吗? 我们是不是可以从这里下手。 当需要刷新定时任务的时候,我们只需要在 map 中找到对应的 TimerTask,然后直接在新的位置上在挂上这个任务。但是这时候存在一个问题,就是我们原来挂的这个定时任务并没有从数组中删除,同时我们也不敢直接删除,因为删除的话就需要析构了。所以我们至少不能在栈上为定时任务申请空间,而是需要在堆上申请,后续拿指针进行操作。
但是尽管如此,当我们的秒针走到这个超时任务的原定时间时,还是需要将这个位置上的任务全部执行。同时,如果是使用原生指针进行管理的话,那么我们还不能直接 clear ,我们需要一个一个遍历,然后 delete 释放堆空间。
所以现在就面临两个问题: 原位置上的定时任务会执行,并且释放空间 ; 刷新之后,新位置的指针是个野指针。
那么基于这两个问题,我们可以使用 shared_ptr 来管理堆上开辟的定时任务。
原因很简单,我们需要秒针再经过原位置的时候,直接clear,释放了旧的 shared_ptr 对象,但是由于刷新过定时任务,我们还有一个该对象的 shared_ptr 对象在后面,那么就意味着,我们不会真正释放这个堆空间,只是将计数减 1 。 只有计数为 0 的时候,才会真正释放这个对象调用析构函数,而执行定时任务。
那么就达到了刷新或者说延迟定时任务的目的。
但是又有一个问题,因为我们需要通过 map 中保存的定时任务对象的信息来构造一个新的 shared_ptr 对象加入到时间轮中,如果在 map 中也保存该对象的 shared_ptr 的话,就会占用一个计数,那么我们的定时任务就永远不会执行。 所以我们在设计 map 的时候,里面不能保存TimerTask对象指针的 shared_ptr ,而是保存他的 weak_ptr ,那么未来我们在延迟定时任务的时候,也可以直接通过 weak_ptr 来构造一个 shared_ptr , 但是我们的这个 weak_ptr 必须要从原始的shared_ptr 来,之后这样,后续使用 weak_ptr 构造的 share_ptr 对象才会和原始的 shared_ptr共享一个计数。
那么根据上面的思路,我们在数组中就使用 shared_ptr来管理任务对象,而在map中使用 shared_ptr的weak_ptr来保存定时任务的信息,方便查找以及构造新的shared_ptr 来增加原始shared_ptr的计数。
class TimerWheel
{
using task = std::function<void()>;
private:
std::vector<std::vector<std::shared_ptr<TimerTask>>> _wheel;
std::unordered_map<uint64_t,std::weak_ptr<TimerTask>> _tasks;
int _timer_idx;
public:
};
而他的接口设计也很简单了,对于任务的操作就是三个接口: 添加定时任务,刷新/延迟定时任务,以及取消定时任务,然后就是执行任务,这时候我们其实就只需要移动秒针就行了。
public:
TimerWheel():_wheel(60),_timer_idx(0){} //我们默认时间轮的最大刻度就是 60
//添加定时任务
bool AddTimerTask(uint64_t id , uint64_t delay,task cb);
//刷新/延迟定时任务
bool RefreshTimerTask(uint64_t id );
//取消定时任务
bool CancelTimerTask(uint64_t id );
//移动秒针
void RunTick();
添加定时任务
添加定时任务的思路很简单,外部需要传入定时任务的相关信息,我们只需要检查 id 是否合法,以及构建 shared_ptr 和 weak_ptr 对象管理起来,然后将shared_ptr 对象放入对应的位置就行了。
bool AddTimerTask(uint64_t id , uint64_t delay,task cb)
{
assert(_tasks.find(id) == _tasks.end()); //确保 id 合法
std::shared_ptr<TimerTask> pt(new TimerTask(id,delay,cb)); //构建任务对象
std::weak_ptr<TimerTask> wpt(pt); //weak_ptr
int pos = (_timer_idx + delay) % MAXTIME; //计算到期时间
_wheel[pos].push_back(pt); //定时任务放入时间轮
_tasks[id] = wpt; //添加到map中管理
return true;
}
刷新/延迟定时任务
延迟定时任务的时候,我们要注意,不是使用 weak_ptr 直接一个新的 shared_ptr ,因为标准库允许我们直接使用 weak_ptr 构造shared_ptr 的话,其实会有一种情况,就是这个weak_ptr 对应的原始shared_ptr或者说资源已经被释放了,但是我们还使用这个 weak_ptr 来构造的话,就相当于构造一个 shared_ptr 去管理一个没有权限的资源了,这是不允许的。
实际上,我们要使用weak_ptr 构造 shared_ptr 的时候,要调用 weak_ptr 的 lock 接口.
调用lock接口的时候,如果对应的空间未释放,那么就会返回该空间的一个 shared_ptr ,这个shared_ptr 和构造 weak_ptr 的shared_ptr 是共享计数的。 因为他会使用 weak_ptr 中保存的信息来构造。 而如果这个空间已经被释放了,那么它会返回一个空的shared_ptr对象。
那么构造出一个新的shared_ptr 之后,我们将其插入到新的到期时间的位置就行了。
在我们的设计中,如果延迟定时任务的话,那么是直接延迟初始设置的 delay 的时间,所以我们的TiemrTask类还需要提供一个接口用于获取构造的 delay 。
uint64_t GetDelay(){return _delay;} //获取定时任务设置的延时
//刷新/延迟定时任务
bool RefreshTimerTask(uint64_t id)
{
std::unordered_map<uint64_t,std::weak_ptr<TimerTask>>::iterator it = _tasks.find(id);
if(it==_tasks.end()) return false; //id 不合法直接返回false
std::shared_ptr<TimerTask> pt = it->second.lock(); //构造新的shared_ptr
int pos = (_timer_idx + pt->GetDelay()) % MAXTIME; //找到新的位置
_wheel[pos].push_back(pt);
return true;
}
移动秒针并执行到期任务就
移动秒针就很简单了,只需要 _timer_idx 加一就行了,然后就是 clear 掉对应时间的vector。
但是有一个问题,就是如果我们直接 clear 这一个vector,我们怎么清理 map 中的管理的数据呢?一个任务被执行之后,势必是要把信息去掉的。
这时候,我们还是很好想到,删除 map 中的数据,其实和执行定时任务是同一时间的,也就是前后脚的关系,那么我们是不是可以在 Ti恶魔人Wheel类中设置一个接口,专门用来通过id删除map中的映射,然后跟定时任务一样,也是在对象析构的时候调用,这样不就好了吗。
//删除map的映射
bool RealeaseTask(uint64_t id)
{
std::unordered_map<uint64_t,std::weak_ptr<TimerTask>>::iterator it = _tasks.find(id);
if(it==_tasks.end()) return false;
_tasks.erase(it);
return true;
}
那么我们只需要在添加定时任务的时候,将这个函数也设置成TimerTask的回调,不就好了吗,无非就是TimerTask中在增加一个成员。
那么修改之后的 TimerTask 类就应该是这样的。
class TimerTask
{
using task = std::function<void()>; //无参的回调,如果需要参数,有上层进行使用std::bind() 进行参数的绑定
using releasetask = std::function<void(uint64_t)>;
private:
uint64_t _id;
uint64_t _delay;
task _cb;
releasetask _release;
public:
TimerTask(uint64_t id,uint64_t delay,task cb,releasetask rcb):_id(id),_delay(delay),_cb(cb),_release(rcb){}
~TimerTask(){_cb();_release(_id);}
uint64_t GetDelay(){return _delay;} //获取定时任务设置的延时
};
而TimerWheel类在添加定时任务的时候也需要再额外传一个参数
std::shared_ptr<TimerTask> pt(new TimerTask(id,delay,cb,std::bind(&TimerWheel::RealeaseTask ,this,std::placeholders::_1))); //构建任务对象
//移动秒针
void RunTick()
{
_timer_idx ++;
_timer_idx %= MAXTIME;
_wheel[_timer_idx].clear();
}
取消任务
取消任务其实有点绕。因为我们的对象是在添加任务的时候就已经构造好了,后续就算取消定时任务,这个定时任务对象也会需要调用析构函数,所以我们要想办法让已经取消的任务不再调用任务回调函数,但是与此同时,也需要调用 release 回调函数来删除掉 map 中的信息。
这时候我们自然而然的就想到了再在TimerTask类中增加一个成员,用来表示该定时任务是否取消,然后在析构函数的时候就可以根据这个成员来判断是否还需要执行任务的回调函数。
bool _is_canceled; //表示是否被取消
我们将这个成员初始化为 false ,表示还未被取消,那么在析构的时候,如果他为 false 就执行任务回调,否则就不需要执行任务回调。
那么在TimerWheel中的取消任务的接口就很简单了,只需要将对应的定时任务的对象的_is_canceled设置为 false就行了。
不过由于在 TimerWheel中无法直接访问 TiemrTask 类中的成员,所以我们需要在TimerTask类中专门设置一个成员函数用于取消某个任务。
//TimerTask类
void CancelTask(){_is_canceled = true;}
然后在TimerWheel类中直接调用这个函数就行了。
不过在这里要注意的是,由于weak_ptr没有重载 -> ,所以我们需要构造出一个临时的shared_ptr 来调用对象中的接口。
//取消定时任务
bool CancelTimerTask(uint64_t id)
{
std::unordered_map<uint64_t,std::weak_ptr<TimerTask>>::iterator it = _tasks.find(id);
if(it==_tasks.end()) return false;
(it->second).lock()->CancelTask();
return true;
}
这样一个时间轮就完成了。
我们可以测试一下这个时间轮是否有问题,
测试的时候,为了检测我们创建的定时任务对象是否都正确释放,我们可以在构造和析构的时候将资源的地址打印出来。
然后我们可以用这样一段代码来测试:
#include"TimerWheel.hpp"
#include<unistd.h>
void f1(uint64_t n){std::cout<<"f1() -- taskid: "<<n<<std::endl;}
void f2(uint64_t n){std::cout<<"f2() -- taskid: "<<n<<std::endl;}
void f3(uint64_t n){std::cout<<"f3() -- taskid: "<<n<<std::endl;}
void f4(uint64_t n){std::cout<<"f4() -- taskid: "<<n<<std::endl;}
void f5(uint64_t n){std::cout<<"f5() -- taskid: "<<n<<std::endl;}
int main()
{
TimerWheel tw;
uint64_t id = 1 ;
tw.AddTimerTask(id,5,std::bind(f1,id));
id++;
tw.AddTimerTask(id,5,std::bind(f2,id));
id++;
tw.AddTimerTask(id,5,std::bind(f3,id));
id++;
tw.AddTimerTask(id,5,std::bind(f4,id));
id++;
tw.AddTimerTask(id,5,std::bind(f5,id));
id++;
int t =0;
while(1)
{
sleep(1);
t++;
std::cout<<"第"<<t<<"秒:" <<std::endl;
tw.RunTick();
if(t==3)
{
//第 3 秒时取消任务4,刷新任务 5
tw.RefreshTimerTask(5);
tw.CancelTimerTask(4);
}
}
return 0;
}
在我们这段代码中,首先添加了五个5s的定时任务,然后在一个循环中打印每一秒发生了什么。
而在循环中,还有一个逻辑就是,在第三秒时,我们取消了 4 号任务,延迟了 5 号任务,那么按照我们的逻辑,在 第 5 秒的时候,会执行 1,2,3的任务,同时会释放1,2,3,4 这四个任务对象,然后再第 8 秒的时候,会执行并释放第 5 个任务。
C++11正则库
正则表达式(regular expression)是一种字符串匹配格式,可以用来检查一个字符串是否含有某种子串,将匹配的子串替换,取出匹配的子串等。
为什么要使用正则表达式呢?
因为后续我们需要支持HTTP协议,其实不管是什么协议都是一样的,因为他们有固定的报文格式,我们使用正则表达式就可以在解析报文的时候方便很多。
当然,使用正则表达式只是让程序员的工作变得简单,并不意味着处理效率会变高,实际上使用正则表达式的效率是低于直接匹配的。
而C++11支持了正则库。
我们主要使用一个函数:
这个接口简化理解就是:
bool regex_match ( const string& src , smatch& matches , regeg& e );
他需要传入三个参数,一个是原字符串,第二个是我们需要提取的匹配的子串,结果存放在一个容器,我们可以理解为数组,第三个参数就是一个正则表达式,正则表达是里面就是我们的匹配方法。 而函数的返回值表示是否匹配成功。在结果matches 中,matches第一个保存的其实是我们的原始的字符串。
比如我们可以写一个最简单的正则表达式:
std::string src = "/telephone/121";
std::smatch matches;
std::regex e("/telephone/(\\d+)");
bool ret = std::regex_match(src,matches,e);
if(!ret) abort();
for(auto s : matches)
std::cout<<s<<std::endl;
这段代码使用了一个简单的正则表达式,正则表达式中, \d 表示匹配一个数字字符, \d+ 表示匹配多个数字字符,由于 \ 是特殊字符,所以需要使用 \ 来对 \ 进行转义。 正则表达式使用 ( ) ,圆括号里面的东西就是我们要提取出来的符合匹配规则的子串。
那么这段代码的运行结果如图:
而未来我们需要使用正则表达式来对 HTTP 的请求行来做分割与匹配,首先我们需要知道HTTP请求行的基本格式就是 请求方法 url 协议版本 ,其中 url 又包含资源路径和提交的参数。那么我们一个一个来提取。
我们可以先来看一下基本的匹配的语法
提取请求方法
我们常用的请求方法其实就几种 ,GET ,POST , PUT , GET , DELETE ,GEAD 这几个方法,同时,请求方法是出现在请求方法的最开头,而他只有几种情况,那么我们可以直接将这几种情况列出来,只要匹配任何一个请求方法就提取出来,我们可以使用 | 。
那么我们可以使用这样一个正则表达式:
std::regex e ("(GET|POST|PUT|HEAD|DELETE).*");
首先,括号中的就是我们要提取的满足匹配规则的字符子串,只要满足任何一个条件,我们就会提取出来。比如我们使用这样一个 请求行来用作示例:
std::string src = "GET /index.html?name=zhangsan&pwd=12345 HTTP/1.0";
那么首先,会匹配上 GET 并提取放到 matches 中。而后面的 . 类似于一个通配符,他能匹配除\r和\n之外的所有字符,而 * 表示前面的匹配规则匹配任意次 ,注意,任意次可以是 0 次,也可以是多次。
std::string src = "GET /index.html?name=zhangsan&pwd=12345 HTTP/1.0";
std::smatch matchs;
std::regex e ("(GET|POST|PUT|HEAD|DELETE).*");
bool ret = std::regex_match(src,matchs,e);
for(auto s : matchs)
std::cout<<s<<std::endl;
那么上面这个程序运行出来的结果就是:
提取资源路径
资源路径是在请求方法之后,中间隔了一个空格,而资源路径和提交的参数之间隔了一个 ?,那么我们就可以以 ?作为资源路径的结束。
我们可以使用如下的正则表达式:
std::regex e ("(GET|POST|PUT|HEAD|DELETE) ([^?]*).*");
注意,请求方法和资源路径之间是有空格的
[^?] 表示匹配非 ? 字符,而 * 表示匹配任意多次。
那么我们正则表达式在匹配的时候,就会向后匹配到第一个 ? 然后终止,将这一个子串放入我们的macthes中,而后面剩下的字符我们暂时使用 .* 来通配。
运行结果:
提取查询字符串
查询字符串/提交参数 就是在资源路径后面的 ? 后面,而在空格之间的这个子串。
那么这里有两种思路,一种就是找这个子串的结尾,也就是空格,那么我们可以按照上面的方法:
std::regex e ("(GET|POST|PUT|HEAD|DELETE) ([^?]*)\\?([^ ]*).*");
我们可以在资源路径之后,先把 ?匹配了,在正则表达式中, ? 是特殊字符,所以需要使用 \? 来表示 ? ,而由于 \ 也是特殊字符,所以 \ 也需要使用 \ 来转义,所以我们需要使用 \\? 来匹配一个?。
而在 ? 之后,我们匹配并提取非空格字符任意多个,直到遇到空格 也就是: ([^ ]*),后续的字符我们还是先使用 .* 来通配。
结果:
当然,我们也可以使用通配字符来匹配,我们可以这样用:
std::regex e ("(GET|POST|PUT|HEAD|DELETE) (.*)\\?(.*) .*");
我们可以直接匹配多个通配字符,而在通配字符之后,我们可以设置一个通配的结束字符,比如上面的我们匹配资源路径的时候 使用 ? 来结尾, (.*)\\? ,匹配参数的时候使用空格来结尾,(.*) ,那么也可以把我们需要的东西提取出来。
运行结果:
提取协议版本
我们所使用到的协议版本其实就是 HTTP1.0 和 HTTP1.1 ,那么意思就是HTTP/1. 这几个字符可以直接匹配,剩下一个字符是 0 或者 1 ,那么我们可以使用 [ ] 来匹配。
std::regex e ("(GET|POST|PUT|HEAD|DELETE) (.*)\\?(.*) (HTTP/1\\.[01]).*");
还是一样的, . 是特殊字符,所以我们可以使用 \\. 来表示 . ,而后面要匹配的字符为 0 或者 1 中的其中一个,我们可以使用 [01] 来匹配。 提取 HTTP/1. 开头,后面跟了一个 0 或者 1 的子串。
结果:
那么目前就已经完成了一个最基础的HTTP请求行提取的正则表达式,但是上面的正则表达式还是太过粗糙了,有很多的细节问题。比如 HTTP 协议版本不一定就是 1.0 或者 1.1 ,也可能是 2.0 的,如果我们使用上面的匹配规则,就会匹配失败
这个我们也可以暴力解决。也就是用前面我们所使用到的请求方法中的 | 这个提取方法
std::regex e ("(GET|POST|PUT|HEAD|DELETE) (.*)\\?(.*) (HTTP/1\\.0|HTTP/1\\.1|HTTP/2\\.0).*");
这种暴力就能解决了。
还有一个问题就是,我们上面使用的案例其实都是没有 \r\n 或者 \n 的,而实际上的客户端发送的HTTP请求的请求行的默认是会出现 \r\n 或者 \n(有的客户端设计不严谨,会出现 \n 结尾),那么这时候我们要怎么办呢?拿什么来匹配 \r\n 或者 \n ? 首先,前面的通配符 . 我们已经明确说了无法匹配 \r和\n 这两个字符。
我们可以考虑到: 换行符就两种, \r\n 和 \n ,那么我们是不是可以直接使用 | 来匹配呢?我们可以试一下:
std::string src = "GET /index.html?name=zhangsan&pwd=12345 HTTP/2.0\r\n";
std::smatch matches;
std::regex e ("(GET|POST|PUT|HEAD|DELETE) (.*)\\?(.*) (HTTP/1\\.0|HTTP/1\\.1|HTTP/2\\.0).*\r\n|\n");
我们发现,regex_match 也能调用成功。
但是如果这样用的话,那么如果这个请求行就是没有 \r\n 或者 \n 呢?有可能上层在提取的时候,已经把换行符拿走了,这时候就会出匹配失败,这是毋庸置疑的。
那么我们的匹配规则其实是:可以出现 \r\n 或者 \n 或者二者都不出现。
我们可以这样用:
(?:\r\n|\n)?
我们可以注意到,最开始我们使用 | 的时候,是需要一个括号的,在括号里面使用 | ,但是使用()的话,里面的字符串就要拿出来,但是实际上我们并不需要这个 \r\n或者\n,所以我们可以在前面加上一个 ?: ,表示在这个() 中匹配的字符串我们不提取,我们只是需要这个 ()来帮助我们进行匹配。 而括号外面这个 ? ,表示前面这个括号里面的匹配规则可以匹配依次或者匹配0次,那么这样就可以应对没有换行符的情况了。
那么,现在我们还需要考虑最后一种情况就是:
请求行中可能没有携带参数部分,这是可能出现的,所以其实前面的参数匹配的()后面我们也是需要带上 ? ,表示匹配1次或者0次的。
但是这时候我们要考虑的其实就多了,因为如果没有参数的话,那么我们的资源路径的提取就不能使用 ? 作为结尾。
最终具体的操作我们在 HTTP 部分再来进行讲解。
日志打印宏
在项目中,我们在完成了一个小模块之后,都避免不了要进行调试,但是如果能够通过打印信息来判断出错原因,我们一般就不会使用 gdb 来进行调试,毕竟gdb 没有想象中的这么好用。所以我们肯定是需要一个日志的打印的函数或者宏的。
首先,对于日志打印的宏,我们需要获取到在哪个文件的哪一行调用这个宏的,未来方便我们排查错误。 其次,我们需要一个错误信息,也就是一个字符串,这是最基础的。
那么我们的最简单的日志宏就是这样的:
#define LOG(msg) fprintf(stdout,"[%s:%d] %s\n",__FILE__,__LINE__,msg)
在这个宏中,我们使用 fprintf 来打印,这是因为,后续我们可以将stdout换成一个文件,将日志输出到文件中。 而我们可以借助两个全局的宏,__FILE__,__LINE__来获取当前所在文件以及所在行数。
那么在此基础上,我们需要能够承载更多信息的日志打印。
首先,未来我们可能需要将时间也打印出来,因为我们需要检测超时释放这个功能。
那么怎么获取时间呢?我们可以使用 localtime接口来获取当前的时间。
这个接口需要传入一个 time_t* 的参数,而后返回一个当前时间的结构体对象。
那么我们就需要定义一个 time_t 的变量,以及调用loacltime 获取一个struct tm 的结构体,这时候就不是简单的一行代码能解决的了,所以我们需要宏体封装成一个代码块,使用 do{ }while(0) 封装,同时,由于宏体必须是一行,我们需要在每一次换行的时候加上 \ 进行续行。
#define LOG(msg) do{\
time_t t =time(NULL);\
struct tm* ptm = localtime(&t);\
fprintf(stdout,"[%s:%d] %s\n",__FILE__,__LINE__,msg);\
}while(0)
那么获取到这个 struct tm对象之后,怎么用呢?它里面有很多成员
我们怎么将其转换为我们想要的格式呢?
这时候我们需要一个新的接口: strftime ,它用于将一个 struct tm 对象转换为字符串形式的我们指定的格式的时间。
他有什么格式呢?
其实我们所需要的就只是 时分秒 这样的时间就行了,这些 %H 之类的符号,我们可以理解为 printf中的 %d %s 等占位符,但是它只需要传入一个 struct tm 对象就行了。
同时,这个函数需要一个 char* 参数,作为输出型参数,转换出来的特定格式的时间会以字符串的形式放在这个参数对应的空间中。
所以我们可以这样用:
#define LOG(msg) do{\
time_t t =time(NULL);\
struct tm* ptm = localtime(&t);\
assert(ptm);\
char str[16]={0};\
size_t ret = strftime(str,sizeof str -1,"%H:%M:%S",ptm);\
assert(ret!=-1);\
fprintf(stdout,"%s [%s:%d] %s\n",str,__FILE__,__LINE__,msg);\
}while(0)
同时,我们不想让日志的格式过于固定,未来我们想要像使用 printf 一样来使用 日志打印,也就是说,未来我们可以传入格式,以及对应的参数,我们想要将其设置为可变参数的版本。
LOG( format , ...)
同时,在日志宏中,会固定打印时间,文件以及行数,同时也会打印我们所传入的指定格式的内容。那么怎么使用可变参数呢?
不定参数在接收的时候,可以使用 ... 来接收,但是在使用的时候,需要使用 __VA_ARGS__ 这个宏。
#define LOG(format,...) do{\
time_t t =time(NULL);\
struct tm* ptm = localtime(&t);\
assert(ptm);\
char str[16]={0};\
size_t ret = strftime(str,sizeof str -1,"%H:%M:%S",ptm);\
assert(ret!=-1);\
fprintf(stdout,"%s [%s:%d] " format"\n",str,__FILE__,__LINE__,__VA_ARGS__);\
}while(0)
但是如果单纯这样用的话,其实也有一个问题,我们正常这样使用:
LOG("%s","log");
传了 format 和可变参数不为0的时候,是没问题的。
但是当我们只传了 format ,可变参数个数为0的时候,就会出现问题
LOG("log");
在正常情况下,我们是可以这样用的,就好比我们使用printf的时候也经常这样用。
那么要怎么解决呢?或者说为什么这样用就会报错呢?
其实很简单,问题就出在了
fprintf(stdout,"%s [%s:%d] " format"\n",str,__FILE__,__LINE__,__VA_ARGS__);
在使用 fprintf 的时候,如果我们可变参数个数为 0 个,那么编译的时候,展开可变参数之后,就变成了这样:
fprintf(stdout,"%s [%s:%d] "log"\n",str,__FILE__,__LINE__,;
我们很容易发现,问题就出在了__VA_ARGS__前面的这个逗号上,如果我们的可变参数个数为0,那么这个逗号是多余的。
那么为了避免这种情况,我们可以在使用 __VA_ARGS__的时候,在其前面加上 ## ,这个##在这里的作用就是,如果__VA_ARGS__中参数个数为0,那么他就会将前面的这个 , 去掉。
所以我们正确的写法是这样的:
#define LOG(format,...) do{\
time_t t =time(NULL);\
struct tm* ptm = localtime(&t);\
assert(ptm);\
char str[16]={0};\
size_t ret = strftime(str,sizeof str -1,"%H:%M:%S",ptm);\
assert(ret!=-1);\
fprintf(stdout,"%s [%s:%d] " format"\n",str,__FILE__,__LINE__,##__VA_ARGS__);\
}while(0)
未来我们在使用__VA_ARGS__传参的时候,最好都加上 ## 。
那么到目前,我们的日志打印宏其实已经能用了。
但是,我们还需要再加一个设定,就是日志等级。 日志等级有什么用呢? 日志信息是分等级的,这一点我们在网络或者说系统的学习过程中,使用日志的时候就已经提到过了。 在使用日志的过程中,有时候我们只是单纯的打印一下获取的结果,来判断是否和我们的预期相符,这时候其实就类似于printf。 而有时候,我们是想打印一些调试信息,便于我们后续如果出现问题我们能够更简单定位问题。 而有时候,我们是想打印一些程序出现的错误,这时候就类似于 perror 。
那么非等级有什么好处呢?很简单,日志分等级的话,我们就可以有目的性的选择需要打印的信息。比如我们的程序出现了一些bug,导致程序崩溃,那么这时候,我们其实想要看到的日志信息是调试信息以及错误信息,而不想看到那些正常的打印,这时候我们就可以设定只打印调试等级及更高等级的日志信息,而不打印低等级的信息。
那么要怎么设计呢?
我们可以将日志等级都用宏定义出来,同时定义一个打印的最低等级。
//日志等级
#define NORMAL 0
#define DEBUG 1
#define ERROR 2
//要打印的日志等级
#define ENABLE_LOG_LEVEL DEBUG //假设我们只打印DEBUG及以上的等级
那么我们其实可以在封装三个宏,分别用来打印不同等级的日志,
//普通日志
#define NORMAL_LOG(format,...) LOG(NORMAL,format,##__VA_ARGS__);
//调试日志
#define DEBUG_LOG(format,...) LOG(DEBUG,format,##__VA_ARGS__);
//错误日志
#define ERROR_LOG(format,...) LOG(ERROR,format,##__VA_ARGS__);
然后在LOG宏中再接收一个日志等级的参数
#define LOG(level,format,...) do{\
if(level<ENABLE_LOG_LEVEL) break; /*过滤掉低等级的日志信息*/ \
time_t t =time(NULL);\
struct tm* ptm = localtime(&t);\
assert(ptm);\
char str[16]={0};\
size_t ret = strftime(str,sizeof str -1,"%H:%M:%S",ptm);\
assert(ret!=-1);\
fprintf(stdout,"%s [%s:%d] " format"\n",str,__FILE__,__LINE__,##__VA_ARGS__);\
}while(0)
那么未来我们想要打印指定等级和以上的日志信息时,就可以直接修改ENABLE_LOG_LEVEL就行了。
int main()
{
NORMAL_LOG("normal");
DEBUG_LOG("debug");
ERROR_LOG("error");
return 0;
}
那么目前我们就设计这样一个日志打印功能就行了。
通用类型Any
Any类是用于在Connection中保存协议处理的上下文的,而我们的Connection是在服务器模块中,他是不能和具体的应用层协议强耦合,而未来这个服务器的上层可能是各种的应用层协议,而对于每一个连接需要保存请求处理的进度上下文。 那么就注定我们的Connection 需要一个对象或者说变量,能够用来保存不同类型的数据,但是它本身的类型又必须是固定的,不受保存的数据类型的影响。
用C语言设计的话,我们可以使用一个 void* 来保存不同类型的数据。而在C++中,boost库和C++17给我们提供了一个通用类型any,我们可以使用他来保存任意类型的数据。
但是我们不使用库里面的,而是自己设计一个Any类,他的功能其实就类似于库里面的any,只不过他只提供一些基础的功能。
那么我们要怎么设计这个类呢?
在C++中,我们想要设计一个类,用来保存不同类型的数据,那么难免会想到模板类,但是即使是使用模板类,它里面的成员的个数也是固定的,只支持成员的类型的不同。同时,使用模板的话,实例化的时候,我们也需要指定模板的类型参数,也就是说我们需要这样声明或者定义一个对象Any<int> 。
但是这样一来,不就出现悖论了吗? 我们想要的是不管什么类型都能直接保存在Any中,而如果我们Any<int>这样声明,就只能保存int的数据了,类型还是固定了。
同时,由于服务器本身是不知道未来保存在Any中的数据是什么类型的,所以我们也根本就无法指定类型参数。
我们需要的是这样的定义方式 Any a; 这里的a可以是任意类型。
这时候,我们可以想到一种方法,就是在类对象中不保存数据本身,而是保存数据的指针,当然可能是我们自己拷贝的一份数据的指针。 但是其实这也是无法做到的,因为我们既然要保存数据的指针,那么我们也是需要知道数据的类型的,这里当然也可以直接使用 void* 来保存,但是我们认为这样十分不安全,我们需要存储这个数据的类型,未来上层需要取出这个数据时,我们也需要保证他需要的类型就是我们保存的数据的类型。 如果使用 void* ,就不能进行类型的检查了。
既然我们无法使用一个类直接保存,那么我们可以考虑嵌套定义类。
我们可以设计一个类,他只负责保存不同类型的数据,而我们的Any类则只需要保存这个类就行了,未来的功能我们也可以使用多态来提供。
class Any
{
private:
class Data // 保存数据的类
{
private:
//保存数据的指针
public:
};
Data _data;
public:
};
但是这样设计的话,我们还是会发现一个问题,由于 Any 类是需要定义一个Data类对象的,而Data类中需要保存任意类型。 那么Data类的成员怎么声明呢?数据的指针声明成什么类型呢?这就又回到了上面的问题。 不管怎么说,这个 Data 类其实必须要是模板类,否则它内部无法声明指针类型。
同时,由于我们构造Any对象的时候,会使用不同类型的数据,那么Any类的构造函数也必须是模板函数,那么我们目前的设计就是这样的。
class Any
{
private:
template<class T>
class Data // 保存数据的类
{
private:
T*_pd;
//保存数据的指针
public:
};
Data<???> _data; // 我们无法在Any的成员中使用模板类型参数,因为这样一来,就意味着Any也是一个模板类
template<class T>
Any(T* val):_data<T>(val){}
};
那么这时候其实还是这个问题,Any中我们无法指定Data成员的类型。
这时候,我们就会想到一个方法,既然都已经嵌套一层了,那么干脆一不做二不休,再嵌套一层,我们可以在这里面设计一个继承关系。
设计一个父类BaseData,而Data类则继承这个BaseData类。为什么要这样设计呢? 父类和子类之间有一种技术或者说特点就是 切片。 我们可以用一个父类的指针指向一个子类对象。 那么未来子类我们可以设计成一个模板类,也就是上面的Data设计成模板类(所以严格意义上来说,其实由类模板实例化出的类都是BaseData的子类)。这样一来,我们在Any中只需要保存一个父类的指针就行了,而父类他是一个普通类,无需指定类型。
为了我们后续能够拿到数据,我们需要把子类的保存数据的成员设计成公有成员。
那么我们的成员以及类的基本设计如下:
class Any
{
private:
class BaseData
{
public:
//提供几个虚函数,交给子类继承,未来能够通过父类指针调用子类的重写的虚函数
};
template<class T>
class Data :public BaseData// 保存数据的类
{
public:
T*_pd;
//保存数据的指针
public:
Data(const T& val):_pd(nullptr){_pd = new T(val);} //数据保存在 Any 中,new一段空间拷贝一个,因为传进来的可能是一个临时对象
};
private:
BaseData* _pbase; //在Any类中只需要保存一个父类指针就行了
public:
//要提供的接口
};
那么简单的成员设计已经有了,我们该设计一些什么接口呢? 要知道,未来我们在Any类中只能使用父类指针进行操作,所以我们必须要在父类中设计一些我们需要用到的接口。 因为我们在Any类中,其实是无法使用 Dynamic_cast 进行向上转换, 因为要转换的话,我们需要知道子类的类型,但是子类的类型我们是无法知道的。
首先设置 BaseData 和 Data 类的成员函数。
未来我们需要在子类中重写的接口: 拷贝/克隆一份数据,获取数据的类型,以及析构函数必须要设置为虚函数。
为什么要提供一个克隆的接口呢?主要是后续我们需要实现Any的拷贝构造。
接口实现:
class BaseData
{
public:
//提供几个虚函数,交给子类继承,未来能够通过父类指针调用子类的重写的虚函数
virtual const std::type_info& Type()const = 0; //获取数据类型
virtual BaseData* Clone() = 0; //克隆出一份子类对象,但是返回其父类切片指针
virtual ~BaseData(){} //析构函数必须是虚函数
};
template<class T>
class Data :public BaseData// 保存数据的类
{
public:
T*_pd;
//保存数据的指针
public:
Data(T val):_pd(nullptr){_pd = new T(val);} //数据保存在 Any 中,new一段空间拷贝一个,因为传进来的可能是一个临时对象
~Data(){delete _pd;}
const std::type_info& Type()const {if(!_pd) return typeid(void);
return typeid(T);/*typeid(*_pd)*/}
BaseData* Clone()const {return new Data<T>(*_pd);}
};
然后我们就需要考虑Any需要向外提供什么接口:
首先设计构造,拷贝构造,赋值重载,析构这几个默认成员函数。
//要提供的接口
Any():_pbase(nullptr){} //提供空构造
template<class T> //构造
Any(const T& val):_pbase(new Data<T>(val)){}
BaseData* Clone()const{return _pbase->Clone();} //调用 Data 的 Clone 接口就行
Any(const Any& a) //拷贝构造
{
if(a.Type()==typeid(void)) _pbase=nullptr; //注意在拷贝之前要判断被拷贝的对象是否为空对象
else _pbase = a.Clone();
}
Any& operator=(const Any&a) //赋值重载
{
Any tmp(a);
std::swap(_pbase,tmp._pbase);
return *this;
}
template<class T>
Any& operator=(const T&val) //支持隐式类型转换
{
Any tmp(val);
std::swap(_pbase,tmp._pbase);
return *this;
}
然后就是一些功能性接口,首先,提供一个接口用于获取数据类型
const std::type_info& Type()const{return _pbase->Type();} //获取数据类型
提供一个接口用于获取数据
T* GetData()
{
//首先判断返回类型是否和我们的数据类型一致
assert(typeid(T)==Type());
//走到这里说明我们的数据类型就是 T ,换句话说,父类指针指向的是一个 Data<T> ,那么我们就可以强转成子类指针获取公有成员了
return dynamic_cast<Data<T>*>(_pbase)->_pd;
}
Any 类测试:
Any a;
a=Any(10);
std::cout<<a.Type().name()<<std::endl;
std::cout<<"ret:"<<*a.GetData<int>()<<" addr:"<< a.GetData<int>()<<std::endl;
a=20;
std::cout<<a.Type().name()<<std::endl;
std::cout<<"ret:"<<*a.GetData<int>()<<" addr:"<< a.GetData<int>()<<std::endl;
这个测试用例我们能够测试到拷贝构造和赋值重载以及GetData以及Type等接口。 同时,我们需要在构造以及析构的时候打印一些信息来看一下是否有内存泄漏。
但是要注意,我们设计的这个Any类,对于传入指针这种操作是不支持的,底层并不会去深拷贝,我们只能传值进去。 比如我们传一个字符串,那么未来 new 的底层就会出错。
Any a("hello");
这一点我们也不解决了,因为确实有点复杂。
那么前置的知识点就先这样了,后续遇到新的知识点再补充。