`
61party
  • 浏览: 1051292 次
  • 性别: Icon_minigender_2
  • 来自: 北京
文章分类
社区版块
存档分类
最新评论

C++标准库的一个有趣的小bug

阅读更多

在看别人的代码时,意外发现了一个标准库的问题(不知到标准委员会的c++ standard lib.core issue文件里有没有提到,不管它),是这样的,代码如下:
struct X
{
};
ostream& operator<<(ostream& out, X& x /*坏习惯*/)
{^^^^ ---- #1 non-const reference
...
return out;
}
void use1()
{
vector<x></x> v;
v.push_back(X());
copy(v.begin(),v.end(),ostream_iterator<x></x>(cout,"\n") ); //编译错误!
}
void use2()
{
X x;
cout<<x;

}

按照语义,use1和use2都应该通过编译,但事实是use1不能通过编译。原因就在于#1处,如果将X&改为X const&则万事大吉。
到底为什么呢?看一下ostream_iterator的定义吧:
其中有一个成员函数是这样的
ostream_iterator& operator=(const _Tp& __value)
{^^^^^^^^^^^^ -----#2 const reference
*_M_stream << __value; //应该转到用户重载的operator<<中去
...
}
上面的例子中的copy(...)每迭代进一步都要调用这个函数,将迭代器指向的值给它,然后在这个函数中的:*_M_stream << __value;其实就相当于cout<<__value,输出该值,又什么不对吗?看看参数__value的类型,是const &,而我们重载的那个operator<<只接受non-const引用,所以就找不到匹配的函数了。这里的关键在于,ostream_iterator的成员operator=为它的参数强加上了不该加的语义——const——而不管其原先是否为const。

解决方法有两种:
对于用户,解决办法是重载operator<<时注意第二个参数最好写成const引用参数,否则像上面的"copy(v.begin(),v.end(),ostream_iterator<x></x>(cout) )"调用方式就会出漏子了。这个篓子还不算严重,至少你还被阻止在编译期,另外一种情况更为严重——通过编译但运行结果让人摸不着头脑,我们为原先的例子加上一些代码:
ostream& operator<<(ostream& out,X* & px)
{
...
return out;
}
void use3()
{
X* px=new X;
cout<vector v;
v.push_back(px);
copy(v.begin(),v.end(),ostream_iterator(cout,"\n") ); //编译没问题,但结果确打印出px里面存放的地址
}

copy(...)为什么会打印出地址?因为ostream有一个成员函数是为void*重载的,所以发生了一个隐蔽的类型转换,调用了它。
与前面的错误相比,这种错误更为隐蔽。要到运行期才会出现。
对于库的设计者,这是个疏忽,有一个简单的解决办法,为ostream_iterator添加一个operator=成员函数,其调用参数是non-const引用。这样通过重载决议,上面的copy(...)将调用添加的版本,由于其参数是non-const引用,所以接下来会转往用户重载的operator<<去。这个解决方案要求编译器能在 const引用和non-const引用之间正确进行重载决议。

--------------------------------------------------------------

to 大家:
这个问题我已经不打算再往下讨论了,问题已经很明显,关键是如何折衷,这倒是需要库的开发者的经验来决定了。我也不想在这个问题上耗费精力了,只不过,虽然是个小问题,但可以看出大家思考问题的深度和广度,我将文章改了一下,把我的几个关键回帖写到了下面,以表明我的意见,因为很多朋友都是光看了文章没看我后面的回帖就下的结论。

--------------------------------------------------------------------------------------------------------------------------------------
to solotony:
你的问题我也想过,但是你有没有想过:
第一:程序员不一定都那么有自觉性,一旦他们无意间犯下了这个隐蔽的错误(?),却得到了不一致性的编译(或运行)结果,他们可能无所适从。如何理解“直接输出没有问题而放到模板容器中就有问题呢”,最关键的是对于指针,问题处在运行期,更惨。光就这个不一致性,就应该将重载版本加上去。如果不加,除非你有办法将这种情况下的普通输出也禁止,但这显然是不行的。
第二:对于要用到用户重载的operator<<的类来说,自定义的输出语义应该在该类的对象身上,应该是“该对象知道如何将自身输出到流”而不是“流知道如何获取对象的信息并输出”,这个“输出”的行为是对象拥有,而非流。对于对象来说,任何流都是一样,抽象的说就是一个“管子”,具有最基本的有关输出的一集语义。所以,输出只不过是对象自身的一个行为,这个行为是否为const,则由该对象(类)决定——谁规定对象在输出自身的时候不能改变自身的状态的?虽然我一时举不出例子,但是肯定有!至少,当这种情况出现的时候,你不能不让用户的代码通过编译吧。
第三:对于一个拥有“引用”语义的指针来说,输出它就输出它指向的对象又有何不可呢?本来,指针和引用的语义就十分相近。另外,在一个具有输出输入(serialize&unserialize)的系统中,输入输出都属于同一个系统,不关系统使用者的事,使用者也不必知道输出了哪些东西,读入了哪些东西,这是个黑箱,MFC的序列化就是例子。形象一点说,A负责将一个指针输出去,A也会负责将它读出来,至于如何出如何入则全都是A的事情,所以A不会对这种语义感到迷惑。极端情况下,A输出一个指针(实际输出了一个对象)但要求B来读入,那么它们之间应该就此有一个“契约”,如果A和B开发的是一个系统的两部分,那么这种“契约”就应该表现为对输出输入格式的约定。如果B是用户,那么A应该给B提供文档(或用户手册)。即使是后一种情况,用户一般只会看到一个对象,至于这个对象是否经由指针输出则仍然是程序员的事情。唯一的坏情况是代码重用,这就要求程序员有好的编码习惯和文档习惯,从另一个方面说,没有好习惯,即使程序语义明确也会给重用造成麻烦。再说这种语义也属常见,一个稍有素质的代码阅读者也不至于看不到那个重载过的operator<<,就算跟踪也跟踪到了。如果是二进制重用呢?很显然,又回到上面说的黑箱的情况了,打住。
第四:根据Stroustrup的意思,“不应该强求程序员做他们不愿意做的事情,在模棱两可的事情上保持自由度”,按照你的观点,库就某种程度上强制了程序员一定要将被输出参数置为const的(否则就不一致,而一致性对于避免语义含混是非常重要的),万一程序员有特殊要求呢?
-------------------------------------------------------------------------------------------------------------------------------------------
to rainmain123:
标准库不是黑盒,C++标准库符合Open Close Principle。
只有二进制复用的库才是完整的黑盒。STL是个可扩展的库,“白盒”的成分比较大一点。
另外,你说的的确有道理,我也这么认为,但这里的问题并非在于哪一方对哪一方不对,而是一个权衡利弊的问题。

我是这样权衡的:

这里有两种做法,第一,维持ostream_iterator的原状,其结果是:

1. 用户在使用ostream_iterator并自定义operator<<时被强制将其第二个参数设为const &(或干脆

为值拷贝传递参数),这也就是强制用户将对象的“打印自身到输出流”的行为const化。

1的问题在于:
(1) 虽然ostream_iterator有此限制,但是考虑下面的三份代码:

copy(v.begin(),v.end(),ostream_iterator(cout));

for(iter_type iter=v.begin();iter!=v.end();++iter)
{
cout<<*iter;
}

for_each(v.begin(),v.end(),cout<<_1);
这三份代码语义完全一样,最后的for_each还使用了标准库函数,但是它们的行为因

由于用户重载的operator<<而异。
(2)考虑一种特殊需求,有一种文件对象,其限制是在其生命期里只能被输出一次,

后续的输出都是nop(无操作),很显然,这是一种特殊的文件,就像那些“只能被读一次,然后就自动

销毁”的密码一样。很显然,这种需求是存在的。当遇到这种情况时,operator<<的第二个参数总该是

non-const &了吧!但是ostream_iterator却阻止编译继续,难道不是个问题吗?我知道你可能会想到

mutable关键字,但这个关键字只能解决语法问题,不能解决语义问题,就不细说了。这里的关键是,如

果维持ostream_iterator原状,那么对于特殊要求来说就无所适从。

2.对于指针,如果用户自定义的是operator<<(...,X* &),那么用ostream_iterator将输出指针中的

地址值,而如果用户自定义的是operator<<(...,X* const&),那么就能按照用户自定义的意愿输出。这

个行为是会让人迷惑的!特别是当cout<

迷惑人,除非有办法将cout<

为如果不一致就会导致迷惑。

总的说来,如果维持现状,只有一个极其有限的优点,就是当且仅当用户使用ostream_iterator向流中输

出时,能够在编译期确保“输出”操作是const的。但如果用户手写循环输出或以另外的函数输出,

ostream_iterator就鞭长莫及了。而缺点则是牺牲了对特殊情况的支持(自由度),和一致性——谁都看

得出自由度和一致性是多么重要。

第二种做法,增加一个成员的operator =(T&)函数,其结果是:
1. 用户必须适当注意操作的const性——这本来就是用户的职责。虽然前一种情况下

ostream_iterator能起到(强制)提醒用户这一点的程度,但是在语言里并没有这种内建的“提醒”能力

,所以说到底这种强制提醒还是极其有限的,归根到底还是靠用户的自觉性,而自觉性则是每个C++用户

的必备能力——C++语言设计理念就是如此:不强迫用户,但告诉用户做正确的事。这正如operator*也可

以被重载来作除法一样,C++不强制,但是程序员要自觉。
2. 顾全了语义的一致性。
3. 提供了自由度,现在对特殊情况也支持了。
----------------------------------------------------------------------------------------------------------------------------------
to Cynics:
你可能误会我的意思了,我的意思是为ostream_iterator 添加 一个重载函数,这样ostream_iterator就有两个版本的operator = :
ostream_iterator& operator = ( _Ty const & ); -- #1//原来的
ostream_iterator& operator = ( _Ty & ); -- #2//新加的
这样,不管用户如何重载operator << ,行为都会一致,例如:

一: ostream& operator<<(ostream& , X const & );

{
cout<< x; //调用 #1
copy(..., ostream_iterator(cout) ); //调用 #1
}

二: ostream& operator<<(ostream& , X & );

{
cout<< x; //调用 #2
copy(..., ostream_iterator(cout) ); //调用 #2
}

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics