C++类名冲突

47
以下是C++代码,我得到了意外的行为。这个行为已经被最近的GCC、Clang和MSVC++验证过了。要触发它,需要将代码拆分成几个文件。

def.h

#pragma once

template<typename T>
struct Base
{
    void call() {hook(data);}
    virtual void hook(T& arg)=0;
    T data;
};

foo.h

#pragma once
void foo();

foo.cc

#include "foo.h"
#include <iostream>
#include "def.h"

struct X : Base<int>
{
    virtual void hook(int& arg) {std::cout << "foo " << arg << std::endl;}
};


void foo()
{
    X x;
    x.data=1;
    x.call();
}

bar.h

#pragma once
void bar();

bar.cc

#include "bar.h"

#include <iostream>
#include "def.h"

struct X : Base<double>
{
    virtual void hook(double& arg) {std::cout << "bar " << arg << std::endl;}
};


void bar()
{
    X x;
    x.data=1;
    x.call();
}

main.cc

#include "foo.h"
#include "bar.h"

int main()
{
    foo();
    bar();
    return 0;
}

期望输出:
foo 1
bar 1

实际输出:

bar 4.94066e-324
bar 1

我期望发生的事情:

在foo.cc内部,定义了一个X的实例,并通过调用call()来调用foo.cc内的hook()的实现。bar也是同样的情况。

实际发生的情况:

在foo()中定义了一个在foo.cc中定义的X的实例。但是,在调用call时,它并不会分派到foo.cc中定义的hook(),而是分派到bar.cc中定义的hook()。这导致出现损坏,因为hook的参数仍然是int,而不是double。

问题可以通过将foo.cc中X的定义放在与bar.cc中X的定义不同的命名空间中来解决。

最终的问题是:没有编译器警告。无论是gcc、clang还是MSVC++都没有显示任何关于此的警告。按照C++标准的定义,这种行为是否有效?

这种情况似乎有点牵强,但它确实发生在真实世界的场景中。我正在使用rapidcheck编写测试,其中对要测试的单元的可能操作被定义为类。大多数容器类都具有相似的操作,因此当为队列和向量编写测试时,类似“Clear”、“Push”或“Pop”的名称的类可能会出现多次。由于这些只在本地需要,所以我直接将它们放在执行测试的源文件中。


14
https://en.wikipedia.org/wiki/One_Definition_Rule - Hans Passant
5
为避免违反ODR,请将X移动到匿名命名空间中(这将为它们提供一个唯一但隐藏的名称)。 - Acorn
4
是的,这是有效的行为。标准将ODR违规行为视为电梯里的放屁,不需要进行任何诊断。第3.2章第4节明确说明了这一点。这不能是编译错误,必须在链接时检测到。但链接器永远是薄弱环节,它们不足以智能地检测到这个问题。 - Hans Passant
1
进行Unity/Jumbo构建和/或LTO/WPO可能会触发警告。 - Acorn
5
@Acorn Unity构建(这是一种可恶的做法)会产生编译错误而不是链接器错误,因为这甚至不是一个ODR违规,只是在Unity构建中重新声明时形式不正确。您说得对,LTO可以诊断此问题,例如,GCC将使用“-flto -Wodr”进行诊断。 - Jonathan Wakely
@JonathanWakely:确实,我应该写“诊断信息”,抱歉! - Acorn
2个回答

47

这个程序是不合法的,因为它违反了一定义规则,在类X中有两个不同的定义。因此,它不是一个有效的C++程序。需要注意的是,标准明确允许编译器不诊断这种违规情况。因此,编译器是符合标准的,但程序不是有效的C++程序,在执行时会出现未定义行为(即任何事情都可能发生)。


2
感谢回复。许多评论和所有答案都指向了正确的方向,但遗憾的是我不能将它们全部标记为正确。在这一点上,C++标准中的定义对我来说似乎非常危险,但这超出了问题的范围。也许由于C++中模板的工作方式,需要以这种方式进行,但现代语言至少应该抱怨一下。 - Johannes
1
@Johannes 正如我所解释的,编译器(= 语言无法检测到这一点,因此也无法抱怨。然而,加载器/链接器可能会进行检测(并且通常都会)。 - Walter
5
@Walter,编译器和链接器之间没有正式的区别。编译器本身并不实现语言。不过,标准的措辞是故意这样设计的,以便在许多情况下可以使用非 C++ 意识的链接器(例如平台链接器,最初为 C 和汇编语言编写)。特别是旧的链接器仍然不太适合使用,主要是因为实现限制,例如可重整符号的最大长度。 - Arne Vogel
1
@inheanyi,这只是一个定义问题。如果您按照官方定义,可以创建一个程序,在一次编译和链接中完成,并仍然称其为“编译器”。我认为这就是ArneVogel的意思。 - Nearoo
1
@iheanyi 再次提醒,标准没有编译器/链接器的概念(除了定义不同类型的“链接”和一个脚注,例如在[lex.name]中关于外部字符集的说明)。在实践中,“实现”被视为一个整体,尽管它由编译器、链接器和各种库组成。话虽如此,“智能”链接器可以检测到简单的ODR违规,比如这里给出的违规,但需要额外的成本,假设编译器发出适当的注释。所有现代目标文件格式都是可扩展的,这也被用于ThinLTO - Arne Vogel
显示剩余10条评论

26

在不同的编译单元中,你有两个名称相同但不同的类X,导致程序不合法,因为现在有两个具有相同名称的符号。由于问题只能在链接期间检测到,因此编译器无法(也无需)报告此问题。

避免这种情况的唯一方法是将任何未打算导出的代码(特别是所有未在头文件中声明的代码)放入匿名命名空间中:

#include "foo.h"
#include <iostream>
#include "def.h"

namespace {
    struct X : Base<int>
    {
        virtual void hook(int& arg) {std::cout << "foo " << arg << std::endl;}
    };
}

void foo()
{
    X x;
    x.data=1;
    x.call();
}

同样地,对于bar.cc也是如此。实际上,这是未命名命名空间的主要(唯一?)目的。

仅仅重新命名你的类(例如fooXbarX)在实践中可能对你起作用,但这不是一个稳定的解决方案,因为不能保证这些符号名没有被某些晦涩的第三方库在链接或运行时(现在或将来的某个时间点)使用。


谢谢您的建议,看起来这是一个通用的解决方案。在我看来,这应该放在每个进行本地定义的C++文件中,就像头文件中的#pragma once一样。也许下一个C++标准可以默认让编译器将这些东西放在那里 :P - Johannes
@Johannes 我同意在全局命名空间中放置符号应该受到警告(如果确实想这样做,应该有一种方法来抑制这样的警告)。目前,我认为这只是C++开发人员之间的常识。 - Walter
是的,记住所有这些东西很难。每天都必须在Python、C#和C++之间切换,有时甚至还要做一些Java。即使有超过10年的C++经验,你仍然会感到惊讶。通常我会尽量避免像这样的全局定义,但对于单元测试来说...谁在乎呢... - Johannes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接