西安网站品牌建设,做网站需要的东西,正规网站开发流程,wordpress 首页第 7 章 按值传递还是按引用传递 从一开始#xff0c;C就提供了按值传递#xff08;call-by-value#xff09;和按引用传递#xff08;call-by-reference#xff09;两种参数传递方式#xff0c;但是具体该怎么选择#xff0c;有时并不容易确定#xff1a;通常对复杂类…第 7 章 按值传递还是按引用传递 从一开始C就提供了按值传递call-by-value和按引用传递call-by-reference两种参数传递方式但是具体该怎么选择有时并不容易确定通常对复杂类型用按引用传递的成本更低但是也更复杂。C11 又引入了移动语义move semantics也就是说又多了一种按引用传递的方式 1. X const const 左值引用 参数引用了被传递的对象并且参数不能被更改。 2. X 非 const 左值引用 参数引用了被传递的对象但是参数可以被更改。 3. X 右值引用 参数通过移动语义引用了被传递的对象并且参数值可以被更改或者被“窃取”。仅仅对已知的具体类型决定参数的方式就已经很复杂了。在参数类型未知的模板中就更难选择合适的传递方式了。 不过在 1.6.1 节中我们曾经建议在函数模板中应该优先使用按值传递除非遇到以下情况 对象不允许被 copy。
参数被用于返回数据。参数以及其所有属性需要被模板转发到别的地方。可以获得明显的性能提升。 本章将讨论模板中传递参数的几种方式并将证明为何应该优先使用按值传递也列举了不该使用按值传递的情况。同时讨论了在处理字符串常量和裸指针时遇到的问题。在阅读本章的过程中最好先够熟悉下附录 B 中和数值分类有关的一些术语lvaluervalueprvaluexvalue。
7.1 按值传递 当按值传递参数时原则上所有的参数都会被拷贝。因此每一个参数都会是被传递实参的一份拷贝。对于 class 的对象参数会通过 class 的拷贝构造函数来做初始化。调用拷贝构造函数的成本可能很高。 但是有多种方法可以避免按值传递的高昂成本事实上编译器可以通过移动语义move semantics来优化掉对象的拷贝这样即使是对复杂类型的拷贝其成本也不会很高。 比如下面这个简单的按值传递参数的函数模板
#include utility
#include string
#include iostream
#include type_traitstemplatetypename T
void printV(T arg) {}int main() {std::string returnString();std::string s hi;printV(s); //copy constructorprintV(std::string(hi)); //copying usually optimized away (if not,move constructor)printV(returnString()); // copying usually optimized away (if not, moveconstructor)printV(std::move(s)); // move constructorreturn 0;
} 在第一次调用中被传递的参数是左值lvalue因此拷贝构造函数会被调用。 但是在第二和第三次调用中被传递的参数是纯右值prvaluepure right value临时对象或者某个函数的返回值参见附录 B此时编译器会优化参数传递使得拷贝构造函数不会被调用。从 C17 开始C标准要求这一优化方案必须被实现。在 C17 之前如果编译器没有优化掉这一类拷贝它至少应该先尝试使用移动语义这通常也会使拷贝成本变得比较低廉。 在最后一次调用中被传递参数是 xvalue一个使用了 std::move()的已经存在的非const 对象这会通过告知编译器我们不在需要 s 的值来强制调用移动构造函数move constructor。 综上所述在调用 printV()参数是按值传递的的时候只有在被传递的参数是lvalue对象在函数调用之前创建并且通常在之后还会被用到而且没有对其使用std::move()时 调用成本才会比较高。不幸的是这唯一的情况也是最常见的情况因为我们几乎总是先创建一个对象然后在将其传递给其它函数
按值传递会导致类型退化decay 关于按值传递还有一个必须被讲到的特性当按值传递参数时参数类型会退化decay。也就是说裸数组会退化成指针const 和 volatile 等限制符会被删除就像用一个值去初始化一个用 auto #include utility
#include string
#include iostream
#include type_traitstemplatetypename T
void printV(T arg) {}int main() {std::string const c hi;printV(c); // c decays so that arg has type std::stringprintV(hi); //decays to pointer so that arg has type char const*int arr[4];int arr[4];printV(arr); // decays to pointer so that arg has type int *return 0;
} 当传递字符串常量“hi”的时候其类型 char const[3]退化成 char const *这也就是模板参数 T 被推断出来的类型。此时模板会被实例化成
void printV (char const* arg)
{ …
} 这一行为继承自 C 语言既有优点也有缺点。通常它会简化对被传递字符串常量的处理但是缺点是在 printV()内部无法区分被传递的是一个对象的指针还是一个存储一组对象的数组。在 7.4 节将专门讨论如何应对字符串常量和裸数组的问题。 7.2 按引用传递 现在来讨论按引用传递。按引用传递不会拷贝对象因为形参将引用被传递的实参。而且按引用传递时参数类型也不会退化decay。不过并不是在所有情况下都能使用按引用传递即使在能使用的地方有时候被推断出来的模板参数类型也会带来不少问题。
7.2.1 按 const 引用传递 为了避免不必要的拷贝在传递非临时对象作为参数时可以使用const 引用传递。
#include utility
#include string
#include iostream
#include type_traitstemplatetypename T
void printR(T const arg) {
}int main() {std::string returnString();std::string s hi;printR(s); // no copyprintR(std::string(hi)); // no copyprintR(std::move(s)); // no copyint i 42;printR(i); // passes reference instead of just copying ireturn 0;
} 这个模板永远不会拷贝被传递对象不管拷贝成本是高还是低 即使是按引用传递一个 int 类型的变量虽然这样可能会事与愿违不会提高性能见下段中的解释也依然不会拷贝。 这样做之所以不能提高性能是因为在底层实现上按引用传递还是通过传递参数的地址实现的。地址会被简单编码这样可以提高从调用者向被调用者传递地址的效率。不过按地址传递可能会使编译器在编译调用者的代码时有一些困惑被调用者会怎么处理这个地址理论上被调用者可以随意更改该地址指向的内容。这样编译器就要假设在这次调用之后所有缓存在寄存器中的值可能都会变为无效。而重新载入这些变量的值可能会很耗时可能比拷贝对象的成本高很多。你或许会问在按 const 引用传递参数时为什么编译器不能推断出被调用者不会改变参数的值不幸的是确实不能因为调用者可能会通过它自己的非const 引用修改被引用对象的值这个解释太好另一种情况是被调用者可以通过const_cast 移除参数中的 const。 不过对可以 inline 的函数情况可能会好一些如果编译器可以展开inline 函数那么它就可以基于调用者和被调用者的信息推断出被传递地址中的值是否会被更改。函数模板通常总是很短因此很可能会被做 inline 展开。但是如果模板中有复杂的算法逻辑那么它大概率就不会被做 inline 展开了。
按引用传递不会做类型退化decay 按引用传递参数时其类型不会退化decay。也就是说不会把裸数组转换为指针也不会移除 const 和 volatile 等限制符。而且由于调用参数被声明为 T const 被推断出来的模板参数 T 的类型将不包含 const。比如
std::string const c hi;
printR(c); // T deduced as std::string, arg is std::string constprintR(hi); // T deduced as char[3], arg is char const()[3]
int arr[4];
printR(arr); // T deduced as int[4], arg is int const()[4]
因此对于在 printR()中用 T 声明的变量它们的类型中也不会包含 const。
7.2.2 按非 const 引用传递 如果想通过调用参数来返回变量值比如修改被传递变量的值就需要使用非const 引用要么就使用指针。同样这时候也不会拷贝被传递的参数。被调用的函数模板可以直接访问被传递的参数。 考虑如下情况
templatetypename T
void outR(T arg) {
} 注意对于 outR()通常不允许将临时变量prvalue或者通过 std::move()处理过的已存在的变量xvalue用作其参数
#include utility
#include string
#include iostream
#include type_traitstemplatetypename T
void outR(T arg) {
}int main() {std::string returnString();std::string s hi;outR(s); //OK: T deduced as std::string, arg is std::stringoutR(std::string(hi)); //ERROR: not allowed to pass a temporary(prvalue)outR(returnString()); // ERROR: not allowed to pass a temporary(prvalue)outR(std::move(s)); // ERROR: not allowed to pass an xvaluereturn 0;
}
同样可以传递非 const 类型的裸数组其类型也不会 decay
int arr[4];
outR(arr); // OK: T deduced as int[4], arg is int()[4]
这样就可以修改数组中元素的值也可以处理数组的长度。比如
#include utility
#include string
#include iostream
#include type_traitstemplatetypename T
void outR(T arg) {if (std::is_arrayT::value) {std::cout got array of std::extentT::value elems\n;}
}int main() {int arr[4];outR(arr); // OK: T deduced as int[4], arg is int()[4]return 0;
} 但是在这里情况有一些复杂。此时如果传递的参数是 const 的arg 的类型就有可能被推断为 const 引用也就是说这时可以传递一个右值rvalue作为参数但是模板所期望的参数类型却是左值lvalue
std::string const c hi;
outR(c); // OK: T deduced as std::string const
outR(returnConstString()); // OK: same if returnConstString() returnsconst string
outR(std::move(c)); // OK: T deduced as std::string const6
outR(hi); // OK: T deduced as char const[3] 在这种情况下在函数模板内部任何试图更改被传递参数的值的行为都是错误的。在调用表达式中也可以传递一个 const 对象但是当函数被充分实例化之后可能发生在接接下来的编译过程中任何试图更改参数值的行为都会触发错误但是这有可能发生在被调用模板的很深层次逻辑中具体细节请参见 9.4 节。 如果想禁止想非 const 应用传递 const 对象有如下选择 可以将任意类型的参数传递给转发引用而且和往常的按引用传递一样都不会创建被传递参数的备份
#include utility
#include string
#include iostream
#include type_traitstemplatetypename T
void passR(T arg) {if (std::is_arrayT::value) {std::cout got array of std::extentT::value elems\n;}
}int main() {std::string s hi;passR(s); // OK: T deduced as std::string (also the type of arg)passR(std::string(hi)); // OK: T deduced as std::string, arg is std::stringpassR(std::string()); // OK: T deduced as std::string, arg is std::stringpassR(std::move(s)); // OK: T deduced as std::string, arg is std::stringint arr[4];passR(arr); // OK: T deduced as int()[4] (alsoreturn 0;
} 但是这种情况下类型推断的特殊规则可能会导致意想不到的结果 看上去将一个参数声明为转发引用总是完美的。但是没有免费的午餐。比如由于转发引用是唯一一种可以将模板参数 T 隐式推断为引用的情况此时如果在模板内部直接用 T 声明一个未初始化的局部变量就会触发一个错误引用对象在创建的时候必须被初始化
templatetypename T
void passR(T arg) {T x;
}
int main() {passR(42); // OK: T deduced as intint i;passR(i); // ERROR: T deduced as int, which makes the declaration ofxin passR() invalidreturn 0;
} 没看懂问题
7.3 使用 std::ref()和 std::cref() 限于模板 从 C11 开始可以让调用者自行决定向函数模板传递参数的方式。如果模板参数被声明成按值传递的调用者可以使用定义在头文件中的 std::ref()和std::cref()将参数按引用传递给函数模板。比如
#include utility
#include string
#include iostream
#include type_traitstemplatetypename T
void printT(T arg) {
}int main() {std::string s hello;printT(s); //pass s By valueprintT(std::cref(s)); // pass s “as if by reference”return 0;
}
7.4 处理字符串常量和裸数组
到目前为止我们看到了将字符串常量和裸数组用作模板参数时的不同效果 按值传递时参数类型会 decay参数类型会退化成指向其元素类型的指针。按引用传递是参数类型不会 decay参数类型是指向数组的引用。 两种情况各有其优缺点。将数组退化成指针就不能区分它是指向对象的指针还是一个被传递进来的数组。另一方面如果传递进来的是字符串常量那么类型不退化的话就会带来问题因为不同长度的字符串的类型是不同的。比如 这里 foo(“hi”, “guy”)不能通过编译因为”hi”的类型是 char const [3]而”guy”的类型是char const [4]但是函数模板要求两个参数的类型必须相同。 这种 code 只有在两个字符串常量的长度相同时才能通过编译。因此强烈建议在测试代码中使用长度不同的字符串。 如果将 foo()声明成按值传递的这种调用可能可以正常运行 但是这样并不能解决所有的问题。反而可能会更糟编译期间的问题可能会变为运行期间的问题
7.4.1 关于字符串常量和裸数组的特殊实现 有时候可能必须要对数组参数和指针参数做不同的实现。此时当然不能退化数组的类型。 为了区分这两种情况必须要检测到被传递进来的参数是不是数组。 通常有两种方法 可以将模板定义成只能接受数组作为参数
templatetypename T, std::size_t L1, std::size_t L2
void foo(T (arg1)[L1], T (arg2)[L2])
{
T* pa arg1; // decay arg1
T* pb arg2; // decay arg2
if (compareArrays(pa, L1, pb, L2)) { …
}
}
参数 arg1 和 arg2 必须是元素类型相同、长度可以不同的两个数组。但是为了支持多种不同类型的裸数组可能需要更多实现方式参见 5.4 节。
可以使用类型萃取来检测参数是不是一个数组
templatetypename T, typename
std::enable_if_tstd::is_array_vT
void foo (T arg1, T arg2)
{ …
} 由于这些特殊的处理方式过于复杂最好还是使用一个不同的函数名来专门处理数组参数。或者更近一步让模板调用者使用 std::vector 或者 std::array 作为参数。但是只要字符串还是裸数组就必须对它们进行单独考虑
7.5 处理返回值
将返回类型声明为 auto从而让编译器去推断返回类型这是因为auto 也会导致类型退化
templatetypename T
auto retV(T p) // by-value return type deduced by compiler
{
return T{…}; // always returns by value
}
7.6 关于模板参数声明的推荐方法
正如前几节介绍的那样函数模板有多种传递参数的方式
将参数声明成按值传递 这一方法很简单它会对字符串常量和裸数组的类型进行退化但是对比较大的对象可能会受影响性能。在这种情况下调用者仍然可以通过 std::cref()和 std::ref()按引用传递参数但是要确保这一用法是有效的。 将参数声明成按引用传递 对于比较大的对象这一方法能够提供比较好的性能。尤其是在下面几种情况下
将已经存在的对象lvalue按照左值引用传递 将临时对象prvalue或者被 std::move()转换为可移动的对象xvalue按右值引用传递或者是将以上几种类型的对象按照转发引用传递。
由于这几种情况下参数类型都不会退化因此在传递字符串常量和裸数组时要格外小心。对于转发引用需要意识到模板参数可能会被隐式推断为引用类型引用折叠。
一般性建议 基于以上介绍对于函数模板有如下建议 1. 默认情况下将参数声明为按值传递。这样做比较简单即使对字符串常量也可以正常工作。对于比较小的对象、临时对象以及可移动对象其性能也还不错。对于比较大的对象为了避免成本高昂的拷贝可以使用 std::ref()和 std::cref() 2. 如果有充分的理由也可以不这么做
如果需要一个参数用于输出或者即用于输入也用于输出那么就将这个参数按非const 引用传递。但是需要按照 7.2.2 节介绍的方法禁止其接受 const 对象。如果使用模板是为了转发它的参数那么就使用完美转发perfect forwarding。也就是将参数声明为转发引用并在合适的地方使用 std::forward()。考虑使用std::decay或者 std::common_type来处理不同的字符串常量类型以及裸数组类型的情况。如果重点考虑程序性能而参数拷贝的成本又很高那么就使用const 引用。不过如果最终还是要对对象进行局部拷贝的话这一条建议不适用。
3. 如果你更了解程序的情况可以不遵循这些建议。但是请不要仅凭直觉对性能做评估。在这方面即使是程序专家也会犯错。真正可靠的是测试结果
不要过分泛型化 值得注意的是在实际应用中函数模板通常并不是为了所有可能的类型定义的。而是有一定的限制。比如你可能已经知道函数模板的参数只会是某些类型的 vector。这时候最好不要将该函数模板定义的过于泛型化否则可能会有一些令人意外的副作用。针对这种情况应该使用如下的方式定义模板
templatetypename T
void printVector (std::vectorT const v)
{ …
} 这里通过的参数 v可以确保 T 不会是引用类型因为 vector 不能用引用作为其元素类型。而且将 vector 类型的参数声明为按值传递不会有什么好处因为按值传递一个vector 的成本明显会比较高昂vector 的拷贝构造函数会拷贝 vector 中的所有元素。此处如果直接将参数 v 的类型声明为 T就不容易从函数模板的声明上看出该使用那种传递方式了。
以 std::make_pair为例 std::make_pair()是一个很好的介绍参数传递机制相关陷阱的例子。使用它可以很方便的通过类型推断创建 std::pair对象。它的定义在各个版本的 C中都不一样 在第一版 C标准 C98 中std::make_pair被定义在 std 命名空间中并且使用按引用传递来避免不必要的拷贝
templatetypename T1, typename T2
pairT1,T2 make_pair (T1 const a, T2 const b)
{
return pairT1,T2(a,b);
} 但是当使用 std::pair存储不同长度的字符串常量或者裸数组时这样做会导致严重的问题。 因此在 C03 中该函数模板被定义成按值传递参数
templatetypename T1, typename T2
pairT1,T2 make_pair (T1 a, T2 b)
{
return pairT1,T2(a,b);
} 不过在 C11 中由于 make_pair()需要支持移动语义就必须使用转发引用。因此其定义大体上是这样
templatetypename T1, typename T2
constexpr pairtypename decayT1::type, typename
decayT2::type
make_pair (T1 a, T2 b)
{
return pairtypename decayT1::type, typename
decayT2::type(forwardT1(a), forwardT2(b));
} 完 整 的 实 现 还 要 复 杂 的 多 为 了 支 持 std::ref() 和 std::cref() 该函数会将std::reference_wrapper 展开成真正的引用。 目前 C标准库在很多地方都使用了类似的方法对参数进行完美转发而且通常都会结合std::decay使用 7.7 总结
最好使用不同长度的字符串常量对模板进行测试。 模板参数的类型在按值传递时会退化按引用传递则不会。 可以使用 std::decay对按引用传递的模板参数的类型进行退化。 在某些情况下对被声明成按值传递的函数模板可以使用 std::cref()和std::ref()将参数按引用进行传递。 按值传递模板参数的优点是简单但是可能不会带来最好的性能。 除非有更好的理由否则就将模板参数按值传递。 对于返回值请确保按值返回这也意味着某些情况下不能直接将模板参数直接用于返回类型。 在比较关注性能时做决定之前最好进行实际测试。不要相信直觉它通常都不准确
第 8 章 编译期编程
8.1 模板元编程 模板的实例化发生在编译期间而动态语言的泛型是在程序运行期间决定的。事实证明C模板的某些特性可以和实例化过程相结合这样就产生了一种 C自己内部的原始递归的“编程语言”。因此模板可以用来“计算一个程序的结果”。第 23 章会对这些特性进行全面介绍这里通过一个简单的例子来展示它们的用处。
判断一个数是不是质数
下面的代码在编译期间就能判断一个数是不是质数
#include utility
#include string
#include iostream
#include type_traits// p: number to check, d: current divisor
templateunsigned p, unsigned d
struct DoIsPrime {static constexpr bool value (p % d ! 0) DoIsPrime p, d - 1 ::value;
};// end recursion if divisor is 2
templateunsigned p
struct DoIsPrimep, 2 {static constexpr bool value (p % 2 ! 0);
};// primary template
templateunsigned n
struct IsPrime {// start recursion with divisor from p/2:static constexpr bool value DoIsPrime n, n / 2 ::value;
};// special cases (to avoid endless recursion with template instantiation):
template
struct IsPrime0 { static constexpr bool value false; };
template
struct IsPrime1 { static constexpr bool value false; };
template
struct IsPrime2 { static constexpr bool value true; };
template
struct IsPrime3 { static constexpr bool value true; };int main() {std::cout IsPrime9::value;return 0;
} IsPrime模板将结果存储在其成员 value 中。为了计算出模板参数是不是质数它实例化了DoIsPrime模板这个模板会被递归展开以计算 p 除以 p/2 和 2 之间的数之后是否会有余数。 正如以上实例化过程展现的那样
我们通过递归地展开 DoIsPrime来遍历所有介于 p/2 和 2 之间的数以检查是否有某个数可以被 p 整除。用 d 等于 2 偏特例化出来的 DoIsPrime被用于终止递归调用。但是以上过程都是在编译期间进行的。
8.2 通过 constexpr 进行计算 在 C14 中constexpr 函数可以使用常规 C代码中大部分的控制结构。因此为了判断一个数是不是质数可以不再使用笨拙的模板方式C11 之前以及略显神秘的单行代码方式
#include utility
#include string
#include iostream
#include type_traitsconstexpr bool IsPrime(unsigned int n) {for (unsigned int d 2; d n / 2; d) {if (n % d 0) {return false;}}return n 1;
}int main() {constexpr bool b1 IsPrime(9);return 0;
} 但是上面所说的“可以”在编译期执行并不是一定会在编译期执行。在需要编译期数值的上下文中比如数组的长度和非类型模板参数编译器会尝试在编译期对被调用的 constexpr 函数进行计算此时如果无法在编译期进行计算就会报错因为此处必须要产生一个常量。 在其他上下文中编译期可能会也可能不会尝试进行编译期计算如果在编译期尝试了但是现有条件不满足编译期计算的要求那么也不会报错相应的函数调用被推迟到运行期间执行。 比如
constexpr bool b1 isPrime(9); // evaluated at compile time 会在编译期进行计算因为 b1 被 constexpr 修饰。而对
const bool b2 isPrime(9); // evaluated at compile time if in namespacescope 如果 b2 被定义于全局作用域或者 namespace 作用域也会在编译期进行计算。如果b2 被定义于块作用域{}内那么将由编译器决定是否在编译期间进行计算。下面这个例子就属于这种情况
bool fiftySevenIsPrime() {
return isPrime(57); // evaluated at compile or running time
}
此时是否进行编译期计算将由编译期决定。
另一方面在如下调用中
int x;
…
std::cout isPrime(x); // evaluated at run time
不管 x 是不是质数调用都只会在运行期间执行 8.3 通过部分特例化进行路径选择
诸如 isPrime()这种在编译期进行相关测试的功能有一个有意思的应用场景可以在编译期间通过部分特例化在不同的实现方案之间做选择。
比如可以以一个非类型模板参数是不是质数为条件在不同的模板之间做选择
#include utility
#include string
#include iostream
#include type_traitsconstexpr bool IsPrime(unsigned int n) {for (unsigned int d 2; d n / 2; d) {if (n % d 0) {return false;}}return n 1;
}// primary helper template:
templateint SZ, bool IsPrime(SZ)
struct Helper;
// implementation if SZ is not a prime number:
templateint SZ
struct HelperSZ, false {};
// implementation if SZ is a prime number:
templateint SZ
struct HelperSZ, true {};int main() {Helper9 h;return 0;
}