🔥 本文专栏:c++
🌸作者主页:努力努力再努力wz
💪 今日博客励志语录 :
迷茫是灵魂在绘制新地图时的必要留白。它不是迷失,而是为了更精确地找到自己。
特殊类
C++ 是一门面向对象的编程语言,类和对象是其核心概念。使用 C++ 编写的程序必然涉及类的定义和对象的实例化。然而,某些对象具有特殊要求,其创建过程并非简单的实例化即可完成。本文将探讨如何实现一类具有特殊要求的类。
只能在堆上创建对象的类
首先介绍的特殊类是其对象实例只能存在于堆内存中,而无法在静态存储区或栈上创建的类。实现这种限制需要特定的机制:将类的构造函数声明为私有。
然而,将构造函数私有化会引发一个问题:虽然这阻止了在栈或静态存储区创建对象(因为外部无法直接调用构造函数),但也同时阻止了在堆上创建对象。这是因为创建堆对象通常通过 new
运算符完成,其底层操作涉及两个关键步骤:首先调用全局的 operator new
函数分配内存,然后调用构造函数初始化对象。由于构造函数是私有的,外部代码(包括 new
运算符)无法访问它,因此堆对象也无法创建。
解决方案如下:在类中定义一个公有的静态成员函数。静态成员函数属于类本身,而非类的某个实例对象,因此无需对象实例即可调用。关键点在于,静态成员函数在类的内部作用域内,具有访问类私有成员(包括私有构造函数)的权限。
因此,可以在该静态成员函数内部使用 new
运算符创建堆对象。如前所述,new
会调用 operator new
分配内存并调用构造函数进行初始化。该静态函数可以返回指向新创建对象的指针或引用。这样,外部代码通过调用此静态函数即可获得一个堆对象。
但需注意,如果类支持拷贝构造,用户仍可能通过拷贝构造函数(例如 MyClass obj = MyClass::CreateInstance();
)在栈上创建对象的副本。为了彻底禁止在栈或静态区创建对象,必须显式禁用拷贝构造函数(以及拷贝赋值运算符,以保持完整性)。
最终,实现一个只能在堆上创建的类如下:
cpp
class MyClass
{
public:
// 公有的静态成员函数,用于创建堆对象
static MyClass& CreateInstance()
{
MyClass* ptr = new MyClass; // 在堆上分配并构造对象
return *ptr; // 返回对象的引用
}
// 禁用拷贝构造和拷贝赋值
MyClass(const MyClass&) = delete;
MyClass& operator=(const MyClass&) = delete;
private:
// 私有构造函数,阻止外部直接实例化(包括栈、静态区、以及外部直接new)
MyClass()
{
// 构造函数实现
}
};
只能在栈上创建对象的类
上文介绍了如何实现只能在堆上创建实例对象的类。本文将介绍与之对应的机制:实现只能在栈上创建实例对象的类。其核心原理与前者相似,即将类的构造函数私有化。
构造函数私有化后,面临一个直接问题:无法在类外部调用构造函数,因为其访问权限受限,只能在类内部调用。然而,无论是创建堆对象、栈对象还是静态对象,其底层过程都涉及两个关键步骤:分配内存空间和调用构造函数。这意味着目前无法在类外部创建任何类型的对象实例。
解决方式与上文所述如出一辙。既然无法在类外部访问构造函数,但可以在类内部访问,因此需要在类中定义一个公有的静态成员函数。静态成员函数不依赖于对象实例,其声明和定义位于类作用域内,故可在类外部直接调用,无需创建对象。同时,由于静态成员函数在类内部,它有权访问并调用类中的私有成员函数(包括构造函数)。
因此,可以在该静态成员函数内部定义一个局部的栈对象,并将其返回。这里需要注意:返回的是栈对象而非堆对象。堆对象的生命周期由程序员显式管理(需调用 delete
释放),否则其生命周期与程序相同。而栈对象的生命周期由程序自动管理,与其所在的作用域(即函数栈帧)绑定。一旦函数调用结束,栈对象随栈帧一同销毁。因此,静态成员函数的返回值类型不能是引用类型,必须是传值返回。若返回引用,引用所指向的对象空间已被销毁,其内容可能已成为随机值。
然而,传值返回会引发第二个问题:理论上会涉及拷贝构造函数的调用。这意味着无法像上文那样简单地禁用拷贝构造函数。如果不禁用拷贝构造,则可能导致堆对象或静态对象被间接创建------即先创建一个栈对象,然后通过拷贝该栈对象来创建堆对象或静态对象。
思路一:利用移动语义
C++11 引入了移动语义。当函数内定义的局部变量作为返回值返回时,该变量在函数结束时即将被销毁,可被识别为亡值(xvalue)。此时可以调用移动构造函数进行返回。在 C++11 及以后,移动构造函数和移动赋值运算符是类的默认成员函数。但如果用户定义了拷贝构造函数、拷贝赋值运算符或析构函数,编译器将不会自动生成移动构造和移动赋值。因此,需要在移动构造函数声明后添加 = default
关键字让编译器生成默认版本,并在拷贝构造函数声明后添加 = delete
关键字显式删除它。
cpp
class stackonly {
public:
static stackonly CreateInstance() {
stackonly temp;
return temp; // 返回局部栈对象,可能触发移动构造(如果可用)
}
stackonly(const stackonly&) = delete; // 禁用拷贝构造
stackonly(stackonly&&) = default; // 显式要求生成默认移动构造
size_t getmember() {
return a;
}
private:
stackonly(int _a = 10) : a(_a) {} // 私有构造函数
size_t a;
};
尝试通过拷贝构造函数创建堆对象或静态对象将导致编译器报错:
cpp
int main() {
stackonly val1 = stackonly::CreateInstance();
stackonly* ptr = new stackonly(val1); // 错误:拷贝构造函数被删除
static stackonly val2(val1); // 错误:拷贝构造函数被删除
return 0;
}

然而,这种方式虽然避免了直接通过拷贝构造函数创建堆对象和静态对象,但别忘了 C++ 标准库中的 std::move
函数。该函数接收一个左值对象,返回其右值引用。这意味着我们可以尝试通过 std::move
调用移动构造函数来创建堆对象或静态对象:
cpp
#include <iostream>
#include <utility>
int main() {
stackonly val1 = stackonly::CreateInstance();
stackonly* ptr = new stackonly(std::move(val1)); // 调用移动构造
std::cout << ptr->getmember() << std::endl;
stackonly val2 = stackonly::CreateInstance();
static stackonly val3(std::move(val2)); // 调用移动构造
std::cout << val3.getmember() << std::endl;
return 0;
}

思路二:禁用 operator new
创建堆对象需要调用 new
运算符。new
的底层操作分为两步:首先调用 operator new
函数分配内存,然后调用构造函数。需要注意,operator new
是一个内存分配函数,并非标准的运算符重载函数(如 operator+
)。
运算符重载函数可以在类内或全局定义。对于非静态的类内运算符重载函数,编译器在调用时会隐式传递 this
指针。当调用一个运算符时(如 a + b
),如果涉及类类型对象作为左操作数,编译器会同时检查该类域内和全局域中定义的匹配运算符重载函数,并选择最匹配的版本进行调用。
operator new
的调用规则不同:当为类类型对象申请堆空间时,编译器首先在该类中查找匹配的 operator new
函数。如果找到,则调用类内版本;否则,调用全局 operator new
。
cpp
#include <iostream>
class myclass {
public:
void* operator new(size_t n) {
std::cout << "my operator new" << std::endl;
return malloc(n);
}
myclass(size_t _a = 200) : a(_a) {}
size_t getmember() { return a; }
private:
size_t a;
};
int main() {
myclass* ptr = new myclass; // 调用 myclass::operator new
std::cout << ptr->getmember() << std::endl;
return 0;
}

如果我们在类中将 operator new
声明为 = delete
,编译器检测到类内 operator new
被删除后,既不会调用类内版本,也不会转而调用全局版本,而是直接导致 new
表达式编译失败。这种方式可以严格阻止堆对象的创建。
cpp
class stackonly {
public:
static stackonly CreateInstance() {
stackonly temp;
return temp;
}
stackonly(const stackonly&) = delete;
stackonly(stackonly&&) = default;
// 关键:删除 operator new
void* operator new(size_t) = delete;
void* operator new = delete; // 通常也删除 new
size_t getmember() {
return a;
}
private:
stackonly(int _a = 10) : a(_a), ptr(new inta) {}
~stackonly() { delete ptr; }
size_t a;
int* ptr;
};
int main() {
stackonly val = stackonly::CreateInstance();
stackonly* ptr = new stackonly(std::move(val)); // 错误:operator new 被删除
return 0;
}

这种方式已接近完美,因为它严格阻止了堆对象的创建。但它仍然无法阻止通过移动构造函数在静态存储区创建对象(如 static stackonly val(std::move(temp));
)。
思路三:利用返回值优化 (RVO) 和强制拷贝消除
返回值优化 (Return Value Optimization, RVO) 是一种编译器优化技术。当函数返回一个与函数返回类型相同的局部对象,且该返回值用于初始化一个相同类型的新对象时,编译器可能直接在接收对象的内存位置上构造该局部对象,从而避免拷贝或移动操作。
理论上,如果满足 RVO 条件(返回类型与局部对象类型完全一致,且用于初始化同类型的新对象),并且我们禁用了拷贝和移动构造函数,编译器可能应用 RVO 来构造对象。然而,在 C++17 之前,即使满足 RVO 条件,编译器通常也要求拷贝或移动构造函数是可访问的(即使最终未被调用)。如果它们被删除,编译器可能不会进行优化,导致代码编译失败。
C++17 引入了强制拷贝消除 (Mandatory Copy Elision) 规则。在特定条件下(包括返回纯右值(prvalue)且用于初始化同类型对象),编译器必须省略拷贝/移动操作,直接在目标位置构造对象。即使拷贝/移动构造函数被删除或不可访问,只要满足强制消除的条件,代码仍是合法的。
cpp
class stackonly {
public:
static stackonly CreateInstance() {
return stackonly(); // 返回纯右值(prvalue):临时对象
}
stackonly(const stackonly&) = delete; // 禁用拷贝构造
stackonly(stackonly&&) = delete; // 禁用移动构造
stackonly& operator=(const stackonly&) = delete;
stackonly& operator=(stackonly&&) = delete;
size_t getmember() {
return a;
}
private:
stackonly(int _a = 10) : a(_a), ptr(new inta) {}
~stackonly() { delete ptr; }
size_t a;
int* ptr;
};
int main() {
// C++17 强制拷贝消除:直接在 val1 的位置构造对象,不调用拷贝/移动构造
stackonly val1 = stackonly::CreateInstance();
// 以下所有尝试均因拷贝/移动构造被删除而失败
stackonly* ptr1 = new stackonly(val1); // 错误:拷贝构造被删除
stackonly* ptr2 = new stackonly(std::move(val1)); // 错误:移动构造被删除
static stackonly val2(val1); // 错误:拷贝构造被删除
static stackonly val3(std::move(val1)); // 错误:移动构造被删除
return 0;
}

在 C++17 及以上标准中,结合强制拷贝消除规则并彻底删除拷贝和移动构造函数,可以实现真正意义上的栈上对象限制,同时阻止堆对象和静态对象的创建(无需额外禁用 operator new
)。
不能被继承的类
不可继承类的实现相较于前文所述的特殊类更为直接。最简洁且推荐的方式是在类声明后添加 final
关键字。一旦类被声明为 final
,它将无法被继承:
cpp
class base final
{
private:
int member;
};
class derive : public base
{
private:
int d_member;
};
int main()
{
derive d; // 此处将导致编译错误,因为base是final类
return 0;
}

