C/C++ 难题解析 #15
Posted CPP开发者
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C/C++ 难题解析 #15相关的知识,希望对你有一定的参考价值。
问题来源:Github - stackoverflow-top-cpp
这是一个C/C++难题清单,题源来自Github的stackoverflow-top-cpp。这个题库精选并总结了StackOverflow上的高赞回答,可以测试你有多了解C/C++,刷新你的知识,或者帮助你的 coding 面试!
CPP开发者公号计划定期更新一期,推送的文章中列出题目,回复关键字获取答案和解析。希望大家先自己思考解答,再发关键字看答案 如果觉得对你学习巩固C/C++知识有帮助,欢迎推荐给好友。
问题:什么是智能指针?什么时候用它们?
本期问题直接给出答案,查看其他问题答案请给CPP开发者公号发送关键字 难题解析 获取本期和往期的的全部解答。
回答:
从较浅的层面看,智能指针其实是利用了 RAII(资源获取即初始化)技术对普通的指针进行封装,这使得智能指针实质是一个对象,行为表现的却像一个指针。
作用很明显,防止忘记调用 delete。当然还有另一个作用,就是异常安全。在一段进行了 try/catch 的代码段里面,即使你写入了 delete,也有可能因为发生异常。程序进入 catch 块,从而忘记释放内存,这些都可以通过智能指针解决。
但是智能指针还有一重更加深刻的含义,就是把 value 语义转化为 reference 语义。C++ 和 Java 有一处最大的区别在于语义不同。
在 Java 里面下列代码:
Animal a = new Animal();
Animal b = a;
在上述代码中其实只生成了一个对象,a 和 b 仅仅是把持对象的引用而已。但在 C++ 中不是这样,
Animal a;
Animal b;
而这里就生成了两个对象。
在编写 OOP 程序时,value 语义带来太多的困扰。例如 TCP 连接中我封装一个 accept 函数接收请求,那么应该是这样的:
Socket accept();
这就带来一个问题,采用对象作返回值,这里面有一个对象复制的过程。但是 Socket 因为某些原因,我让它继承了 boost::noncopyable,所以它失去了复制和赋值的能力。
那么该怎么办?我们首先想到指针,在 accept 内部 new 生成一个对象,然后返回指针。
但是问题更多。这个对象何时析构?过早析构,程序发生错误;不进行析构,又造成了内存泄露。
这里的解决方案就是智能指针,而且是引用计数型的智能指针。
typedef boost::shared_ptr<Socket> SocketPtr;
SocketPtr accept();
这样外部就可以用智能指针去接收,那么何时析构?当然是引用计数为 0。这样,我们利用了 SockerPtr,实现了跟 Java 类似的 Reference 语义。
还有一个例子,Java 中往容器中放对象,实际放入的是引用,不是真正的对象,而 C++ 在 vector 中 push_back 采用的是值拷贝。如果想实现 Java 中的引用语义,就应该使用智能指针,可以参考《C++ 标准库程序》(侯捷/孟岩 译)的第五章讲容器的部分,有一节叫做 "用 Value 语义实现 Reference 语义",还有陈硕的那本《Linux 多线程服务器端编程》11.7 节。
C++ 标准一共有四种智能指针:auto_ptr、unique_ptr、shared_ptr 和 weak_ptr。其中 auto_ptr 在 C++11 已被摒弃,C++17 中被移除不可用了。
auto_ptr
auto_ptr 可以实现对象的 RAII,那为什么在 C++17 里要摒弃呢?先来看下面的赋值语句:
Struct Object
{
...
...
};
auto_ptr<Object> objPtr1(new Object);
auto_ptr<Object> objPrt2;
objPtr2 = objPtr1;
上述赋值语句将完成什么工作呢?在执行完 objPtr2 = objPtr1
赋值后,objPtr1 和 objPtr2 两个指针都将指向同一个 Object 对象。这就会出现问题,因为程序将试图删除同一个对象两次:一次是 objPtr1 过期,另一次是 objPtr2 过期。
要避免这种问题,方法有多种:
-
定义赋值运算符,使之执行深复制。这样两个指针将指向不同的对象,其中的一个对象是另一个对象的副本,缺点是浪费空间,所以智能指针都未采用此方案。 -
建立所有权(ownership)概念。对于特定的对象,只能有一个智能指针可拥有,这样只有拥有对象的智能指针的构造函数会删除该对象。然后让赋值操作转让所有权。这就是用于 auto_ptr 和 unique_ptr 的策略,但 unique_ptr 的策略更严格。 -
创建智能更高的指针,跟踪引用特定对象的智能指针数,这称为引用计数。例如,赋值时,计数将加 1,而指针过期时,计数将减 1,。当减为 0 时才调用 delete。这是 shared_ptr 采用的策略。
你会发现,使用 auto_ptr 所隐藏的的弊处可能远比它带来的利要多,这就是为何要摒弃 auto_ptr 的原因。也因此 C++ 11 发布了新式的三种智能指针用以取代和扩展更丰富的功能。
unique_ptr
unique_ptr 是 auto_ptr 的继承者,对于同一块内存只能有一个持有者,而 unique_ptr 和 auto_ptr 唯一区别就是 unique_ptr 不允许赋值操作,也就是不能放在等号的左边(函数的参数和返回值例外),这一定程度上避免了一些误操作导致指针所有权转移,然而 unique_str 依然有提供所有权转移的方法: std::move。调用 move 后,原 unique_ptr 就会失效,再用其访问裸指针也会发生和 auto_ptr 相似的 crash,如下面示例代码,
unique_ptr<int> up(new int(5));
auto up2 = up; // 编译错误,不可进行赋值操作
auto up2 = move(up); // 但可以 move
cout << *up << endl; // crash,up 已经失效,无法访问其裸指针
所以,即使使用了 unique_ptr,也要慎重使用 move 方法,防止指针所有权被转移导致的 crash。
shared_ptr 和 weak_ptr
shared_ptr 是目前工程内使用最多最广泛的智能指针,它使用引用计数实现对同一块内存的多个引用,在最后一个引用被释放时,指向的内存才释放,这也是和 unique_ptr 最大的区别。
void f()
{
typedef std::shared_ptr<MyObject> MyObjectPtr;
MyObjectPtr p1;
{
std::shared_ptr<MyObject> p2(new MyObject()); // There is now one "reference" to the created object
p1 = p2; // Copy the pointer. // There are now two references to the object.
} // p2 is destroyed, leaving one reference to the object.
} // p1 is destroyed, leaving a reference count of zero.
// The object is deleted.
使用 shared_ptr 过程中有几点需要注意:
-
不要用同一个原始指针初始化多个 shared_ptr,会造成二次销毁。
int *p = new int;
{
std::shared_ptr<int> sp1(p); // ok
}
{
std::shared_ptr<int> sp2(p);
} // after leave the scope, crash
-
禁止使用指向 shared_ptr 的裸指针,也就是智能指针的指针,这听起来就很奇怪,但开发中我们还需要注意,使用 shared_ptr 的指针指向一个 shared_ptr 时,引用计数并不会加一,操作 shared_ptr 的指针很容易就发生野指针异常。
shared_ptr<int>sp = make_shared<int>(10);
cout << sp.use_count() << endl; // 输出 1
shared_ptr<int> *sp1 = &sp;
cout << (*sp1).use_count() << endl; // 输出依然是 1
(*sp1).reset(); //sp 成为野指针
cout << *sp << endl; // crash
-
循环引用。
// 一段内存泄露的代码
struct Son;
struct Father
{
shared_ptr<Son> son_;
};
struct Son
{
shared_ptr<Father> father_;
};
int main()
{
auto father = make_shared<Father>();
auto son = make_shared<Son>();
father->son_ = son;
son->father_ = father;
return 0;
}
分析一下 main 函数是如何退出的,一切就都明了:
-
main 函数退出之前,Father 和 Son 对象的引用计数都是 2。 -
son 指针销毁,这时 Son 对象的引用计数是 1。 -
father 指针销毁,这时 Father 对象的引用计数是 1。 -
由于 Father 对象和 Son 对象的引用计数都是 1,这两个对象都不会被销毁,从而发生内存泄露。
为避免循环引用导致的内存泄露,就需要使用 weak_ptr。weak_ptr 并不拥有其指向的对象,也就是说,让 weak_ptr 指向 shared_ptr 所指向对象,对象的引用计数并不会增加:
auto ptr = make_shared<string>("senlin");
weak_ptr<string> wp1{ ptr };
cout << "use count: " << ptr.use_count() << endl; // use count: 1
使用 weak_ptr 就能解决前面提到的循环引用的问题,方法很简单,只要让 Son 或者 Father 包含的 shared_ptr 改成 weak_ptr 就可以了。
// 修复内存泄露的问题
struct Son;
struct Father
{
shared_ptr<Son> son_;
};
struct Son
{
weak_ptr<Father> father_;
};
int main()
{
auto father = make_shared<Father>();
auto son = make_shared<Son>();
father->son_ = son;
son->father_ = father;
return 0;
}
同样,分析一下 main 函数退出时发生了什么:
-
main 函数退出前,Son 对象的引用计数是 2,而 Father 的引用计数是 1。 -
son 指针销毁,Son 对象的引用计数变成 1。 -
father 指针销毁,Father 对象的引用计数变成 0,导致 Father 对象析构,Father 对象的析构会导致它包含的 son_ 指针被销毁,这时 Son 对象的引用计数变成 0,所以 Son 对象也会被析构。
然而,weak_ptr 并不是完美的,因为 weak_ptr 不持有对象,所以不能通过 weak_ptr 去访问对象的成员,例如:
struct Square
{
int size = 0;
};
auto sp = make_shared<Square>();
weak_ptr<Square> wp(sp);
cout << wp->size << endl; // compile-time ERROR
你可能猜到了,既然 shared_ptr 可以访问对象成员,那么是否可以通过 weak_ptr 去构造 shared_ptr 呢?事实就是这样,实际上 weak_ptr 只是作为一个转换的桥梁(proxy),通过 weak_ptr 得到 shared_ptr,有两种方式:
-
调用 weak_ptr 的 lock() 方法,要是对象已被析构,那么 lock() 返回一个空的 shared_ptr。 -
将 weak_ptr 传递给 shared_ptr 的构造函数,要是对象已被析构,则抛出 std::exception 异常。
既然 weak_ptr 不持有对象,也就是说 weak_ptr 指向的对象可能析构了,但 weak_ptr 却不知道。所以需要判断 weak_ptr 指向的对象是否还存在,有两种方式:
-
weak_ptr 的 use_count() 方法,判断引用计数是否为 0。 -
调用 weak_ptr 的 expired() 方法,若对象已经被析构,则 expired() 将返回 true。
转换过后,就可以通过 shared_ptr 去访问对象了:
auto sp = make_shared<Square>();
weak_ptr<Square> wp(sp);
if (!wp.expired())
{
auto ptr = wp.lock(); // get shared_ptr
cout << ptr->size << endl;
}
线程安全
这里特指 shared_ptr,因为只有它允许多引用。
如何选择智能指针
(1)如果程序要使用多个指向同一个对象的指针,应选择 shared_ptr。这样的情况包括:
-
有一个指针数组,并使用一些辅助指针来标示特定的元素,如最大的元素和最小的元素; -
两个对象包含都指向第三个对象的指针; -
STL 容器包含指针。很多 STL 算法都支持复制和赋值操作,这些操作可用于 shared_ptr,但不能用于 unique_ptr(编译器发出 warning)和 auto_ptr(行为不确定)。如果你的编译器没有提供 shared_ptr,可使用 Boost 库提供的 shared_ptr。
(2)如果程序不需要多个指向同一个对象的指针,则可使用 unique_ptr。如果函数使用 new 分配内存,并返还指向该内存的指针,将其返回类型声明为 unique_ptr 是不错的选择。这样,所有权转让给接受返回值的 unique_ptr,而该智能指针将负责调用 delete。
可以将 unique_ptr 存储到 STL 容器中,只要不调用将一个 unique_ptr 复制或赋给另一个的算法(如 sort())。例如,可在程序中使用类似于下面的代码段。
unique_ptr<int> make_int(int n)
{
return unique_ptr<int>(new int(n));
}
void show(unique_ptr<int> &p1)
{
cout << *a << ' ';
}
int main()
{
vector<unique_ptr<int> > vp(size);
for(int i = 0; i < vp.size(); i++)
p[i] = make_int(rand() % 1000); // copy temporary unique_ptr
vp.push_back(make_int(rand() % 1000)); // ok because arg is temporary
for_each(vp.begin(), vp.end(), show); // use for_each()
}
其中 push_back 调用没有问题,因为它返回一个临时 unique_ptr,该 unique_ptr 被赋给 vp 中的一个 unique_ptr。
另外,如果按值而不是按引用给 show() 传递对象,for_each() 将非法,因为这将导致使用一个来自 vp 的非临时 unique_ptr 初始化 pi,而这是不允许的,编译器将发现错误使用 unique_ptr 的企图。
以上是关于C/C++ 难题解析 #15的主要内容,如果未能解决你的问题,请参考以下文章