提到输入输出流,作为CPPer很自然的就会想到std::iostream,对于文本流的处理,iostream可以说足够强大,应付一般复杂度的需求毫无压力。对二进制流处理却只能用“简陋”来形容,悲催的是,作为一个在多媒体软件领域默默耕耘多年的码农日常打交道最多的偏偏就是二进制流。

前些年流行过一本书叫做什么男人来自火星女人来自金星之类的,同样的,如果说文本流来自火星那二进制流就是来自金星。对一个文本流,我们可能期望这样的接口函数:

 string text = stream.get_line();  // 基础接口
string word = stream.get_word(); // 增强接口

而对二进制流,我们期望的接口可能是这个样子的:

 int read_bytes = stream.read(buffer, size); // 基本接口
int value = stream.read_int32(); // 增强接口

做为iostream灵魂的插入/提取运算符("<<"/">>")重载对文本流来说是神一般的存在,但在二进制流中却完全无法使用,因为二进制流需要精确到byte(甚至bit)的控制,所以会有下面这样的接口:

int v1 = stream.read_uint16();   // 读取short int
int v2 = stream.read_int16(); // 读取unsigned short int
int v3 = stream.read_uint24(); // 读取3字节无符号整型,没错是3字节24位
int v4 = stream.read_int24(); // 想想这个函数有多变态!

基于编译期推导的运算符重载很难满足类似的需求。在我看来,把两类方法合并在同一个类中是得不偿失的,核心需求几乎没什么相似之处,非核心的需求则无关紧要,而且基本上不会有既是文本流又是二进制流的情况出现。iostream偏偏就这么做了,对此,只(wo)能(cuo)呵(le)呵(ma)了。

二进制流按照流的方向可以划分为输入流和输出流(废话);按另外一个纬度上则可以划分为顺序访问流和可随机访问流,两者最主要的区别是是否支持定位操作(seek),前者不支持,后者支持,比如标准输入输出流就是顺序访问流,而文件流一般都是可随机访问流。站在更高的层次上来理解,顺序访问流内置了一个时间箭头,既不能回头也不能跳跃,可随机访问流则是内置的空间轴,没有方向性(或者说方向性很弱),如果你愿意完全可以从一个文件的尾部往头部读。因此带有时间属性的实时流一般是顺序访问流,比如录音、录屏产生的数据流,比如在线直播视频的直播流。

两个纬度各两个分类共四种组合,由此我们就可以设计一个newbility的架构出来了:

哈哈,是不是很强大,是不是……怕了?……怕了就对了,这还只是抽象接口类,如果再把实现类以及各种派生考虑进去,这个系统的复杂度至少还要增加两倍。

……

上面的图是开个玩笑,图中的系统是典型的臆造抽象,连过度设计都算不上,甚至不如没有设计。虽然夸张了些,但现实中也不是没有犯了类似错误的系统,比如DirectShow的base classes内部的实现代码就颇有些神似的地方(说出这样的话,我对DirectShow的怨念得有多深啊……)。

解决任何问题的第一步首先就是简化问题,也就是抓住主要矛盾,忽略次要矛盾。至少在曾经某一段时间,CPPer特别追求精致的设计,而精致的设计往往首先就把简单的问题复杂化了,还记得那个经典的C++版的Hello World吗?精致设计的目的本是为了代码复用,而现实却是:越简单的代码越容易复用,越是精心设计的代码越容易因为复杂而难以复用。回到我们的问题,首先要找到主要矛盾,也就是核心需求:一组能应付大部分日常任务的简单的输入和输出流,注意,这里用的是“输入和输出流”而不是“输入输出流”。事实上,在实际的开发工作中很少会遇到要求一个流即是输入流又是输出流的情况,如果遇到又往往是因为业务需求复杂,此种情况下即使专门写一个应对特殊需求的流也不是不可接受。

所以,“既是输入流又是输出流”这种需求被我们作为次要矛盾砍掉了,尚未考虑清楚的继承关系也暂时砍掉,文件流之类的派生扩展也砍掉,系统的剩余部分就简单的一目了然了:

只有四个孤零零的抽象类。输出流和输入流类似但无关联,所以我们以输入流为例做进一步的考察,也就是两个抽象类:random_istream和sequential_istream。前面说过了,顺序访问流和可随机访问流最主要的区别是顺序访问流不支持支持定位操作(seek)而可随机访问流支持,也就是说,如果sequential_istream设计成下面这样:

 class sequential_istream
{
public:
virtual void read(void* buffer, int bytes) = ;
};

则random_istream是这样的:

class random_istream
{
public:
virtual void read(void* buffer, int bytes) = ;
virtual void seek(int offset) = ;
};