上述使用 final
关键字的方式是 C++11 提供的标准解决方案。在 C++11 之前,实现不可继承类的一种机制是将基类的构造函数声明为私有(private
)。
其原理在于:当派生类继承基类时,创建派生类对象的过程会先分配内存空间,随后调用构造函数进行初始化。派生类的构造函数初始化过程中必须调用基类的构造函数。然而,如果基类的构造函数是私有的,则只能在基类内部被调用,派生类构造函数无法访问它。因此,尝试创建派生类对象会导致编译失败,从而间接地阻止了类的继承。
但是,将构造函数私有化也意味着无法在类外部直接实例化基类对象。因此,需要提供一个公有的静态成员函数(如 createInstance )。静态成员函数不依赖于对象实例,可在类外部直接调用。由于它在类内部声明,有权访问并调用私有的构造函数。通过这个静态函数,可以创建并返回基类对象(例如栈对象)。如果需要堆对象或静态对象,该函数也可以返回其指针或引用。
cpp
class base
{
public:
static base createInstance()
{
base temp;
return temp; // 返回自动存储期对象(栈对象)
}
int getMember()
{
return member;
}
private:
base(int _member = 20)
: member(_member)
{
}
int member;
};

不能被拷贝的类
那么要讲解的最后一个特殊类就是不能被拷贝的类。 实现不可拷贝的类主要有两种方式。
第一种实现方式(C++11 及之后版本最为简洁且推荐的方式) 是在拷贝构造函数、移动构造函数、拷贝赋值运算符以及移动赋值运算符的声明后添加 = delete
关键字。
cpp
class Myclass
{
Myclass(const Myclass&) = delete; // 禁用拷贝构造函数
Myclass(Myclass&&) = delete; // 禁用移动构造函数
Myclass& operator=(const Myclass&) = delete; // 禁用拷贝赋值运算符
Myclass& operator=(Myclass&&) = delete; // 禁用移动赋值运算符
private:
int _member;
};
此方式显式删除了这些成员函数的定义,并阻止了编译器生成它们的默认版本。 对象的拷贝和赋值操作需要通过调用相应的拷贝或赋值函数来完成。由于这些函数在此处已被删除,任何尝试拷贝或赋值该类对象的行为都将导致编译错误。
而在 C++11 之前的标准中,实现方式是将拷贝构造函数、移动构造函数、拷贝赋值运算符以及移动赋值运算符声明为类的私有(private
)成员,并且通常省略其具体实现(即只有声明,不提供定义)。
设计模式
接下来我们将进入设计模式部分。可能有些读者对设计模式还不太熟悉,因此我将通过一个具体的例子来帮助大家理解。
我们可以将程序员类比为建筑设计师。建筑设计师负责设计完整的建筑,而程序员则负责设计并实现完整的程序。对于建筑设计师来说,设计一栋建筑通常会遵循一定的流程:首先分析建筑所需的原材料,以及如何将这些原材料加工成建筑所需的零件;接着考虑如何将这些零件拼接成更大的结构单元;最后则是处理这些结构单元之间的关系。
C++ 是一门面向对象的语言,其核心是类与对象。上文所提到的建筑设计流程,同样适用于我们编写 C++ 程序。第一个环节中,建筑所需的"原材料"对应代码中定义的类;将原料加工为零件的过程,相当于将类实例化为对象;而第二个环节,即处理零件之间的拼接关系,则对应程序中对象之间的组合方式,而第三个环节,即处理结构单元之间的关系,则对应程序中对象之间的交互方式
基于这样的思维方式,我们可以更好地设计和理解程序结构。接下来正式引入设计模式的概念:设计模式是 C++ 开发者针对特定场景或软件需求,所总结出的一套可重用的解决方案或模板。
回到建筑设计的类比,设计模式可以理解为一种"蓝图"。当设计师面对一些特殊的要求,比如建筑的结构形式或占地面积限制时,若没有现成蓝图,就需要从头构思解决方案;对程序员而言,则意味着从零开始编码。而有了设计模式之后,设计师就无需每次都重新构思,只需根据需求从"工具箱"中选取对应的蓝图,按照其中提供的方案实现即可,从而避免重复劳动。
程序设计中存在各种各样的需求与场景,正如建筑设计师的工具箱中存有众多蓝图。为提高设计人员检索和使用蓝图的效率,我们可以对这些蓝图进行分类,分类方式可参照上文所提到的建筑设计流程。
在第一个环节------将原料加工为零件------中,会涉及多种特定场景与需求。对应到程序中,即是设计满足特定需求的类,这类模式一般称为创建型模式
。
创建型模式关注的是如何生成具有特定需求的类。它是一个大的类别,其中包含多种针对具体特殊需求的子模式,例如单例模式
、工厂模式
等。
引入创建型模式
有助于实现业务逻辑的分离。在没有这类模式之前,程序员就像建筑设计师直接在工地上现场寻找原料、加工制造符合特定要求的砖瓦。而工地本身的工作本应专注于搭建房屋,不应包含原料生产这一环节。有了创建型模式之后,我们不再需要在"工地"上现场加工零件,而是将所需零件的规格告知"加工厂",由加工厂专门负责生产所需零件。生产过程对工地是不透明的且工地并不关心具体制造流程;而对加工厂来说,它接到工地提出的零件需求后,只需找到对应的创建型模式(即蓝图),并按其提供的方案生成符合要求的零件即可。这一过程实际上对应创建型模式中的工厂模式
。
接下来是零件的连接与拼接环节,对应程序中处理实例化对象之间的组合关系,这类模式属于结构型模式
。
结构型模式
关注如何将对象组合成更大的结构体,核心在于合理运用对象的继承与组合机制。该类别包含多种具体解决方案的子模式,例如适配器模式
和桥接模式
等。
最后的环节是结构单元之间的关联处理,对应程序中实例化对象之间的动态交互,这类模式即行为型模式
。其子模式包含观察者模式
、策略模式
等多种具体实现方案。
需要说明的是,C++领域公认的设计模式共有23种,每种模式都针对特定应用场景。受限于篇幅,本文无法逐一展开讲解。后续内容将聚焦于创建型模式中的典型子模式------单例模式
,深入解析其实现原理与应用场景。
单例模式
单例模式
的核心在于确保一个类仅能创建一个对象实例。这意味着该类在整个程序生命周期内只能被实例化一次,禁止创建多个实例。
单例模式
的重要性可以通过一个比喻理解:如同一个军队只能有一位指挥官。军队接收并执行该指挥官的所有命令。若存在多位指挥官,则可能发出相互冲突的指令(例如,一位命令进攻而另一位命令防守),导致混乱。因此,单例模式对于维护唯一控制权至关重要。
单例模式
的典型应用场景是内存池管理。一个程序通常只需维护唯一的内存池实例。若存在多个内存池,程序可能从其中一个池申请内存,却错误地尝试释放到另一个池中,造成严重问题。单例模式
在此场景下能有效保证内存池的唯一性。
理解了单例模式
的含义与意义后,我们探讨其实现方法。
单例模式
相较于仅能创建特定类型对象的类更为严格,其核心要求是仅允许创建唯一实例。实现的关键机制是将构造函数私有化。私有化构造函数后,外部代码无法访问或调用构造函数,从而阻止了在类外部创建任何形式的实例(包括栈对象、静态对象或堆对象)。
解决方案是:在类中定义一个公有的静态成员函数。静态成员函数不依赖于实例对象,因此可在类外部直接调用。此函数的核心作用是提供访问唯一实例的公共接口。
接下来需考虑唯一实例的类型:
- 能否使用栈对象? 静态成员函数位于类内部,有权访问私有构造函数,故可在其内部创建栈对象。然而,栈对象的生命周期与其作用域绑定。函数调用结束时,栈对象随之销毁。若通过传值返回该对象,接收方得到的是其副本(涉及拷贝构造函数或赋值运算符)。由于副本地址与原对象不同,无法保证实例的唯一性。因此,静态成员函数不能返回栈对象。
- 能否使用静态对象? 静态对象可定义于全局域或局部域(函数内)。其关键特性在于分配空间与初始化操作可分离,这与栈对象和堆对象不同(创建栈/堆对象时,分配空间与调用构造函数通常是连续步骤,除非显式使用
malloc
配合placement new
)。
静态对象的初始化时机取决于其作用域:
- 全局域静态对象:在
main
函数执行前,编译器会为其预分配空间并调用构造函数进行初始化。 - 局部域静态对象:编译器预分配空间,但构造函数仅在首次执行到其定义所在的函数时才被调用。
实现单例模式的一种方法是:在类内部声明一个静态成员变量(即目标类的静态对象)。例如:
cpp
class myClass {
public:
// ... (其他成员)
private:
size_t a;
static myClass singleton; // 声明静态成员变量
myClass(int _a = 4) : a(_a) {} // 私有构造函数
};
myClass myClass::singleton; // 在类外定义静态成员变量 (位于全局域)
注意:静态成员变量存储在静态区,不属于任何类实例。因此,在类内部声明一个
static myClass
成员变量不会导致"套娃"(无限递归包含)。实例化的myClass
对象仅包含非静态成员a
。
由于静态成员变量定义于全局域,编译器在 main
函数执行前会为其分配空间并调用构造函数(此特性将在后文讨论其潜在问题)。
在访问唯一实例的静态成员函数中(类内部函数可访问静态成员变量),可直接返回此静态对象。为确保外部无法创建副本,需注意:
- 将静态成员函数的返回值类型设为引用 (
myClass&
)。 - 显式删除拷贝构造函数和拷贝赋值运算符,防止通过拷贝返回的引用间接创建新实例。
结合上述两点,外部代码只能通过引用访问该静态对象,从而保证了实例的唯一性。示例代码如下:
cpp
#include <iostream>
namespace hunger {
class myclass {
public:
static myclass& Getinstance() { // 返回引用的静态成员函数
return singleton;
}
size_t GetMember() {
return a;
}
myclass(const myclass&) = delete; // 禁用拷贝构造
myclass& operator=(const myclass&) = delete; // 禁用拷贝赋值
~myclass() {
std::cout << "Hunger::~myclass()" << std::endl;
}
private:
size_t a;
static myclass singleton; // 声明静态成员
myclass(int _a = 4) : a(_a) {} // 私有构造
};
myclass myclass::singleton; // 全局域定义 (初始化在main前)
}
int main() {
hunger::myclass& ref1 = hunger::myclass::Getinstance();
std::cout << &ref1 << std::endl;
hunger::myclass& ref2 = hunger::myclass::Getinstance();
std::cout << &ref2 << std::endl; // 输出相同地址,证明唯一性
return 0;
}

