C++:The One-Definition Rule

(注:本文翻译自《C++ template:the complete guide》的Appendix A)


Appendix A. The One-Definition Rule

    被 亲切的称为ODR的One-Define-Rule是构建良好的C++程序的基础。ODR常见的表现形式很容易理解和使用:对non-inline function在所有文件中保证只存在一处定义;对于类和内联函数,在每个TU中最多被定义一次,而且要保证不同TU之间定义的一致性。

    然而,魔鬼存在于细节之中;当与模板机制相结合时,这些细节可能会变得令人沮丧。该附录用于为感兴趣的读者提供ODR的全面概览。


A.1 编译单元(Translation Units)

    我们实际编写C++程序时是在文件中写入代码。然而对于ODR原则,文件之间的边界并不很重要;真正重要的是所谓的编译单元(Translation Units)。本质上来说,一个编译单元是对程序员提交给编译器的文件执行预处理后的结果。预处理器按照条件编译命令((#if,#ifdef)去除掉未 被选中的代码块,去除注释,递归的插入"#include"指令所引用的文件,并将宏展开。

   
    因此,假设有如下两个文件
   
// File header.hpp:

#ifdef DO_DEBUG
#define debug(x) std::cout << x << ' '
#else
#define debug(x)
#endif

void  debug_init();


// File myprog.cpp:

#include "header.hpp"

int  main()
...
{

debug_init();
debug(
"main()"
);
}

    从OCR所关心的角度来看,这两个文件和下面的单一文件是等价的

// File myprog.cpp:
void  debug_init();

int
 main()
...
{
debug_init();
}


    要建立跨跨编译单元的联系,需要在两个编译单元中将相应的声明设置为external linkage,或者借助被导出的模板的实例化过程中的ADL

    注意编译单元的概念比起"预处理后的文件"要更为抽象一些。例如,如果我们将同一个"预处理后的文件”提交给编译器两次,结果会在程序中存在两个不同的编译单元(这么作毫无意义,所以不要去做)。

A.2 Declarations and Definitions

    术语"声明”和"定义”在程序员的交流中经常交替使用。然而对于ODR来说,这些术语的确切含义是很重要的。
   
    C++中的声明是用于在成语中引入或重新引入一个名字。声明也可以是定义,这依赖于它所引入的实体和引入的方式
   
    1)    Namespace and namespace aliases
   
    声明就是定义,尽管在这个上下文中术语"定义”的含义不太寻常,因为名字空间的成员列表可以在这之后被扩展(这点于class和enumeration不一样)。
   
    2)    Class,Class template,functions,function templates,member functions,member function templates
   
    声明同时也是定义,当且仅当声明中包括"brace-enclosed body"。该规则适用于union,运算符,成员运算符,静态成员函数,构造函数和析构函数,以及模板的显式特化版本。
   
    3)    Enumeration:
    声明也是定义,当且仅当声明中包含"brace-enclosed list of enumerators”
   
    4)    Local variables and nonstatic data members
    这些实体的声明总是可以被视为定义,虽然差别很少起作用
   
    5)    Global variables
    如果声明前面没有加关键字extern 或者该声明包含初始化,全局变量的声明同时也是该变量的定义
   
    6)    Static data members
    声明也是定义,当且仅当该声明出现在其所属的类或类模板的外部
   
    7)    Typedefs, using-declarations, and using-directives
    这些永远不是定义,尽管typedef可以与class或union的定义组合使用
   
    8)    Explicit instantiation directives
    它们被视为定义
   
A.3 The One-Definition Rule in Detail
   
    正如我们在该附录的介绍部分所暗示的,实际规则中存在很多的细节。我们根据范围(scope)来组织ODR规则的各种约束。
   
A.3.1 One-per-Program Constraints
   
    如下实体在整个程序中最多存在一个定义:
   
    1).    non-inline 函数和 non-inline 成员函数

    2).    具有external linkage的变量(即,在名字空间范围和全局范围内声明的变量,以及带有static修饰符的变量)

    3).    类的静态数据成员

    4).    non-inline 函数模板,non-inline成员函数模板和通过export声明的类模板的non-inline成员

    5).    类模板的静态数据成员,当它们通过export声明时
   
    例如,下面这个包含两个编译单元的C++程序是非法的
   
