std :: function vs template


161

感谢C ++ 11,我们获得了std::function函子包装器系列。不幸的是,我一直只听到关于这些新功能的坏消息。最受欢迎的是,它们运行缓慢。我测试了一下,与模板相比,它们确实很烂。

#include <iostream>
#include <functional>
#include <string>
#include <chrono>

template <typename F>
float calc1(F f) { return -1.0f * f(3.3f) + 666.0f; }

float calc2(std::function<float(float)> f) { return -1.0f * f(3.3f) + 666.0f; }

int main() {
    using namespace std::chrono;

    const auto tp1 = system_clock::now();
    for (int i = 0; i < 1e8; ++i) {
        calc1([](float arg){ return arg * 0.5f; });
    }
    const auto tp2 = high_resolution_clock::now();

    const auto d = duration_cast<milliseconds>(tp2 - tp1);  
    std::cout << d.count() << std::endl;
    return 0;
}

111毫秒和1241毫秒。我认为这是因为模板可以很好地内联,而functions通过虚拟调用覆盖了内部。

显然,模板在我看来有其问题:

  • 必须将它们作为标头提供,而在将库作为封闭代码发布时,您可能不希望这样做,
  • 除非extern template引入类似政策,否则它们可能会使编译时间更长。
  • 没有(至少对我来说是这样)代表模板需求(概念,有人吗?)的简洁方法,并在注释中描述期望的仿函数。

因此,我可以假定functions可以用作传递函子的事实上的标准,并且应该在期望使用高性能模板的地方使用吗?


编辑:

我的编译器是不带 CTP 的Visual Studio 2012 。


16
std::function仅当您确实需要异构的可调用对象集合时才使用(即,在运行时没有其他区分信息可用)。
Kerrek SB

30
您正在比较错误的事情。两种情况都使用模板-不是“ std::function或模板”。我认为这里的问题只是包装了lambda,std::function而不是包装了lambda std::function。目前,您的问题就像是问“我要苹果还是碗?”
Lightness Races in Orbit

7
无论是1ns还是10ns,两者都不算什么。
2013年

23
@ipc:虽然1000%并非没有。正如OP所标识的那样,出于任何实际目的,您都会开始关心可伸缩性。
Lightness Races in Orbit

18
@ipc慢10倍,这是巨大的。需要将速度与基线进行比较;认为仅仅因为它是纳秒级就没有关系。
保罗·曼塔

Answers:


170

通常,如果您面临的设计环境让您可以选择,请使用模板。我之所以强调“ 设计 ”一词,是因为我认为您需要关注的是的用例std::function和模板之间的区别,两者之间有很大的不同。

通常,模板的选择只是更广泛原则的一个实例:尝试在编译时指定尽可能多的约束。基本原理很简单:即使在生成程序之前,如果您能够捕获错误或类型不匹配,也不会将有错误的程序发送给客户。

此外,正如您正确指出的那样,对模板函数的调用是静态解决的(即在编译时),因此编译器具有所有必要的信息以优化并可能内联代码(如果通过a vtable)。

是的,确实模板支持不是完美的,并且C ++ 11仍然缺少对概念的支持;这是对的。但是,我看不出std::function在这方面能为您带来什么帮助。std::function不能替代模板,而是用于无法使用模板的设计情况的工具。

当您需要在运行时通过调用遵循特定签名但在编译时未知其具体类型的可调用对象来解决调用时就会出现这种用例。当您具有可能不同类型的回调的集合,但是需要统一调用时,通常会出现这种情况;注册的回调的类型和数量在运行时根据程序的状态和应用程序逻辑确定。这些回调中的某些可能是函子,某些可能是简单的函数,某些可能是将其他函数绑定到某些参数的结果。

std::function并且std::bind还提供了一个自然的习惯用法,用于启用C ++中的函数式编程,其中将函数视为对象,并自然进行咖喱化和组合以生成其他函数。尽管也可以通过模板来实现这种组合,但是类似的设计情况通常伴随用例,这些用例需要在运行时确定组合的可调用对象的类型。

最后,还有其他一些std::function不可避免的情况,例如,如果您要编写递归lambda;但是,这些限制更多是由技术限制决定的,而不是我认为是概念上的区别。

综上所述,专注于设计并尝试了解这两种构造的概念性用例是什么。如果以您的方式将它们进行比较,则将它们逼入他们可能不属于的竞技场。


