谁设计了C++的IOStreams,现在是否仍然被认为是良好设计?

138

首先,这似乎看起来是在寻求主观意见,但这不是我想要的。我希望听到一些有根据的关于这个话题的论点。


为了了解现代流/序列化框架应该如何设计,我最近购买了Angelika Langer和Klaus Kreft合著的书《C++标准库IOStreams和Locales》。我认为,如果IOStreams没有被很好地设计,它就不会首先进入C++标准库。

在阅读了这本书的各个部分后,我开始怀疑IOStreams是否可以从整体结构的角度与STL相比较。例如,请阅读与STL之父Alexander Stepanov的采访,了解一些导致STL的设计决策。

特别令我惊讶的是:

  • 似乎不知道谁负责IOStreams的整体设计(我很想阅读一些关于此背景信息的资源 - 有人知道好的资源吗?);

  • 一旦你深入IOStreams的表面,例如如果你想用自己的类扩展IOStreams,你就会遇到一个接口,其中成员函数名称相当神秘和令人困惑,例如getloc/imbueuflow/underflowsnextc/sbumpc/sgetc/sgetnpbase/pptr/epptr(可能还有更糟糕的例子)。这使得理解整体设计及其单个部分如何协作变得更加困难。即使是我上面提到的那本书也没能帮助那么多。


因此我的问题是:

如果按照当今的软件工程标准(如果实际上有任何普遍协议),您会认为C++的IOStreams仍然是设计良好的吗?(我不想从被普遍认为已经过时的东西中提高我的软件设计技能。)


8
有趣的Herb Sutter观点 https://dev59.com/fXE95IYBdhLWcg3wCJbk#2486085 :) 很遗憾那个人只参与了几天就离开了SO - Johannes Schaub - litb
5
有其他人看到STL流中混杂的问题吗?流通常设计为读取或写入字节,不涉及其他内容。能够读取或写入特定数据类型的东西是格式化器(它可以使用流来读取/写入格式化后的字节,但并非必须)。将两者混合到一个类中,使得实现自己的流更加复杂。 - mmmmmmmm
5
@rsteven,这里有两个不同的概念。std::streambuf 是读写字节的基类,而 istream / ostream 则是用于格式化输入和输出的类,将一个指向 std::streambuf 的指针作为它们的目标/来源。 - Johannes Schaub - litb
2
@rstevens,ostream foo(&somebuffer); foo << "huh"; foo.rdbuf(cout.rdbuf()); foo << "see me!"; - Johannes Schaub - litb
1
@JohannesSchaub-litb "std::streambuf是读写字节的基类" 这是错误的。std::streambuf支持文本I/O。 - curiousguy
显示剩余8条评论
11个回答

47

关于它们的设计者,最初的库(毫不意外地)是由Bjarne Stroustrup创建的,然后由Dave Presotto重新实现。然后,Jerry Schwarz又在Cfront 2.0中使用了Andrew Koenig的操纵器思想对其进行了重新设计和重新实现。标准版本的库基于这个实现。

来源:“C++的设计与演化”,第8.3.1节。


3
你的意思是让我翻译这句话吗?“Neil,你对这个设计有什么看法?根据你之前的回答,很多人都想听听你的意见......” - DVK
1
@DVK 刚刚发布了我的意见作为一个独立的答案。 - anon
2
刚刚发现了一份采访Bjarne Stroustrup的文字记录,其中提到了一些IOStreams历史的细节:http://www2.research.att.com/~bs/01chinese.html(目前该链接似乎暂时无法访问,但您可以尝试使用Google页面缓存)。 - stakx - no longer contributing
2
更新链接:http://www.stroustrup.com/01chinese.html。 - FrankHB

39

标准库中有一些不成熟的想法,例如auto_ptrvector<bool>valarrayexport等。所以,出现了iostreams并不一定是高质量设计的标志。