// Translation unit 1:
int  counter;

// Translation unit 2:

int counter; // ERROR: defined twice! (ODR violation)

   
    该规则不作用于具有internal linkage的实体(即,在匿名名字空间中、或在全局空间中使用static修饰符声明的实体),因为即使这样的两个实体有同样的名称,它们也被视为不 同的。同样的,在不同编译单元中大的匿名名字空间中声明的同名实体,也被视为不同。
   
    (译注:此处有误,匿名名字空间中声明的实体仍然是默认具备external linkage)。
   
    例如,下面两个编译单元可以合并成一个合法的C++程序:
   
   

// Translation unit 1:
static int counter = 2// unrelated to other translation units

namespace ... {
void unique() // unrelated to other translation units

... {
}

}


// Translation unit 2:
static int counter = 0//  unrelated to other translation units

namespace ...
{
void unique() // unrelated to other translation units

... {
++
counter;
}

}

int  main()
...
{
unique();

}



    此外,如果程序中"使用"了前述某实体,则该实体在程序中必须存在且只存在一份定义。这里的"使用"一词的精确意识是:在程序中的某处存在对实体的"引用 "。这里的"引用"不应理解为C++中狭义的引用;它可以是对变量值的访问,或者是对实体地址的访问;可以是在源码中显式出现的,也可能是隐式的。例如, 一个new表达式在构造函数抛出异常、要求清理已分配但尚未使用的内存是,会隐式的调用相关的delete运算符来完成必要的处理。另一个例子则是复制构 造函数,即使可以被优化掉,仍然要求其定义的存在。虚函数也被隐式使用(被实现虚函数调用机制的内部结构),除非它们是纯虚函数。还存在其他类型的隐式使 用,但我们这里为了简明起见略去了。

    有两种"引用",不构成前述的"使用":第一种是对实体的引用作为sizeof运算符的一部分出现;第二种类似但有些变化:如果引用作为typeid运算 符的一部分出现,那么就不构成前述的"使用",除非typeid运算符的操作对象是一个多态对象(即具有虚函数)

    例如,考虑下面这个单文件程序:

#include <typeinfo>
class Decider ... {
#if defined(DYNAMIC)

    
virtual ~Decider() ... {
    }

#endif
}
;

extern
 Decider d;

int
 main()
...
{
    
const char* name =
 typeid(d).name();
    
return (int)sizeof
(d);
}
  


    这是一个合法的C++程序,当且仅当符号DYNAMIC是未定义符号的时候。实际上此时,变量d是未定义的,然而在"sizeof(d)"中出现的对d 的"引用"并不构成"使用";而"typeid(d)"中对d的引用,当且仅当d是一个多态类型的对象是才构成"使用"(这是因为通常来说直到运行时才能 够判断出typeid运算符的运算结果)

    根绝C++标准,在本节描述的约束并不要求C++实现(编译器)给出诊断信息;实际上,它们几乎总是由连接器来报告重复或缺少定义。

A.3.2 One-per-Translation Unit Constraints

    在一个编译单元中,实体最到被定义一次。因此下面的例子在C++中是非法的:

inline void f() ...{}
inline 
void f() ...{} // ERROR: duplicate definition


    这是在头文件中用所谓的guard将代码保护起来的主要原因之一。

// File guard_demo.hpp:
#ifndef GUARD_DEMO_HPP
#define GUARD_DEMO_HPP


