智能指针
1. 垃圾回收
垃圾回收机制已经大行其道,得到了诸多编程语言的支持,例如Java、Python、
C#、PHP等。而C++虽然从来没有公开得支持过垃圾回收机制,但C++98/03标准中,支持使用auto_ptr智能指针来实现堆内存的自动回收; C++11新标准在废弃auto_pt的同时,增添了unique_ptr、shared_ptr以及weak_ptr这3个智能指针来实现堆内存的自动回收。
所谓智能指针,可以从字面上理解为"智能"的指针。具体来讲,智能指针和普通指针的用法是相似的,不同之处在于,智能指针可以在适当时机自动释放分配的内存。
也就是说,使用智能指针可以很好地避免"忘记释放内存而导致内存泄漏"问题出现。由此可见,C++也逐渐开始支持垃圾回收机制了。
2. 智能指针
C++中的智能指针是一种用于管理动态分配内存的资源对象。它们能够自动地跟踪资源的所有权,当资源不再需要时,会自动释放它们,从而避免内存泄漏和悬挂指针等问题。C++标准库提供了三种主要类型的智能指针:std::shared_ptr
, std::unique_ptr
和std::weak_ptr
,智能指针都是通过类模板的方式实现的。
std::shared_ptr
和 std::unique_ptr
,还有 std::weak_ptr
。这三种智能指针在不同的情况下具有不同的用途和语义。
- std::shared_ptr :多个
std::shared_ptr
对象可以共享同一个资源的所有权。资源只有在最后一个引用计数归零时才会被释放。 - std::unique_ptr :
std::unique_ptr
表示独占所有权,一个资源只能由一个std::unique_ptr
拥有。这种指针在需要确保只有一个拥有者时非常有用。 - std::weak_ptr :
std::weak_ptr
是一种弱引用指针,用于解决std::shared_ptr
循环引用问题。std::weak_ptr
可以与std::shared_ptr
共同使用,但不会增加资源的引用计数,从而避免循环引用导致的内存泄漏。
这三种智能指针类型提供了灵活的内存管理选项,可以根据具体的需求选择适当的类型来管理资源。
2.1 shared_ptr
shared_ptr(T表示指针指向的数据类型)的定义位于头文件中,并位于std命名空间中,所以使用智能指针时,程序都需要包含下面两行代码:
cpp
#include<memory>
using namespace std;
和unique_ptr,weak_ptr不同之处在于,多个shared_ptr智能指针可以共用一块内存空间。并且,即使多个共用一块空间的shared_ptr指针中有一个指针放弃了该空间的"使用权",也不会影响到其他指针。只有到该空间的引用次数为0时,该空间才会被释放。
因为智能指针的底层是用类模板实现的,所以智能指针的声明和初始化跟C++中的容器基本相同,下面我们来看个例子:
cpp
#include<iostream>
#include<memory>
using namespace std;
int main()
{
//默认初始化
shared_ptr<int> p;
//传参初始化
//声明一个指向一块有5个int空间大小的share_ptr
shared_ptr<int>p1(new int[5]);
//传入空指针
//空指针的引用次数为0
shared_ptr<int>p2(nullptr);
//使用C++11标准提供的std:make_shared<T>模板函数
shared_ptr<int>p3 = make_shared<int>(5);
//这段代码和第二段代码的效果相同
return 0;
}
在初始化shared_ptr指针时,还可以自定义所指内存的释放规则,这样当内存的引用次数为0时,会优先调用自定义的释放规则。
在某些场景中,自定义释放规则是很用必要的。比如,对于申请的动态数组来说,shared_ptr指针默认的释放规则是不支持释放数组的,只能自定义对应的释放规则,才能正确释放动态开辟的内存。
当然,对于申请的动态数组来说,释放规则可以使用C++标准中提供的default_delete模板类,不过,我们也可以自定义释放规则。
废话不多说,我们直接上代码:
cpp
#include<iostream>
#include<memory>
using namespace std;
void DeleteInt(int* p)
{
delete[]p;
}
int main()
{
//使用内置的default_delete作为释放规则
shared_ptr<int>p4(new int[5], default_delete<int[]>());
//初始化智能指针,并自定义规则
shared_ptr<int>p5(new int[5], DeleteInt);
//使用lambda表达式
shared_ptr<int>p6(new int [5], [](int* p) {delete[]p; });
return 0;
}
2.2 unique_ptr
unique_ptr指针自然也具备"在适当时机自动释放堆内存空间"的能力。和shared_ptr指针最大不同之处在于,unique_ptr指针指向的堆内存无法同某它unique_ptr共享,也就是说,每个unique_ptr指针都独自拥有对其所指堆内存空间的所有权。
例如:
cpp
#include <iostream>
#include <memory>
int main() {
std::unique_ptr<int> unique1 = std::make_unique<int>(42);
// std::unique_ptr<int> unique2 = unique1; // 错误!不允许复制独占指针
std::cout << "Value of unique1: " << *unique1 << std::endl;
unique1.reset(); // 释放资源
if (!unique) {
std::cout << "unique is nullptr" << std::endl;
}
return 0; // 在main函数结束时,unique的资源被释放
}
2.3 weak_ptr
在 C++ 中,std::shared_ptr
允许多个智能指针共享同一个资源,它们会共同维护一个引用计数来跟踪资源的使用。这样做通常很有用,但可能导致循环引用问题,即两个或多个对象互相引用,导致它们的引用计数永远不会归零,资源永远不会释放,从而产生内存泄漏。
我们来看一个案例:
cpp
class A {
public:
std::shared_ptr<B> b_ptr;
};
class B {
public:
std::shared_ptr<A> a_ptr;
};
在这个例子中,类 A 持有类 B 的智能指针,同时类 B 持有类 A 的智能指针。因此,它们之间形成了循环引用,即使不再需要它们,它们的引用计数也不会降到零,导致内存泄漏。
std::weak_ptr
的作用是打破这种循环引用,同时允许检查资源是否仍然存在。std::weak_ptr
不会增加资源的引用计数,它只是一个观察者,当资源被释放后,它会自动变成空指针。
下面是 std::weak_ptr
的使用示例:
cpp
#include <iostream>
#include <memory>
class A;
class B;
class A {
public:
std::shared_ptr<B> b_ptr;
A() {
std::cout << "A constructor" << std::endl;
}
~A() {
std::cout << "A destructor" << std::endl;
}
};
class B {
public:
std::weak_ptr<A> a_weak_ptr; // 使用 std::weak_ptr 避免循环引用
B() {
std::cout << "B constructor" << std::endl;
}
~B() {
std::cout << "B destructor" << std::endl;
}
};
int main() {
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->b_ptr = b;
b->a_weak_ptr = a;
// 输出 A 和 B 的析构函数调用
// 注意:输出会在 main 函数结束时显示,资源会正确释放
return 0;
}
在这个例子中,类 A 持有类 B 的 shared_ptr
,而类 B 持有类 A 的 weak_ptr
。当 main
函数结束时,shared_ptr
的引用计数会归零,类 A 和类 B 的析构函数会被调用,并释放资源,而不会导致内存泄漏。
总之,std::weak_ptr
是一种用于解决循环引用和避免内存泄漏的智能指针,它允许观察资源的状态而不增加引用计数。
2.4 使用智能指针的好处
- 自动内存管理:不需要手动调用
delete
来释放内存,减少内存泄漏的风险。 - 避免悬挂指针:当资源不再需要时,智能指针会自动释放资源,避免悬挂指针问题。
- 引用计数和独占所有权:
std::shared_ptr
和std::unique_ptr
提供了不同的所有权管理方式,根据需要选择。
总之,智能指针是C++中一种非常有用的工具,可以大大简化内存管理,提高代码的可维护性和安全性。在编写现代C++代码时,推荐优先使用智能指针来管理动态内存分配。
3 可变参数模板
C++11之前,类模板和函数模板中只能含固定数量的模板参数。
C++11的新特性可变参数能够让您创建可以接受可变参数的函数模板和类模。
下面是一个例子:
cpp
#include<iostream>
using namespace std;
//函数模板的参数个数为0到多个参数,每个参数的类型可以各不相同。
template<class...T>
//args一包形参,T一包类型
void func(T...args){
cout<<sizeof...(args)<<endl; //sizeof...固定语法计算获取到的模板参数个数
cout<<sizeof...(T)<<endl; //sizeof... 只能计算可变参数
}
int main()
{
func(12,13,14,15);
return 0;
}
这段代码中的两个 sizeof...
分别用于计算不同的东西:
-
sizeof...(args)
:这行代码计算的是可变参数args
中的参数个数。在main
函数中,调用了func(12,13,14,15);
,所以args
包含了四个参数,因此sizeof...(args)
会输出4
。 -
sizeof...(T)
:这行代码计算的是模板参数包T
中的类型数量。在函数模板func
中,T
是一个模板参数包,代表可变参数的类型。由于在main
函数中传递了四个整数参数,所以T
中包含了四个类型,因此sizeof...(T)
会输出4
。
总结一下,sizeof...(args)
计算的是可变参数的个数,而 sizeof...(T)
计算的是可变参数的类型数量,这两个值在这个例子中都是相等的,都是 4
。
4. 参数包和参数展开
当我们谈论参数包和参数包展开时,我们实际上在讨论可变参数模板的一部分。
参数包(Parameter Pack):
参数包是可变参数模板中的一种特殊类型,它允许你声明一个接受不定数量参数的模板函数或类。参数包的形式为 typename... Args
或 class... Args
,其中 Args
是参数包的名字,可以是任何有效的标识符名字。
cpp
template <typename... Args>
void MyFunction(Args... args) {
// 在函数体中可以访问 Args 的参数列表
}
在这个例子中,Args
是一个参数包,它表示可以接受零个或多个参数。当你调用 MyFunction
时,你可以传递任意数量的参数给它。
参数包展开(Parameter Pack Expansion):
参数包展开是指在模板函数或类中使用参数包中的参数的过程。展开操作使用展开操作符 ...
来实现,将参数包中的参数逐个提取出来,用于函数调用、初始化列表、递归展开等操作。
示例:
让我们以一个更具体的示例来说明参数包和参数包展开。假设我们有一个可变参数模板函数 PrintArgs
,它可以打印任意数量的参数:
cpp
#include <iostream>
using namespace std;
template <typename... Args>
void PrintArgs(Args... args) {
( (cout << args << " "), ...);// 参数包展开并打印
}
int main() {
PrintArgs(1, 2, "Hello", 3.14);
return 0;
}
在这个示例中,PrintArgs
接受不定数量的参数,并使用展开操作符将它们逐个打印出来。在 main
函数中,我们调用 PrintArgs
函数并传递了整数、字符串和浮点数等不同类型的参数,它们都被正确地打印出来。
5. 参数包展开的主要方式
参数包展开有几种不同的方式,它们在使用时具有不同的语法和应用场景。以下是一些常见的参数包展开方法以及它们的区别:
-
左折叠展开(Left Fold Expansion):
左折叠展开使用括号表达式
(expr, ...)
和展开操作符...
,将参数包中的参数从左到右展开并应用于表达式expr
。这是最常见和直观的展开方式。cpp(expr, ...);
例如,上面提到的
PrintArgs
示例就是使用了左折叠展开。 -
右折叠展开(Right Fold Expansion):
右折叠展开使用括号表达式
(expr, ...)
和展开操作符...
,将参数包中的参数从右到左展开并应用于表达式expr
。右折叠展开通常用于一些需要反向处理参数的情况。cpp(... , expr);
例如,如果你想计算参数包中所有整数的乘积,可以使用右折叠展开。
-
参数包展开初始化列表(Parameter Pack Expansion in Initialization Lists):
这种展开方式允许你在初始化列表中使用参数包展开,用于初始化数组、集合、结构体等数据结构。通常在对象的构造函数中使用。
cppMyClass(std::initializer_list<T>{args...}) { // 在构造函数中使用参数包展开初始化列表 }
这种展开方式在容器类和自定义数据结构的设计中非常有用。
-
递归展开(Recursive Expansion):
当参数包中包含不定数量的参数,而你希望逐个处理它们时,可以使用递归展开。这通常需要一个递归函数或类来处理参数包中的每个参数。
cpptemplate <typename T> void ProcessArg(T arg) { // 处理参数的逻辑 } template <typename T, typename... Rest> void ProcessArgs(T first, Rest... rest) { ProcessArg(first); ProcessArgs(rest...); // 递归展开 }
这种展开方式适用于需要按顺序处理参数的情况。
递归展开是一种在模板函数或模板类中使用递归来处理参数包的方法。通常,递归展开需要两个模板函数,一个用于处理第一个参数,另一个用于处理其余的参数,并递归调用自身。
以下是一个具体的示例,演示如何使用递归展开来打印参数包中的所有参数:
cpp#include <iostream> // 基本情况:处理最后一个参数 template <typename T> void PrintArg(T arg) { std::cout << arg << std::endl; } // 递归情况:处理第一个参数并递归处理其余参数 template <typename T, typename... Rest> void PrintArgs(T first, Rest... rest) { PrintArg(first); PrintArgs(rest...); // 递归展开 //会陷入死递归 } int main() { PrintArgs(1, 2, "Hello", 3.14); return 0; }
在这个示例中,
PrintArgs
函数用于处理参数包中的参数,首先调用PrintArg
处理第一个参数,然后递归调用PrintArgs
处理剩余的参数。这样看起来,参数包中的所有参数都会被逐个打印出来。但显然,忽略了一个致命的问题,递归没有终止条件,会陷入死循环。此时,为了程序顺利运行,我们需要添加一个判断条件来终止递归函数。
那么,该怎么添加判断条件呢?
在C++17中, 引入了
if constexpr
语句,它允许在编译时根据条件选择不同的代码分支。这对于编写更加灵活的模板代码非常有用,因为它允许你在编译时剔除不需要的代码分支,减少模板实例化的复杂性。我们可以通过该语句来判断条件是否终止。
cpp#include <iostream> // 基本情况:处理最后一个参数 template <typename T> void PrintArg(T arg) { std::cout << arg << std::endl; } // 递归情况:处理第一个参数并递归处理其余参数 template <typename T, typename... Rest> void PrintArgs(T first, Rest... rest) { PrintArg(first); if constexpr(sizeof...(rest)>0) PrintArgs(rest...); // 递归展开 } int main() { PrintArgs(1, 2, "Hello", 3.14); return 0; }
递归展开 和
if constexpr
这两个特性在编写泛型和模板代码时非常有用,能够提高代码的灵活性和性能。
这些展开方式的选择取决于你的具体需求和代码逻辑。通常,左折叠展开是最常见和最直观的选择,但在某些情况下,其他展开方式可能更合适。要根据具体情况灵活选择展开方式,以满足你的编程需求。
总之,参数包是一种允许可变参数模板处理不定数量参数的机制,而参数包展开是将参数包中的参数逐个提取出来并在代码中使用的过程。这使得可变参数模板非常强大和灵活。