iostreams的历史很曲折。实际上它们是早期流库的重制版,在作者撰写它们时,现在许多C++习语都不存在,这样设计者没有后见之明。一个只有随着时间推移才显现的问题是,由于要在最细微的粒度上大量使用虚拟函数和内部缓冲区对象转发,以及一些难以理解的本地化定义和实现方式,所以几乎不可能像C的stdio那样高效实现iostreams。我对此的记忆非常模糊,我承认;我记得几年前在comp.lang.c++.moderated上进行了激烈的辩论。


3
谢谢您的回复。如果我找到有价值的东西,我会浏览“comp.lang.c++.moderated”档案并在我的问题下面发布链接。此外,关于“auto_ptr”,我敢不同意您:在阅读Herb Sutter的《Exceptional C ++》之后,它似乎是实现RAII模式时非常有用的类。 - stakx - no longer contributing
5
然而,它正在被unique_ptr所取代,后者具有更清晰和更强大的语义。 - UncleBens
7
但是 auto_ptr 的复制/赋值语义有缺陷,这使得它容易出现解引用错误的问题,因此只适用于某些特定情况。 - Matthieu M.
3
@Billy: 我更喜欢boost::scoped_ptr的语义,如果一个复制构造函数不能像复制构造函数那样运作,我宁愿不要它。你可以随时使用swap显式地交换两个对象的内容,或者使用另一个名称恰当的方法。 - Matthieu M.
6
它不是一个向量,也不存储布尔值,这使它有些具有误导性。 ;) - jalf
显示剩余7条评论

31
如果按照当今软件工程标准(如果有普遍的共识),是否仍然认为C++的IOStreams是设计良好的?(我不想从被普遍认为已过时的东西中提高我的软件设计技能。)
我的答案是NO,出于以下几个原因: 错误处理不佳 应该使用异常而不是operator void*来报告错误条件。
“僵尸对象”反模式是导致这些错误的原因之一。 格式化和I/O之间的分离不佳 这使得流对象不必要复杂,因为它们必须包含额外的状态信息以进行格式化,无论您是否需要它。
这也增加了编写以下错误的几率:
using namespace std; // I'm lazy.
cout << hex << setw(8) << setfill('0') << x << endl;
// Oops!  Forgot to set the stream back to decimal mode.
如果你写的内容是这样的:

如果相反,你写了这样的东西:

cout << pad(to_hex(x), 8, '0') << endl;

如果没有与格式相关的状态位,就不会有问题。

请注意,在像Java、C#和Python这样的“现代”语言中,所有对象都有一个toString/ToString/__str__函数,该函数由I/O例程调用。 据我所知,只有C++采用另一种方式,通过使用stringstream作为转换为字符串的标准方式。

i18n支持差

Iostream输出基于字符串字面值进行拆分处理。

cout << "My name is " << name << " and I am " << occupation << " from " << hometown << endl;

格式化字符串将整个句子放入字符串字面值中。

printf("My name is %s and I am %s from %s.\n", name, occupation, hometown);
后一种方法更容易适应国际化库,比如GNU gettext,因为整个句子的使用为翻译人员提供了更多的上下文。如果您的字符串格式化程序支持重新排序(例如POSIX中的$ printf参数),那么它也可以更好地处理不同语言之间的词序差异。

4
实际上,对于国际化(i18n)而言,替换应该通过位置标识(%1,%2等)来识别,因为翻译可能需要更改参数顺序。否则,我完全同意,点赞(+1)。 - peterchen
5
ињЩе∞±жШѓPOSIX $иІДиМГдЄ≠зЪДprintfж†ЉеЉПиѓіжШОзђ¶гАВ - jamesdlin
2
问题不在于格式字符串,而在于C++具有非类型安全的可变参数。 - dan04
5
从C++11开始,它现在具有类型安全的可变参数。 - Mooing Duck
2
在我看来,“额外状态信息”是最糟糕的问题。cout是全局的;将格式化标志附加到它会使这些标志成为全局的,而且考虑到它们的大多数用途都有一个预期的范围只有几行,那就相当糟糕了。可以通过使用“格式化器”类来修复这个问题,该类绑定到ostream但保留自己的状态。而且,与printf(如果可能)相比,使用cout通常看起来很糟糕。 - greggo
显示剩余2条评论