#endif // GUARD_DEMO_HPP


    这样的guard能够保证:该头文件在同一编译单元中第二次被"#include"指令引入时,内容被忽略,因此避免了对其包含的任何类、inline函数或模板的重复定义。

    ODR原则还规定,某些实体必须在某些环境中定义。这点对于class,inline function和non-export template成立。在下面几段,我们将详细的描述规则

    一个class类型 X(包括struct和union),必须在一个编译单元中对其的如下任何使用之前被定义:

    1)    创建一个类型为X的对象(例如,通过变量声明或new表达式)。创建可能是间接的,例如,当一个内部包含类型X的对象的对象被创建的时候。
    2)    声明类型X中的数据成员
    3)    对一个类型为X的对象使用sizeof或typeid运算符
    4)    显式或隐式的访问类型X的成员
    5)    将一个表达式转换为类型X或反过来,或者是到类型 X*,X&的相互转换
    6)    对类型X的对象进行赋值
    7)    定义或调用函数,该函数具有类型为X的参数;只是对函数进行声明并不需要该类型的定义

    上述规则同样适用于由类模板生成的类型X,这意味着相关模板在这些情形下都必须具备定义。这些情形构成了所谓的实例化点(POI)

    inline函数必须在每它们被使用(被调用或取地址)的每个编译单元中被定义。然而,于class类型不同,它们的定义可出现在使用点之后。

inline int  not_so_fast();

int
 main()
...
{
    not_so_fast();
}

inline int
 not_so_fast()
...
{
}


    尽管这是合法的C++代码,某些编译器实际上不会对尚未看见函数体的函数调用实行inline,因此,预期的效果可能不会实现。

    类似于类模板,对参数化的函数声明(函数模板、成员函数模板或者类模板的成员函数)所生成的函数的调用,构成了一个POI。然而,与类模板不同的是,相应的定义可以出现在POI之后(如果使用了export的话,甚至根本不用出现)。

    本节中解释的ODR的各个方面可以很容易的在C++编译器中进行检查。因此,C++标准要求当规则之一被违背时编译器生成诊断信息。一个例外是缺少non-exported 参数化函数的定义,这样的情况通常不生成和诊断信息。

A.3.3 Cross-Translation Unit Equivalence Constraints


    对于某些实体,允许在多个编译单元中存在定义引入了出现一种新的错误的可能:多个定义之间的不匹配。不行的是,在传统的一次处理一个编译但愿的编译器技术 下,这样的错误是难以检查的。因此,C++标准并不强制多个定义之间的不同被检测到或生成诊断信息(当然,它允许!)。然而,如果跨编译单元的约束被违背 的话,C++标准限定这将导致undefined behaviour,即意味着任何合理或不合理的事情都可能发生。通常,这样未检测到的错误可能导致程序崩溃或错误的结果,但是原则上它们可能引发其他更 直接的损害(例如,文件损坏)。
   
    跨编译单元的约束规定:当一个实体在两个地点被定义时,两个地点必须有完全相同的一串符号(关键字,运算符,标识符以及其他预处理后的符号)。而且,这些符号必须在各自的上下文中具备一致的意思(例如,标识符都指代同一个变量)。
   
    考虑下面的例子:
   
// Translation unit 1:
static int counter = 0 ;
inline 
void
 increase_counter()
...
{
    ++
counter;
}

int  main()
...
{
}

// Translation unit 2:
static int counter = 0 ;

inline 
void
 increase_counter()
...
{
++
counter;
}


    这个例子是非法的,因为尽管两个单元中内联函数increase_counter的符号串看起来一样,它们含有一个指向不同实体的符号:counter。 实际上,因为这两个名为counter的变量具有internal linkage(static修饰符),尽管有相同的名字,他们是无关的。注意,尽管两个内联函数都未被调用嗯,这依然是个错误。

    将可以在多个编译单元中定义的实体的定义放入头文件,并在任何需要的时候通过"#include"命令将头文件引入,能够保证在绝大多数情况下符号序列是 相同的。使用这种方法,两个相同符号指代不同实体的情况会变得相当罕见,但是当它确实发生是,产生的错误通常是神秘而难以追踪的

    跨编译单元的约束的作用对象不仅包括可以在多个地点定义的实体,还包括声明中的默认参数。换句话说,下面的程序属于undefined behavior。
   
