回到顶部 暗色模式

EffectiveModernCpp(4):引用

1. moveforward

        $std::move$ 和 $std::forward$ 理论上什么都没有做,仅仅是负责转换。

template<typename T>
typename remove_reference<T>::type &&
move(T &&param) {
  using ReturnType =
    typename remove_reference<T>::type &&;

  return static_cast<ReturnType>(param);
}

        以上为C++11的 $std::move$ 示例实现,尽管不完全满足标准,但是已经十分接近了。$std::move$ 接受一个对象引用,返回一个指向同一对象的引用。&& 表示 $std::move$ 返回一个右值引用,但是由于引用折叠的存在,为了防止其被折叠为左值引用,我们使用了 $std::remove_-reference$ 。
        $std::move$ 可以在C++14中以更简单的方式实现。

template<typename T>
decltype(auto) move(T &&param) {
  using ReturnType = remove_reference_t<T> &&;
  return static_cast<ReturnType>(param);
}

        假设你有一个类,它用于表示一段注解:

class Annotation {
public:
  explicit Annotation(std::string text);
  // ...
};

        考虑到你需要的只是读取 $text$ ,并不用修改,所以你自然而然地想到 $const$ :

class Annotation {
public:
  explicit Annotation(const std::string text);
  // ...
};

        这里你可能会想到使用移动操作,因为你的入参是值方式传递的:

class Annotation {
public:
  explicit Annotation(const std::string text)
    : value(std::move(text)) {}
  // ...
private:
  std::string text;

        可惜的是,这里的 $text$ 并不是移动过去的,而是拷贝过去的。要理解这个问题,我们可以看一下 $std::string$ 的构造函数:

class string {
public:
  string(const string &rhs);
  string(string &&rhs);
  // ...
};

        问题的关键就在于 $std::string$ 的移动构造函数接收一个非常量,而我们之前传递的是常量。思考以下也可以理解,因为我们无法修改常量,自然也就无法移动了。
        $std::forward$ 是有条件的转换。

void process(const Widget &lvalArg);
void process(Widget &&rvalArg);

template<typename T>
void logAndProcess(T &&param) {
  auto now = std::chrono::system_clock::now();
  makeLogEntry("calling 'process'", now);
  process(std::forward<T>(param));
}

Widget w;
logAndProcess(w);
logAndProcess(std::move(w));

        $process$ 分别对左值和右值参数进行重载。当我们通过左值调用 $logAndProcess$ 时,自然希望参数以左值形式转发给 $process$ ;同样,当我们通过右值调用时,也希望参数通过右值形式转发。但是不管我们以什么方式传入参数,$param$ 都是一个左值,这也意味着如果我们不进行处理, 永远只会调用左值形式的 $process$ 。$std::forward$ 就负责这个转换,它将以右值初始化的参数转换为右值。
        $std::move$ 和 $std::forward$ 的最主要区别就在于前者总是进行转换,而后者只是有时进行转换。

2. 通用引用和右值引用

void f(Widget &&param);  // 右值引用
Widget &&var1 = Widget();  // 右值引用
auto &&var2 = var1;  // 通用引用

template<typename T>
void f(std::vector<T> &&param);  // 右值引用

template<typename T>
void f(T &&param);  // 通用引用

        从上面的示例中,我们可以发现 && 的意义并不仅限于右值引用。事实上,它有两种意思:一种是右值引用,另一种是通用引用 ( $universal$ $references$ ),也被叫做转发引用 ( $forwarding$ $references$ ),这意味着它既可以是一个左值也可以是一个右值。
        在两种情况下会出现通用引用,一种是模版函数参数,另一种是 $auto$ 。它们的共同之处在于都存在类型推导 ( $type$ $deduction$ )。

void f(Widget &&param);  // 没有类型推导,右值引用
Widget &&var1 = Widget();  // 没有类型推导,右值引用

template<typename T>
void f(T &&param);  // 存在类型推导,通用引用
auto &&var2 = var1;  // 存在类型推导,通用引用

        但是也并不意味着只要有类型推导就是通用引用:

template<typename T>
void f(std::vector<T> &&param);  // 右值引用

