目录
1. 请设计一个类,只能在堆上创建对象
2. 请设计一个类,只能在栈上创建对象
3.请设计一个类,不能被拷贝
C++98
C++11
4. 请设计一个类,不能被继承
C++98
C++11
5. 请设计一个类,只能创建一个对象(单例模式)
设计模式
单例模式
单例模式的两种实现方式
饿汉模式
懒汉模式
单例对象的释放
懒汉模式与饿汉模式的优缺点
现代懒汉模式的写法
1. 请设计一个类,只能在堆上创建对象
实现方式:
- 1. 将类的构造函数私有,拷贝构造声明成私有。防止别人调用拷贝在栈上生成对象。
- 2. 提供一个静态的成员函数,在该静态成员函数中完成堆对象的创建
class HeapOnly
{
public:
static HeapOnly* CreateObj()
{
return new HeapOnly;
}
HeapOnly(const HeapOnly& ho) = delete; //防止通过拷贝构造开在栈上
private:
HeapOnly()
:_a(0)
{}
private:
int _a;
};
2. 请设计一个类,只能在栈上创建对象
目前看似杜绝在堆上创建出对象,但是我们却可以通过这样的方式在堆上创建出对象 。
我这里的new虽然没有调用构造函数,但是调用了拷贝构造,又因为获取对象的函数是传值返回,所以我们不能删除拷贝构造,但是我们却可以屏蔽new,因为new在底层调用void* operator new(size_t size)函数,只需将该函数屏蔽掉即可。 一个类可以重载它专属的operator new,没有重载之前调用的是全局的new,但是重载专属的operator new以后,调用的就是专属的operator new。
class StackOnly
{
public:
static StackOnly CreateObj()
{
return StackOnly(); //不能删除拷贝构造,因为这里是传值返回
}
void* operator new(size_t size)=delete;
void operator delete(void* p) = delete;
private:
StackOnly()
:_a(0)
{}
private:
int _a;
};
3.请设计一个类,不能被拷贝
C++98
- 将拷贝构造函数与赋值运算符重载只声明不定义,并且将其访问权限设置为私有即可。
class CopyBan
{
// ...
private:
CopyBan(const CopyBan&);
CopyBan& operator=(const CopyBan&);
//...
};
原因:
- 1. 设置成私有:如果只声明没有设置成private,用户自己如果在类外定义了,就可以不能禁止拷贝了
- 2. 只声明不定义:不定义是因为该函数根本不会调用,定义了其实也没有什么意义,不写反而还简单,而且如果定义了就不会防止成员函数内部拷贝了。
C++11
- C++11扩展delete的用法,delete除了释放new申请的资源外,如果在默认成员函数后跟上=delete,表示让编译器删除掉该默认成员函数。
class CopyBan
{
// ...
CopyBan(const CopyBan&)=delete;
CopyBan& operator=(const CopyBan&)=delete;
//...
};
4. 请设计一个类,不能被继承
C++98
// C++98中构造函数私有化,派生类中调不到基类的构造函数。则无法继承
class NonInherit
{
public:
static NonInherit GetInstance()
{
return NonInherit();
}
private:
NonInherit()
{}
};
C++11
class A NonInherit
{
// ....
};
5. 请设计一个类,只能创建一个对象(单例模式)
设计模式
设计模式(Design Pattern)是一套被反复使用、多数人知晓的、经过分类的、代码设计经验的总结。为什么会产生设计模式这样的东西呢?就像人类历史发展会产生兵法。最开始部落之间打仗时都是人拼人的对砍。后来春秋战国时期,七国之间经常打仗,就发现打仗也是有套路的,后来孙子就总结出了《孙子兵法》。孙子兵法也是类似。
使用设计模式的目的:为了代码可重用性、让代码更容易被他人理解、保证代码可靠性。 设计模式使代码编写真正工程化;设计模式是软件工程的基石脉络,如同大厦的结构一样。
迭代器就是一种设计模式,被广泛的用于数据结构中。这个数据结构可能是个数组,图,哈希表,二叉树等等,如果要去访问它的话要把数据结构的底层全部都暴露出来,但是C++是不希望暴露结构的,暴露的缺点在于:1.访问不方便(需要熟悉底层结构) 2.暴露底层结构,别人直接访问修改数据,不方便管理。所以就出现了迭代器模式,好处在于:1.统一方式封装访问结构。底层结构不暴露。2.可以使用统一的方式轻松访问容器,不关心底层是树,还是链表等。
单例模式
一个类只能创建一个对象,即单例模式,该模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息,这种方式简化了在复杂环境下的配置管理。
我们直接从实例入手,对于下面的这段代码(我们是采取分成三个部分写的,快排的定义放在一个文件里,实现放在一个文件里,最终的调用也放在一个文件里),我要进行统计快速排序递归调用次数。我们一般的思路就是定义一个全局变量。每次递归,这个全局变量就++,最终拿到这个全局变量就是一共递归调用的次数。
Singleton.h
#pragma once
#include<iostream>
#include<time.h>
using namespace std;
int callCount = 0; //统计次数的全局变量
// 统计快速排序递归调用次数
void QuickSort(int* a, int left, int right);
Singleton.cpp
#include"Singleton.h"
void QuickSort(int* a, int left, int right)
{
++callCount;
if (left >= right)
{
return;
}
int keyi = left;
int prev = left;
int cur = prev + 1;
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
swap(a[cur], a[prev]);
}
++cur;
}
swap(a[prev], a[keyi]);
keyi = prev;
// [left, keyi-1]keyi[keyi+1, right]
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
test.cpp
#include"Singleton.h"
void TestOP()
{
srand(time(0));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
}
int begin5 = clock();
QuickSort(a1, 0, N - 1);
int end5 = clock();
printf("QuickSort:%d\n", end5 - begin5);
printf("QuickSort Call Time:%d\n", callCount);
free(a1);
}
int main()
{
TestOP();
return 0;
}
但是对这段代码进行编译的时候,会有报错
为什么会报一个全局变量callCount重定义的错误呢?
Singleton.h中有一个callCount的定义,Singleton.h被Singleton.cpp和test.cpp都所包含。.h文件在.cpp文件中展开,.cpp会生成两个.obj(linux下叫做.o)然后这两个.obj中都有一个叫做callCount的变量。编译器链接的时候是都要包这些东西合在一起的,Singleton.h有callCount的定义,Singleton.cpp有一个callCount的变量,test.cpp中也有一个叫callCount的变量,这就会导致合并在一起的时候就冲突了,同一个域中不能有同名的变量,所以就会有重定义的错误。总之就是因为全局变量在多个文件中都可见就导致了这个问题。
static关键字
static这个关键字就可以解决这个问题,普通的全局变量在多个文件都可见(在Singleton.obj和test.obj的符号表里面都有,符号表里面除了有函数名还有变量。当别人要用这个变量的时候不知道是用你Singleton.obj的还是用test.obj的)。而静态全局变量只在当前文件可见,static除了会改变生命周期,还会影响变量和函数的链接属性,让他只在当前文件可见(意思就是Singleton.cpp和test.cpp中都有一个callCount的变量,但是Singleton.cpp中的callCount只在Singleton.cpp可见,test.cpp中的callCount只在test.cpp可见)这样链接的时候就不会看见他们冲突,因为他们只在内部可见。
但是也并没有真正的解决掉整个问题,我们发现最终的callCount是0。这是因为只在当前文件可见,导致 Singleton.cpp和test.cpp中的callCount并不是同一个。
我们通过打印地址发现并不是同一个
extern关键字
用extern关键字,我们在Singleton.h是定义,要做到把定义和声明分离就要有extern关键字。我们把声明放在一个.h中,定义也只放在一个.cpp中
这个时候只有Singleton.cpp生成的Singleton.obj中有这个变量,test.obj中是没有这个变量的,test.cpp包着Singleton.h只有callCount的声明,只有声明的话编译器是可以编过的,只是我不知道这个callCount的地址,我在链接的时候就可以访问到这个变量了。extern就告诉test.cpp说这个callCount变量是有的,是全局的,但是它在其他的.obj中定义的,你链接的时候自己去找吧,所以链接的时候除了要找这个QuickSort函数(因为他也是只有声明)的地址还要找这个callCount的地址,这个时候就可以把callCount这个变量访问到。
这个时候就可以统计出递归调用的次数了。
ps:我们可以通过小区间优化,优化一下递归调用的次数,当排序的数据量变小用插排
Singleton.cpp
#include"Singleton.h"
int callCount = 0; //然后我们在这个cpp里面放上定义
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; ++i)
{
// x[0, end]
int end = i;
int x = a[end + 1];
while (end >= 0)
{
if (a[end] > x)
{
a[end + 1] = a[end];
--end;
}
else
{
break;
}
}
a[end + 1] = x;
}
}
void QuickSort(int* a, int left, int right)
{
if (callCount == 0)
{
cout << "Singleton.cpp中的callCount的地址" << &callCount << endl;
}
++callCount;
if (left >= right)
{
return;
}
if (right - left + 1 > 10)
{
int keyi = left;
int prev = left;
int cur = prev + 1;
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
swap(a[cur], a[prev]);
}
++cur;
}
swap(a[prev], a[keyi]);
keyi = prev;
// [left, keyi-1]keyi[keyi+1, right]
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
else
{
InsertSort(a + left, right - left + 1);
}
}
递归次数明显减少
单例模式的两种实现方式
假设我不仅要把全局变量保存下来,我还要有一个vector,要求把递归调用的区间也存储下来。你就又需要向之前一样一边声明一边定义。这个时候就可以用面向对象的思想来解决这个问题。
我们需要全局有一个变量或者对象,这个对象里面有一些信息,这个信息是全局的,并且全局只有唯一一份,且提供一个访问它的全局访问点 。我们就可以设计一个单例模式。
把需要统计的放在一个类里面,这个时候就不需要加extern,因为类里面的变量天生就是声明,类对象才是定义。我们同时不期望别人随便创建对象,我们就可以把构造函数私有化,并且我们还要提供一个让你获取对象的方式,并且这个对象是唯一的一个对象。这个时候我们就可以采用懒汉和饿汉两种方式获取。
饿汉模式
- 就是说,我已经把饭提前准备好了,我随时可以吃;在main函数之前就创建好了单例对象,程序随时可以访问这个单例对象
- 如果这个单例对象在多线程高并发环境下频繁使用,性能要求较高,那么显然使用饿汉模式来避免资源竞争,提高响应速度更好。
这个_inst对象目前只有声明,它的定义可以放在Singleton.h,但是这样的话就又有之前全局变量的问题,Singleton.cpp和test.cpp就各有一份了,所以我们最好在Singleton.cpp中定义。
这样的话每次调用GetInstance都是调用的_inst这个对象,并且都是同一个对象。
接下来统计递归调用的次数
因为这里涉及到类成员变量的++,一种方式就是把成员变量设置成public,一种就是通过提供成员函数间接++。博主这里选择增加成员函数进行++
test.cpp则用成员函数获取总次数
运行结果:
这个地方用类去封装,用一个单例的类是非常的好用的,避免我们统计的信息只有唯一一份。为了防止恶意拷贝,我们就可以把拷贝构造封死
//单例模式--任意一个类都可以设计成单例模式
//饿汉模式
class CallInfo
{
public:
static CallInfo& GetInstance()
{
return _inst;
}
int GetCallCount()
{
return _callCount;
}
void AddCallCount(int n)
{
_callCount += n;
}
void Push(const pair<int, int>& kv)
{
_v.push_back(kv);
}
CallInfo(const CallInfo& cl) = delete;
private:
CallInfo()
:_callCount(0)
{}
private:
int _callCount; //统计次数
vector<pair<int, int>> _v; //记录区间信息
static CallInfo _inst; //类里面定义的变量都是声明
//构造函数虽然是私有的,但是静态的变量是可以调用构造函数初始化的,因为它的作用域是类里面
//它也是类的成员,只不过是在类外定义的而已。
};
PS:在类里面定义对象,如果是普通的对象是不可以定义的,因为这就成了递归;但如果是静态的就没问题,因为静态的就相当于是全局的,就是你这个类定义好了,然后我又定义了一个全局的,这个对象属于类整体,相当于你就是定义了一个全局的变量,只是它的作用域被限定在这个类域里面,且在类里面只是一个声明和你定义一个全局的变量没有任何区别。
官方来讲:因为声明的静态数据成员不占用对象的内存。但是普通的数据成员在初始化呢的时候,首先要为对象分配内存,然后里面又有一个类,一直持续下去就不行,会无限迭代的分配内存。当然,引用和指针是可以的。引用和指针的大小是固定的。
//例如:
class A
{
private:
int i;
static A a;
}
//A 的大小其实只是 i 的大小。
懒汉模式
- 事先没有准备好,只有第一次访问的时候,才创建单例对象
- 如果单例对象构造十分耗时或者占用很多资源,比如加载插件啊, 初始化网络连接啊,读取文件啊等等,而有可能该对象程序运行时不会用到,那么也要在程序一开始就进行初始化,就会导致程序启动时非常的缓慢。 所以这种情况使用懒汉模式(延迟加载)更好。
//懒汉模式
class CallInfo
{
public:
static CallInfo& GetInstance()
{
if (_pInst == nullptr)
{
_pInst = new CallInfo;
}
return *_pInst;
}
int GetCallCount()
{
return _callCount;
}
void AddCallCount(int n)
{
_callCount += n;
}
void Push(const pair<int, int>& kv)
{
_v.push_back(kv);
}
CallInfo(const CallInfo& cl) = delete;
private:
CallInfo()
:_callCount(0)
{}
private:
int _callCount; //统计次数
vector<pair<int, int>> _v; //记录区间信息
static CallInfo* _pInst; //类里面定义的变量都是声明
};
这就是懒汉模式,懒汉就是创建一个指针,静态指针执行的这个对象保证全局唯一,保证每次进来获取的都是那个唯一的对象。
但是当前写的懒汉模式的GetInstance是存在大问题的,是存在线程安全问题的。
如果你有一个t1和一个t2,t1和t2都调用GetInstance,导致统计的次数不准确。假设t1和t2都走到62行,t1和t2走到这里都是空指针,t1走到这时间片到了,t2先new,new完后,返回这个对象,然后去调用++,把次数加到1。然后t2的时间片到了,t1开始运行,虽然此时的指针已经不为空了,但是t1不会判断(因为一个线程回来的时候是从它切出去的那一行开始运行的),然后t1在new一个,返回。t2在来的时候发现不为空,但是获取到的是t1刚刚new的,所以次数又从0次开始,t2之前统计的次数就丢失了。而且后面new出来的还会把前面new出来的覆盖掉会存在内存泄露的问题。
eg:
我们可以通过加锁解决:
PS:用静态的锁一方面是要保证两个线程使用同一把锁,另外一方面就是静态成员函数只能访问静态成员变量,不能访问普通成员变量
为什么不能在静态成员函数中使用非静态变量?
所谓静态就是程序编译好以后,就已经给它分配了内存区域,它一直在那里,所谓动态就是运行时候临时分配内存的变量。
程序最终都将在内存中执行,变量只有在内存中占有一席之地时才能被访问。因为静态是针对类的,而成员变量为对象所有。
静态成员函数不属于任何一个类对象,没有this指针,而非静态成员必须随类对象的产生而产生,所以静态成员函数”看不见”非静态成员,自然也就不能访问了
类的静态成员(变量和方法)属于类本身,在类加载的时候就会分配内存,可以通过类名直接去访问;非静态成员(变量和方法)属于类的对象,所以只有在类的对象产生(创建类的实例)时才会分配内存,然后通过类的对象(实例)去访问。
在一个类的静态成员中去访问其非静态成员之所以会出错是因为在类的非静态成员不存在的时候类的静态成员就已经存在了,访问一个内存中不存在的东西当然会出错。
C++会区分两种类型的成员函数:静态成员函数和非静态成员函数。这两者之间的一个重大区别是,静态成员函数不接受隐含的this自变量。所以,它就无法访问自己类的非静态成员。
当前就可以保证不会发生线程安全的问题了。但是我们还可以进一步去优化,加锁是为了保护第一次获取对象。只要对象创建出来以后,就没有线程安全的问题,现在这种写法,后面每次获取对象都要进行加锁,就会影响效率。
所以我们可以采用双检查加锁
这个时候,单例对象创建好了以后,获取单例对象的时候,就不用每次进行加锁解锁。
单例对象的释放
一般情况下,单例对象是不需要释放的,不用担心它内存泄露的问题,因为它是进程堆上的资源,通常也不大,main函数结束以后,进程就销毁了,进程结束后的资源都会还给系统。但是你非要释放的话也是可以的、
1.直接主动的去提供一个释放的接口,但是这样做并不常见
2.提供一个内部类进行回收
这个gc对象出了作用域就会去调用它的析构函数,这个析构函数就会把new的对象带走。
一般懒汉的单例对象,不需要回收,因为进程正常结束,资源都会还给系统,这个对象只有一个,系统自动回收也没什么问题,但是如果你在单例对象释放析构时,有一些要完成的动作,比如要记录日志等等。那么可以考虑搞一个类似下面的回收类帮助去完成这个事情。
懒汉模式与饿汉模式的优缺点
饿汉没有线程安全的问题,因为它在main函数之前就准备好了,main函数之前是没有多线程竞争的问题的。
饿汉优点:简单。缺点:无法控制单例创建初始化顺序(假设两个单例类A,B,要求A单例先创建,B单例后创建,B的创建依赖A。饿汉是无法实现这样的需求的);如果单例对象初始化很费时间,会导致程序启动慢,就像卡死一样。
懒汉优点:对应饿汉的两个缺点。缺点:1.相对复杂,尤其是还要控制线程安全的问题,
现代懒汉模式的写法
class CallInfo
{
public:
static CallInfo& GetInstance()
{
static CallInfo sInst;
return sInst;
}
int GetCallCount()
{
return _callCount;
}
void AddCallCount(int n)
{
_callCount += n;
}
void Push(const pair<int, int>& kv)
{
_v.push_back(kv);
}
CallInfo(const CallInfo& cl) = delete;
private:
CallInfo()
:_callCount(0)
{
cout << "CallInfo()" << endl;
}
private:
int _callCount; //统计次数
vector<pair<int, int>> _v; //记录区间信息
};
对于这样的写法,我在GetInstance中,先创建了一个局部的静态对象,对于局部的静态对象,只有第一个调用它的人会进行初始化,后面的人都不会初始化,直接返回对象,并且每次都返回同一个,局部的静态对象生命周期属于全局,但是它的作用域只在GetInstance中。
但是这样的写法在C++98中,多线程调用的时候,静态的对象的构造初始化并不能保证线程安全,C++11优化了这个问题,C++11中,静态对象的构造初始化是线程安全的。
所以双加锁的懒汉模式是在任何环境下都安全的,其次这种双加锁的好处是如果new出来的对象很大,new出来的对象在堆上,堆就很大,如果把这个对象放在数据段上相对而言没那么好。
设计模式的扩展:工厂模式,观察者模式