19

随着时间的推移,我对C++ iostreams的看法有了很大的改观,特别是在我开始通过实现自己的流类来扩展它们后。尽管成员函数名称非常糟糕(例如xsputn等),但我开始欣赏其可扩展性和整体设计。无论如何,我认为I/O流比C stdio.h要好得多,因为它具有类型安全性且不会受到重大安全漏洞的困扰。

我认为IO流的主要问题在于它们混淆了两个相关但有些正交的概念:文本格式化和序列化。一方面,IO流旨在产生一个人类可读的、格式化的对象文本表示,另一方面,将对象序列化为可移植格式。有时这两个目标是相同的,但其他时候却会出现一些非常恼人的不协调之处。例如:

std::stringstream ss;
std::string output_string = "Hello world";
ss << output_string;

...

std::string input_string;
ss >> input_string;
std::cout << input_string;
在此,我们得到的输入并不是我们原来输出到流中的内容。这是因为`<<`操作符输出整个字符串,而`>>`操作符只会从流中读取直到遇到空格字符为止,因为流中没有存储长度信息。所以即使我们输出了一个包含“hello world”的字符串对象,我们只会输入一个包含“hello”的字符串对象。虽然流已经完成了它作为格式化工具的目的,但它未能正确地序列化和反序列化对象。
你可能会说,I/O流并不是被设计成序列化设施的,但如果是这样,那么输入流真正的用途是什么?此外,在实践中,I/O流常常用于序列化对象,因为没有其他标准的序列化设施。请考虑boost::date_time或boost::numeric::ublas::matrix,如果你使用`<<`操作符输出了一个矩阵对象,你将使用`>>`操作符输入相同的矩阵对象。但是,为了实现这一点,Boost的设计者必须将行数和列数信息作为文本数据存储在输出中,这就损害了实际的人类可读性显示。再次强调了文本格式化设施和序列化之间的尴尬组合。
请注意,大多数其他语言都将这两个设施分开。例如,在Java中,通过`toString()`方法完成格式化,而通过`Serializable`接口完成序列化。
我认为,最好的解决方案是在标准的基于字符的流之外引入基于字节的流。这些流将操作二进制数据,不考虑人类可读的格式/显示。它们可以仅用作序列化/反序列化设施,将C++对象转换为便携式字节序列。

谢谢回答。我可能错了,但是关于你最后提到的(基于字节 vs. 基于字符的流),IOStream 的(部分?)解决方案不是将 _stream 缓冲区_(字符转换、传输和缓冲)与 _streams_(格式化/解析)分开吗?而且,您是否可以创建新的流类,一些仅用于(机器可读的)序列化和反序列化,另一些则专门针对(人类可读的)格式化和解析? - stakx - no longer contributing
@stakx,是的,事实上我已经做到了。这比听起来要麻烦一些,因为std::char_traits不能被可移植地专门用于unsigned char。然而,有解决方法,所以我想可扩展性再次拯救了我们。但我认为基于字节的流不是标准库的一个弱点。 - Charles Salvia
4
此外,实现二进制流需要你实现新的流类和新的缓冲区类,因为格式化问题并没有完全从std::streambuf中分离出来。所以,基本上你要扩展的只有std::basic_ios类。因此,在"扩展"和"完全重新实现"之间存在一条界线,使用C++ I/O流设施创建二进制流似乎接近这个界限。 - Charles Salvia
说得很好,正是我怀疑的。事实上,C和C ++都极力避免对特定的位宽和表示做出保证,这确实在进行I/O时可能会变得棘手。 - stakx - no longer contributing
1
将一个对象序列化为可移植格式。不,它们从未打算支持这个。 - curiousguy
我在使用IOStream时仍然遇到问题。除了奇怪的名称之外,当使用wchar_t变量时情况变得更糟。wifstream会进行多字节转换,而wstringstream则不会。 - gast128

17

我将此作为一个单独的回答发布,因为它纯属个人意见。

