首页 新闻 论坛 群组 Blog 文档 下载 读书 Tag 网摘 搜索 开源 FAQ 第二书店 博文视点 程序员
频道: 研发 数据库 中间件 信息化 视频 .NET Java 游戏 移动 服务: 人才 外包 培训
    图书品种:235680
       
热门搜索: ASP.NET Ajax Spring Hibernate Java

6.2 序列点

序列点(sequence points)是C++程序中的稳定点(islands of stability),在那里可以确定某些动作已经完成,且其他动作尚未开始。所有计算机语言都隐式地拥有这样的点,但在C++ 中(与在C中一样)它们由标准显式地提供。良好的编程习惯要求要么严格坚持一套方针,要么透彻理解在序列点之间发生的事情。

大多数程序都由求解表达式构成。表达式的求解会得出一个值。另外,许多表达式还具有副作用。较为显著的副作用包括诸如打开和关闭文件、从文件中提取数据、将数据插入文件中,以及其他形式的输入和输出等。

一个较为阴险的副作用是改变程序自身的状态。我指的是对程序的内存写入某些东西。最明显的例子是使用赋值来存储结果。许多程序员对此感到吃惊:这种存储结果的过程远非好事。那些拥有函数式语言(例如Haskell)编程经验的人甚至可能学过:赋值是一种危险且高度可疑的操作。

这样的C++语句由两个独立的元素构成。首先是求解表达式i = j * k。我们几乎总是丢弃结果,虽然如此,存在一个可以在某些环境中使用的结果。举个例子,

以上代码将返回表达式result = lhs * rhs的值。一般而言,我们通常对如下事实更感兴趣:求解赋值表达式会导致将一个值存储在由赋值操作符之左操作数指定的对象中。函数foo()完成了两件事情。它返回一个值,但也有副作用,即将计算lhs * rhs得到的值存储在由result指定的对象中。注意它与下面这个函数有何不同:

函数bar()没有副作用;它计算出并且返回lhs * rhs的结果。以计算机科学的术语表达,bar()是一个纯函数(pure function),因为对它的调用在外部对象(例如打印机)或程序的内部资源(例如内存)上,都没有永久的作用。

伴随副作用的一个问题是确定它们将在何时发生。在大多数机器上,将结果存入内存是一个相对较慢的过程。另外,一些硬件在内存写入之后、程序再次访问该块内存之前要求一段稳定时间(stabilization time)。C++使用的解决方案(继承自C)指定了称为序列点(sequence point)的东西,如果有必要,程序必须等待内存稳定。这是序列点概念的动机之一。作为程序员,我们更关心它在实践中的意义。

在两个序列点之间,我们随时可以自由地读取任何表示对象的内存,前提是我们没有改写那片内存。然而,如果在序列点之间改写了一片内存,则必须只能改写一次。此外,我们只能将对那片内存的读取,作为“决定程序将要向那片内存写入某些东西的过程”的一部分。违反这些规则中任意之一,都将导致未定义行为。

大多数程序员对规则的第一部分感到愉快:对于序列点之间的一片内存,只能写入一次。很多人不理解第二条限制。那条限制确保了这一点:如果在序列点之间内存既被读取又被写入,那么读取将在写入开始之前完成。在C++未指定子表达式求值顺序的上下文中,这是确保安全的唯一准则。

现在你应该明白为何“知道源代码中的序列点在哪里”很重要了。下面是一个完整的清单:

完全表达式(full expression):在完全表达式求值的结尾存在一个序列点。完全表达式的值不被直接作为求解某个其他表达式的一部分使用。举个例子,在上面的函数bar()中,lhs * rhs是一个完全表达式。然而,在函数foo()中,lhs * rhs不是一个完全表达式,因为在计算对result的赋值中使用了它的结果。

注意,一条语句可以包含不止一个完全表达式。例如,语句

包含两个完全表达式:a < b和i++。

函数调用(function call):两个序列点保护一个函数调用。在所有实参赋值之后立即有一个序列点,从而函数体可以在“所有初始化形参的副作用已经完成”的假定之上继续执行。第二个序列点位于返回点,它确保任何提供返回值的副作用在调用该函数的代码恢复执行前已经完成。

很少有程序员编写的代码存在返回序列点的问题,不过入口的序列点有时会被误解。比方说,

可能直到你更仔细地检查对bar()的调用之前,这段代码看上去都很健康。为了调用bar(),必须计算两个表达式i和i++。它们不是完全表达式,因为结果将被作为实参用于初始化bar()的形参。这意味着在i和i++的计算之间不存在序列点。然而,对第一个实参的求解要求读取存储于由i指定的对象中的值,但我们无法确定什么东西将被写入i(考虑求解第二个实参期间递增i 的副作用),换句话说,我们破坏了关于在序列点之间读写同一个对象的规则。这就意味着我们身处未定义行为的境地,任何事情都有可能发生。在实践中,这个问题通常表现为两种求值顺序导致第一个实参具有不同的值。这种行为不应该麻痹你的警惕心,因为这并不是未指定的行为,而是未定义的行为。请弄清楚这两种行为的区别,早晚有一天会派上用场。

逗号操作符(comma operator):在某些上下文中,逗号(,)仅仅是分隔一串项目(items)的标点。在其他上下文中,则是C++序列操作符(sequence operator)。知道它到底在发挥什么作用,很大程度上是经验问题。不幸的是,这对你的程序可能有“深远的影响”。当逗号是序列操作符时,它会向代码中注入一个序列点,意味着逗号左边的表达式是完全求值的(fully evaluated),在触及右边的表达式之前所有副作用均已完成。

更糟糕的是,如果逗号操作符的至少一个操作数是用户自定义类型,则C++允许程序员重新定义它。在那些环境中,它不再是序列操作符,左右操作数(表达式)可以按任意顺序求解。因此,或许最好假定逗号不是序列操作符,除非你确切地知道它确实是。

条件操作符(conditional operator):在条件操作符的左操作数的求值,和其他两个操作数中被选中的那一个的求值之间,存在一个序列点。因此

就第一条语句来说,这段代码没问题。我无法想像怎么可能写出这样的语句,但其中不存在未定义行为。由于在? 处的序列点的保护,第一次对value的读取不会受到稍后对同一存储区的写入的影响。

|| 和&& 操作符:对于这些操作符的内建版本而言,左操作数(表达式)的求值后面都存在一个序列点。这意味着左操作数是完全求解的,所有结果的副作用在计算右操作数之前已经完成。注意,只在有必要确定其结果的时候才会计算右操作数。这意味着求解右操作数的任何副作用依赖于左操作数的值。

多序列点

一个表达式常常包含多个序列点,程序员必须注意不要假定“序列点强制求值的顺序”。序列(逗号)操作符、条件操作符,以及|| 和&& 操作符可以强制操作数的求值顺序,但这只是就事论事而已。如果你编写

“在expr2之前完全求解(包含副作用)expr1”,和“在expr4之前完全求解expr3”的任何求值序列,都在规则允许之内。比方说,不存在必须在expr4之前求解expr1的要求。

查看所有评论(0)条】

最近评论



正在载入评论列表...
热点评论