How Does it End()?

本文探讨了C++ STL中end()函数的使用陷阱,特别是在字符串split()函数中的应用。通过对具体示例的深入分析,揭示了如何避免对end()进行非法解引用以及解决迭代器越界的潜在问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

How Does It End()?

By 刘未鹏

C++的罗浮宫(http://blog.youkuaiyun.com/pongba)

The End() of the Story

STL里的end()看似是个简单得不能再简单的函数,遵从内建数组的遍历手法,end()返回指向最后一个元素之后一位的迭代器,从而使得使用end()的循环遍历变得更容易和直观。然而,由于end()的特殊性(即它从逻辑上和物理上都不属于指向其容器内元素的迭代器,故而无法对其解引用),从而会导致一些微妙的问题,使得看上去完全无辜的代码也会出现莫明其妙的错误。

这里要说的便是一个这样的例子,是在教MM C++的时候无意间发现的。代码是要实现一个字符串的split()算法,一个经典的课后作业,呵呵:) Anyway,片刻之后写出来的代码像这个样子:

namespace MyPrj

{

namespace detail

{

template<typename InputIter>

InputIter skip(InputIter iter, InputIter end, typename InputIter::value_type c = ' ')

{

while(iter != end && *iter == c)++iter;

return iter;

}

} // namespace detail

template<typename SequenceT, typename StringT>

inline SequenceT&

split(SequenceT& seq, StringT s, typename StringT::value_type delim)

{

typename StringT::iterator end = s.end(),

start = detail::skip(s.begin(),end,delim),

over = start;

for(;start != end;++over)

{

if(*over == delim || over == end)

{

seq.push_back(StringT(start,over));

start = over = detail::skip(over,end,delim);

}

}

return seq;

}

} // namespace MyProj

上面这个小小的函数看上去很简单:首先我们跳过所有可能存在的前导分隔符(分隔符一般为空格),然后进入一个循环,over迭代器每次后移一格,一旦发现等于分隔符的字符或者发现到了串的结尾(显然,结尾总是算作分隔符的),就把卡在[start,over)区间内的串拷贝到目标容器内(注意,start用于记住上次拷贝出的区间往后的第一个非分隔符的地方),并同时把startover后移(skip)到下一个非分隔符处以便进行下一轮的卡位-拷贝。循环结束条件为start == end(注意,决不能是over == end,想想为什么),也就是说如果在某一次拷贝完毕,将startover往后skip掉所有分隔符之后,我们发现已经到了串末尾,这时就代表不用接着循环了。

很直观的逻辑是不是?然而编程跟数学的区别之一就在于编程随所用语言的不同会需要程序员去注意各种各样的细节。

所以以上这寥寥数行代码中存在着两处非常隐诲的错误,你能直接看出来吗?(至少我没有:(

We End() Up with a big Assertion!

使用”C++ Rocks!”为测试串来编译运行上述代码会发现得到的是一个大大的Assertion!(如果你用的是Debug模式并且你的STL实现支持迭代器的RangeCheck的话)。当然,在不支持RangeCheckSTL实现上也许你会得到一个无声崩溃,甚至更恐怖的——顺利运行。后一种情况等于埋下了一颗不定时。代码完全不可移植,换一个STL实现可能就挂了。

在使用STL的时候,遇到莫明其妙的崩溃或是明确的Assertion,第一时间想到的是越界/非法访问(似乎这句话不光对STL适用吧,呵呵:))。而上面这个函数则是熊掌兼得了,一个越界,一个非法访问。所以

To That End(), …

实际上直接引发我们的测试崩溃的那个bug是非法访问,更直接地说就是对end()解引用。但,怎么会呢?前面不是说过,会对start == end的情况作检查吗?事实上,这个对end()解引用的行为发生在over迭代器递增到达串结尾的时候,此时我们进入循环体,执行if判断,我们发现if判断里面的逻辑或的两个分式是*over == delim在前,over == end在后,而此时over == end()! 然而,虽说over == end成立从而足够使得该逻辑或判断成立,然而由于这一分式在后面,所以先被求值的表达式是*over == delim,而*over就是对end()解引用,于是还没等到看一眼后一个逻辑分式(over ==end)是否成立,就悲壮的挂了!出师未捷身先死。(你看,数学上并列可交换的逻辑或,到了程序里面就变成不可交换的了:))这个问题有一个很简单的解决方案——把这两个逻辑分式互换位置。只不过这种做法非常晦涩和危险,跟利用&&的短路性质一样,可读性和可维护性都不佳,但眼下也管不了这么多了,先通过测试再说,完了大不了再重构就是了。

于是交换两者顺序,编译,执行。哗啦~再次牺牲。为什么呢?稍稍跟踪一下便会发现,问题出在循环结束条件的摆放位置上。看起来放在循环继续条件中的“start!=end”,跟在循环体中放置“if(start == end) break;”没什么区别,如:

for(;;++over)

{

if(over == end || *over == delim)

{

seq.push_back(StringT(start,over));

start = over = detail::skip(over,end,delim);

}

if(start == end)

break;

}

然而这两者实际上却是有着很微妙但实质性的区别的,看一下它们的逻辑图示便清楚了:

原先的逻辑的示意图

