找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2345

积分

0

好友

327

主题
发表于 14 小时前 | 查看: 2| 回复: 0

在 C++ 开发的广袤天地里,智能指针作为内存管理的得力助手,已经成为现代 C++ 编程不可或缺的一部分。其中 std::shared_ptr 以其共享所有权的特性,让多个指针能够共同管理同一块资源,极大地减少了内存泄漏的风险。在创建 std::shared_ptr 对象时,我们常常面临两种选择:使用 std::make_shared 函数,或者通过构造函数传递指针。

这两种方式乍看之下似乎都能达成目的,但实则在底层实现、性能表现、异常安全等方面存在着诸多差异。今天,就让我们深入剖析一下 std::make_shared 与构造函数传指针之间的区别,看看在不同的应用场景下,如何做出最恰当的选择,写出更高效、更健壮的 C++ 代码。

一、认识 std::make_shared 和构造函数传指针

1.1 std::make_shared 是什么

std::make_shared 是 C++11 标准库中引入的一个非常实用的模板函数,它的主要使命是创建一个指向动态分配对象的 std::shared_ptr 智能指针实例。从实现机制来看,std::make_shared 最大的亮点在于它能够通过一次内存分配,就完成对象本身以及与之关联的控制块的创建。这里的控制块,就像是一个幕后管家,它主要负责记录引用计数(即有多少个 std::shared_ptr 共同指向这个对象),以及其他一些用于管理对象生命周期的元数据。

举个简单的例子,当我们使用 std::make_shared<int>(42) 时,编译器会在堆上一口气分配一块足够大的内存,这块内存一部分用来存放 int 类型的对象(值为 42),另一部分则用来存放控制块信息。然后,std::make_shared 会返回一个 std::shared_ptr<int> 类型的智能指针,这个指针既指向对象的内存区域,又与控制块紧密关联。

这种一次分配内存的方式带来了诸多好处。从性能角度而言,减少内存分配次数意味着降低了内存分配器的开销,也减少了内存碎片产生的可能性,从而提高了内存使用效率。在代码简洁性方面,std::make_shared 让我们无需显式地使用 new 关键字,代码看起来更加清爽、易读。例如创建一个自定义类 MyClass 的智能指针,使用 std::make_shared 只需 auto ptr = std::make_shared<MyClass>(arg1, arg2);,而传统方式则需要 std::shared_ptr<MyClass> ptr(new MyClass(arg1, arg2));,高下立判。

1.2 构造函数传指针是什么?

构造函数传指针的方式,是先通过 new 操作符在堆上创建一个对象,然后将这个对象的指针传递给 std::shared_ptr 的构造函数,从而让 std::shared_ptr 来接管对象的生命周期管理。例如:

class MyClass {
public:
    MyClass(int value) : data(value) {}
private:
    int data;
};
// 使用构造函数传指针创建std::shared_ptr
std::shared_ptr<MyClass> ptr(new MyClass(10));

在这个过程中,new MyClass(10) 首先在堆上为 MyClass 对象分配内存,并调用构造函数进行初始化,返回一个指向该对象的原始指针。接着,std::shared_ptr<MyClass> 的构造函数接受这个原始指针,创建一个智能指针实例,并在内部为其分配一个控制块,用于管理引用计数等信息。这种方式虽然直观,逻辑上也很清晰,先创建对象,再交给智能指针管理,但它存在一些潜在的问题。与 std::make_shared 相比,它需要进行两次独立的内存分配操作,一次是为对象本身分配内存,另一次是为控制块分配内存,这不仅增加了内存分配的开销,还可能导致更多的内存碎片。

在异常安全方面,在 C++17 之前,如果在传递指针给 std::shared_ptr 构造函数的过程中,其他表达式抛出异常,就有可能导致内存泄漏。例如:

void func(const std::shared_ptr<MyClass>& p1, const std::shared_ptr<MyOtherClass>& p2);
// 在C++17之前,存在内存泄漏风险
func(std::shared_ptr<MyClass>(new MyClass(1)), std::shared_ptr<MyOtherClass>(new MyOtherClass(2)));

如果 new MyOtherClass(2) 抛出异常,new MyClass(1) 分配的内存就会泄漏,因为此时还没有 std::shared_ptr 来接管它的所有权。虽然 C++17 明确了函数参数内表达式求值顺序,消除了这种特定场景下的泄漏风险,但在更复杂的表达式或嵌套函数调用中,仍需小心谨慎。

