如何通过模板和友元来骇客C++

译者序

本文翻译自https://ledas.com/post/857-how-to-hack-c-with-templates-and-friends/,
作者通过讲述模板和模板中的友元来说明可以获取到C++中类的私有成员及有状态的模板元编程,方法比较trick,同样也是C++中的黑魔法,自己写代码时谨慎使用,以下则是正文开始。
原文链接:https://ledas.com/post/857-how-to-hack-c-with-templates-and-friends/

模版在C++语言中很强大的特性,因为使用它可以写出来接受类型作为参数的范型程序。这个特性意味着我们不需要重复去写由于类型不同的重复或相似的代码。

当模板首次在C++中引入时,它们的功能还是无法预见的。从那时起,模板慢慢发展成为C++内部的一种单独的函数式语言。这种语言有自己的语法、规则和大量的细节,其中一个我将在后面讨论。

模版如何工作

每个模板定义了类似是一族的函数,毕竟他是一种函数式语言。但是这个族函数的模板运行时不会自己产生任何代码,相反编译器负责只为程序中实际使用的函数生成代码。这个过程是一个被称为实例化的多步骤过程。

template<typename T>
struct Foo {
  T val;
};

template<>                      // generated code
struct Foo<int> {
  int val;
};

...
template struct Foo<int>;       // explicit specialization(显式具体化)
Foo<int> foo;                   // implicit specialization(隐式具体化)
...

首先,用户必须进行显式或隐式的具体化。编译器看到这个就开始进行实例化的过程,这其中就包括替换模版参数等。如果具有替代模板参数的代码有效,则编译器将生成最终的实际的具体化。

译注:也就是说只是写模板并不产生任何代码,只有在编译期对模板进行实例化时才会产生具体的代码,进行实例化的前提必须要对模板具体化,无论是隐式还是显式,显式或者隐式的语法如上所写

创建一种新的友元

“友元”在c++中通常是允许非成员函数或者其他类访问本类的私有或者保护的成员的一种方式。这样做是为了允许对非成员比较运算符进行对称转换,或允许工厂类对类的构造函数进行独占访问,以及其他原因。

void bar();

struct Foo {
  // bar now may use private members of Foo
  friend void bar();
};

请注意,函数bar并不要求是定义与声明在同一个翻译单元,那么如果我们忘记定义它会发生什么呢?

struct Foo {
  friend void bar();
};

可以通过编译,kennel会很惊讶,但也确实是有效的完整代码。

这被称为友元声明。如果之前没有声明的话,声明函数bar(如果有,它就会重新声明一个)。与常规声明相比,此类声明确实有一些限制,但这些限制对于本主题并非必不可少。

译注:对于友元函数的使用,一般的定义是在类外。但如果是这样的话,还需要在类外进行一次额外的声明才能正常调用。友元声明并不算真正的声明。但是这里要将确实对于友元声明的利用。

它声明了一个函数,但是这个函数的作用域是什么呢,当一个函数首次在类中的友元声明中声明时,它成为该类内部封闭命名空间的成员。但它不是声明它的类的成员。

struct Foo {
  friend void bar();
};

...
bar();		// error - bar is not declared
::bar();	// error - bar is not in the global ns
Foo::bar();	// error - bar is not a member of Foo
...

调用该函数的唯一方法是使用依赖于参数的查找(ADL)。ADL是一组用于查找非限定函数名的规则。除了通常的非限定名查找所考虑的范围和名称空间之外,还将在其参数的名称空间中查找这些函数名。

我更改这个示例,如下图所示。

struct Foo {
  friend void bar(Foo) { cout << "Got it!" << endl; }
};

...
Foo foo;
bar(foo);	// Ok - found through ADL
...

注意,友元函数可以在类定义中定义,这些是内联函数,类似于成员内联函数,它们的行为就好像它们是在看到所有类成员之后,但是在关闭类作用域之前立即定义的一样。****

深入一下

组合一下上边谈到技巧

template<typename T>
struct Foo {
  friend void bar() { cout << "Got it!" << endl; }
};

和前面一样,我们有一个结构体Foo和一个隐藏的友元函数bar,只是这个结构体是个模板。我们知道,要调用 bar,我们必须在全局名称空间中声明它。