<shapetype id="_x0000_t75" stroked="f" filled="f" path="m@4@5l@4@11@9@11@9@5xe" o:preferrelative="t" o:spt="75" coordsize="21600,21600"><stroke joinstyle="miter"></stroke><formulas><f eqn="if lineDrawn pixelLineWidth 0"></f><f eqn="sum @0 1 0"></f><f eqn="sum 0 0 @1"></f><f eqn="prod @2 1 2"></f><f eqn="prod @3 21600 pixelWidth"></f><f eqn="prod @3 21600 pixelHeight"></f><f eqn="sum @0 0 1"></f><f eqn="prod @6 1 2"></f><f eqn="prod @7 21600 pixelWidth"></f><f eqn="sum @8 21600 0"></f><f eqn="prod @7 21600 pixelHeight"></f><f eqn="sum @10 21600 0"></f></formulas><path o:connecttype="rect" gradientshapeok="t" o:extrusionok="f"></path><lock aspectratio="t" v:ext="edit"></lock></shapetype><shape id="_x0000_i1025" style="WIDTH: 207pt; HEIGHT: 283.5pt" type="#_x0000_t75"><imagedata src="file:///C:%5CDOCUME~1%5Cpongba%5CLOCALS~1%5CTemp%5Cmsohtml1%5C01%5Cclip_image001.gif" o:title="how_p1"></imagedata></shape>

修改后的逻辑示意图

<shape id="_x0000_i1026" style="WIDTH: 183pt; HEIGHT: 278.25pt" type="#_x0000_t75"><imagedata src="file:///C:%5CDOCUME~1%5Cpongba%5CLOCALS~1%5CTemp%5Cmsohtml1%5C01%5Cclip_image002.gif" o:title="how_p2"></imagedata></shape>

这两者的拓扑结构是不一样的。前者的over!=end检查位于区间提取操作之前,完了之后进行可能的区间提取,start/over后移,并接着直接++over。这种安排下,如果中间进行了区间提取并且将start/over后移到了串末尾,接下来的++over便会使over超过end(),越界!而第二种拓扑就不一样了,over!=end位于区间提取、start/over后移之后,这样就能及时发现start/over后移到串末尾的情况,从而阻止下面的++over操作越界。

此外,实际上”if(start == end) break;”也可以放在前面那个if的里面,像这样:

for(;;++over)

{

if(over == end || *over == delim)

{

seq.push_back(StringT(start,over));

start = over = detail::skip(over,end,delim);

if(start == end)

break;

}

}

这是因为导致start==end的只可能是由于start被后移了,而导致start后移的只可能是由于外围的if成立。这样修改了之后,就免除了每重循环都去检查if(start == end)的额外开销。虽然对于这个小函数来说根本无关痛痒,然而节俭是美德:)说不定遇到什么场合下这类节省可以省下可观开销呢。

最后,由于循环结束检查被放到了循环体中部,所以一开始就是直接进入循环的了。然而,如果一开始循环就不该进入呢?比如说,一个空串,这时候start/over一开始就等于end,而按照上面的逻辑,循环仍然进入,if(over == end || *over == delim)仍然满足,结果往seqpush了一个空串。然后,才是if(start == end)check,从而退出循环。那个pushseq的空串虽然无伤大雅,然而并不符合算法向外界提供的保证,所以最好还是在进入循环之前检查一下start是否等于end,是就立即让函数返回吧。

The End()

说实话这种看似简单直观的基础编程题其实还是挺锻炼基本编程素养的,毕竟,墙也是一块块砖垒起来的。

不过,实际编程中这类情况还真罕见,即便有也可以用其它方案绕过去。如:

while(start!=end)

{

over = std::find(over,end,delim);

seq.push_back(StringT(start,over));

start = over = detail::skip(over,end,delim);

}

以上做法由于将寻找下一个分隔符“skip连续的分隔符这两个逻辑分离了开来(前面的实现是把“skip连续的分隔符套在了寻找分隔符逻辑的内部了,耦合在一起,所以弄得逻辑异常复杂。所以还是谨遵爱因斯坦的名言吧:)别乱耍花枪。

不过话说回来(类)STL容器的迭代器越界/失效一直都是令STL使用者们头大的问题之一,所以才诞生了如safe_STL之类的为STL安全防护网的库,VC2005STL实现(by P.J Plauger)也带了安全性检查。不过,这些安全性检查能够带来的也只是令错误提前或显式表现出来(如,给出一个assertion失败消息而不是悄无声息地崩溃甚至不崩溃而访问随机数据),真正查错还是要自己动手的。不过毕竟还是帮了很大忙的,俗话说隐患险于明火嘛。然而别忘了还有一句防患胜于救灾,所以平素编程时多留个心眼可以免得事后亡羊补牢。

End()-less

P.S. 近日将放出一组关于C++0x核心进展的简讯,由于事情多,尽管有好几个特性想好好单独来个全景回顾的(如ConceptsVariadic Templates,甚至Opaque Type),可惜没时间去细细理一理那些proposal,然而为了最近的几个令我开心的进展,还是忍不住做一个近期的介绍,顺便也让感兴趣的朋友对即将到来的0x标准热一热身:)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值