发现什么没有?random_istream是sequential_istream的超集,这意味着可以让random_istream从sequential_istream继承下来,既不必设计成两个孤零零的类,也不必为了通用强行给两个类提取一个公共基类。从概念上讲也是完美的,一个可随机访问的流当然可以当作顺序流来访问,这是典型的“is-a”的关系。重新调整后的设计如下:

 class input_stream
{
public:
virtual void read(void* buffer, int bytes) = ;
}; class random_istream : public input_stream
{
public:
virtual void seek(int offset) = ;
};

这里去掉顺序流的sequential关键字,让概念的继承逻辑更加顺畅。

事实上,还有另外一种设计方案,可以把input_stream设计成胖接口(fat interface),同时支持顺序流和可随机访问流:

 class input_stream
{
public:
virtual void read(void* buffer, int bytes) = ;
virtual void seek(int offset) = ;
virtual bool seekable() const = ;
};

注意seekable这个方法,它返回了一个布尔值指示seek方法是否有效,有效表明这是一个可随机访问的流,无效则是顺序流。

我们没有采用这个方案,虽然少了一层继承关系看起来简单了,实际应用却并不比前面的方案简单。seekable在语义上是一种状态属性(有这个说法吗)表示对象的一类状态,一个布尔型可以表示两种状态,每增加一个则应用复杂度就翻一倍,呈指数增长(不要信我,我随口乱说的)。这里虽然只有一个状态属性,但已经足以给我们造成不少的困扰了:

  • seekable返回的状态究竟是暂时的还是永久的?是否可能中途改变?至少我们从接口上看不出答案;
  • 如果seekable返回false,仍然调用了seek方法会怎样?
  • 一个顺序流的派生类根本不需要但还是要实现seekable和seek两个方法,哪怕只是简单的返回false和抛出异常;
  • 使用者每次试图调用seek方法前都要先调用seekable判断一下,最后会有一堆的if-else;

上面的问题也是胖接口固有的问题,所以一定要慎重使用胖接口,这次我们选择了抛弃。

把各种派生类和各种辅助类都加上,得到最终的结构图:

至此,我们的工作已基本完成,剩下的都是无聊的体力活。

input_stream和random_istream接口:

 class input_stream
{
public:
virtual void read(void* buffer, uint32_t bytes) = ;
virtual void skip(uint32_t bytes) = ;
virtual uint64_t tell() const = ;
}; class random_istream : public input_stream
{
public:
virtual void seek(int64_t offset, seek_origin origin) = ;
virtual uint64_t size() const = ;
};

注意skip方法,这个方法用于在顺序读入时跳过指定字节数,其功能也可以通过read后丢弃数据的方式实现,在random_istream中则可以直接使用seek方法实现,最终决定加入这个方法主要是为了使用方便,在效率上则与当前流的最优替代方式相当。

output_stream和random_ostream:

 class output_stream
{
public:
virtual void write(void* buffer, uint32_t bytes) = ;
virtual void flush() = ;
virtual uint64_t tell() const = ;
}; class random_ostream : public output_stream
{
public:
virtual void seek(int64_t offset, seek_origin origin) = ;
};

binary_reader和binary_writer两个类是对input_stream和output_stream的扩展,采用外部扩展的方式相对于继承扩展更加灵活。如果用继承扩展的话binary_reader究竟从input_stream还是random_istream继承呢,或者是把binary_reader设计成独立的接口类,实现类比如file_istream同时继承binary_reader和random_istream呢?这些都是让人纠结的问题,并且每一种方案都不完美。外部扩展的方式则堪称完美,实现起来也简单,只要给binary_reader塞一个input_stream的指针就可以了,用起来就像下面这个样子:

 input_stream* ist = ...

 binary_reader  reader(ist);

 int v1 = reader.read_uint8();

 reader.skip();
int v2 = reader.read_uint16_be();
... // seek操作也可以支持 random_istream* ist = ... binary_reader reader(ist); int v1 = reader.read_uint8(); ist->seek(, see_origin::current);
int v2 = reader.read_uint16_be();
...

binary_reader的完整声明大体如下,binary_writer与之类似:

 class binary_reader
{
public:
binary_reader(input_stream* stream); void read(void* buffer, uint32_t read_bytes);
void skip(uint32_t offset); uint64_t tell() const; uint8_t read_uint8(); uint16_t read_uint16_be();
uint32_t read_uint24_be();
uint32_t read_uint32_be();
uint64_t read_uint64_be(); uint16_t read_uint16_le();
uint32_t read_uint24_le();
uint32_t read_uint32_le();
uint64_t read_uint64_le(); .... private:
input_stream* _stream;
};

……

花了一周的业余时间总算把这一篇写完了,一个简单的设计方案想讲清楚却也不是那么容易。这个方案特别是具体接口函数的设计还很不完善,需要在实际应用的过程中逐渐丰富改进,当然了,在一个简单的方案上做改进想来也不会太麻烦。下一篇,准备写一下这个方案背后的东西——错误处理,具体来讲是基于异常的错误处理方案。

