提到输入输出流,作为CPPer很自然的就会想到std::iostream,对于文本流的处理,iostream可以说足够强大,应付一般复杂度的需求毫无压力。对二进制流处理却只能用“简陋”来形容,悲催的是,作为一个在多媒体软件领域默默耕耘多年的码农日常打交道最多的偏偏就是二进制流。
前些年流行过一本书叫做什么男人来自火星女人来自金星之类的,同样的,如果说文本流来自火星那二进制流就是来自金星。对一个文本流,我们可能期望这样的接口函数:
1 string text = stream.get_line(); // 基础接口
2 string word = stream.get_word(); // 增强接口
而对二进制流,我们期望的接口可能是这个样子的:
1 int read_bytes = stream.read(buffer, size); // 基本接口
2 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设计成下面这样:
1 class sequential_istream
2 {
3 public:
4 virtual void read(void* buffer, int bytes) = 0;
5 };
则random_istream是这样的:
class random_istream
{
public:
virtual void read(void* buffer, int bytes) = 0;
virtual void seek(int offset) = 0;
};
发现什么没有?random_istream是sequential_istream的超集,这意味着可以让random_istream从sequential_istream继承下来,既不必设计成两个孤零零的类,也不必为了通用强行给两个类提取一个公共基类。从概念上讲也是完美的,一个可随机访问的流当然可以当作顺序流来访问,这是典型的“is-a”的关系。重新调整后的设计如下:
1 class input_stream
2 {
3 public:
4 virtual void read(void* buffer, int bytes) = 0;
5 };
6
7 class random_istream : public input_stream
8 {
9 public:
10 virtual void seek(int offset) = 0;
11 };
这里去掉顺序流的sequential关键字,让概念的继承逻辑更加顺畅。
事实上,还有另外一种设计方案,可以把input_stream设计成胖接口(fat interface),同时支持顺序流和可随机访问流:
1 class input_stream
2 {
3 public:
4 virtual void read(void* buffer, int bytes) = 0;
5 virtual void seek(int offset) = 0;
6 virtual bool seekable() const = 0;
7 };
注意seekable这个方法,它返回了一个布尔值指示seek方法是否有效,有效表明这是一个可随机访问的流,无效则是顺序流。