二、两者在性能上的显著差异

2.1 内存分配次数不同

std::make_shared 与构造函数传指针在性能上的首要差异体现在内存分配次数上。std::make_shared 通过一次内存分配,就搞定了对象本身和控制块的创建。以创建一个 std::shared_ptr<int> 为例:

auto ptr1 = std::make_shared<int>(42);

在这行代码中,编译器在堆上分配一块内存,这块内存既存放 int 类型的对象(值为 42),又存放控制块,控制块里包含引用计数等管理信息。

而构造函数传指针的方式,则需要进行两次独立的内存分配。先看代码示例:

std::shared_ptr<int> ptr2(new int(42));

这里,new int(42) 首先在堆上为 int 对象分配内存并初始化,返回一个原始指针。接着,std::shared_ptr<int> 的构造函数为这个原始指针创建控制块,又进行了一次内存分配。多一次内存分配,就多一次与内存分配器打交道的开销,并且增加了内存碎片化的风险,在频繁进行内存分配和释放的场景中,这对性能的影响不容小觑。

2.2 缓存友好度有别

从缓存友好度的角度来看,std::make_shared 也展现出明显优势。由于 std::make_shared 将对象和控制块分配在同一块连续内存中,当 CPU 从内存中读取数据时,很可能将对象和控制块的数据一起加载到 CPU 缓存中,大大提高了缓存命中率。假设我们有一个自定义类 MyData,频繁通过 std::make_shared 创建 std::shared_ptr<MyData> 智能指针:

class MyData {
public:
    int value;
    // 其他成员和方法
};
// 频繁创建MyData对象的智能指针
for (int i = 0; i < 10000; ++i) {
    auto ptr = std::make_shared<MyData>();
    // 使用ptr
}

在这个过程中,由于对象和控制块在同一缓存线,后续对 ptr 的操作(如访问对象成员、修改引用计数),很多时候可以直接从缓存中获取数据,减少了内存访问延迟。

相比之下,构造函数传指针方式下,对象和控制块是两次独立分配,它们在内存中的位置很可能不相邻,导致 CPU 缓存无法同时加载两者数据,缓存命中率降低。例如同样是创建 std::shared_ptr<MyData>,使用构造函数传指针:

for (int i = 0; i < 10000; ++i) {
    std::shared_ptr<MyData> ptr(new MyData);
    // 使用ptr
}

这种情况下,每次访问 ptr 所指向的对象和控制块时,都有可能发生缓存未命中,需要从主存中读取数据,大大降低了访问效率。

2.3 性能测试数据说话

为了更直观地感受两者的性能差异,我们可以进行实际的性能测试。以下是一个简单的性能测试代码示例,测试创建 100 万个 std::shared_ptr<int> 对象时,std::make_shared 和构造函数传指针方式所耗费的时间:

#include <iostream>
#include <memory>
#include <chrono>
int main() {
    auto start1 = std::chrono::high_resolution_clock::now();
    for(int i = 0; i < 1000000; ++i) {
        auto ptr = std::make_shared<int>(i);
    }
    auto end1 = std::chrono::high_resolution_clock::now();
    auto duration1 = std::chrono::duration_cast<std::chrono::milliseconds>(end1 - start1).count();
    auto start2 = std::chrono::high_resolution_clock::now();
    for(int i = 0; i < 1000000; ++i) {
        std::shared_ptr<int> ptr(new int(i));
    }
    auto end2 = std::chrono::high_resolution_clock::now();
    auto duration2 = std::chrono::duration_cast<std::chrono::milliseconds>(end2 - start2).count();
    std::cout << "std::make_shared time: " << duration1 << " ms" << std::endl;
    std::cout << "Constructor with pointer time: " << duration2 << " ms" << std::endl;
    return 0;
}

在我的测试环境([具体编译器和硬件环境])下,多次运行上述代码,得到的结果大致是:std::make_shared 耗时约 [X1] 毫秒,构造函数传指针耗时约 [X2] 毫秒。可以明显看出,std::make_shared 在创建大量智能指针时,性能优势非常明显,这进一步验证了我们前面关于内存分配次数和缓存友好度对性能影响的分析。

三、异常安全方面的天壤之别

3.1 std::make_shared 的异常安全保障

