美文网首页
一道面试题:你了解哪些编译器优化行为?知道Copy elisio

一道面试题:你了解哪些编译器优化行为?知道Copy elisio

作者: fibonaccii | 来源:发表于2021-02-27 08:52 被阅读0次

    C++11以后,g++ 编译器默认开启复制省略(copy elision)选项,可以在以值语义传递对象时避免触发复制、移动构造函数。copy elision 主要发生在两个场景:

    • 函数返回的是值语义时
    • 函数参数是值语义时

    返回值优化

    返回值优化RVO(Return Value Optimization,RVO),即避免返回过程触发复制 / 移动构造函数。根据返回的值是否是匿名对象,可以分为两类:

    • 具名返回值优化 NRVO (Named Return Value Optimization,NRVO)
    • 匿名返回值优化 URVO(Unknown Return Value Optimization,URVO )

    二者的区别在于返回值是具名的局部变量(NRVO)还是无名的临时对象(URVO)。

    假定现在有类Foo,实现了复制构造函数(ctor)、 移动构造函数(mtor)。

        class Foo { 
        public:
            Foo() { std::cout<<"default"<<std::endl; }
    
            Foo(const Foo& rhs) { std::cout<<"ctor"<<std::endl; }
            Foo(Foo&& rhs) { std::cout<<"mtor"<<std::endl; }
        };
    

    现在,有返回类型是Foo的 两个函数:return_urvo_valuereturn_nrvo_value ,实现如下:

        Foo return_urvo_value() { 
          return Foo{}; 
        }
    
        Foo return_nrvo_value() { 
          Foo local_obj;
          return local_obj; 
        }
    

    按照常规,return_urvo_value函数返回Foo{}应该触发mtorreturn_nrvo_value函数返回local_obj应该触发ctor。真的如此吗?

        int main(int argc, char const *argv[]) {
            
          auto x = return_urvo_value();
          auto y = return_nrvo_value();  
          return 0;
        }
    

    输出如下:

        g++ rvo.cc  -o rvo && ./rvo
        default
        default
    

    输出结果,令人惊讶!竟然都只调用了一次默认构造函数。这是因为编译器默认开启了RVO,为了禁止这个优化策略,需要为编译加上 -fno-elide-constructors 选项,此时输出如下:

        $ g++ -fno-elide-constructors rvo.cc  -o rvo && ./rvo
        default
        mtor
        mtor
        default
        mtor
        mtor
    

    下面对输出结果,逐个分析。

    URVO

    首先,return_urvo_value函数,触发两次移动构造函数,这很好理解:

    1. 基于return的Foo{}构造return_urvo_value函数的返回值,触发一次;
    2. 基于return_urvo_value函数返回的右值构造x,触发一次。

    return_urvo_value函数return的Foo{},中间经过两次mtor,才将Foo{}的内部数据转移到了x。但是,这中间的两次mtor是可以避免的:由于return之后Foo{}就结束生命周期,那为什么不直接将Foo{}用于x呢?

    因此,编译器默认开启RVO,省略中间两次调用mtor的过程,直接基于return_urvo_value函数中return的Foo{}构造x。此时,整个过程简化如下:

    Foo x{}; 
    

    NRVO

    但是!!!,return_nrvo_value函数,怎么也触发了两次mtor,而不是ctor+ mtor

    1. 这是因为 local_obj 是局部变量,return_nrvo_value函数执行return语句的同时,local_obj的生命周期也即将结束。既然如此,与其返回local_obj的副本,不如直接将local_obj返回回去,既避免了析构local_obj,也避免了重新分配Foo对象。

    2. 编译器默认开启RVO时,则可以完成上述优化。当编译加上 -fno-elide-constructors 标志禁止RVO优化时,那么编译器也会优先选择mtor,将local_obj的内部数据转移到return_nrvo_value的返回值中,最后用于构造y,避免重新为local_obj中的数据分配内存。

    因此,return_nrvo_value函数,即使禁止了RVO优化,也是触发两次移动构造函数,而不是一次复制构造、一次移动构造。为了验证确实是将local_obj的内部数据转移到了y,对return_nrvo_value函数修改如下:

        std::vector<int> return_nrvo_value() {
    
          std::vector<int> local_vec{1,2,3,4};
          std::cout<<"object address: "<< std::addressof(local_vec)
                   <<" |data address:" << std::addressof(local_vec[0])<<std::endl;
          return local_vec;
        }
    
        int main(int argc, char const *argv[]) {
    
          auto y = return_nrvo_value(); 
          std::cout<<"object address: "<< std::addressof(y)
                   <<" |data address:" << std::addressof(y[0])<<std::endl;
          return 0; 
        }
    

    分别开启rvo优化、禁止rvo优化,输出如下:

        $ g++  rvo.cc  -o rvo && ./rvo
        object address: 0x7ffffc262da0 |data address:0x7ffff55e9eb0
        object address: 0x7ffffc262da0 |data address:0x7ffff55e9eb0
    
        $ g++ -fno-elide-constructors rvo.cc  -o rvo && ./rvo
        object address: 0x7fffc9b6ee80 |data address:0x7fffc2969eb0
        object address: 0x7fffc9b6ef00 |data address:0x7fffc2969eb0
    

    从输出,可以看出:

    • 当开启RVO时,不仅ylocal_vec指向的数据内存一致,ylocal_vec对象本身地址都是一致,即y就是local_vec
    • 当使用 -fno-elide-constructors 禁止RVO时,ylocal_vec 仍指向同一片内存区,但是此时y的地址不是local_vec的地址,说明local_vec将数据转移到了y后,local_Vec本身还是析构了,而y是基于移动构造函数重新创建的对象。

    C++17强制编译器实现 URVO

    在上面的demo中,Foomtor必须是可访问的,即移动构造函数没有加上=delete标志,也没有设置为private属性。到了C++17,时代变了,强制编译器实现RVO,就是即便你禁止了移动构造函数,对象也能具有URVO能力。比如,将上面的类Foo修改如下:

        class Foo { 
        public:
            Foo() { std::cout<<"default"<<std::endl; }
            // 禁止复制、移动构造函数
            Foo(const Foo& rhs) = delete;
            Foo(Foo&& rhs) =delete;
        };
    
        int main(int argc, char const *argv[]) {
            
            auto x = return_urvo_value();
            auto y = return_nrvo_value(); 
            return 0;
        }
    

    下面分别在C++14、17的编译输出:

    C++14编译输出如下:

        $ g++ -std=c++14  rvo.cc  -o rvo && ./rvo
        rvo.cc: In function ‘Foo return_urvo_value()’:
        rvo.cc:15:14: error: use of deleted function ‘Foo::Foo(Foo&&)’
           15 |   return Foo{};
              |              ^
        rvo.cc:10:3: note: declared here
           10 |   Foo(Foo&& rhs) =delete;
              |   ^~~
        rvo.cc: In function ‘Foo return_nrvo_value()’:
        rvo.cc:21:10: error: use of deleted function ‘Foo::Foo(const Foo&)’
           21 |   return local_obj;
              |          ^~~~~~~~~
        rvo.cc:9:3: note: declared here
            9 |   Foo(const Foo& rhs) = delete;
              |   ^~~
    

    C++17编译输出如下:

            $ g++ -std=c++17  rvo.cc  -o rvo && ./rvo
            rvo.cc: In function ‘Foo return_nrvo_value()’:
            rvo.cc:21:10: error: use of deleted function ‘Foo::Foo(const Foo&)’
               21 |   return local_obj;
                  |          ^~~~~~~~~
            rvo.cc:9:3: note: declared here
                9 |   Foo(const Foo& rhs) = delete;
                  |   ^~~
    

    从两编译输出可以看出,即使在Foo同时禁止复制、移动构造函数时,C++17编译器仍然能强实现NRVO,但是都不支持NRVO。但是如果仅禁止Foo的复制构造函数呢?注意,在禁止复制构造函数时,要主动实现移动构函数,否则效果和同时禁止ctormtor一样。

        class Foo { 
        public:
            Foo() { std::cout<<"default"<<std::endl; }
            // 禁止复制、移动构造函数
            Foo(const Foo& rhs) = delete;
            Foo(Foo&& rhs) { std::cout<<"mtor"<<std::endl;}
        };
    

    此时输出如下:

            $ g++ -std=c++17  rvo.cc  -o rvo && ./rvo
            default
            default
            $ g++ -std=c++14  rvo.cc  -o rvo && ./rvo
            default
            default
    

    因此,可总结如下:当函数的返回类型是值类型时,

    1. URVO:在C++17之前,对象的motor必须是可访问的,才能开启URVO。

          // return_urvo_value 导致编译失败
          class Foo { 
          public:
              Foo() =default;
              Foo(const Foo& rhs) =default;
              Foo(Foo&& rhs) =delete;      // mtor 不可访问
          };
      
          // 编译通过
          class Foo { 
          public:
              Foo() =default;
              Foo(const Foo& rhs) =default;
          };
      

      C++17开始,即使完全禁止了对象的ctormotr,编译器一样可以实现URVO。

    2. NRVO:对象的mtor必须可访问的,才能开启。

    URVO 应用

    根据URVO特性,我么可以为 std::unique_ptrstd::atomic等提供一个工厂函数 make_instance

        template <typename T, typename... Args>
        T make_instance(Args&& ... args) {
            return T {std::forward<Args>(args)...};
        }
    
        int main(int argc, const char* argv[]) {
            // 普通类型
            int i   = make_instance<int>(42);
            // std::unique_ptr 实现了 移动构造函数,因此可以编译成功 
            auto up = make_instance<std::unique_ptr<int>>(new int{ 42 }); 
            // 禁止了复制构造函数,但是也没有实现移动构造函数,因此要到 C++17 才能编译过
            auto ai = make_instance<std::atomic<int>>(42);                  
            return 0;
        }
    

    在上面的make_instance对于std::unique_ptrstd::atomic要求不同:

    • std::unique_ptr:虽然禁止了ctor,但实现mtor,因此它在C++11中可以开启NRVO。注意,在C++14中已经为std::unique_ptr提供了工厂函数std::make_unique,实现如下:

            // 和 make_instance 如出一辙
            template <typename _Tp, typename... _Args>
            unique_ptr<_Tp> make_unique(_Args && ...__args)
            {
              return unique_ptr<_Tp>(new _Tp(std::forward<_Args>(__args)...));
            }
      
    • std::atomic:同时禁止了ctormtor,因此必须等到 C++17,make_instance函数才能为std::atmoic创建对象。

    函数值传递

    函数模板之值传递与引用传递的不同类型推导规则辨析 一文中,深度讲解了函数模板基于值传递和引用传递的优劣。在讲值传递时,未必总是发生复制行为:pass_by_value函数传入右值时,也会发生copy elision 行为,即使禁止编译器的copy elision 行为,也是优先调用对象的mtor

        void pass_by_value(Foo foo) { 
          // ...
        }
    
        int main(int argc, char const *argv[]) {
          auto x = return_urvo_value();
          auto y = return_nrvo_value(); 
    
          pass_by_value(Foo{});
          pass_by_value(std::move(x));
    
          return 0;
        }
    

    最终的输出也是调用默认三次构造函数:

        $ g++ -std=c++11  rvo.cc  -o rvo && ./rvo
        default
        default
        default
    

    到此,copy elision 的两个主要应用场景基本分析结束。


    感谢你的观看,你的点赞、关注与分享就是对我最大的支持

    相关文章

      网友评论

          本文标题:一道面试题:你了解哪些编译器优化行为?知道Copy elisio

          本文链接:https://www.haomeiwen.com/subject/qaowfltx.html