        $param$ 的类型并不是 $T$&& ,而是 $std::vector$<$T$>&& ,这是一个右值引用。
        即便是一个 $const$ ,也会对引用属性造成改变:

template<typename T>
void f(const T &&param);  // 右值引用

        还要注意,在模版类内部也不一定会发生类型推导:

template<class T, class Allocator = allocator<T>>
class vector {
// ...
public:
  void push_back(T &&x);
};

        由于 $push_-back$ 在被调用前 $T$ 已经被确定,从而不会发生类型推导,因此这里也不是通用引用。相反,$std::vector$ 的另一个成员 $emplace_-back$ 是通用引用:

template<class T, class Allocator = allocator<T>>
class vector {
// ...
public:
  template<class... Args>
  void emplace_back(Args &&...args);
};

        类型为 $auto$ 的变量可以是通用引用,准确地来说,声明为 $auto$&& 的变量是通用引用。这种形式不如模版函数参数常见,但是在C++11中经常突然出现,在C++14则出现的更多。

auto timeFuncInvocation = [](auto &&func, auto &&...params) {
  // start timer;
  std::forward<decltype(func)>(func)(
    std::forward<decltype(params)>(params)...
  );
  // stop timer and record elapsed time
};

        通过 $auto$ ,$timeFuncInvocation$ 可以对几乎所有函数使用。

3. 再谈moveforward

        假设我们有一个右值引用参数,如果我们想要以移动方式传递,需要使用 $std::move$ :

class Widget {
public:
  Widget(Widget &&rhs)
  : name(std::move(rhs.name)), p(std::move(rhs.p)) {}

private:
  std::string name;
  std::shared_ptr<SomeDataStructure> p;
};

        另一方面,通用引用既可能是左值,也可能是右值,我们可以通过 $std::forward$ 进行转发:

class Widget {
// ...
public:
  template<typename T>
  void setName(T &&newName) {
    name = std::forward<T>(newName);
  }
};

        尽管我们也可以在右值引用上使用 $std::forward$ ,但是相比直接使用 $std::move$ ,它还需要指定一个类型。为了避免出错,所以对于右值引用,我们建议使用 $std::move$ 。
        反过来看,我们是否能在通用引用上使用 $std::move$ 呢?

class Widget {
// ...
public:
  template<typename T>
  void setName(T &&newName) {
    name = std::move(newName);
  }
};

std::string getWidgetName();

Widget w;
auto n = getWidgetName();
w.setName(n);

        $setName$ 内部使用 $std::move$ 无条件将 $newName$ 转为右值,最终会导致 $n$ 变为未定义的值,很明显这并不是我们想要的。当然你可以通过重载函数避免这个问题:

class Widget {
// ...
public:
  void setName(const std::string &newName) {
    name = newName;
  }
  void setName(std::string &&newName) {
    name = std::move(newName);
  }
};

        但是,这又会引入新问题,考虑如下调用:

w.setName("A New Name");

        在通用引用版本中,字符串常量会被构造为 $string$ ,然后通过移动函数直接赋值,不需要再创建一个中间对象。但是在重载版本中,程序首先需要使用该字符串创建一个 $string$ ,然后在调用拷贝赋值运算符创建一个临时对象,最后将这个临时对象移动到 $name$ 上。这里我们的例子使用的是 $string$ ,没有那么明显,但是假设数据类型不再是 $string$ ,而是其他某个类型,那么性能开销可能会超出我们的预期。而且,如果我们的函数不只是接收一个参数,而是多个的话,那么我们需要的重载函数数量会以指数形式增长。所以,对于通用引用,我们应该使用 $std::forward$ 进行转发。
        如果你使用以值返回的函数,并且返回值会绑定到右值引用或通用引用上时,也需要使用 $std::move$ 和 $std::forward$ 。

Matrix operator+(Matrix &&lhs, const Matrix &rhs) {
  lhs += rhs;
  return std::move(lhs);
}

        这样 $lhs$ 就可以直接移动到返回值的内存位置。如果 $Matrix$ 不支持移动操作,我们将其转为右值也没问题,因为右值也可以被拷贝函数使用,而且如果之后支持了移动操作,也不需要改变代码。
        使用通用引用的情况也是一样。

template<typename T>
Fraction reduceAndCopy(T &&frac) {
  frac.reduce();
  return std::forward<T>(frac);
}