std::make_shared 在异常安全方面堪称典范。由于它将对象的创建和控制块的构造合并为一次原子性的内存分配操作,要么整个操作成功完成,创建出对象和与之关联的控制块,并返回一个指向该对象的 std::shared_ptr;要么在操作过程中遇到异常,就不会分配任何资源,自然也不存在内存泄漏的风险。假设有一个自定义类 MyComplexClass,其构造函数可能会抛出异常,比如在构造过程中需要分配大量内存,而内存不足时就会抛出异常。使用 std::make_shared 创建 std::shared_ptr<MyComplexClass> 的代码如下:

class MyComplexClass {
public:
    MyComplexClass(int num) {
        // 模拟可能抛出异常的操作,比如内存分配失败
        if(num < 0) {
            throw std::runtime_error("Invalid argument");
        }
        // 正常构造逻辑
    }
};
try {
    auto ptr = std::make_shared<MyComplexClass>(10);
    // 使用ptr
} catch(const std::exception& e) {
    std::cerr << "Exception caught: " << e.what() << std::endl;
}

在这段代码中,如果 MyComplexClass 的构造函数抛出异常,std::make_shared 不会分配任何内存,也就不会出现内存泄漏的情况。因为 std::make_shared 的操作是原子的,要么全部成功,要么全部失败,不会处于部分成功的中间状态。

3.2 构造函数传指针的潜在风险

构造函数传指针的方式,在异常安全方面就显得有些“脆弱”。在 C++17 之前,函数参数的求值顺序是未指定的,这就为构造函数传指针带来了潜在的内存泄漏风险。当我们使用构造函数传指针创建 std::shared_ptr,并且在一个函数调用中传递多个 std::shared_ptr 参数时,如果其中一个 std::shared_ptr 构造过程中抛出异常,就可能导致之前创建的对象内存泄漏。例如:

class AnotherClass {
public:
    AnotherClass(int val) {
        if(val < 0) {
            throw std::runtime_error("Negative value not allowed");
        }
    }
};
void someFunction(const std::shared_ptr<MyComplexClass>& p1, const std::shared_ptr<AnotherClass>& p2) {
    // 函数逻辑
}
// 在C++17之前存在风险的调用
try {
    someFunction(
        std::shared_ptr<MyComplexClass>(new MyComplexClass(-1)),
        std::shared_ptr<AnotherClass>(new AnotherClass(2))
    );
} catch(const std::exception& e) {
    std::cerr << "Exception caught: " << e.what() << std::endl;
}

在这个例子中,如果 new MyComplexClass(-1) 先执行,并且 MyComplexClass 的构造函数抛出异常,此时 new AnotherClass(2) 已经分配了内存,但由于 std::shared_ptr<AnotherClass> 的构造函数还未执行完,这块内存没有被 std::shared_ptr 接管,就会导致内存泄漏。

虽然 C++17 明确了函数参数内表达式求值顺序,规定了在函数调用时,每个参数表达式的求值和副作用都在进入函数体之前完成,并且按照从左到右的顺序进行。但在复杂的代码结构中,嵌套函数调用、宏定义等情况仍可能让开发者一不小心陷入异常安全的“陷阱”。所以,在异常安全方面,构造函数传指针相较于 std::make_shared,确实存在更多需要小心处理的地方。

四、内存释放时机的微妙不同

4.1 std::make_shared 的内存占用特点

std::make_shared 在内存释放时机上有其独特之处,这与它的内存分配方式紧密相关。由于 std::make_shared 将对象和控制块分配在同一块连续内存中,这就导致在引用计数为 0 时,这块内存并不会立即被释放。因为控制块不仅记录着引用计数,还包含了一些用于管理对象生命周期的其他信息,只有当最后一个指向控制块的 std::shared_ptr 被销毁时,整个内存块(包括对象和控制块)才会被释放。例如:

{
    auto ptr1 = std::make_shared<int>(42);
    auto ptr2 = ptr1;
} // 在此处,引用计数降为0,但内存不会立即释放

在这个代码块结束时,ptr1ptr2 的作用域结束,它们对对象的引用计数降为 0。然而,由于控制块仍然存在(因为它与对象在同一块内存中),这块内存不会被立即释放,直到程序中没有任何指向该控制块的 std::shared_ptr。这种特性在某些情况下可能会导致内存占用时间延长,尤其是在创建大量临时 std::shared_ptr 对象的场景中。如果这些临时对象占用的内存较大,可能会对系统内存资源造成一定压力。

4.2 构造函数传指针的内存释放优势