23
我认为“当您有可能具有不同类型的回调的集合,但是需要统一调用时,通常会出现这种情况;” 是重要的一点。我的经验法则是:“首选std::function存储端,首选Fun接口模板”。
R. Martinho Fernandes

2
注意:隐藏具体类型的技术称为类型擦除(不要与托管语言中的类型擦除混淆)。它通常是根据动态多态性实现的,但是功能更强大(例如,unique_ptr<void>即使没有虚拟析构函数的类型也调用适当的析构函数)。
2013年

2
@ecatmur:尽管我们在术语上有些不一致,但我同意实质内容。动态多态性对我来说意味着“在运行时假设不同的形式”,而静态多态性则被我理解为“在编译时假设不同的形式”。后者无法通过模板实现。对我来说,类型擦除是在设计上完全能够实现动态多态性的一种先决条件:您需要一些统一的接口才能与不同类型的对象进行交互,而类型擦除是一种抽象出类型的方式-具体信息。
安迪·普罗

2
@ecatmur:所以从某种意义上说,动态多态是概念模式,而类型擦除是一种可以实现它的技术。
安迪·普罗

2
@Downvoter:我很想知道您在此答案中发现了什么错误。
Andy Prowl

89

Andy Prowl很好地介绍了设计问题。当然,这非常重要,但是我相信原始问题涉及与相关的更多性能问题std::function

首先,快速介绍一下测量技术:所获得的11ms calc1毫无意义。确实,查看生成的程序集(或调试程序集代码),您可以看到VS2012的优化器足够聪明,可以意识到调用的结果calc1与迭代无关,并将调用移出了循环:

for (int i = 0; i < 1e8; ++i) {
}
calc1([](float arg){ return arg * 0.5f; });

此外,它意识到呼叫calc1没有可见的效果,并且完全放弃了呼叫。因此,111ms是空循环运行所需的时间。(我很惊讶优化器保持了循环。)因此,在循环中进行时间测量时要小心。这并不像看起来那么简单。

正如已经指出的那样,优化器有更多的麻烦要理解std::function,并且不会将调用移出循环。因此1241ms是的合理测量calc2

注意,std::function能够存储不同类型的可调用对象。因此,它必须对存储执行某种类型擦除魔术。通常,这意味着动态内存分配(默认情况下通过调用new)。众所周知,这是一个相当昂贵的操作。

标准(20.8.11.2.1 / 5)包含实现,以避免为小型对象动态分配内存,谢天谢地,VS2012确实这样做了(特别是对于原始代码)。

为了了解在涉及内存分配时它可以变慢多少,我更改了lambda表达式以捕获三个floats。这使可调用对象太大而无法应用小对象优化:

float a, b, c; // never mind the values
// ...
calc2([a,b,c](float arg){ return arg * 0.5f; });

对于此版本,时间约为16000毫秒(原始代码为1241毫秒)。

最后,请注意,lambda的寿命包含的寿命std::function。在这种情况下,std::function可以存储对其的“引用” ,而不是存储lambda的副本。所谓“引用” std::reference_wrapper,是指可以通过函数std::ref和轻松构建的std::cref。更准确地说,通过使用:

auto func = [a,b,c](float arg){ return arg * 0.5f; };
calc2(std::cref(func));

时间减少到大约1860ms。

我前一阵子写道:

http://www.drdobbs.com/cpp/ficient-use-of-lambda-expressions-and/232500059

就像我在文章中所说的那样,由于VS2010对C ++ 11的支持不佳,因此这些参数不适用于VS2010。在撰写本文时,仅提供了VS2012的beta版,但它对C ++ 11的支持已经足够解决此问题。


我确实发现了这一点很有趣,想用玩具示例证明代码速度,这些玩具示例由于没有任何副作用而被编译器优化了。我要说的是,如果没有一些实际/生产代码,很少有人会押注这类测量。
Ghita 2013年

@ Ghita:在此示例中,为防止代码被优化,calc1可以采用float上一次迭代的结果作为参数。有点像x = calc1(x, [](float arg){ return arg * 0.5f; });。另外,我们必须确保calc1使用x。但是,这还不够。我们需要产生副作用。例如,测量后,x在屏幕上打印。即使如此,我同意使用玩具代码进行时标测量并不能总是完美地表明真实/生产代码会发生什么。
卡西欧内里

