☰
C++基础入门教程(六):为什么创建类的时候要用new?
2026/10/20 13:31:39 网站建设 项目流程

前言


这个问题的前提本身值得纠正:创建类对象并不一定要用new。C++ 里最常见的对象创建方式恰恰是不用new的自动变量——Circle c;就是一个完整构造、完整析构的对象。new解决的不是“如何创建对象”,而是“对象放在哪里、活多久、由谁释放”。


初学者容易形成“类必须 new”的印象,原因通常有三:教材为了演示多态,统一写成Shape* p = new Circle;;从 Java/C# 转过来,习惯了引用类型;或者早期教程用new演示动态数组。这些写法在 C++ 里能用,但不是默认选择,而且裸new带来手动释放、异常路径泄漏、所有权不清等一连串问题。


本文先讲对象可以创建在哪些存储区,再讲什么场景确实需要动态分配,最后给出以std::unique_ptr、std::vector、栈对象为主的现代写法。示例以 C++17 为基准,不涉及任何需要运行环境的结论。


一、对象可以创建在哪里


C++ 对象的存储期(storage duration)决定了它的生命周期。常见的有三种:




存储期创建方式生命周期释放方式



自动局部变量Circle c;离开作用域自动调用析构

静态全局、命名空间、静态局部程序运行期间程序结束时析构

动态new Circle从 new 到 delete手动delete



自动存储期的对象通常放在栈上(标准只规定存储期,不规定物理位置,但主流实现如此)。它的构造发生在声明处,析构发生在作用域结束时,包括通过异常退出的路径——这是 RAII(resource acquisition is initialization,资源获取即初始化)的基础。


// 适用于 C++17
#include <iostream>
#include <string>

class Greeter {
public:
explicit Greeter(std::string name) : name_(std::move(name))
{
std::cout << "construct " << name_ << '\n';
}
~Greeter() { std::cout << "destroy " << name_ << '\n'; }

void hello() const { std::cout << "hello " << name_ << '\n'; }

private:
std::string name_;
};

int main()
{
Greeter g("stack"); // 自动对象,不需要 new
g.hello();
return 0; // 离开作用域,析构自动执行
}

Greeter g("stack");与Greeter* p = new Greeter("stack");创建的是同一种对象,区别只在存储期和释放责任。前者的析构是确定的,后者必须有人记得delete p。


二、什么时候确实需要动态分配


不用new是默认,但以下场景确实需要“对象活过当前作用域”或“大小在运行期决定”:


生命周期超出作用域


工厂函数要返回一个对象,而对象不能随函数栈帧销毁。此时可以按值返回(现代 C++ 会做复制消除或移动),也可以返回智能指针。


// 适用于 C++17
#include <memory>
#include <string>
#include <utility>

class Connection {
public:
explicit Connection(std::string endpoint) : endpoint_(std::move(endpoint)) {}
void send(const std::string& data) { (void)data; }
private:
std::string endpoint_;
};

// 方式一:按值返回,调用方持有对象
Connection make_connection(std::string ep)
{
return Connection(std::move(ep));
}

// 方式二:需要多态或共享所有权时,返回智能指针
std::unique_ptr<Connection> make_connection_ptr(std::string ep)
{
return std::make_unique<Connection>(std::move(ep));
}

按值返回不必然发生堆分配,也不必然发生复制:编译器可以直接在调用方预留的空间里构造返回值。标准允许这种复制消除,C++17 起对纯右值返回还强制了部分场景的消除。


运行期决定数量


需要一组数量在运行期才知道的对象时,用std::vector,而不是new T[n]:


// 适用于 C++17
#include <vector>

struct Item {
int id = 0;
double weight = 0.0;
};

std::vector<Item> make_items(int n)
{
return std::vector<Item>(static_cast<std::size_t>(n));
}

std::vector自己管理缓冲区,扩容、析构、拷贝、移动都由它负责。只有需要“不初始化”的底层缓冲区、或与 C 接口交互时,才考虑new[],并且要用std::unique_ptr<T[]>包装。


多态容器


基类指针的容器需要间接层,因为对象大小不同,不能按值放进同一个数组:


// 适用于 C++17
#include <iostream>
#include <memory>
#include <vector>

class Shape {
public:
virtual ~Shape() = default;
virtual void draw() const = 0;
};

class Circle : public Shape {
public:
void draw() const override { std::cout << "circle\n"; }
};

class Square : public Shape {
public:
void draw() const override { std::cout << "square\n"; }
};

int main()
{
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>());
shapes.push_back(std::make_unique<Square>());

for (const auto& s : shapes) {
s->draw();
}
return 0;
}

这里用unique_ptr而不是裸指针:容器析构时自动释放,异常路径也不会泄漏。基类析构函数必须是virtual,否则通过基类指针销毁派生对象是未定义行为。


可选与延迟构造


