在C++类成员函数中使用#ifdef守卫是否安全?

18
假设您有以下C++类的定义:
class A {
// Methods
#ifdef X
// Hidden methods in some translation units
#endif
};

这是否违反了类的单一定义规则?相关的危险是什么? 如果使用成员函数指针或虚函数,很可能会出现问题。否则,可以安全使用吗?

我正在考虑在 Objective C++ 的上下文中使用它。头文件包含在纯 C++ 和 Objective C++ 翻译单元中。我的想法是使用 OBJC 宏来保护带有 Objective-C 类型的方法。否则,我必须在头文件中为所有 Objective-C 类型使用 void 指针,但这样我会失去强类型,并且还必须在整个代码中添加丑陋的静态转换。


6
这明显违反了ODR规则。它在某个编译器上可能是安全的,但为什么要冒这个险呢?你从中得到了什么好处?继承似乎是实现相同效果的正确方法。 - john
3
这有什么用?private:不可以做到吗?你要把它们隐藏在谁的视线之外,因为拥有该文件的任何人都可以读取它? - Aganju
2
你需要确保不同语言编写的单元之间对象的布局匹配,更不用说使用不同的调用约定了。你需要一些包装器来在ObjectiveC和C++之间进行调用,反之亦然。这样的问题以前已经被问过了,例如:https://dev59.com/FXNA5IYBdhLWcg3wL6sc - Swift - Friday Pie
3
如果第一个“virtual”函数位于“#ifdef”/“#endif”块内部,可能会出现严重的问题。您可能会发现虚函数表的重复定义具有不同的索引。 - David Schwartz
3
你真的需要成员函数吗?标准库和许多使用相同编码风格的库(如标准库)只会向类型添加最基本的成员函数。其他所有操作都是通过自由函数完成的。 - t.niese
显示剩余4条评论
3个回答

17

是的,如果允许不同编译单元具有不同状态的宏定义X,则可能会导致ODR违规的危险。在包含该类定义之前,程序(及共享对象)应在全局范围内定义(或未定义)X以满足要求。就C++编译器而言(而不是预处理器),这些是两个不同、不兼容、不相关的类类型

想象一种情况,在编译单元A.cpp中,在class A之前定义了X ,而在单元B.cpp中未定义X。如果在B.cpp中没有使用那些被“删除”的成员,则不会收到任何编译器错误。两个单元都可以被认为是良好形式的。现在,如果B.cpp包含新表达式,则会创建一个大小较小的不兼容类型的对象。但是,来自class A的任何方法,包括构造函数,都可能由于使用更大的定义而在使用在B.cpp中创建的对象时访问对象存储外的内存而导致UB。

这种错误的变体是,在构建树的两个或多个不同文件夹中包含标题文件副本,它们具有相同的文件名和POD结构类型,其中一个文件夹可以通过#include <filename>访问。 使用#include "filename"的单元应该使用替代方案。 但是它们不会。因为在这种情况下,头文件查找的顺序是平台定义的,程序员无法完全控制每个平台上哪个头文件将包含在哪个单元中#include "filename"。 一旦一个定义发生了变化,即使仅重新排序成员,ODR也会遭到破坏。

为了特别安全起见,这些事情应该只在编译器领域中使用模板、PIMPL等完成。对于跨语言通信,应使用包装器或适配器来进行中间处理,因为C++和ObjectiveC++可能具有非POD对象的不兼容内存布局。