// Translation unit 1:
void unused(int = 3 );
int
 main()
...
{
}

// Translation unit 2:
void unused(int = 4);


    这里需要注意符号序列的等价性有时可能涉及微妙的隐式效果。下面的例子引自C++标准:
   

// Translation unit 1:
class X ... {
public
:
X(
int
);
X(
int, int
);
}
;

X::X(
int = 0
)
...
{
}


class D : public X ... {
}
;
D d2; 
//
 X(int) called by D()


// Translation unit 2:

class X ... {
public
:
X(
int
);
X(
int, int
);
}
;

X::X(
int = 0, int = 0
)
...
{
}


class D : public X ... // X(int, int) called by D();
} // D()'s implicit definition violates the ODR


    在这个例子中,问题的发生是因为隐式生成的class D的构造函数在两个编译单元中是不同的。一个调用带有一个参数的X的构造函数,而另一个调用带有两个参数的X的构造函数。如果说这个例子有什么实际意义的 话,那就是将默认参数的声明限制在一个位置(如果可能的话,应该位于头文件中)。幸运的是在实际使用中,在类外定义中使用默认参数是很罕见的。
   
    对于"相同符号必须指代同一实体"这一约束还存在一个例外。如果相同符号指代具有相同值的不相关的常量,而且并未对表达式的结果取地址,那么相同符号被认为是定价的。这条例外允许编写如下的结构:
   
// File header.hpp:
#ifndef HEADER_HPP
#define HEADER_HPP

int const length = 10 ;
class MiniBuffer ...
{
char
 buf[length];
...
}
;
#endif // HEADER_HPP


    原 则上说,当这个头文件被包含进两个不同的编译单元是,两个不同的名为length的常量(constant variable)被创建,因为const默认static。然而,这样的常量经常用于定义编译时常值,而不是某个运行时特定的存储位置。因此,如果我们 不强制这样的存储位置存在(通过引用变量的地址),两个常量具备相同的值就足够了。ODR的这条例外只对整数和枚举类型的值有效(浮点和指针类型不适用)

    最 后,关于模板的一点注意。模板中的名字綁定是分两阶段几行的。所谓的非从属(non-dependent)名字是在模板被定义处进行綁定的。对于这些名 字,同样和类似的应用等价规则。对于在实例化时进行綁定的名字,等价规则必须在那个时刻应用,并且綁定必须是等价的。这导致了一个微妙的结果:尽管使用了 export的模板只在一处进行定义,他们可能拥有多个必须遵守等价规则的实例。

    这里是一个特意构造的对ODR规则的违反:


// File header.hpp:

#ifndef HEADER_HPP
#define HEADER_HPP

enum Color ...{ red, green, blue } ;
// the associated namespace of Color is the global namespace

export template<typename T> void  highlight(T);
void
 init();

#endif // HEADER_HPP




// File tmpl_def.cpp:

#include 
"header.hpp"
export template
<typename T>

void  highlight(T x)
...
{
paint(x); 
// (1) a dependent call: argument-dependent lookup required

}


// File init.cpp:
#include "header.hpp"

namespace ... // unnamed namespace!
void paint(Color c) // (2)
... {

}


}


void  init()
...
{
highlight(blue); 
// argument-dependent lookup of (1) resolves to (2)

}


// File main.cpp:
#include "header.hpp"
namespace ... // unnamed namespace!
void paint(Color c) // (3)
... {

}

}


int  main()
...
{
init();
highlight(red); 
// argument-dependent lookup of (1) resolves to (3)

}


    要 理解这个例子,我们必须记住在匿名名字空间中定义的函数具有external linkage,但是他们与其他编译单元的匿名空间中的任何函数都不同。因此,例子中的两个paint()函数是不同的。然而,在exported template中的函数paint()的调用有一个依赖于模板的参数,因此綁定在实例化的时候才进行。在我们的例子中,存在两处 highlight<Color>的实例化点,但是它们綁定到不同的paint函数,因此这个程序是非法的。

 
  • 0
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值