对象可能不存在,或需要推迟到某个条件满足后再创建。C++17 的std::optional可以表达“可能没有值”,并且不需要堆分配:


// 适用于 C++17
#include <optional>
#include <string>

std::optional<std::string> find_name(int id)
{
if (id == 1) {
return std::string("one");
}
return std::nullopt;
}

如果对象很大、构造很贵,且确实需要延迟,才考虑std::unique_ptr<T>加空指针表示“未创建”。


大对象与栈空间


栈空间通常有限(具体大小由平台和配置决定),把巨大的数组或深递归的对象放在栈上可能栈溢出。此时堆分配更合适。但“大”是相对的,不要凭感觉把普通对象都推到堆上;堆分配有分配器开销,且访问模式不如栈紧凑。


三、裸 new 的代价


入门代码常见的写法是:


// 适用于 C++17
void bad()
{
Shape* p = new Circle;
p->draw();
// 忘记 delete:泄漏
}

如果draw()抛出异常,后面的delete也不会执行;如果中间有多个new,还要处理“部分成功”的回滚。手工管理多条资源路径时,正确性靠人记,很容易漏。


现代写法用智能指针把“释放”绑定到生命周期:


// 适用于 C++17
#include <memory>

void good()
{
auto p = std::make_unique<Circle>();
p->draw();
// 离开作用域自动 delete,异常路径同样适用
}

std::make_unique是 C++14 引入的,它把分配和构造合成一次表达式,避免std::unique_ptr<T>(new T)中裸new先执行、构造智能指针时又抛异常导致的泄漏窗口。std::make_shared同理,并且通常把控制块与对象放在一次分配里(这是实现策略,不是标准保证)。


还需要注意所有权语义:



  • std::unique_ptr表示独占所有权,不可拷贝、可移动;

  • std::shared_ptr表示共享所有权,引用计数归零时释放;

  • std::weak_ptr观察shared_ptr但不增加计数,用于打破循环引用。


如果函数只是“借用”对象,不接管生命周期,应传引用或裸指针,并在接口文档中写清“不拥有”。把所有参数都改成shared_ptr会让所有权变得模糊,也会增加引用计数开销。


四、把 new 换成什么




需求不推荐推荐



普通局部对象Circle* p = new Circle;Circle c;

运行期数组new T[n]+delete[]std::vector<T>

独占所有权裸指针 + 手动 deletestd::unique_ptr<T>/make_unique

共享所有权裸指针 + 约定std::shared_ptr<T>/make_shared

可能没有值空指针std::optional<T>

多态容器std::vector<Shape*>std::vector<std::unique_ptr<Shape>>

延迟构造大对象裸指针std::unique_ptr<T>



一个实用的判断顺序是:先问“能不能放在栈上”,能就放栈上;再问“数量是否运行期决定”,是就用vector;再问“是否需要多态或跨作用域”,是就用unique_ptr;只有确实需要共享所有权时才用shared_ptr。裸new在这条链上没有位置。


常见坑点



  1. ❌ 认为“创建类对象必须用 new”。


✅ 局部对象Circle c;就是完整对象,构造与析构都自动完成。



  1. ❌new之后忘记delete,或异常路径提前返回导致泄漏。


✅ 用std::unique_ptr/std::make_unique,让析构自动发生。



  1. ❌new T[n]配delete p。


✅ 必须配delete[];更好的选择是std::vector<T>。



  1. ❌ 多态基类析构函数不写virtual,却通过基类指针delete。


✅ 基类析构必须是virtual,否则是 UB;用= default写虚析构。



  1. ❌ 用std::shared_ptr<T>(new T)对同一个裸指针构造两次。


✅ 会产生两个独立控制块,最终双重释放。用make_shared,且只从一个裸指针构造一次。



  1. ❌ 返回局部对象的指针或引用。


✅ 按值返回,或返回std::unique_ptr;局部对象在函数返回后已销毁。



  1. ❌ 把大数组放在栈上导致栈溢出,或反过来把所有小对象都堆分配。


✅ 按对象大小、生命周期和所有权选择存储期,不要一刀切。



  1. ❌ 函数参数一律用shared_ptr,导致所有权语义模糊。


✅ 借用用const T&或T*,独占用unique_ptr,只有真正共享才用shared_ptr。


总结




问题答案



创建类必须用 new 吗不需要,栈对象是默认选择

new 真正解决什么动态存储期与运行期决定的生命周期

什么时候需要堆跨作用域、多态容器、大对象、可选值

如何安全地堆分配make_unique、make_shared、容器

裸 new 的风险泄漏、异常不安全、所有权不清

多态销毁的前提基类虚析构



“为什么创建类的时候要用 new”更准确的问法是“什么时候需要动态存储期”。大多数对象应该像普通变量一样随作用域生灭;需要堆的时候,也应把释放责任交给智能指针或容器,而不是留给下一个人去记。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询