        但是要注意这不是每种情况都适用。

Widget makeWidget1() {
  Widget w;
  // ...
  return w
}

Widget makeWidget2() {
  Widget w;
  // ...
  return std::move(w);
}

        这种情况下应该使用第一个版本。因为编译器在发现返回值是局部变量时,会进行返回值优化 ( $RVO$ ),直接将局部变量作为返回值使用。但是使用 $std::move$ 后,因为返回值不再是局部变量,而是局部变量的引用,所以编译器不会进行优化,这导致 $Widget$ 会被再创建一遍。
        而且,标准规定RVO的条件如果满足,但是编译器决定不进行拷贝避免,就需要隐式对返回值调用 $std::move$ 。这意味着,就算我们不写 $std::move$ ,编译器也是可能会自动调用的。所以说,在这种情况下,我们使用 $std::move$ 不仅没帮助优化,反而添了倒忙。

4. 通用引用函数与重载

std::multiset<std::string> names;

void logAndAdd(const std::string &name) {
  auto now = std::chrono::system_lock::now();
  log(now, "logAndAdd");
  names.emplace(name);
}

        上面这段代码没有问题,但也没有效率。

std::string petName("Darla");
logAndAdd(petName);  // 传递左值
logAndAdd(std::string("Persephone"));  // 传递右值
logAndAdd("Patty Dog");  // 传递字符串常量

        在第一个调用中,$name$ 最终会以拷贝形式传递给 $emplace$ ,因为它是以左值传递的;在第二个调用中,$name$ 绑定一个右值,但是 $name$ 本身还是左值,所以它还是会以拷贝形式传递给 $emplace$ ;在第三个调用中,$name$ 又被绑定了一个右值,这次是通过字符串常量创建的临时对象,与第二个调用一样,它也需要被拷贝一次。
        我们可以通过通用引用提升第二个调用和第三个调用的效率。在第二个调用中,由于我们是以右值方式传递的,所以我们可以直接移动;而在第三个调用中,我们以字符串常量方式传递,我们甚至不需要移动,而是直接传入该常量,让字符串直接在 $multiset$ 中构建。修改后的代码如下:

template<typename T>
void logAndAdd(T &&name) {
  auto now = std::chrono::system_lock::now();
  log(now, "logAndAdd");
  names.emplace(std::forward<T>(name));
}

        但是,要考虑到,客户端一般不能直接访问 $names$ ,有些是以下标形式访问的。我们需要重载一下:

template<typename T>
void logAndAdd(T &&name);

std::string nameFromIdx(int idx);
void logAndAdd(int idx) {
  auto now = std::chrono::system_lock::now();
  log(now, "logAndAdd");
  names.emplace(nameFromIdx(idx));
}

logAndAdd(22);

        看上去还是一切正常,但是实际上它只能基本按照预期工作。我们换个调用方式:

short nameIdx;
logAndAdd(nameIdx);

        上面调用会报错,因为存在一个模版函数,它推导出类型 $short$ ,从而精确匹配,导致该模版函数被调用。这也是通用引用的一个问题,只要可能,它们就会精确匹配任何类型。

class Person {
public:
  template<typename T>
  explicit Person(T &&n) : name(std::forward<T>(n)) {}
  explicit Person(int idx) : name(nameFromIdx(idx)) {}
  Person(const Person &rhs);
  Person(Person &&rhs);
  // ...

private:
  std::string name;
};

Person p("Nancy");
auto cloneOfP(p);  // 错误

        这里我们尝试通过一个实例创建另一个实例。你可能认为它会调用拷贝构造函数,但其实不是,它调用的是完美转发构造函数。它会尝试使用 $p$ 初始化 $name$ ,从而导致错误。

const Person cp("Nancy");
auto cloneOfP(cp);

        如果我们修改变量为 $const$ ,那么这个调用就可以正常进行,因为它精确匹配了拷贝构造函数。
        如果涉及继承,完美转发构造函数也会有令人疑惑的行为:

class SpecialPerson : public Person {
public:
  SpecialPerson(const SpecialPerson &rhs): Person(rhs) {}
  SpecialPerson(SpecialPerson &&rhs): Person(std::move(rhs)) {}
};