C++二进制输入输出流接口设计的更多相关文章

  1. Verilog学习笔记简单功能实现(七)...............接口设计(并行输入串行输出)

    利用状态机实现比较复杂的接口设计: 这是一个将并行数据转换为串行输出的变换器,利用双向总线输出.这是由EEPROM读写器的缩减得到的,首先对I2C总线特征介绍: I2C总线(inter integra ...

  2. java 3 接口与多态&输入输出流

    接口 中的所有方法都是方法 抽象 使用接口实现多继承 类型的装换 数据成员就变成了static 和 final food 和 snow 都是可以吃的 可以同时实现多个接口 接口与接口之间也可以有继承关 ...

  3. Java基础学习总结(47)——JAVA输入输出流再回忆

    一.什么是IO Java中I/O操作主要是指使用Java进行输入,输出操作. Java所有的I/O机制都是基于数据流进行输入输出,这些数据流表示了字符或者字节数据的流动序列. Java的I/O流提供了 ...

  4. Java中IO流,输入输出流概述与总结

    总结的很粗糙,以后时间富裕了好好修改一下. 1:Java语言定义了许多类专门负责各种方式的输入或者输出,这些类都被放在java.io包中.其中, 所有输入流类都是抽象类InputStream(字节输入 ...

  5. Java 输入输出流 转载

    转载自:http://blog.csdn.net/hguisu/article/details/7418161 1.什么是IO Java中I/O操作主要是指使用Java进行输入,输出操作. Java所 ...

  6. java输入输出流总结 转载

    一.基本概念 1.1 什么是IO?     IO(Input/Output)是计算机输入/输出的接口.Java中I/O操作主要是指使用Java进行输入,输出操作.     Java所有的I/O机制都是 ...

  7. Java输入输出流(转载)

    转自http://blog.csdn.net/hguisu/article/details/7418161 目录(?)[+] 1.什么是IO Java中I/O操作主要是指使用Java进行输入,输出操作 ...

  8. [自制操作系统] BMP格式文件读取&图形界面系统框架/应用接口设计

    本文将介绍在本人JOS中实现的简单图形界面应用程序接口,应用程序启动器,以及一些利用了图形界面的示例应用程序. 本文主要涉及以下部分: 内核/用户RW/RW调色板framebuffer共享区域 8bi ...

  9. Java高级特性 第4节 输入输出流

    一.使用I/O操作文件 关键步骤: 使用File类操作文件或目录属性 使用FileInputStream类读文本文件 使用FileOutputStram类写文本文件 使用BufferedReader类 ...

随机推荐

  1. Python:使用异常处理来判断运行的平台

    try: import termios, TERMIOS 1 except ImportError: try: import msvcrt 2 except ImportError: try: fro ...

  2. 一个对眼睛很好的vim 颜色主题

    地址:https://github.com/altercation/vim-colors-solarized 安装: $ cd vim-colors-solarized/colors $ mv sol ...

  3. C# 多线程六之Task(任务)二

    前面介绍了Task的由来,以及简单的使用,包括开启任务,处理任务的超时.异常.取消.以及如果获取任务的返回值,在回去返回值之后,立即唤起新的线程处理返回值.且如果前面的任务发生异常,唤起任务如果有效的 ...

  4. webstorm引用ESLint进行静态代码检查

    安装 ESLint 基于 Node 平台,所以 Nodejs 是必须安装的,然后通过 npm 安装 ESLint 包,至于全局安装还是作为开发依赖安装,取决于个人. 然后在 WebStorm 中,打开 ...

  5. 每一行代码都有记录—如何用git一步步探索项目的历史

    每一行代码都有一块被隐藏了的文档信息. 下面的代码片段不管是谁写的,其第4行因为某些原因要访问一个DOM结点的clientLeft属性,但却对结果不作任何处理.这十分的莫名其妙,你能告诉我他们为什么要 ...

  6. docker 使用时一些问题点

    1.run 参数 --privileged,默认是关闭的,使用该参数,container 内的 root 拥有真正的 root 权限,否则,container 内的 root 只是外部的一个普通用户权 ...

  7. Solidity的自定义结构体深入详解

    一.结构体定义 结构体,Solidity中的自定义类型.我们可以使用Solidity的关键字struct来进行自定义.结构体内可以包含字符串,整型等基本数据类型,以及数组,映射,结构体等复杂类型.数组 ...

  8. Java中mongodb使用and和or的复合查询

    在MongoDB的JAVA查询中对应这些问题 and查询 //条件 startsAt< curr and endsAt > curr long curr = new Date().getT ...

  9. Python 工匠:编写条件分支代码的技巧

    欢迎大家前往腾讯云+社区,获取更多腾讯海量技术实践干货哦~ 本文由鹅厂优文发表于云+社区专栏 作者:朱雷 | 腾讯IEG高级工程师 『Python 工匠』是什么? 我一直觉得编程某种意义是一门『手艺』 ...

  10. 使用Unicode字符实现换行

    要让inline元素换行可以使用Unicode字符实现: <!DOCTYPE html> <html lang="en"> <head> < ...