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 过期。

要避免这种问题,方法有多种:

  1. 定义赋值运算符,使之执行深复制。这样两个指针将指向不同的对象,其中的一个对象是另一个对象的副本,缺点是浪费空间,所以智能指针都未采用此方案。
  2. 建立所有权(ownership)概念。对于特定的对象,只能有一个智能指针可拥有,这样只有拥有对象的智能指针的构造函数会删除该对象。然后让赋值操作转让所有权。这就是用于 auto_ptr 和 unique_ptr 的策略,但 unique_ptr 的策略更严格。
  3. 创建智能更高的指针,跟踪引用特定对象的智能指针数,这称为引用计数。例如,赋值时,计数将加 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 过程中有几点需要注意:

  1. 不要用同一个原始指针初始化多个 shared_ptr,会造成二次销毁。
int *p = new int;

{
    std::shared_ptr<int> sp1(p); // ok
}
{
    std::shared_ptr<int> sp2(p);
} // after leave the scope, crash
  1. 禁止使用指向 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
  1. 循环引用。
// 一段内存泄露的代码

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 函数是如何退出的,一切就都明了:

  1. main 函数退出之前,Father 和 Son 对象的引用计数都是 2。
  2. son 指针销毁,这时 Son 对象的引用计数是 1。
  3. father 指针销毁,这时 Father 对象的引用计数是 1。
  4. 由于 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 函数退出时发生了什么:

  1. main 函数退出前,Son 对象的引用计数是 2,而 Father 的引用计数是 1。
  2. son 指针销毁,Son 对象的引用计数变成 1。
  3. 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,有两种方式:

  1. 调用 weak_ptr 的 lock() 方法,要是对象已被析构,那么 lock() 返回一个空的 shared_ptr。
  2. 将 weak_ptr 传递给 shared_ptr 的构造函数,要是对象已被析构,则抛出 std::exception 异常。

既然 weak_ptr 不持有对象,也就是说 weak_ptr 指向的对象可能析构了,但 weak_ptr 却不知道。所以需要判断 weak_ptr 指向的对象是否还存在,有两种方式:

  1. weak_ptr 的 use_count() 方法,判断引用计数是否为 0。
  2. 调用 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。这样的情况包括:

  1. 有一个指针数组,并使用一些辅助指针来标示特定的元素,如最大的元素和最小的元素;
  2. 两个对象包含都指向第三个对象的指针;
  3. 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的主要内容,如果未能解决你的问题,请参考以下文章

C/C++ 难题解析 #10

C/C++ 难题解析 #32

C/C++ 难题解析 #03

C/C++ 难题解析 #16

C/C++ 难题解析 #21

C/C++ 难题困境