上述实现方式(饿汉模式)的缺点:
- 启动延迟:由于静态成员变量
singleton
定义于全局域,其构造函数在main
函数执行前即被调用。静态对象的空间分配虽快,但其构造函数可能执行耗时操作(如建立数据库连接、打开大文件)。这会导致程序启动延迟,用户可能无法区分程序是启动缓慢还是已挂起。
并且我也可以通过代码来验证这一点,那么这里我在
单例模式
类的构造函数写了一个打印语句,并且main函数的第一条语句也是打印语句,通过打印语句的顺序,我们能够验证静态成员变量的构造函数的执行是在main函数调用之前:
cpp
#include<iostream>
namespace hunger {
class myclass {
public:
//省略
private:
size_t a;
static myclass singleton;
myclass(int _a = 4) : a(_a) {
std::cout << "myclass()" << std::endl;
}
};
myclass myclass::singleton;
}
int main() {
std::cout << "main()" << std::endl;
hunger::myclass& ref1 = hunger::myclass::Getinstance();
std::cout << &ref1 << std::endl;
hunger::myclass& ref2 = hunger::myclass::Getinstance();
std::cout << &ref2 << std::endl;
return 0;
}

- 静态对象初始化顺序问题 (关键补充):需特别注意,若饿汉模式的单例类 (
MyClass
) 内部依赖其他静态成员对象(例如,类中同时声明了static MyClass singleton
和static OtherClass dependency
),且MyClass
的初始化依赖于OtherClass
对象先完成初始化,则存在潜在风险。因为 C++标准未明确定义不同编译单元中静态对象的初始化顺序。编译器可能先初始化MyClass::singleton
,后初始化OtherClass
对象。若MyClass
构造函数依赖OtherClass
对象处于有效状态,此时访问未初始化的OtherClass
对象将导致未定义行为(如崩溃或数据错误)。这种依赖关系难以安全管理。
这种实现被称为饿汉模式 (Eager Initialization) 。类比餐饮店:饿汉模式如同在营业前预先做好所有菜品,顾客点单后立即可上菜。但缺点是顾客需在店外等待所有菜品制作完成,导致入店延迟。
接下来介绍懒汉模式(Lazy Initialization)。所谓懒汉模式,是指在 main
函数执行之前,不会调用单例类的构造函数。懒汉模式的实现主要有两种方式。
第一种方式:在类中定义一个静态成员指针变量。
cpp
#include<iostream>
namespace lazy1
{
class myclass
{
public:
myclass(const myclass&) = delete;
myclass& operator=(const myclass&) = delete;
static myclass& Getinstance()
{
if (_ptr == nullptr)
{
_ptr = new myclass;
}
return *_ptr; // 返回引用,避免拷贝
}
// ... (其余代码保持不变)
private:
myclass() = default;
static myclass* _ptr;
};
myclass* myclass::_ptr = nullptr; // 显式初始化为 nullptr
}
int main()
{
lazy1::myclass& ref1 = lazy1::myclass::Getinstance();
std::cout << &ref1 << std::endl;
lazy1::myclass& ref2 = lazy1::myclass::Getinstance();
std::cout << &ref2 << std::endl;
// 问题:未调用 delete _ptr; 导致内存泄漏且析构函数未执行
return 0;
}