进行输入输出(特别是输入)是一个非常非常困难的问题,因此不足为奇的是,iostreams库中充满了一些应急措施和本来可以做得更好的东西。但我觉得所有I/O库,无论用什么语言编写,都是如此。我从未使用过一个编程语言,其中的I/O系统是一个美丽的事物,让我对其设计者惊叹不已。iostreams库确实具有优势,特别是相对于C I/O库(可扩展性,类型安全等),但我不认为有人会将其视为优秀的面向对象或通用设计的典范。


13

我一直认为C++的IOStreams设计不够合理: 它们的实现使得定义新类型流变得非常困难。它们还混合了IO功能和格式化功能(例如操作符)。

个人而言,我发现Ada编程语言中拥有最好的流设计和实现。它是一个解耦模型,在创建新类型流时非常方便,而且输出函数始终可用,无论使用的流是什么。这要归功于一个最小公分母:你向流输出字节,就是这样。流函数负责将字节放入流中,而不是将整数格式化为十六进制等(当然,还有一组类型属性,相当于类成员,用于处理格式化)。

我希望C++的流能像这样简单...


我提到的书解释了基本的IOStreams架构如下:有一个“传输层”(流缓冲区类)和一个“解析/格式化层”(流类)。前者负责从字节流中读取/写入字符,而后者负责解析字符或将值序列化为字符。这似乎足够清楚,但当地域设置介入时,我不确定这些关注点是否真正清晰分离。--我也同意你对实现新的流类的困难的看法。 - stakx - no longer contributing
"混合 I/O 特性和格式化特性" <--这有什么问题吗?这是该库的目的。关于创建新流,您应该创建一个 streambuf 而不是一个流,并在 streambuf 周围构建一个普通流。" - Billy ONeal
似乎这个问题的答案让我明白了一些从未被解释过的东西:我应该派生一个 streambuf 而不是一个 stream... - Adrien Plisson
@stakx:如果streambuf层做了你说的那样,那就没问题了。但是字符序列和字节之间的转换都与实际的I/O(文件、控制台等)混在一起。没有办法进行文件I/O而不进行字符转换,这非常不幸。 - Ben Voigt

10

我认为IOStreams的设计在可扩展性和实用性方面非常出色。

  1. Stream buffers: take a look on boost.iostream extensions: create gzip, tee, copy streams in few lines, create special filters and so on. It would not be possible without it.
  2. Localization integration and formatting integration. See what can be done:

    std::cout << as::spellout << 100 << std::endl;
    

    Can print: "one hundred" or even:

    std::cout << translate("Good morning")  << std::endl;
    

    Can print "Bonjour" or "בוקר טוב" according to the locale imbued to std::cout!

    Such things can be done just because iostreams are very flexible.

能否做得更好?

当然可以!实际上有很多可以改进的地方...

今天,从stream_buffer中正确推导是非常痛苦的,向流添加额外的格式信息也相当复杂,但是有可能。

但回顾多年前,我仍然认为库设计足够好,可以带来许多好处。

因为你并不总是能看到全局视角,但如果留下扩展点, 即使在你没有考虑到的点上,它也会给你更好的能力。


5
您能否解释一下为什么您在第二点中提供的示例会比仅使用像 print (spellout(100));print (translate("Good morning")); 这样的代码更好?这似乎是个好主意,因为这将格式化和国际化(i18n)与输入输出(I/O)分离。 - Schedler
3
因为它可以根据流中蕴含的语言进行翻译。例如:french_output << translate("Good morning")english_output << translate("Good morning")将会得到:"Bonjour Good morning"。 - Artyom
4
本地化在需要在一种语言中执行“<<text<<value”而在另一种语言中执行“<<value<<text”的情况下比printf困难得多。 - Martin Beckett
@Martin Beckett 我知道,看看 Boost.Locale 库,这种情况下你可以这样做 out << format("text {1}") % value,它可能被翻译成 "{1} 已翻译"。所以它工作正常 ;-) - Artyom
18
“可以做到”的并不是很相关。作为程序员,只要付出足够的努力,任何事情都“可以做到”。但是,IOStreams让大多数“可以做到”的事情非常痛苦。而且,你通常会因此得到差劲的性能表现。 - jalf
@Artonym:我仍然不明白为什么 english_output << translate("Good morning")english_output(translate("Good morning")) 更高效可行。 - Sebastian Mach

