• Effective Modern C++:08调整


    41:针对可复制的形参,在移动成本低且一定会被复制的前提下,考虑将其按值传递

    class Widget {
    public:
        void addName(const std::string& newName) 
        { names.push_back(newName); } 
        void addName(std::string&& newName) 
        { names.push_back(std::move(newName)); } 
        …  
    private:
        std::vector<std::string> names;
    };

              上面的addName函数,针对左值实施复制,右值实施移动。但是它实际上是在两个函数中做同一件事情,是冗余代码。可以考虑使用万能引用的函数模板:

    template<typename T> 
    void addName(T&& newName) {
        names.push_back(std::forward<T>(newName)); 
    }

              虽然这消除了冗余代码,但是万能引用会导致其他方面的复杂性,作为模板,addName的实现通常必须位于头文件中,而且它可能在对象代码中产生好几个函数。

             像addName这样的函数,是否存在一种方法,针对左值实施复制,针对右值实施移动,而且无论在源码中,还是目标代码中只有一个函数,还能避免万能引用的怪癖?这种方法是存在的,就是按值传递:

    void addName(std::string newName) 
    { names.push_back(std::move(newName)); } 

              这可能颠覆了你的认知,毕竟按值传递会带来效率上的问题。在C++98中,这确实会引发效率问题,无论调用方传入的是什么,形参newName都会经由复制构造函数创建,但是到了C++11中,newName仅在传入左值时才会被复制构造,而传入右值时,会被移动构造(如果编译器有优化措施的话,实际上可能是直接在参数newName上构造实参,类似于返回值优化那样。去掉优化措施,则使用移动构造,GCC 5.4.0 20160609测试):

    Widget w;
    …
    std::string name("Bart");
    w.addName(name); // call addName with lvalue
    
    w.addName(name + "Jenne"); // call addName with rvalue

             

             虽然按值传递看起来干净利落,但是还有有一些成本需要考虑。回顾addName的三个版本及其调用方式,可以总结如下:

             重载版本,对于左值是一次复制,对于右值是一次移动;使用万能引用版本,左值是一次复制,右值是一次移动;按值传递,对于左值是一次复制加一次移动,对于右值是两次移动。

             所以,本条款的标题:针对可复制的形参,在移动成本低且一定会被复制的前提下,考虑将其按值传递。使用这样的措辞是有理由的,具体如下:

             按值传递的成本总会更高一些,某些情况下,还会产生另外的成本;仅对于可复制的形参才考虑按值传递,不符合这个条件的形参是move-only的,因而也就不需要针对左值和右值分别提供重载版本;按值传递仅在形参移动成本低廉的前提下才值得考虑,只有这样,一次移动带来的额外成本才是可以接受的,如果这个前提不成立,那么执行不必要的移动和执行不必要的复制就没有区别了;而且针对一定会复制的形参才考虑按值传递,比如:

    void addName(std::string newName) {
        if ((newName.length() >= minLen) &&
            (newName.length() <= maxLen))
        {
            names.push_back(std::move(newName));
        }
    }

              该函数只有在满足一定条件时才复制,在条件不满足的情况下,还需要付出构造和析构newName的成本。

    有时候,即使你面对的函数确实是针对可复制类型实施无条件复制,并且移动成本也低廉,但是还是存在不适合采用按值传递的一些情况。原因在于,函数可以经由两种方式来实施复制:构造以及赋值。如果是赋值操作,则情况要复杂的多,比如下面的函数:

    class Password {
    public:
        explicit Password(std::string pwd)
        : text(std::move(pwd)) {}
        void changeTo(std::string newPwd)
        { text = std::move(newPwd); }
        …
    private:
        std::string text; // text of password
    };
    
    std::string initPwd("Supercalifragilisticexpialidocious");
    Password p(initPwd);
    std::string newPassword ="Beware the Jabberwock";
    p.changeTo(newPassword);

     传递给changeTo的是个左值(newPassword),当构造参数newPwd时,会调用复制构造函数,该函数会有申请内存的动作,之后,newPwd移动赋值给text,这会造成text原来持有的内存被释放。因此,changeTo函数就有了申请内存和释放内存的额外成本。但是实际上,在上面的调用中,新密码要比旧密码短,实际上没有必要申请和释放任何内存。如果采用重载的方法的话,就不存在这样的成本:

    void changeTo(const std::string& newPwd) // the overload for lvalues
    { 
        text = newPwd; // can reuse text's memory if text.capacity() >= newPwd.size()
    }

     在上面的场景中,内存申请和释放带来的成本很可能会超过移动操作的成本。有趣的是,如果旧密码比新密码更短,则赋值过程中不可避免的要涉及内存分配和释放的成本,这种情况下,按值传递又与按引用传递没有太大的区别了。因此,按值传递的成本还取决于参与赋值的实参值,这一分析结果适用于可能在动态分配的内存中持有值得任何形参类型,比如std::vector和std::string。

    这种潜在的成本增加仅存在于左值的情况,因为只有复制时才会设计内存的分配和释放,对于右值而言,使用移动就行了。

    所以,当通过赋值实施形参复制时,按值传参的成本分析会是复杂的。通常情况下,应该总是采用重载或万能引用而非按值传递,除非已经有确凿的证据表明按值传递能够为所需的形参类型生成可接受效率的代码。

    还有一个与效率无关,不同于按引用传递,按值传递比较容易遭遇切片问题。因此,欲实现函数以利用可复制右值类型的移动语义,就需要重载或使用万能引用两者之一,但这两者都有一定的缺点,对于可复制的,移动成本低廉的类型,并且传入的函数总是对其实施复制这种特殊情况,在切片问题也无需担心的情况下,按值传递可以提供一个易于实现的替代方法,他和按引用传递的竞争对手效率相近,但是避免了它们的不足。

    42:考虑置入而非插入

    std::vector<std::string> vs; 
    vs.push_back("xyzzy"); 

              std::vector的push_back针对左值和右值给出了不同的重载版本:

    template <class T, class Allocator = allocator<T>>
    class vector {
    public:
        …
        void push_back(const T& x); // insert lvalue
        void push_back(T&& x); // insert rvalue
    };

              上面的调用语句中,从字符串字面量“xyzzy”创建了一个std::string类型的临时对象,该对象没有名字,称其为temp,它是个右值;temp被传递给push_back的右值重载版本,它被绑定到右值引用形参x,然后,会在内存中为std::vecotor构造一个x的副本。这一次的构造,结果就是在std::vector内创建了一个新的对象;在push_back返回的那一时刻,temp被析构,所以,就需要调用一次std::string的析构函数。

             实际上,还是有方法能使得上面的代码效率更加高效。那就是使用emplace_back,它使用传入的实参在std::vector内直接构造一个std::string,不会涉及任何临时对象的构造和析构。emplace_back使用了完美转发,所以只要没有遇到完美转发的限制,就可以通过emplace_back传递任意类型,任意数量的实参。

             emplace_back可用于支持push_back的标准容器,类似的,所有支持push_front的标准容器也支持emplace_front;还有,任何支持insert的都支持emplace。

             置入函数优于插入函数的原因,是置入函数更加灵活的结构。插入函数接收的是带插入对象,而置入函数接收的则是待插入对象的构造函数实参。这一区别就让置入函数可以避免临时对象的创建和析构,但插入函数就无法避免。即使插入函数不会创建临时对象的情况下,也可以使用置入函数。那种情况下,插入函数和置入函数本质上做的是同一件事,比如:

    std::string queenOfDisco("Donna Summer");
    vs.push_back(queenOfDisco); // copy-construct queenOfDisco at end of vs
    vs.emplace_back(queenOfDisco); // ditto

              这么一来,置入函数就能够做大插入函数所能做到的一切,有时候他们还比后者更加高效,至少理论上不会比后者效率更低。既然如此,为何不总是使用置入函数呢?

            

    理论和现实还有有差距的,有时候,存在插入函数运行的更快的情况,这些情况难以具体描述,因为它取决于传递的实参类型,使用的容器类型,请求插入或置入的容器位置,所持有类型构造函数异常安全性等,还有对于禁止出现重复值的容器而言,容器中是否已经存在要添加的值。所以,这里使用一般的性能调优建议:确定置入或插入哪个运行的更快,需对两者实施基准测试。

             有些指导性原则可以帮助你决定是使用插入函数还是置入函数,如果下列情况都成立,那么置入函数将几乎肯定要比插入更高效:

             A、要添加的值是以构造而非赋值方式加入容器,比如之前的例子,就是从值”xyzzy”构造出std::string类型对象,并加入到std::vector末尾,该位置之前没有值,所以新值必须以构造方式加入std::vector。但是如果新std::string加入到容器的位置,已经被对象占用了,则变成另外一种情况:

    std::vector<std::string> vs;
    vs.emplace(vs.begin(), "xyzzy"); // add "xyzzy" to beginning of vs

              上面的代码,很少有实现会将待添加的std::string在vs[0]占用的内存中实施构造。这里一般会采用移动赋值的方式来让该值就位。但既然是移动赋值,就会需要一个移动源,这就需要创建一个临时对象作为移动源,那置入相对于插入的主要优点就在于既不会创建也不会析构临时对象,所以,置入的优势也就趋于消失了。

             向容器中添加值究竟是通过构造还是赋值,这取决于实现。但是基本的指导原则是,基于节点的容器几乎总是使用构造来添加新值,而大多数标准容器都是基于节点的,例外情况是std::vector、std::deque和std::string。在不是基于节点的容器中,可以可靠的说emplace_back是使用构造来将新值就位的,而这一点对于std::deque的emplace_front也成立。

             B、传递的实参类型与容器持有物类型不同。置入相对于插入的优势通常基于这样一个事实,当传递的实参类型并非容器持有之物的类型时,其接口不要求创建和析构临时对象。当类型为T的对象被添加到container<T>中时,没有理由期望置入的运行速度会比插入块,因为并不需要为了满足插入的接口去创建临时对象。

             C、容器不太可能由于出现重复情况而拒绝待添加新值。与检测某值是否已经在容器中,置入的实现通常会使用该新值创建一个节点,以便将该节点的值与容器的现有节点进行比较,如果待添加的值尚不再容器中,则将节点加入到容器中,但是如果该值已经存在,则置入就会终止,节点也就会被析构,这意味着构造和析构的成本被浪费了。这样的节点会更经常的为置入函数,而非插入函数创建。

             在决定是否使用置入函数时,还有其他两个问题值得考虑。第一个是和资源管理有关,比如下面的代码:

    std::list<std::shared_ptr<Widget>> ptrs;
    void killWidget(Widget* pWidget);
    
    ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));
    ptrs.push_back({ new Widget, killWidget });

              由于需要指定删除器,所以不能使用std::make_shared。上面两个push_back的调用本质上是相同的。不管哪一个,都会创建一个std::shared_ptr的临时对象。但是如果使用emplace_back,虽然可以避免创建临时对象,但是在本例中,临时对象带来的收益要远超成本。

    考虑下面的情况:上面的push_back调用会构造一个std::shared_ptr<Widget>类型的临时对象,用以持有new Widget返回的裸指针,称该临时对象为temp;push_back使用按引用的方式接受temp,在为链表节点分配内存以持有temp的副本的过程中,抛出了内存不足的异常,该异常传播到了push_back之外,temp被析构,自然它也就会释放Widget,也就是调用killWidget析构Widget对象。

    尽管发生了异常,但是没有资源泄漏。但是如果使用了emplace_back:

    ptrs.emplace_back(new Widget, killWidget);

              从new返回的裸指针会完美转发,并运行到emplace_back内为链表节点分配内存的执行点时,然后内存分配失败了,抛出了内存不足的异常;异常传播到了emplace_back之外,此时还没有创建std::shared_ptr,因此造成了内存泄漏。

             这里的问题不在于std::shared_ptr,使用std::unique_ptr也会有同样的问题。从根本上来讲,std::shared_ptr和std::unique_ptr这样的资源管理类若要发生作用,前提是资源会立即传递给资源管理对象的构造函数。std::make_shared和std::make_unique这样的函数就是把这一点自动化了。在调用持有资源管理对象的容器的插入函数时,函数的形参类型通常能保证在资源的获取和对资源管理的对象实施构造之间不再有任何其他动作。但是在置入函数中,完美转发会推迟资源管理对象的创建,直到它们能够在容器的内存中构造为止。这就可能会导致资源泄露。因此,在处理持有资源管理对象的容器时,必须小心确保在选用了置入而非插入函数时,不会在提升了一点代码效率的同时,却因异常安全的而导致问题。

             坦率的说,绝不应该把new这样的表达式传递给emplace_back、push_back或者大多数其他函数。实际上应该把从new获得的指针在独立语句中转交给资源管理对象,然后将该对象作为右值传递给其他函数,所以,使用了push_back的代码应该是:

    std::shared_ptr<Widget> spw(new Widget, killWidget);
    ptrs.push_back(std::move(spw));
    
    std::shared_ptr<Widget> spw(new Widget, killWidget);
    ptrs.emplace_back(std::move(spw));

      

             置入函数第二个值得考虑的场景是,它们与带有explicit声明饰词的构造函数之间的互动,比如:

    std::vector<std::regex> regexes;
    regexes.emplace_back(nullptr); // add nullptr to container of regexes?

              上面向emplace_back传入nullptr是个无心之举,但是要查找出该bug却需要费一番事。实际上,指针根本不是正则表达式,如果你用指针赋值给std::regex,则会报错:

    std::regex r = nullptr; // error! won't compile

              而且,如果你使用push_back而非emplace_back,编译器同样会报错:

    regexes.push_back(nullptr); // error! won't compile

              虽然从字符串出发可以构造std::regex,但是实际上它对应的构造函数是explicit的,这也就是为什么上面的语句无法通过编译的原因,上面的调用,我们实际上都要求一次从指针到std::regex的隐式类型转换,由于构造函数带有explicit,因而编译器决绝了。

             但是在使用emplace_back时,我们实际上是向std::regex传递的是个构造函数实参。它被编译器视作下面的代码:

    std::regex r(nullptr); // compiles

              考虑下面的代码:

    std::regex r1 = nullptr; // error! won't compile
    std::regex r2(nullptr); // compiles

              r1使用所谓的复制初始化,而r2使用的是直接初始化。复制初始化不允许调用带有explicit声明的构造函数,但是直接初始化是可以的。而对于push_back和emplace_back而言,或者更一般的插入和置入函数而言,置入函数使用的是直接初始化,所以他们能够调用带有explicit的构造函数,而插入函数使用的复制初始化,就不能调用带有explicit声明的构造函数。因此:

    regexes.emplace_back(nullptr); // compiles. Direct init permits use of explicit std::regex ctor taking a pointer
    regexes.push_back(nullptr); // error! copy init forbids use of that ctor

              因此这里得到的教训就是,在使用置入函数时,要特别小心保证传递了正确的实参,因为即使带有explicit声明饰词的构造函数会被编译器纳入考虑范围。

            

  • 相关阅读:
    我给女朋友讲编程CSS系列(2)- CSS语法、3大选择器、选择器优先级
    我给女朋友讲编程CSS系列(1) –添加CSS样式的3种方式及样式表的优先权
    我给女朋友讲编程总结建议篇,怎么学习html和css
    【转给女朋友】提问的艺术:如何快速获得答案
    我给女朋友讲编程网络系列(3)—网页重定向,301重定向,302重定向
    我给女朋讲编程网络系列(2)--IIS8 如何在本地发布网站
    我给女朋友讲编程分享篇--看我姐和我女朋友如何学编程
    我给女朋友讲编程网络系列(4)—颜色值及如何获取颜色值和下载软件小技巧
    我给女朋友讲编程网络系列(1)—什么是域名及域名投资
    Linux就该这么学 20181007第十章Apache)
  • 原文地址:https://www.cnblogs.com/gqtcgq/p/10017522.html
Copyright © 2020-2023  润新知