说明一下,我用的是g++7.1.0编译器,标准库源代码也是这个版本的。
本篇文章讲解c++标准IO的底层实现结构,以及cin和cout的具体实现。
在看本文之前,建议先看一下之前的一篇文章,至少要知道标准IO里面各个类之间的关系:
1. 标准IO的底层结构
通过通读c++标准IO的源代码,我总结出了它的底层实现结构,如图:
它分为三层结构:外部设备、缓冲区、程序,说明如下:
- 外部设备是指键盘、屏幕、文件等物理或者逻辑设备;
- 缓冲区是指在数据没有同步到外部设备之前,存放数据的一块内存;
- 程序就是我们代码生成的进程了。
下面我们首先以输出一个字符为例来看一下它的实现过程,这个过程是由ostream::put
函数完成,下面就探究一下put函数的具体实现。
1.1 先探探底层实现的底
小贴士:tcc是指template cc,cc是c++实现文件的后缀,加上t表示是模板的实现,所以tcc就是一个模板的实现文件,用于跟其他非模板的实现文件区分开来。
在ostream.tcc
中找到put函数的实现代码:
template<typename _CharT, typename _Traits>
basic_ostream<_CharT, _Traits>&
basic_ostream<_CharT, _Traits>::
put(char_type __c)
{
sentry __cerb(*this);
if (__cerb)
{
ios_base::iostate __err = ios_base::goodbit;
__try
{
const int_type __put = this->rdbuf()->sputc(__c);
if (traits_type::eq_int_type(__put, traits_type::eof()))
__err |= ios_base::badbit;
}
__catch(__cxxabiv1::__forced_unwind&)
{
this->_M_setstate(ios_base::badbit);
__throw_exception_again;
}
__catch(...)
{ this->_M_setstate(ios_base::badbit); }
if (__err)
this->setstate(__err);
}
return *this;
}
以输出一个字符为例,put函数是调用了缓冲区基类basic_streambuf
的sputc
成员函数,而sputc
成员函数实现如下:
int_type
sputc(char_type __c)
{
int_type __ret;
//pptr返回一个指向缓冲区下一位置的指针,epptr返回一个指向缓冲区结束位置的指针
if (__builtin_expect(this->pptr() < this->epptr(), true))
{
*this->pptr() = __c;
//pbump是把缓冲区下一位置加1
this->pbump(1);
__ret = traits_type::to_int_type(__c);
}
else
//overflow会进行缓冲区溢出处理
__ret = this->overflow(traits_type::to_int_type(__c));
return __ret;
}
那么这样看来sputc
函数的作用就很明显了,它有两个分支:
一是当缓冲区当前位置还没有写满的时候,就直接把字符写到缓冲区;
二是如果已经把当前缓冲区写满了,那么就要做缓冲区溢出处理。
对于这两种情况,很明显各个输出类的实现方式是不一样的,先抛开基本的ostream
不说,我们先看一下ostringstream
和ofstream
这两个类在实现时的异同。
对于第一点,ostringstream
和ofstream
在实现上是一样的,都是把字符写入缓冲区并把位置向后移动一位,并没有特殊之处。
但对于第二点,ostringstream
是调用的stringbuf
的overflow
成员函数,它是在原来缓冲区用完的情况下,重新申请一块更大的临时缓冲区,然后把源缓冲区所有的数据复制过来,把当前要输出的数据加入到新的缓冲区,然后在用这个临时缓冲区与源缓冲区进行交换,这样才把一个字符写到了源缓冲区,同时也实现了缓冲区的扩容。
而ofstream
是调用的filebuf
的overflow
成员函数,该函数会检测当前是否写到了缓冲区末尾,很显然对于第二点而言,既然缓冲区已经写满,那肯定是已经写到了末尾,此时会调用系统的write函数把当前缓冲区所有内容都刷新到文件中去,然后对缓冲区指针位置等进行重新初始化,注意filebuf
并没有对缓冲区进行扩充。
小贴士:很显然,对于上面第二点,调用overflow函数,是使用了c++中多态,对于streambuf::overflow,它是一个虚函数,真正的实现是在stringbuf和filebuf里面。
到这里,put函数的具体实现我们就探究完了,大致上也探了探标准库底层实现的底子,但我们还是对于三层结构的实现不是那么清晰,下面就来具体的说一说。
1.2 详解标准IO底层结构
1.2.1 stringbuf的底层结构
对于istringstream、ostringstream、stringstream
这三个类而言,他们都是基于stringbuf
来实现缓冲区的,所以说白了他们的底层实现直接看stringbuf
的底层实现就ok了,那么stringbuf
是基于什么来实现缓冲区的呢。
先来看一张图,如下:
注意,这里箭头指示代表使用关系,并不是继承关系,所以我这里用了比较透明的线,后续同理。
那么现在就很明显了,stringbuf
使用的是标准库中的string来作为缓冲区,如果说读取数据的话,很明显string的大小是不会变化的,但如果是写入string的话,在构造的时候也会调用string的构造,它一开始是一个空字符串,当开始写入第一个字符的时候,默认会给string对象申请一块大小为512个字节的动态内存,后续写入,就直接写入动态内存,当512个字节写完后,就会在当前内存大小基础上乘以2,然后申请一块新的内存,再把之前的数据全部复制到新的内存中来,再在新内存的后面写入要保存的字符。
那对于stringbuf的三层结构而言,它的缓冲区就是申请的内存,外部设备就是string,在逻辑上而言,他们是两层不同的皮,但实际上就实现来讲,我们对string申请的内存进行读写,其实就是对string进行读写,从这个角度而言,stringbuf可以说是三层结构,也可以说是两层结构,就看我们个人怎么理解了,这里不多做讨论。
1.2.2 filebuf的底层结构
同样的,对于fstream
相关类而言,它的底层实现是基于filebuf的,filebuf又比stringbuf稍显复杂一些,先来看图:
filebuf在调用open函数的时候会new一块char类型的动态内存,大小为BUFSIZ,BUFSIZ是系统文件里面定义的一个专门用于缓冲区的默认size,filebuf写数据的时候,是先写到这一块动态内存中去,当写满以后,会把FILE*转换为文件描述符,然后利用write函数直接写到文件中去,再对缓冲区当前写位置进行初始化,读数据则会先把数据读到缓冲区,直到当前缓冲区全部读完,才会重新从文件再次读取,对于filebuf而言,它的缓冲区大小是固定的,不会进行扩充。
所以这里对于filebuf,缓冲区就是申请的这一块动态内存,外部设备就是文件了,filebuf不论是从逻辑上还是实现上看,它都是标准的三层结构。
1.2.3 iostream的底层实现
对于istream,ostream,iostream
而言,他们的缓冲区使用的是streambuf
,但streambuf
的构造函数是保护类型的,所以它是没有办法直接生成一个对象的,也是可以理解的,因为streambuf
既没有提供缓冲区,也没有提供一个外部设备,所以它本来也是不能直接使用的,它只是作为一个基类供stringbuf
和filebuf
调用。
如果想使用istream,ostream,iostream
,那么就需要给他们传入一个可用的缓冲区对象,例如filebuf对象,这样才是可用的,但这样还不如直接使用fstream,所以对于这三个基本模板类而言,既然不可直接使用,那就不存在两层结构还是三层结构了。
2. 标准IO全局变量cin、cout的实现
上一小节说了,iostream类是不可直接使用的,但是我们又知道cin是istream类型的,cout是ostream类型,而且实际上标准IO中还定义了另外两个ostream类型的cerr和clog,那么他们为什么又可以直接使用呢。
在iostream头文件中,定义了这样一个全局静态变量:
static ios_base::Init __ioinit;
ios_base::Init是一个类类型,定义在ios_base.h头文件中,它的构造函数实现如下:
ios_base::Init::Init()
{
if (__gnu_cxx::__exchange_and_add_dispatch(&_S_refcount, 1) == 0)
{
// Standard streams default to synced with "C" operations.
_S_synced_with_stdio = true;
new (&buf_cout_sync) stdio_sync_filebuf<char>(stdout);
new (&buf_cin_sync) stdio_sync_filebuf<char>(stdin);
new (&buf_cerr_sync) stdio_sync_filebuf<char>(stderr);
// The standard streams are constructed once only and never
// destroyed.
new (&cout) ostream(&buf_cout_sync);
new (&cin) istream(&buf_cin_sync);
new (&cerr) ostream(&buf_cerr_sync);
new (&clog) ostream(&buf_cerr_sync);
cin.tie(&cout);
cerr.setf(ios_base::unitbuf);
// _GLIBCXX_RESOLVE_LIB_DEFECTS
// 455. cerr::tie() and wcerr::tie() are overspecified.
cerr.tie(&cout);
#ifdef _GLIBCXX_USE_WCHAR_T
new (&buf_wcout_sync) stdio_sync_filebuf<wchar_t>(stdout);
new (&buf_wcin_sync) stdio_sync_filebuf<wchar_t>(stdin);
new (&buf_wcerr_sync) stdio_sync_filebuf<wchar_t>(stderr);
new (&wcout) wostream(&buf_wcout_sync);
new (&wcin) wistream(&buf_wcin_sync);
new (&wcerr) wostream(&buf_wcerr_sync);
new (&wclog) wostream(&buf_wcerr_sync);
wcin.tie(&wcout);
wcerr.setf(ios_base::unitbuf);
wcerr.tie(&wcout);
#endif
// NB: Have to set refcount above one, so that standard
// streams are not re-initialized with uses of ios_base::Init
// besides <iostream> static object, ie just using <ios> with
// ios_base::Init objects.
__gnu_cxx::__atomic_add_dispatch(&_S_refcount, 1);
}
}
以cin为例,可以看到,实际上是在构造的时候传入了一个stdio_sync_filebuf
类型的对象,那我们知道istream
只接受streambuf
类型的对象,所以可以猜测到stdio_sync_filebuf
应该是继承于streambuf
的,找到stdio_sync_filebuf.h
头文件,看到stdio_sync_filebuf
果然是继承于basic_streambuf
的。
对于类stdio_sync_filebuf
而言,它是不存在缓冲区的,只是它会根据传入的文件指针stdin、stdout、stderr来与外部设备键盘和屏幕扯上关系,所以对于cin而言,它是通过stdin直接从键盘进行读取,而cout则是通过stdout直接输出到屏幕。
所以从结构上而言,cin、cout、cerr、clog
都是只有程序和外部设备两层结构,但还有一点疑惑,我们根据代码,实际上他们都是打开了文件,然后对文件进行了读写,那怎么会显示在外部设备上呢。
根据操作系统的不同,标准输入和输出也是实现不同的,这里我们以linux系统为例,来进行说明。
在linux中,有三个标准的输入和输出文件,分别是stdin,stdout,stderr
,他们都在/dev目录下,由上一章可知,cout实际上打开了/dev/stdout
这个文件,而/dev/stdout
又是一个软链接,它链接的是/proc/self/fd/1
这个文件,而/proc/self/fd/1
又链接到了/dev/pts/0
这个文件,/dev/pts/0
这个文件实际上代表的是当前打开的终端,以当前终端为例,关系图如下:
这样看来,每个程序的输入输出,其实接收的都是当前终端的输入和输出,关于这一点,就写到这里,不再展开说明了。