译注:类中的友元函数如果有实现,可以通过ADL调用,如果是隐藏的也即没有参数,那么需要在外部进行声明,然后即可调用。这里的模板封装友元函数例外。
我们来试一下:

template<typename T>
struct Foo {
  friend void bar() { cout << "Got it!" << endl; }
};

void bar();

...
bar();	// error - unresolved external symbol
...

发生了什么,仅仅在需要的时候才会对模板进行实例化,但是这里还没有对Foo模板进行实例化。bar的定义还不存在。编译器不知道结构体的内容,因此我们必须显式地实例化Foo。

template<typename T>
struct Foo {
  friend void bar() { cout << "Got it!" << endl; }
};

void bar();
template struct Foo<int>;

...
bar();	// Ok
...

现在可以正常运行了,这看起来很有趣,你可以发现问题吗,实际上有两个问题。

首先,注意 bar是一个普通函数(不是模板函数),引用了Foo的具体化。意味着我们可以使用传递给Foo的所有模板参数——即使参数是指向另一个类的私有成员的指针。这个我们后边会讨论。
其次,是否定义函数取决于编译器是否对Foo的具体化进行实例化,这是编译器的内部状态。C++中的模板可以检查这个状态: SFINAE,或者“替换失败不是一个错误”。有很多使用这种技术的例子,但是为了便于理解,我们将研究编译期计数器的实现。由于这段代码还不能用于生产,我们将使用C++20的一个新特性,稍后我将介绍这个特性。
在C++ 模版显式实例化的时候,可以访问类的私有成员,不做检查。***

访问私有成员

假设您有一个包含两个私有内部类的类,其中一个是模板化的,一个是公共方法func。

class Parent {
  struct ChildA {};
  template<typename N> class ChildB {};

public:
  void func();	// Do something with ChildB<ChildA> 
};

假设在func中使用了具体化的ChildB,您希望显式地实例化这种具体化,如何实现呢?简单的问题,简单的答案。如下:

class Parent {
  struct ChildA {};
  template<typename N> class ChildB {};

public:
  void func();	// Do something with ChildB<ChildA>

  //...
};
template class Parent::ChildB<Parent::ChildA>;

这里是一个小的把戏。显式专门化在全局命名空间中,而不在类本身中。但是编译器如何知道使用私有内部类?答案是,没有! 对于这种情况,C++标准包含以下措辞.

通常的访问检查规则不适用于特定的显式实例化,

注意:特别是函数声明中使用的模板参数和名称(包括参数类型、返回类型和异常规范)可能是平常不被访问的私有类型或者对象,而函数声明中使用的模板可能是一个是平常不被访问的成员模板或成员函数。

简而言之,它并不检查是否允许这样做,这意味着我们可以为任何想要的类编写一个外部getter。我们来试一下:

class Private {
  int data;
};

template<int Private::* Member>
int& dataGetter(Private& iObj) {
  return iObj.*Member;
}

template int& dataGetter<&Private::data>(Private&);

...
Private obj;
dataGetter<&Private::data>(obj);	//error
...

这里我们定义了一个函数,该函数参数是一个指向具有 int 类型的 Private 类成员的指针,return 语句中的花哨语法只是通过成员的指针访问成员的一种方法。

当我们尝试调用这个函数时,不幸的是,它会失败。调用不是显式实例化,会去检查访问规则。

幸运的是,我们可以使用友元的注入来达到目标。

template<int Private::* Member>
struct Stealer {
  friend int& dataGetter(Private& iObj) {
    return iObj.*Member;
  }
};

template struct Stealer<&Private::data>;
int& dataGetter(Private&);	//redeclare in global ns

...
Private obj;
dataGetter(obj) = 31;	// Ok
...

这里我们可以改变任意类的一个私有成员。

译注:使用友元可以访问到任意类的私有成员,依赖于显示具体化一个模板类不会检查访问规则,同时又不能隐式具体化模板,也即无法使用static函数和成员函数来进行调用访问。那么这里就只有友元函数满足条件。

编译期计数器

让我们继续看第二部分,看看如何实现编译时计数器。首先,让我们看看计数器的用法

template<int R = reader<0>(int{})>
constexpr int next() {
  return R;
}