2

当使用IOStream时,我总是会遇到一些意想不到的问题。

这个库似乎更偏向于文本而不是二进制。这可能是第一个让人惊讶的地方:在文件流中使用二进制标志并不足以获得二进制行为。用户Charles Salvia正确地观察到了这一点:IOStreams混合了格式化方面(当您需要漂亮的输出,例如浮点数的位数有限)和序列化方面(当您不希望丢失信息时)。也许将这些方面分开会更好。Boost.Serialization已经完成了其中一半。如果您需要,可以使用一个serialize函数来路由插入器和提取器。在那里,您已经有了这两个方面之间的紧张关系。

许多函数的语义也很令人困惑(例如get、getline、ignore和read等)。有些会提取分隔符,有些则不会;还有一些会设置eof。而当使用wchar_t变体时,情况就更糟了。wifstream会将其转换为多字节,而wstringstream则不会。二进制I/O不能直接使用wchar_t:您必须覆盖codecvt。

C缓冲I/O(即FILE)不如C++的对应物强大,但更透明,并且没有太多反直觉的行为。

尽管如此,每当我遇到IOStream时,就会像飞蛾扑火一样被吸引。也许如果有一个非常聪明的人能好好看看整体架构,这将是一件好事。


2

我认为IOStreams比它们的函数等效物复杂得多。当我使用C++编写代码时,我仍然使用cstdio头文件进行“旧式”I/O操作,我发现这种方式更加可预测。另外一点(虽然不是非常重要; 绝对时间差异可以忽略不计),已经有很多次证明IOStreams比C I/O慢。


我认为你的意思是“函数”而不是“功能性”。函数式编程产生的代码比泛型编程看起来更糟糕。 - Chris Becke
谢谢指出那个错误,我已经编辑了答案以反映更正。 - Delan Azabani
5
如果让我设计一个可扩展且易于使用的I/O流框架,那么IOStreams几乎肯定会比传统的stdio慢;鉴于真正的瓶颈可能是文件I/O速度或网络流量带宽,我可能会将速度视为次要因素。 - stakx - no longer contributing
1
我同意对于I/O或网络,计算速度并不是很重要。但是请记住,在C++中进行数字/字符串转换时使用的是sstringstream。我认为速度很重要,尽管它是次要的。 - Matthieu M.
@Matthieu M.:大多数情况下,除非您正在进行i/o操作,否则您不需要执行stringstream所需的那些转换。 - Billy ONeal
1
@stakx 文件I/O和网络瓶颈是“每字节”成本的函数,这些成本非常小,并且由技术改进大幅降低。此外,由于DMA,这些开销不会从同一台机器上的其他线程中占用CPU时间。因此,如果您正在进行格式化输出,则高效执行与否的成本可能很高(至少不会被磁盘或网络所掩盖;更有可能被应用程序中的其他处理所掩盖)。 - greggo

1

C++的iostreams有很多缺陷,正如其他回答中所指出的,但我想为它辩护一下。

C++在众多使用严谨的编程语言中几乎是独一无二的,它使得初学者能够轻松进行变量输入和输出。在其他语言中,用户输入往往涉及类型强制转换或字符串格式化,而C++让编译器完成所有工作。对于输出也基本属实,虽然在这方面C++并不是唯一的。但仍然可以在C++中很好地进行格式化I/O,而无需理解类和面向对象的概念,这在教学上非常有用,也无需理解格式语法。再次强调,如果你正在教初学者,这是一个巨大的优势。

这种初学者的简单性是有代价的,这可能会使处理更复杂的I/O情况成为头疼问题,但希望到那时程序员已经学到足够的知识来应对这些问题,或者至少已经年满饮酒年龄了。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接