在我看来,基准测试也在循环内部构造了std :: function对象,并在循环中调用了calc2。不管编译器可能会或可能不会对此进行优化(并且构造函数可能像存储vptr一样简单),在这种情况下,如果函数一次构造并传递给另一个调用该函数的函数,我会更感兴趣它成一个循环。即,调用开销而不是构造时间(以及调用“ f”而不是calc2)。如果在循环中(在calc2中)而不是一次调用f,也会从任何提升中受益,这也将引起关注。
greggo 2014年

好答案。2件事情:有效使用的好例子std::reference_wrapper(强制模板;它不仅用于常规存储),而且很有趣的是,看到VS的优化器未能丢弃空循环...正如我在此GCC bug re中所volatile注意到的那样。
underscore_d

37

使用Clang,两者之间没有性能差异

使用clang(3.2,trunk 166872)(在Linux上为-O2),两种情况下的二进制文件实际上是相同的

-我将在帖子结尾再说一遍。但首先,gcc 4.7.2:

已经有了很多洞察力,但是我想指出,由于内联等原因,calc1和calc2的计算结果并不相同。例如,比较所有结果的总和:

float result=0;
for (int i = 0; i < 1e8; ++i) {
  result+=calc2([](float arg){ return arg * 0.5f; });
}

与calc2成为

1.71799e+10, time spent 0.14 sec

而使用calc1时

6.6435e+10, time spent 5.772 sec

这是速度差的40倍,值的4倍。第一个是与OP发布的内容(使用Visual Studio)相比,差异要大得多。实际上,最终输出值也是防止编译器删除无可见结果的代码的一个好主意(按规则)。卡西欧·内里(Cassio Neri)已经在回答中说了这一点。请注意结果有何不同-比较执行不同计算的代码的速度因数时应格外小心。

同样,公平地说,比较重复计算f(3.3)的各种方式可能没那么有趣。如果输入是恒定的,则不应循环。(优化程序很容易注意到)

如果我将用户提供的value参数添加到calc1和2,则calc1和calc2之间的速度因数将从40降为5!使用Visual Studio时,差异接近2倍,而使用clang时,两者没有差异(请参见下文)。

另外,由于乘法运算很快,谈论减速的因素通常并不那么有趣。一个更有趣的问题是,您的函数有多小?这些调用是否成为实际程序中的瓶颈?

铛:

lang(我用3.2)实际上产生相同当我在示例代码的calc1和calc2之间翻转时,二进制文件(在下面发布)。在问题中发布原始示例的情况下,两者也是相同的,但根本不花时间(如上所述,循环已完全删除)。在我修改的示例中,使用-O2:

要执行的秒数(最好为3):

clang:        calc1:           1.4 seconds
clang:        calc2:           1.4 seconds (identical binary)

gcc 4.7.2:    calc1:           1.1 seconds
gcc 4.7.2:    calc2:           6.0 seconds

VS2012 CTPNov calc1:           0.8 seconds 
VS2012 CTPNov calc2:           2.0 seconds 

VS2015 (14.0.23.107) calc1:    1.1 seconds 
VS2015 (14.0.23.107) calc2:    1.5 seconds 

MinGW (4.7.2) calc1:           0.9 seconds
MinGW (4.7.2) calc2:          20.5 seconds 

所有二进制文件的计算结果都相同,并且所有测试都在同一台计算机上执行。如果有更深的clang或VS知识的人可以评论可能已经进行了哪些优化,那将很有趣。

我修改的测试代码:

#include <functional>
#include <chrono>
#include <iostream>

template <typename F>
float calc1(F f, float x) { 
  return 1.0f + 0.002*x+f(x*1.223) ; 
}

float calc2(std::function<float(float)> f,float x) { 
  return 1.0f + 0.002*x+f(x*1.223) ; 
}

int main() {
    using namespace std::chrono;

    const auto tp1 = high_resolution_clock::now();

    float result=0;
    for (int i = 0; i < 1e8; ++i) {
      result=calc1([](float arg){ 
          return arg * 0.5f; 
        },result);
    }
    const auto tp2 = high_resolution_clock::now();

    const auto d = duration_cast<milliseconds>(tp2 - tp1);  
    std::cout << d.count() << std::endl;
    std::cout << result<< std::endl;
    return 0;
}