...
static_assert(next() == 0);
static_assert(next() == 1);
static_assert(next() == 2);
...

是不是很美? 我们刚刚侵犯了语言的一个基本方面,然而我强调,一切都完全符合C++的标准。

所以发生了什么?next是一个constexpr 模板函数,只有一个参数 R,该参数具有默认值。
默认值是被模板函数reader生成,reader接受int的模板参数。如果你熟悉模板元编程,然后你可以猜到reader是作为一个尾递归函数实现的,它迭代从数字0开始的连续数字。

template<int>                                         
struct Flag {
  friend constexpr bool flag(Flag);
};

template<int N>
struct Writer {
  friend constexpr bool flag(Flag<N>) { 
    return true; 
  }
  static constexpr int value = N;
};

template<int N = 0>
constexpr int reader(float) {
  return Writer<N>::value;
}

template<int N = 0,
         bool = flag(Flag<N>{}),
         auto = unique()>
constexpr int reader(int) {
  return reader<N + 1>(int{});
}

递归的结束不是由数字本身控制的,而是通过在全局命名空间中定义一个特殊的函数Flag,如果前一个flag被生成了,那么控制权就会传递给下一个reader函数。如果没有被定义,flag函数的定义会被Writer注入到全局空间,并返回当前值。

译注:这里的前一个或者下一个是指不同的拥有不同数字的模板函数。reader<1>相对于reader<0>,Flag<0> 相对于Flag<1>。
flag函数的定义需要等Writer具体化之后,Writer的具体化又是在reader(float)函数中,根据SFINAE规则,调用reader时会先检查reader(int)函数,发现flag函数找不到,继续去找reader(float)函数,返回数字,且当前flag(Flag)被生成。当下一次调用reader时,reader(int)会被检查通过,从而继续调用,以次反复。

需要注意的是,第一个重载的reader优先级较低,因为他接受float。选择此重载需要进行隐式浮点到整型的转换。

最后值得一看的是unique函数的实现,顾名思义unique函数每次调用返回值都是不一样的。更具体地说,它生成了一个具有唯一类型的对象。有必要绕过编译器对默认参数的缓存,如何实现呢?

template<auto T = []{}>
constexpr auto unique() {
  return T;
}

从C++20开始,扩展了非类型模板参数的类型列表,现在包含了lambda表达式。每个lambda表达式都隐式地转换为具有唯一类型的函数,甚至可以使用默认参数。

有状态元编程的研究现状

下面是C++标准对有状态元编程的描述

Section: 17.7.5 [temp.inject] Status: open Submitter: Richard Smith Date: 2015-04-27
在一个模板中定义一个友元函数,然后再引用该函数,该函数提供了捕获和检索元编程状态的方法。这种技术是晦涩难懂的,应该是非标准的。

Notes from the May, 2015 meeting
CWG同意这样的技术应该是非标准的,但是禁止它们的机制尚未确定
http://www.open-std.org/jtc1/sc22/wg21/docs/cwg_active.html

通过阅读本文,您可能会觉得有状态元编程应该是非法的,委员会也有同样的感觉。问题是,他们不知道如何处理这个偶然引入的“特性”。这项技术早在2015年就被发现了,从那以后这个问题一直没有解决。

总结

友元和模板是C++中强大的工具,就像 C++ 中的其他工具一样,它们的行为会让我们大吃一惊。而当结合在一起时会变得令人困惑的行为。并不是说C++的这些方面很少见,而是它们再次证明了语言已经变得多么复杂。

译者后续

文中提到了两个技巧,首先是访问一个类的私有成员,这里实际也有用处,比如说在做单元测试时可以直接访问到私有变量来验证某个函数的正确性,且这个用法目前也被一些单元测试库使用。

第二个有状态模板元编程中关键是有状态,也就是你调用某一个编译期函数会有记录,之后调用别的函数或者相同函数依靠是否有上一次的记录产生不同的结果。不过作者提供的计时器的使用方法又会在不同编译器版本上不会生效。

我们学习这两个的作用可以带着我们去掌握一些开源库中的实现,看懂其代码。或者有了此基础在后续C++标准出现类似功能就会有似曾相识相识的感觉。

ref

https://ledas.com/post/857-how-to-hack-c-with-templates-and-friends/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值