与饿汉模式的关键区别:
虽然这里的静态成员变量 _ptr
也定义在全局作用域(或类作用域),但其类型是内置指针类型(myclass*
),而非类类型(myclass
)。因此,在 main
函数执行之前,不会调用 myclass
的构造函数(因为此时只初始化了指针本身,值为 nullptr
)。这解决了饿汉模式在 main
函数启动前调用构造函数可能导致启动时间延长的问题。
实现要点:
在静态成员函数 Getinstance()
中,首先判断该静态指针 _ptr
是否为空(nullptr
)。若为空,则在堆上创建唯一的对象实例;若不为空,则表明实例已存在,直接返回该实例的引用。返回引用是为了避免传值返回创建不必要的副本。同时,必须禁用拷贝构造函数和赋值运算符重载,以严格保证单例。
致命缺陷(手动管理生命周期):
此方式维护的是一个堆对象(heap object)。堆对象的生命周期需要程序员手动管理,必须显式调用 delete
运算符释放内存。
- 问题核心:如果我们没有手动释放该对象,程序结束时不会自动调用其析构函数。
- 原因:程序结束时,操作系统会回收该进程分配的所有内存(包括堆内存),并解除页表映射关系。但对于堆对象本身,系统并无自动调用其析构函数的机制。
- 对比:
- 静态对象(全局/局部静态):编译器会在
main
函数启动前维护一个注册表,记录所有需要析构的静态对象(包括全局对象和局部静态对象)。在main
函数返回后,编译器会遍历此注册表,按创建顺序的逆序依次调用这些静态对象的析构函数。 - 栈对象:在作用域结束时(如函数返回),编译器会自动按创建顺序的逆序调用其析构函数。
- 堆对象:程序结束时没有自动调用析构函数的机制。
- 静态对象(全局/局部静态):编译器会在
- 潜在风险:如果析构函数中包含重要操作(如向日志文件写入最终信息、关闭数据库连接、释放系统资源等),这些操作将无法执行,可能导致资源泄漏、数据不完整或其他未定义行为。
解决方案一:使用静态智能指针(改进生命周期管理)
使用 std::unique_ptr
管理堆对象生命周期,利用其析构时自动释放内存的特性。
cpp
#include <iostream>
#include <memory>
namespace lazy1_smart
{
class myclass
{
public:
myclass(const myclass&) = delete;
myclass& operator=(const myclass&) = delete;
static myclass& Getinstance()
{
if (!_ptr) // 判断智能指针是否为空(未托管对象)
{
_ptr = std::unique_ptr<myclass>(new myclass);
}
return *_ptr; // 解引用返回对象引用
}
// 注意:不再需要手动 destroy() 函数,unique_ptr 自动管理释放
~myclass()
{
std::cout << "lazy1_smart::~myclass()" << std::endl;
}
private:
myclass() = default;
static std::unique_ptr<myclass> _ptr; // 静态智能指针成员
};
std::unique_ptr<myclass> myclass::_ptr; // 定义并默认初始化为空
}