2
在人类已知的所有实现中,成员函数的数量不会改变对象的大小。 - Peter - Reinstate Monica
3
除了第一个将虚函数表指针添加到对象的虚成员函数外,对吗? - R2RT
2
@R2RT 正确。我的意思是非虚函数(对于虚函数,你的陈述是正确的)。 - Peter - Reinstate Monica
@R2RT 除了最后一个之外,任何虚函数都可能受影响。如果编译器看到虚函数 a、b、c,它将按顺序将 a、b、c 放入 vtable 中。如果在另一个编译单元中只看到 a 和 c,则假定只有 a 和 c 在 vtable 中。因此对 c() 的调用实际上会调用 b()。 - gnasher729
@gnasher729 但是,无论是a+b+c还是a+c,只要有至少一个虚函数,对象大小都会增加相同的单指针到vtable。vtable的大小和完整性是另一个可能的问题。离题了,但为什么编译器在链接期间会选择3个函数(a+b+c)而不是2个函数(a+c)的vtable?我想vtable必须满足ODR,所以我们讨论UB,因此无法确定b()调用是否为c()调用或c()是否尝试执行不存在的函数。 - R2RT
显示剩余3条评论

15
这会造成严重问题,请勿这样做。以下是使用gcc的示例: 头文件:
// a.h

class Foo
{
public:
    Foo() { ; }

#ifdef A
    virtual void IsCalled();
#endif
    virtual void NotCalled();
};

第一个 C++ 文件:

// a1.cpp

#include <iostream>

#include "a.h"

void Foo::NotCalled()
{
    std::cout << "This function is never called" << std::endl;
}

extern Foo* getFoo();
extern void IsCalled(Foo *f);

int main()
{
   Foo* f = getFoo();
   IsCalled(f);
}

第二个 C++ 文件:

// a2.cpp

#define A
#include "a.h"
#include <iostream>

void Foo::IsCalled(void)
{
    std::cout << "We call this function, but ...?!" << std::endl;
}

void IsCalled(Foo *f)
{
    f->IsCalled();
}

Foo* getFoo()
{
    return new Foo();
}

结果:

该函数从未被调用

糟糕!代码调用了虚函数IsCalled,但由于两个编译单元在类虚函数表中的条目位置存在分歧,我们派发到了NotCalled

这里出了什么问题?我们违反了ODR。因此,现在两个编译单元在虚函数表中的位置发生了争议。因此,如果我们在一个编译单元中创建一个类并从另一个编译单元调用其中的虚函数,则可能会调用错误的虚函数。哎呀哇!

请不要故意做那些相关标准说不允许且行不通的事情。你永远无法想到每种可能出错的方式。这种推理已经在我几十年的编程生涯中引起了许多灾难,我真希望人们能停止故意和有意地制造潜在的灾难。


1
@Peter-ReinstateMonica 虚拟成员函数和普通成员函数之间没有根本的区别,几十年的实践告诉我,依赖于我无法想出某些东西会出错的事实是不可行的。我不够聪明去做到这一点。 - David Schwartz
8
“虚拟成员函数”和“普通成员函数”之间并没有根本的区别这种说法是不正确的。“完全不一样”才是正确的表述。 - Peter - Reinstate Monica
2
你和我一样清楚,虚函数的关键基本区别在于多了一个间接层来实现,因此需要运行时机制来解析调用时的类型信息。从概念上讲,它们通常不能在编译时被解决。相比之下,非虚函数可以在编译时解决。这对性能很重要;这也是Bjarne有意设计决策的原因,不仅仅是为了让所有成员函数都是虚函数。因此,所有实现都利用这一点,确实在编译时解析调用。 - Peter - Reinstate Monica
2
为此,他们使用古老的C机制:成员函数(这里没有告诉你任何新东西)是从链接角度解析符号名称的独立函数。这种基本差异在这里是相关的,因为对于标准的C函数调用解析机制,声明的顺序是无关紧要的。然而,对于虚拟调用的运行时机制来说,它通常是相关的。既然你知道所有这些,我真的不确定你提出那个断言或者问那个问题的动机是什么;-)。 - Peter - Reinstate Monica
@Peter-ReinstateMonica ...人们依赖于明确不保证的事情,因为它们在他们有限的经验中恰好是真实的。当人们明确主张通过依赖仅在您刚好使用的系统上恰好为真的事物来导致更多这些灾难时,这真的让我感到困扰。 - David Schwartz
显示剩余3条评论