构造函数传指针的方式在内存释放时机上相对更加灵活。当对象的引用计数降为 0 时,对象所占用的内存会立即被释放,而控制块的内存则在最后一个指向它的 std::shared_ptr 被销毁时释放。因为对象和控制块是分开分配的,它们的生命周期管理相对独立。例如:

{
    std::shared_ptr<int> ptr3(new int(42));
    std::shared_ptr<int> ptr4 = ptr3;
} // 在此处,对象的内存会立即释放,控制块内存稍后释放

在这个例子中,当代码块结束,ptr3ptr4 的作用域结束,引用计数降为 0,对象的内存会立即被释放。而控制块的内存,只有在没有任何 std::shared_ptr 指向它时才会被释放。这种内存释放方式在对内存使用非常敏感的场景中具有优势,比如在嵌入式系统开发或者实时性要求极高的应用中,能够更及时地释放不再使用的内存资源,避免内存占用过高导致系统性能下降或资源耗尽。

五、使用限制和适用场景大剖析

5.1 std::make_shared 的使用限制

std::make_shared 虽然优点众多,但并非在所有场景下都适用,它存在一些使用限制。当类的构造函数是私有的或者被删除时,如果没有将 std::make_shared 声明为友元函数(在 C++17 及之后,部分编译器对此有一定程度的放宽,但仍未成为标准保证),就无法使用 std::make_shared 来创建对象。在单例模式中,构造函数通常是私有的,目的是确保只有一个实例被创建。如果尝试使用 std::make_shared 来创建单例对象,就会因为无法访问私有构造函数而导致编译错误。

当需要自定义删除器时,std::make_shared 也无能为力。因为 std::make_shared 使用默认的 delete 操作符来释放内存,不接受自定义删除器作为参数。如果我们需要管理的资源不是通过 new 操作符分配的,或者在释放资源时需要执行一些额外的操作,比如关闭文件、释放数据库连接等,就不能使用 std::make_shared。假设我们要管理一个通过 fopen 打开的文件,在文件使用完毕后,需要使用 fclose 来关闭文件,而不是 delete。此时,就需要使用构造函数传指针的方式,并传入自定义删除器来确保文件能被正确关闭。

若对象类型重载了 operator newstd::make_shared 也不会调用它。std::make_shared 始终使用全局的 ::operator new 来分配内存,这在一些对内存分配有特殊要求的场景下,可能会导致不符合预期的行为。比如某些高性能计算场景,可能会自定义内存分配器来优化内存使用,此时使用 std::make_shared 就无法满足需求。

5.2 构造函数传指针的适用场景

构造函数传指针的方式在一些特定场景下有着不可替代的作用。当需要自定义删除器时,构造函数传指针就成为了必然选择。正如前面提到的管理文件资源的例子,我们可以通过构造函数传指针,并结合自定义删除器来实现资源的正确释放。代码示例如下:

#include <iostream>
#include <memory>
#include <cstdio>
int main() {
    // 使用构造函数传指针,并传入自定义删除器fclose
    std::shared_ptr<FILE> file(fopen("test.txt", "w"), [](FILE* ptr) {
        if (ptr) {
            fclose(ptr);
            std::cout << "File closed" << std::endl;
        }
    });
    if (file) {
        fputs("Hello, World!", file.get());
    }
    return 0;
}

在这个例子中,std::shared_ptr<FILE> 通过构造函数接受 fopen 返回的指针,并使用一个 lambda 表达式作为自定义删除器,确保文件在不再被使用时能被正确关闭。

当需要访问私有构造函数时,构造函数传指针也能派上用场。比如在实现单例模式时,虽然不能直接使用 std::make_shared,但可以通过在类内部提供一个静态成员函数,在这个函数内部使用 new 操作符创建对象,并将其交给 std::shared_ptr 管理。示例代码如下:

class Singleton {
private:
    Singleton() {}
    ~Singleton() {}
    static std::shared_ptr<Singleton> instance;
public:
    static std::shared_ptr<Singleton> getInstance() {
        if (!instance) {
            instance.reset(new Singleton);
        }
        return instance;
    }
};
std::shared_ptr<Singleton> Singleton::instance = nullptr;
int main() {
    auto singleton = Singleton::getInstance();
    return 0;
}

在这个单例模式的实现中,通过静态成员函数 getInstance,使用 new 创建对象并交给 std::shared_ptr 管理,成功绕过了私有构造函数的访问限制。

六、代码示例深度对比

6.1 创建对象的代码示例

首先看使用 std::make_shared 创建对象的示例:

#include <iostream>
#include <memory>
class MyClass {
public:
    MyClass(int value) : data(value) {
        std::cout << "MyClass constructor with value: " << data << std::endl;
    }
    ~MyClass() {
        std::cout << "MyClass destructor" << std::endl;
    }
private:
    int data;
};
int main() {
    auto ptr1 = std::make_shared<MyClass>(10);
    // 使用ptr1
    return 0;
}

在这个示例中,std::make_shared<MyClass>(10) 通过一次内存分配,创建了 MyClass 对象以及与之关联的控制块。从输出结果可以看到构造函数的调用,当 ptr1 离开作用域时,会调用析构函数释放对象。

再看构造函数传指针的方式:

int main() {
    std::shared_ptr<MyClass> ptr2(new MyClass(20));
    // 使用ptr2
    return 0;
}

这里 new MyClass(20) 先分配对象内存并调用构造函数,然后 std::shared_ptr<MyClass> 的构造函数为其分配控制块。同样,当 ptr2 离开作用域时,会释放对象。从代码结构上,std::make_shared 更为简洁,无需显式使用 new 关键字。

6.2 自定义删除器的代码示例

当需要自定义删除器时,std::make_shared 就无法胜任了,只能使用构造函数传指针的方式。示例如下:

#include <iostream>
#include <memory>
#include <cstdio>
class FileCloser {
public:
    void operator()(FILE* file) const {
        if (file) {
            fclose(file);
            std::cout << "File closed" << std::endl;
        }
    }
};
int main() {
    std::shared_ptr<FILE> file(fopen("test.txt", "w"), FileCloser());
    if (file) {
        fputs("Hello, World!", file.get());
    }
    return 0;
}

在这个例子中,std::shared_ptr<FILE> 通过构造函数接受 fopen 返回的指针,并使用 FileCloser 类的实例作为自定义删除器,确保文件在不再被使用时能被正确关闭。如果尝试使用 std::make_shared 来创建这个 std::shared_ptr<FILE>,由于 std::make_shared 不支持自定义删除器,就会导致编译错误。

6.3 访问私有构造函数的代码示例

对于访问私有构造函数的场景,我们以单例模式为例。先看使用构造函数传指针的实现方式:

class Singleton {
private:
    Singleton() {}
    ~Singleton() {}
    static std::shared_ptr<Singleton> instance;
public:
    static std::shared_ptr<Singleton> getInstance() {
        if (!instance) {
            instance.reset(new Singleton);
        }
        return instance;
    }
};
std::shared_ptr<Singleton> Singleton::instance = nullptr;
int main() {
    auto singleton = Singleton::getInstance();
    return 0;
}

在这个单例模式实现中,通过静态成员函数 getInstance,使用 new 创建对象并交给 std::shared_ptr 管理,成功绕过了私有构造函数的访问限制。

如果尝试使用 std::make_shared,由于 std::make_shared 在类外部调用,无法访问私有的构造函数,会导致编译失败。除非将 std::make_shared 声明为 Singleton 类的友元函数(在 C++17 及之后部分编译器有一定放宽,但未成为标准保证),但这种做法破坏了类的封装性,一般不推荐。

总结

综上所述,std::make_shared 和构造函数传指针各有千秋,是 C++ 智能指针 体系中两种互补的创建方式。

  • 优先选择 std::make_shared:在绝大多数常规场景下,尤其是需要创建大量共享对象、关注性能以及对异常安全有较高要求时,std::make_shared 因其一次内存分配、缓存友好和强异常安全保障,应作为首选。它能让你写出更简洁、高效和健壮的代码。
  • 必须使用构造函数传指针:当遇到需要自定义删除器(如管理文件句柄、网络套接字等非 new 分配的资源)、访问类的私有构造函数(如某些单例模式实现)或对象类型重载了 operator new 等特殊情况时,构造函数传指针是唯一可行的路径。

理解两者在底层机制、性能和安全性上的根本区别,是写出高质量现代 C++ 代码的关键。希望本文的深入对比能帮助你在实际开发中做出最合适的选择。

如果你想了解更多关于 C++ 底层机制、内存优化及其他编程技术,欢迎到云栈社区参与讨论与学习。




上一篇:Outline:基于React+Node.js构建的开源团队知识库指南
下一篇:Python asyncio异步编程指南:从入门到实战,告别代码等待焦虑
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-16 19:37 , Processed in 0.234777 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表