优点:std::unique_ptr
在程序结束时(静态成员析构时)会自动调用 delete
释放其托管的堆对象,从而触发 myclass
析构函数的执行,解决了手动释放的问题。(补充说明:智能指针作为静态成员,其自身析构发生在 main
返回后编译器清理阶段,此时会释放其管理的对象。)
第二种方式(更优方案):局部静态变量 (Meyer's Singleton)
无需在类中定义静态成员变量,直接在静态成员函数 Getinstance()
内部定义一个局部静态对象。
cpp
#include <iostream>
namespace lazy2
{
class myclass
{
public:
myclass(const myclass&) = delete;
myclass& operator=(const myclass&) = delete;
static myclass& Getinstance()
{
static myclass singleton; // 局部静态对象
return singleton;
}
~myclass()
{
std::cout << "lazy2::~myclass()" << std::endl;
}
private:
myclass() = default;
};
}
int main()
{
lazy2::myclass& ref1 = lazy2::myclass::Getinstance();
std::cout << &ref1 << std::endl;
lazy2::myclass& ref2 = lazy2::myclass::Getinstance();
std::cout << &ref2 << std::endl;
return 0;
}

关键优势:
- 延迟初始化:局部静态变量
singleton
的构造函数首次调用时机被延迟到main
函数中首次调用Getinstance()
的时刻,而非main
函数启动之前。 - 自动生命周期管理:编译器保证在程序退出时(
main
函数返回后),按照创建顺序的逆序自动销毁所有局部静态对象,因此singleton
的析构函数会被正确调用。 - 简洁安全:代码更简洁,完全避免了手动内存管理或智能指针的引入,从根本上解决了生命周期管理问题。
- 线程安全 (C++11起):在 C++11 及以后的标准中,局部静态变量的初始化是线程安全的。(补充说明:这是该方式在现代 C++ 中被广泛推荐的重要原因之一。)
结语
那么这就是本篇文章关于特殊类以及设计模式的全部内容,那么下一期我会更新C++的类型转化和IO流,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持,就是我创作的最大动力!