更新:

添加了vs2015。我还注意到calc1,calc2中有double-> float转换。删除它们并不会改变Visual Studio的结论(两者都快很多,但比率大致相同)。


8
可以说这只是表明基准是错误的。恕我直言,有趣的用例是调用代码从其他地方接收到函数对象,因此编译器在编译调用时不知道std :: function的来源。在这里,编译器通过将calc2内联扩展为main来确切地知道std :: function的组成。通过在9月将calc2'extern'轻松修复。源文件。然后,您正在比较带橙色的苹果;calc2正在做calc1不能做的事情。并且,循环可能在calc内部(多次调用f);不在功能对象的ctor周围。
greggo 2014年

1
当我可以找到合适的编译器时。现在可以这样说:(a)实际std :: function的ctor调用'new';(b)当目标是匹配的实际函数时,调用本身是非常瘦的;(c)在有绑定的情况下,有一段代码进行修改,由obj函数中的代码ptr选择,并从obj函数中获取数据(绑定的parms)(d)“ bound”函数可能如果编译器可以看到,则直接插入该适配器。
greggo 2014年

在上述设置中添加了新答案。
greggo 2014年

3
顺便说一句,基准测试没有错,问题(“ std :: function vs template”)仅在同一编译单元的范围内有效。如果您将功能移至另一个单元,则将无法再使用模板,因此无法进行比较。
rustyx '16

13

不同是不一样的。

速度较慢,因为它执行模板无法执行的操作。特别是,它允许您调用任何可以使用给定参数类型调用的函数,并且该函数的返回类型可以从同一代码转换为给定的返回类型。

void eval(const std::function<int(int)>& f) {
    std::cout << f(3);
}

int f1(int i) {
    return i;
}

float f2(double d) {
    return d;
}

int main() {
    std::function<int(int)> fun(f1);
    eval(fun);
    fun = f2;
    eval(fun);
    return 0;
}

请注意,同一个函数对象fun被传递给的两个调用eval。它具有两个不同的功能。

如果你不需要做那些,那么你应该使用std::function


2
只是想指出,当完成'fun = f2'时,'fun'对象最终指向一个隐藏函数,该函数将int转换为double,调用f2并将double结果转换回int。(在实际示例中,则'f2'可以内联到该函数中)。如果将std :: bind分配给fun,则“ fun”对象最终可能包含用于绑定参数的值。为了支持这种灵活性,对“ fun”(或init的赋值)的分配可能涉及分配/取消分配内存,并且所花费的时间可能要比实际的调用开销长得多。
greggo 2014年

8

您已经在这里有了一些很好的答案,因此我不会与它们矛盾,总之,将std :: function与template进行比较就像将虚拟函数与function进行比较。您永远都不应将虚拟函数“偏爱”于函数,而应在适合问题时使用虚拟函数,将决策从编译时移至运行时。这个想法是,您不必使用定制的解决方案(如跳转表)来解决问题,而是使用可以使编译器为您进行优化的更好机会。如果您使用标准解决方案,它也可以帮助其他程序员。


6

该答案旨在为现有答案集做出贡献,我认为这对于std :: function调用的运行时成本而言是更有意义的基准。

应该根据其提供的功能来识别std :: function机制:任何可调用的实体都可以转换为具有适当签名的std :: function。假设您有一个使表面适合z = f(x,y)定义的函数的库,可以将其编写为接受a std::function<double(double,double)>,并且该库的用户可以轻松地将任何可调用实体转换为该函数;它是普通函数,类实例的方法,lambda还是std :: bind支持的任何东西。

与模板方法不同,此方法无需为不同情况重新编译库函数。因此,每种其他情况几乎不需要额外的编译代码。总是有可能做到这一点,但是它曾经需要一些笨拙的机制,并且库的用户可能需要围绕它们的功能构造一个适配器以使其起作用。std :: function会自动构造所需的任何适配器,以在所有情况下获得通用的运行时调用接口,这是一项非常强大的新功能。

我认为,就性能而言,这是std :: function的最重要用例:我对一次构造一次std :: function的代价很感兴趣,因此需要多次调用它。可能是编译器无法通过了解实际被调用的函数来优化调用的情况(即,您需要将实现隐藏在另一个源文件中以获得适当的基准)。