1

在C++类成员函数中使用#ifdef守卫是否安全?

实际上(使用GCC作为 g++ -O2 -fverbose-asm -S 查看生成的汇编代码),你提出的方法是安全的。理论上不应该这样做。

然而,还有另一种实用的方法(在QtFLTK中使用)。在“隐藏”的方法中使用一些命名约定(例如,记录所有这些方法的名称都应该像int dontuseme(void)那样带有dontuse),并编写GCC插件以在编译时发出警告。或者在构建过程中(例如在您的Makefile中)使用一些聪明的grep(1)

另外,您的GCC插件可以实现新的#pragma或函数属性,并警告不当使用这些函数。

当然,您也可以(巧妙地)使用private:,最重要的是,在您的构建过程中生成C++代码(使用类似SWIG的生成器)。

实际上,你的#ifdef守卫可能是无用的。我不确定它们是否会使C++代码更易读。
如果性能很重要(使用GCC),请在编译和链接时同时使用-flto -O2标志。
另请参见GNU autoconf-它使用类似的基于预处理器的方法。
或者使用其他预处理器或C++代码生成器(GNU m4、GPP、自己使用ANTLR或GNU bison制作的)来生成一些C++代码。就像Qt使用其moc一样。
所以我的观点是你想做的事情是无用的。你没有说出来的目标可以通过许多其他方式实现。例如,生成类似于_5yQcFbU0s(在RefPerSys中完成)的“随机”C ++标识符(或C标识符,或ObjectiveC ++名称等),那么名称的意外碰撞就非常不可能。 在评论中,您说:
“否则,我必须在头文件中对所有Objective-C类型使用void *,但这样我会失去强类型”
不,您可以生成一些inline C ++函数(使用reinterpret_cast),以再次获得强类型。 Qt也这样做! FLTKFOXGTKmm也会生成C ++代码(因为GUI代码很容易生成)。
我的想法是使用OBJC宏来保护带有Objective-C类型的方法。如果您使用这些宏生成一些C++或C或Objective C代码,这将非常有意义。
我怀疑如果使用成员函数指针或虚函数,这很可能会出现问题。实际上,如果您生成看起来随机的C++标识符,它不会出错。或者只需在生成的C++代码(或生成的Objective C++代码或生成的C代码)中记录命名约定(如GNU bisonANTLR所做的),请注意,像GCC这样的编译器今天(2021年)内部使用多个C++代码生成器。因此,生成C++代码是一种常见的做法。实际上,如果您小心地生成“随机”标识符(可以在构建时将它们存储在某个sqlite数据库中),名称冲突的风险很小。

还必须在代码中添加不美观的静态转换

如果生成的代码是丑陋的,则这些转换并不重要。

例如,RPCGENSWIG - 或者 Bisoncpp- 会生成丑陋的 C 和 C++ 代码,但它们非常有效(也许还有一些专有的 ASN.1JSONHTTPSMTPXML 相关的内部代码生成器)。

头文件包含在纯C++和Objective C++的翻译单元中。

另一种方法是生成两个不同的头文件......

一个用于C ++,另一个用于Objective C ++。 SWIG 工具可能会启发您。当然,您的(C或C ++或Objective C)代码生成器将发出随机的标识符....就像我在Bismon中做的那样(生成随机的C名称,例如moduleinit_9oXtCgAbkqv_4y1xhhF5Nhz_BM)和RefPerSys(生成随机的C ++名称,如rpsapply_61pgHb5KRq600RLnKD ...); 在两个系统中,意外的名称冲突非常不可能发生。

当然,原则上使用#ifdef守卫不安全,正如这个答案中所解释的那样。

PS. 几年前,我曾经在GCC MELT上工作,为一些旧版本的GCC编译器生成了数百万行的C++代码。今天-2021年-几乎可以直接使用asmjitlibgccjit来生成机器码。部分求值是一个很好的概念框架。


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