        这两个构造函数最终都会调用基类的完美转发构造函数。
        所以总的来讲,我们应该避免对通用引用函数进行重载。

5. 通用引用函数重载的替代方案

5.1 放弃重载

        对于 $logAndAdd$ 的例子,我们可以放弃重载,将函数分别命名为 $logAndAddName$ 和 $logAndAddNameIdx$ 。

5.2 常量左值引用代替

        使用常量左值引用方式而不是通用引用方式,但是这会带来效率问题。不过如果我们要避免出错,放弃一些效率也是可选的。

5.3 传值

        通常一种不增加复杂性且提高性能的方法是将引用传递改为值传递。

class Person {
public:
  explicit Person(std::string p) : name(std::move(n)) {}
  explicit Person(int idx) : name(nameFromIdx(idx)) {}
  // ...

private:
  std::string name;
};

5.4 标签匹配

        如果使用通用引用的动机是完美转发,那么其他方式都无法代替。通用引用通常提供了最优匹配,但是如果通用引用函数包含非通用引用参数,那么非通用引用参数也会影响函数匹配。

template<typename T>
void logAndAdd(T &&name) {
  logAndAddImpl(
    std::forward<T>(name),
    std::is_integeral<typename std::remove_reference<T>::type>()
  );
}

        这个函数转发参数给 $logAndAddImpl$ 函数,但是多传了一个 $std::is_-integeral$ 模版对象,该模版用于判断 $T$ 是否为整型。不过要注意该模版对于诸如 $int$& 等左值引用会返回 $false$ ,所以,我们需要在传入前调用 $std::remove_-reference$ 移除引用。
        然后,我们可以实现 $logAndAddImpl$ :

template <typename T>
void logAndAddImpl(T &&name, std::false_type) {
  auto now = std::chrono::system_clock::now();
  log(now, "logAndAdd");
  names.emplace(std::forward<T>(name));
}

void logAndAddImpl(int idx, std::true_type) {
  logAndAdd(nameFromIdx(idx);
}

        在这个设计中,$std::true_-type$ 和 $std::false_-type$ 就是标签,唯一目的就是强制重载匹配按照我们的想法来执行。实际上,编译器可能会直接把这个参数优化掉。

5.5 约束通用引用模版

        正如我们之前演示的,如果类内存在完美转发构造函数,同时编译器自动生成了拷贝构造函数和移动构造函数,那么即使通过标签匹配,我们也不能解决重载问题。这种情况下,我们可以使用 $std::enable_-if$ 。
        $std::enable_-if$ 提供了一种强制编译器执行某种行为的方法。默认情况下,所有模版都是启用的,但是通过 $std::enable_-if$ ,我们可以在仅满足某个条件的情况下才使用某个模版。

class Person {
public:
  template<
    typename T,
    typename = typename std::enable_if<
      !std::is_same<
        Person, typename std::decay<T>::type
      >::value
    >::type
  >
  explicit Person(T &&n);
  // ...
};

        这里我们想表示的条件是确认 $T$ 不是 $Person$ 类型,看起来好像可以使用 $std::is_-same$ ,但是这不完全正确,因为左值引用、$const$ 以及 $volatile$ 都会影响我们。$std::decay$ 可以帮我们解决这个问题。
        看起来好像我们解决了这个问题,可惜的是,还没有。考虑我们之前提到过的子类问题,子类构造函数会调用完美转发构造函数。由于我们无法控制客户端派生类行为,所以这个问题的解决还是要修改基类。我们可以通过 $std::is_-base_-of$ 实现,它用于判断一个类型是否派生自另一个类型。

class Person {
public:
  template<
    typename T,
    typename = typename std::enable_if<
      !std::is_base_of<
        Person, std::decay<T>::type
      >::value
    >::type
  >
  explicit Person(T &&n);
  // ...
};

        如果使用C++14,可以这样实现:

class Person {
public:
  template<
    typename T,
    typename = std::enable_if_t<
      !std::is_base_of<
        Person, std::decay_t<T>
      >::value
    >
  >
  explicit Person(T &&n);
  // ...
};

        解决了吗?还没有,因为 $Person$ 还有一个接收 $int$ 的构造函数。

class Person {
public:
  template<
    typename T,
    typename = std::enable_if_t<
      !std::is_base_of<
        Person, std::decay_t<T>
      >::value &&
      !std::is_integral<
        std::remove_reference_t<T>
      >::value
    >
  >
  explicit Person(T &&n) : name(std::forward<T>(n)) {}
  explicit Person(int idx) : name(nameFromIdx(idx)) {}
  // ...

private:
  std::string name;
};

5.6 折中方法

        通常情况下,完美转发有着更高的效率,但是也存在缺点,即使某些参数可以传递给特定类型的函数,也无法完美转发,而且也会产生令人难以理解的错误信息。这些错误往往是完美转发函数接收到未定义行为的参数导致的。我们可以在函数内限制参数类型。

class Person {
public:
  template<
    typename T,
    typename = std::enable_if_t<
      !std::is_base_of<
        Person, std::decay_t<T>
      >::value &&
      !std::is_integral<
        std::remove_reference_t<T>
      >::value
    >
  >
  explicit Person(T &&n) : name(std::forward<T>(n)) {
    static_assert(
      std::is_constructible<std::string, T>::value,
      "Parameter n can't be used to construct a std::string"
    );
  // ...
  }
};

6. 引用折叠

        只有当函数是通用引用函数时,类型是左值还是右值的推导才会发生。C++中引用的引用是不合法的,但是往通用引用函数传递一个左值引用是可以的,实现这个机制的关键是引用折叠:

如果任一引用为左值引用,那么引用结果为左值引用。如果所有引用都是右值引用,引用结果为右值引用。

        $std::forward$ 就使用了引用折叠机制:

template<typename T>
T &&forward(typename remove_reference<T>::type &para) {
  return static_cast<T &&>(param);
}

        这不是标准库的实现方式,但是行为是一样的。当我们传入 $Widget$& 时,行为如下:

Widget& &&forward(typename remove_ference<Widget&>::type &param) {
  return static_cast<Widget& &&>(param);
}

        应用引用折叠规则,实际上相当于:

Widget &forward(typename remove_reference<Widget&>::type &param) {
  return static_cast<Widget&>(param);
}

        类似地,如果我们传入的是右值,返回值就变成了右值。
        除了模版实例化之外,$auto$ 也可能发生引用折叠:

Widget w;
auto &&w1 = w;

        这里 $w$ 是左值,$auto$&& 应用到左值引用上,经过引用折叠,类型变为 $Widget$& 。

7. 移动语义

        移动语义可以说是C++11最主要的特性,它具有更小的开销。但是要注意的是,很多类型并不支持移动语义,尤其是那些从C++98时期遗留下来的项目。而且,有时候,移动操作的效率也并没有想象的那么好,对于某些容器来说,根本就不存在开销小的移动方式。
        $std::array$ 是C++11中的新容器,本质上是具有STL接口的原始数组。这与其他的标准库容器比如 $std::vector$ 等不同,$std::vector$ 本身只保存了指向内存区域的指针。这种实现使得在常数时间内进行移动变为可能,只要拷贝指针,并将原指针置空即可。$std::array$ 则不是这种方式实现的,它的数据就保存在容器中,这意味着对它们进行移动操作还是线性时间复杂度的。
        另一个例子是 $std::string$ ,尽管它提供了常数时间复杂度的移动操作和线性时间复杂度的拷贝操作,但这也不意味着移动一定比拷贝快。许多字符串的实现对短字符串进行了优化,短字符串会存储在 $string$ 缓冲区中,而不是堆内存中。这导致对短字符串的移动不一定比拷贝更快。
        标准库中某些容器操作提供了强大的异常安全保证,用于确保C++98代码可以直接升级到C++11,这意味着只有移动操作不会发生异常时,才会使用移动替代拷贝。这样做的结果就是,即使类中存在移动函数,程序可能还是会选择拷贝函数。

8. 完美转发失效情况

        完美转发是C++11最显眼的功能之一。但是对于一些情况,它并不完美。

template<typename T>
void fwd(T &&param) {
  f(std::forward<T>(param));
}

        我们使用上面的函数作为例子,讨论几种失效情况。

8.1 花括号初始化

        假定 $f$ 如下:

void f(const std::vector<int> &v);

f({1, 2, 3});
fwd({1, 2, 3});  // 错误

        当对 $f$ 直接调用时,初始化列表会被隐式转化为 $std::vector$ 。但是通过模版函数调用时,编译器会进行类型推导,结果为 $std::initializer_-list$ ,之后与 $f$ 的参数类型进行对比,发现不匹配,从而产生错误。
        当下面的情况之一发生时,完美转发会失败:

        有趣的是,使用 $auto$ 初始化的类型推导是可以的。

auto il = {1, 2, 3};
fwd(il);

        所以这种问题的一个简单的解决办法就是使用 $auto$ 声明一个局部变量,并转发这个局部变量。

7.2 0或NULL

        当你试图将 $0$ 或者 $NULL$ 作为空指针传递时,类型推导会出错,因为它们可能会被推导为整型而不是指针类型。

7.3 整型静态const成员

        通常,我们不需要在类内声明静态常量,全局变量就可以了。因为全局变量可以通过常量传播的方式直接优化掉。

class Widget {
public:
  static const std::size_t MinVals = 28;
};

std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals);