我在下面进行了测试,类似于OP的测试;但主要变化是:

  1. 每个案例循环10亿次,但std :: function对象仅构造一次。通过查看输出代码,我发现在构造实际的std :: function调用时调用了“ operator new”(也许在优化时未调用)。
  2. 将测试分为两个文件,以防止不必要的优化
  3. 我的情况是:(a)内联函数(b)普通函数指针传递函数(c)函数是包装为std :: function的兼容函数(d)函数是与std ::兼容的不兼容函数绑定,包装为std :: function

我得到的结果是:

  • 情况(a)(内联)1.3 ns

  • 其他所有情况:3.3纳秒。

情况(d)趋于稍微慢一些,但是差异(大约0.05纳秒)被噪声吸收了。

结论是,即使对实际函数进行简单的“绑定”适配,std :: function的开销(在调用时)也可以与使用函数指针相比​​。内联比其他的快2 ns,但这是一个预期的折衷,因为内联是唯一在运行时“硬连线”的情况。

当我在同一台机器上运行johan-lundberg的代码时,我看到每个循环大约39纳秒,但循环中还有很多东西,包括std :: function的实际构造函数和析构函数,这可能相当高因为它涉及一个新的和删除的。

-O2 gcc 4.8.1,到x86_64目标(核心i5)。

请注意,代码被分成两个文件,以防止编译器在调用它们的地方扩展功能(在一种情况下除外)。

-----第一个源文件--------------

#include <functional>


// simple funct
float func_half( float x ) { return x * 0.5; }

// func we can bind
float mul_by( float x, float scale ) { return x * scale; }

//
// func to call another func a zillion times.
//
float test_stdfunc( std::function<float(float)> const & func, int nloops ) {
    float x = 1.0;
    float y = 0.0;
    for(int i =0; i < nloops; i++ ){
        y += x;
        x = func(x);
    }
    return y;
}

// same thing with a function pointer
float test_funcptr( float (*func)(float), int nloops ) {
    float x = 1.0;
    float y = 0.0;
    for(int i =0; i < nloops; i++ ){
        y += x;
        x = func(x);
    }
    return y;
}

// same thing with inline function
float test_inline(  int nloops ) {
    float x = 1.0;
    float y = 0.0;
    for(int i =0; i < nloops; i++ ){
        y += x;
        x = func_half(x);
    }
    return y;
}

-----第二个源文件-------------

#include <iostream>
#include <functional>
#include <chrono>

extern float func_half( float x );
extern float mul_by( float x, float scale );
extern float test_inline(  int nloops );
extern float test_stdfunc( std::function<float(float)> const & func, int nloops );
extern float test_funcptr( float (*func)(float), int nloops );

int main() {
    using namespace std::chrono;


    for(int icase = 0; icase < 4; icase ++ ){
        const auto tp1 = system_clock::now();

        float result;
        switch( icase ){
         case 0:
            result = test_inline( 1e9);
            break;
         case 1:
            result = test_funcptr( func_half, 1e9);
            break;
         case 2:
            result = test_stdfunc( func_half, 1e9);
            break;
         case 3:
            result = test_stdfunc( std::bind( mul_by, std::placeholders::_1, 0.5), 1e9);
            break;
        }
        const auto tp2 = high_resolution_clock::now();

        const auto d = duration_cast<milliseconds>(tp2 - tp1);  
        std::cout << d.count() << std::endl;
        std::cout << result<< std::endl;
    }
    return 0;
}

对于那些感兴趣的人来说,这是编译器构建的适配器,用于使“ mul_by”看起来像是float(float)-当调用以bind(mul_by,_1,0.5)创建的函数时,将称为“调用”:

movq    (%rdi), %rax                ; get the std::func data
movsd   8(%rax), %xmm1              ; get the bound value (0.5)
movq    (%rax), %rdx                ; get the function to call (mul_by)
cvtpd2ps    %xmm1, %xmm1        ; convert 0.5 to 0.5f
jmp *%rdx                       ; jump to the func

(因此,如果我在绑定中写0.5f可能会更快一些。)请注意,“ x”参数到达%xmm0并停留在那里。

这是在调用test_stdfunc之前,在构造函数的区域中的代码-通过c ++ filt运行:

movl    $16, %edi
movq    $0, 32(%rsp)
call    operator new(unsigned long)      ; get 16 bytes for std::function
movsd   .LC0(%rip), %xmm1                ; get 0.5
leaq    16(%rsp), %rdi                   ; (1st parm to test_stdfunc) 
movq    mul_by(float, float), (%rax)     ; store &mul_by  in std::function
movl    $1000000000, %esi                ; (2nd parm to test_stdfunc)
movsd   %xmm1, 8(%rax)                   ; store 0.5 in std::function
movq    %rax, 16(%rsp)                   ; save ptr to allocated mem

   ;; the next two ops store pointers to generated code related to the std::function.
   ;; the first one points to the adaptor I showed above.

movq    std::_Function_handler<float (float), std::_Bind<float (*(std::_Placeholder<1>, double))(float, float)> >::_M_invoke(std::_Any_data const&, float), 40(%rsp)
movq    std::_Function_base::_Base_manager<std::_Bind<float (*(std::_Placeholder<1>, double))(float, float)> >::_M_manager(std::_Any_data&, std::_Any_data const&, std::_Manager_operation), 32(%rsp)


call    test_stdfunc(std::function<float (float)> const&, int)

1
使用clang 3.4.1 x64,结果为:(a)1.0,(b)0.95,(c)2.0,(d)5.0。
rustyx '16

4

我发现您的结果非常有趣,因此我做了一些挖掘工作以了解发生了什么。首先,正如许多其他人所言,在没有计算结果的情况下,编译器只会优化程序的状态。其次,我给常量提供了一个常量3.3作为回调,我怀疑还会进行其他优化。考虑到这一点,我稍微更改了您的基准代码。

template <typename F>
float calc1(F f, float i) { return -1.0f * f(i) + 666.0f; }
float calc2(std::function<float(float)> f, float i) { return -1.0f * f(i) + 666.0f; }
int main() {
    const auto tp1 = system_clock::now();
    for (int i = 0; i < 1e8; ++i) {
        t += calc2([&](float arg){ return arg * 0.5f + t; }, i);
    }
    const auto tp2 = high_resolution_clock::now();
}

在对代码进行此更改之后,我使用gcc 4.8 -O3进行了编译,calc1的时间为330ms,calc2的时间为2702。因此,使用该模板的速度快了8倍,这个数字在我看来是令人怀疑的,幂为8的速度通常表示编译器已向量化了某些东西。当我查看模板版本的生成代码时,显然已将其矢量化

.L34:
cvtsi2ss        %edx, %xmm0
addl    $1, %edx
movaps  %xmm3, %xmm5
mulss   %xmm4, %xmm0
addss   %xmm1, %xmm0
subss   %xmm0, %xmm5
movaps  %xmm5, %xmm0
addss   %xmm1, %xmm0
cvtsi2sd        %edx, %xmm1
ucomisd %xmm1, %xmm2
ja      .L37
movss   %xmm0, 16(%rsp)

哪里没有std :: function版本。这对我来说很有意义,因为使用模板,编译器可以肯定地知道该函数在整个循环中永远不会改变,但是传入的std :: function可能会改变,因此无法进行矢量化。

这导致我尝试其他方法来查看是否可以让编译器在std :: function版本上执行相同的优化。我没有传递函数,而是将std :: function用作全局var,并对此进行了调用。

float calc3(float i) {  return -1.0f * f2(i) + 666.0f; }
std::function<float(float)> f2 = [](float arg){ return arg * 0.5f; };

int main() {
    const auto tp1 = system_clock::now();
    for (int i = 0; i < 1e8; ++i) {
        t += calc3([&](float arg){ return arg * 0.5f + t; }, i);
    }
    const auto tp2 = high_resolution_clock::now();
}

在这个版本中,我们看到编译器现在已经以相同的方式对代码进行矢量化,并且得到了相同的基准测试结果。

  • 模板:330ms
  • std :: function:2702ms
  • 全局std :: function:330ms

因此,我的结论是std :: function与模板函子的原始速度几乎相同。但是,这使优化器的工作更加困难。


1
重点是传递函子作为参数。您的calc3案子没有道理;calc3现在被硬编码为调用f2。当然可以优化。
rustyx

确实,这就是我要展示的。该calc3等效于模板,在这种情况下,就像模板一样,实际上是一个编译时构造。
Joshua Ritterman '16
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.