一个编译问题

感觉这篇文章很不错,就转贴过来了。

一个很巧妙的错误,欺骗编译器。

作者: Panic 2006年7月27日

很久没碰到什么有趣的问题了,今天看到一个,特写随笔一篇以作留念:P

问题的来源:

 一种让另外的类对本类成员只读,本类对本类成员可读写的方法。 [所有相关帖子]

例子
//a.h
class a
{
public:
  void test();
#ifndef A_CPP
const
#endif
   int b;
};

//a.cpp
#define A_CPP
#include "a.h"
#undef A_CPP

void a::test
{
   b = 12; //可以写
}

//b.cpp
#include "a.h"
void b()
{
  a _a;
   _a.b = 12;//编译错误 不可写
}
书童 xulingfv 发表于 2006-7-27 10:46:09


周星星的代码:

// a.hpp
struct foo
{
    foo();
#ifdef SOMETHING
    int a;
#endif
    int b;
};

// a.cpp
#define SOMETHING
#include "a.hpp"
#undef SOMETHING
foo::foo()
{
     a = 1;
     b = 2;
}

// main.cpp
#include "a.hpp"
#include <iostream>
int main()
{
    foo test;                         // 理论上讲,a.cpp和main.cpp中的foo定义不同,是不同的类型,所以应当报foo()未实现,但实际上却正确
    std::cout << test.b << std::endl; // 输出1
}


上面的代码,有兴趣的可以试试,这里只说明问题:

首先澄清几件事:

1,对编译器来说,一个函数定义在什么地方并不重要,无论foo::foo()定义在哪里,它都是foo类型的构造函数。

2,对链接器来说,区分不同函数(或者类型)的唯一方法是这个函数最后生成的符号名字,如果存在两个相同的符号,就会报重定义的错,而反之,无论这个符号来自何处,链接器都会把这个符号和对应的函数代码链接在一起。

3,对同一个编译单元,或者说,同一个.C/.CPP文件,里面的每个定义只会生成一份OBJ代码,模板则会为每个类型生成一份代码-如果可能的话。


这几件事情澄清之后,其实上面的代码就没有任何问题了,简单的说明一下:

第一段代码:

a.cpp和b.cpp中的class a,毫无疑问并非同一个类型,但是利用#define和#undefine,强制生成了完全相同的类型名字。

为了区分,这里把a.cpp中的class a称作a1,b.cpp中的称作a2。

当编译a.cpp的时候,编译器使用的是含有int b的定义,这时候void a::test是void a1::test。

而编译b.cpp的时候使用的是含有const int b的定义这时候a _a;其实是a2 _a;


这两个类型中因为只存在一个成员是否是const的差别,一般情况下具有相同的内存布局,所以成员函数(包括构造函数)的调用不会出现太大的问题,但是实际上,这是两套不同的类型生硬的捆绑在一起的结果。

假如通过a2来调用test函数,由于编译器为a1生成的void a1::test和void a2::test的符号完全一致,于是链接器很自然的把void a1::test嫁接在了a2的调用点上,造成了a1和a2难以区分的假象。


第二段代码:

其实和第一段完全一样,foo::foo()作为foo1版本的构造函数,它的链接符号和int main()中的foo2版本完全一样,导致链接器错误的把这个函数嫁接在了foo2的构造过程中。

不过这个例子中,foo1和foo2的内存布局不同,所以由此引发的内存越界访问的错误可以通过一个简单的方法测试:

写如下代码:

#include "a.hpp"
#include <iostream>
int main()
{
    int a;
    foo test;
    int b;
    std::cout << a << std::endl; // 输出a
    std::cout << test.b << std::endl; // 输出1
    std::cout << b << std::endl; // 输出b
}

在一般的实现中a和b其中之一会输出数值2,也就是foo::foo()中的第二个赋值。


值得注意的是,在debug版本下这种对编译器的欺骗很可能不会带来实质性的错误,但是经由release版本的优化之后,两种类型定义的差别会显著增大,一般会导致运行期错误。

事实上这种错误以前就出现过,在使用了第三方代码库的项目中,项目自身定义了某个函数而忘记实现,恰好库中也有一个同名但功能却大相径庭的函数,链接器忠实的把错误的函数链接在了用户的调用点,引起一个难以发现和修改的bug。

我们在代码中使用各种命名规范区分名称,使用namespace隔离潜在同名空间,从而最大程度的避免同名函数/类型的问题。而以这种错误为技巧来制造看似有效的手法,会给工程留下隐患。

 

写到这里,我忽然对早上和释雪探讨的一个问题有了一些认识,问题是这样的:

一个模板
//a.h
template<class T>
class Test
{
};


假如在两个CPP文件中分别以相同的模板参数做了不同的特化(比如写了两个Test<int>的特化代码),会冲突么?答案是不会。而实际使用的特化版本以最先编译的那个单元为准。


要解释这个问题,需要明白另外一件事,就是“模板的代码,在使用的地方必须是可见的”这条规则。

C++ ISO中并没有规定这种行为,那么是什么导致了编译器的这种限制呢?原因就是每个编译单元都不依赖其他单元完成编译,这样一来模板代码就找不到形成OBJ的时机。

具体来说是这样:

假如模板的所有实现代码都存在于某个CPP文件中,当这个CPP文件作为一个编译单元进行解析的时候,因为不知道模板究竟对哪些模板参数进行了实例化,所以也就无从生成OBJ文件。而对所有类型都生成实例在理论上是不可能的。

而在使用了模板的地方,因为不知道模板的实际代码在哪里,也无从为模板生成OBJ文件。这样一来,模板的代码就完全没办法实现了。

于是实际的编译器,应该是采取了这种策略,当编译到使用某个模板的时候,先检查这个模板实例化后生成的符号是否已经存在,如果不存在就根据代码生成实例,并且把符号记录,反之就直接关联已经存在的符号。

由于编译单元的编译顺序完全是由使用者人为控制的,所以编译器自身无法获取首次实例化的时机,那唯一可行的办法就是在每个调用的地方都使得模板代码可见,这样一来无论哪个编译单元首先进行实例化,都可以顺利生成实例。


这条规则有一个隐患是,规则要求的只是可见性,并不要求“同一性”,换句话说,你可以把模板代码写在头文件中,然后所有的使用者都去包含它。也可以把模板的实现代码写在每一个使用它的地方。

如果你使用后一种方法,写在每个文件中的实现代码,可以是各不相同的,能够生成实例的那个版本是首先编译的那个版本。也就是说,程序最终生成的代码,是和编译顺序相关的。

(注:红色部分是原文的写法,不知道是不是我理解有问题,我觉得所谓的"程序最终生成的代码"中关于模板的部分应该是在编译各单元的时候定的,也就是上面所说的在那个编译单元中模板"可见"的部分。这和原文所说的"和编译顺序相关"不是一个意思。)


写了这么多,也不知道问题有没有说清楚。

有兴趣的朋友可以写个代码测试下,不同的编译器也许还有不同的细节差异。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值