        这里我们通过 $Widget::MinVals$ 来作为 $widgetData$ 的初始大小。编译器会将 $28$ 放入所有位置,从而优化掉 $MinVals$ ,所以没有为 $MinVals$ 提供定义是可以的。但是如果要使用 $MinVals$ 的地址,由于没有提供定义,所以尽管代码可以编译,但是会在链接时出错,因为指针没有地址可以指向。
        将这个思路应用在 $f$ 和 $fwd$ 上:

void f(std::size_t val);

f(Widget::MinVals);
fwd(Widget::MinVals);  // 错误

        尽管代码可以编译,但是不能链接。因为 $fwd$ 接收一个通用引用,引用和指针底层是一样的。所以,如果要以这种方式使用,$MinVals$ 必须要有定义。

const std::size_t Widget::MinVals;

        注意不要重复初始化,因为在类内已经初始化过一次了。

7.4 重载的函数名和模版名

        假定 $f$ 这样实现:

void f(int (*pf)(int));

int processVal(int value);
int processVal(int value, int priority);

        我们可以这样使用:

f(processVal);
fwd(processVal);  // 错误

        当直接调用 $f$ 时,即使存在两个版本的 $processVal$ ,编译器依然可以根据参数类型决定选择哪个版本。但是 $fwd$ 是一个模版函数,它对于两个版本的 $processVal$ 都可以生成对应的代码,从而类型推导失败。
        同样的问题发生在我们使用函数模版作为参数时:

template<typename T>
T workOnVal(T param) { /* ... */ }

fwd(workOnVal);  // 错误

        解决这个问题的办法是指定对应类型的参数:

using ProcessFuncType = int (*)(int);
PRocessFuncType processValPtr = processVal;

fwd(processValPtr);
fwd(static_cast<ProcessFuncType>(workOnVal));

        这有点奇怪,因为我们使用完美转发的目的就是在不知道参数类型的情况下进行转发,但是这里我们却需要先知道类型才能使用完美转发。

7.4 位域

        IPv4头部结构可以这样定义:

struct IPv4Header {
  std::uint32_t version: 4,
                IHL: 4,
                DSCP: 6,
                ECN: 2,
                totalLength: 16;
  // ...
};

        我们这样声明和使用 $f$ :

void f(std::size_t sz);
IPv4Header h;

f(h.totalLength);
fwd(h.totalLength);  // 错误

        这个问题在于 $fwd$ 接收一个引用,而 $h.totalLength$ 是一个非常量位域。C++标准不提倡这种使用方式,因为位域可能包含了机器字节的任意部分 ( 比如 $32$ 位 $int$ 的 $3$ $\sim$ $5$ 位 ),无法直接定位。C++无法创建一个指向位的指针,自然也就无法使用位域了。
        解决这个问题的关键是理解以位域作为参数的函数实质上是接收位域的副本,因为位域只能值传递。

auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length);

EffectiveModernCpp(4):引用