【C++面试】设计模式:手写单例模式与工厂模式

一、设计模式到底是在解决什么问题

设计模式并不是某种:

复制代码
固定语法

也不是:

复制代码
必须照抄的代码模板

它更像是:

针对某类重复出现的软件设计问题,总结出来的一套常见解决思路。

例如:

复制代码
程序中只希望存在一个配置管理对象

可以考虑:

复制代码
单例模式
Singleton

如果:

复制代码
程序需要根据不同条件创建不同对象

可以考虑:

复制代码
工厂模式
Factory

例如:

复制代码
创建不同日志对象

FileLogger
ConsoleLogger
NetworkLogger

如果业务代码直接:

复制代码
FileLogger logger;

那么业务代码就和:

复制代码
FileLogger

绑定得比较死。

如果以后换成:

复制代码
NetworkLogger

很多地方可能都需要修改。

工厂模式的思路就是:

复制代码
业务代码
   ↓
只告诉工厂
"我要什么类型"
   ↓
工厂负责创建对象

所以:

复制代码
单例模式
   ↓
控制对象数量

工厂模式
   ↓
控制对象创建方式

这两种模式解决的是完全不同的问题。

二、单例模式:整个程序只创建一个对象

单例模式的核心要求很简单:

复制代码
1. 构造函数不能让外部随便调用
2. 整个程序只能有一个实例
3. 提供统一接口获取这个实例

例如配置管理器:

复制代码
ConfigManager config1;
ConfigManager config2;
ConfigManager config3;

如果每个地方都创建一个:

复制代码
ConfigManager

可能造成:

复制代码
配置状态不一致
重复加载文件
浪费资源

所以希望整个程序共享:

复制代码
唯一一个ConfigManager

最基本的写法:

复制代码
class Singleton {
private:
    Singleton() {}

public:
    Singleton(const Singleton &) = delete;
    Singleton &operator=(const Singleton &) = delete;

    static Singleton &getInstance() {
        static Singleton instance;
        return instance;
    }
};

使用:

复制代码
Singleton &s1 = Singleton::getInstance();
Singleton &s2 = Singleton::getInstance();

此时:

复制代码
s1
 ↓
Singleton实例
 ↑
s2

两个引用实际上指向:

复制代码
同一个对象

这里最关键的是:

复制代码
private:
    Singleton() {}

把构造函数设为:

复制代码
private

这样外部不能:

复制代码
Singleton s;

直接创建对象。

同时:

复制代码
Singleton(const Singleton &) = delete;
Singleton &operator=(const Singleton &) = delete;

禁止:

复制代码
拷贝
赋值

否则可能通过:

复制代码
Singleton s2 = Singleton::getInstance();

再复制出新的对象。

真正负责创建实例的是:

复制代码
static Singleton &getInstance() {
    static Singleton instance;
    return instance;
}

这里:

复制代码
static Singleton instance;

是:

复制代码
函数内部静态对象

第一次调用:

复制代码
getInstance()

时创建。

以后再次调用:

复制代码
不会重新创建

而是直接返回:

复制代码
同一个instance

这种写法通常叫:

复制代码
Meyers Singleton

也是现代 C++ 中比较推荐的单例实现方式。

从 C++11 开始,函数局部静态变量的初始化具有线程安全保证,因此多个线程第一次同时调用:

复制代码
getInstance()

时,不需要自己再额外写双重检查锁来保证初始化只发生一次。

完整代码:

复制代码
#include <iostream>

class Singleton {
private:
    Singleton() {
        std::cout << "Singleton构造" << std::endl;
    }

    ~Singleton() {
        std::cout << "Singleton析构" << std::endl;
    }

public:
    Singleton(const Singleton &) = delete;
    Singleton &operator=(const Singleton &) = delete;

    static Singleton &getInstance() {
        static Singleton instance;
        return instance;
    }

    void show() {
        std::cout << "Singleton::show()" << std::endl;
    }
};

int main() {
    Singleton &s1 = Singleton::getInstance();
    Singleton &s2 = Singleton::getInstance();

    s1.show();

    std::cout << &s1 << std::endl;
    std::cout << &s2 << std::endl;

    return 0;
}

运行时可以看到:

复制代码
s1地址 == s2地址

说明两次获取的是:

复制代码
同一个对象

整个过程可以理解成:

复制代码
第一次getInstance()
        ↓
创建static instance
        ↓
返回instance

第二次getInstance()
        ↓
instance已经存在
        ↓
直接返回原对象

三、懒汉式、饿汉式和双重检查怎么理解

单例面试经常会继续问:

复制代码
懒汉式
饿汉式

有什么区别。

1. 懒汉式

所谓懒汉式就是:

复制代码
需要的时候才创建

例如:

复制代码
static Singleton &getInstance() {
    static Singleton instance;
    return instance;
}

第一次调用:

复制代码
创建对象

如果程序从来没有使用单例:

复制代码
对象就不会创建

所以叫:

复制代码
懒加载

2. 饿汉式

饿汉式的思路:

复制代码
程序开始时就把对象创建好

例如:

复制代码
class Singleton {
private:
    Singleton() {}

    static Singleton instance_;

public:
    static Singleton &getInstance() {
        return instance_;
    }
};

Singleton Singleton::instance_;

程序进入主要业务逻辑之前:

复制代码
instance_

通常已经完成初始化。

可以理解成:

复制代码
饿汉式
   ↓
提前创建

懒汉式
   ↓
第一次使用时创建

3. 双重检查锁

以前还经常看到:

复制代码
if (instance == nullptr) {
    std::lock_guard<std::mutex> lock(mutex);

    if (instance == nullptr) {
        instance = new Singleton;
    }
}

也就是:

复制代码
Double-Checked Locking
双重检查锁

为什么检查两次?

第一次:

复制代码
if (instance == nullptr)

是为了:

复制代码
实例已经创建以后
避免每次都加锁

第二次:

复制代码
加锁以后再判断一次

是为了防止:

复制代码
多个线程同时通过第一次判断

例如:

复制代码
线程A:instance == nullptr

线程B:instance == nullptr

线程 A 先拿锁创建对象。

线程 B 随后拿到锁时必须重新判断:

复制代码
instance已经不为空

就不能再创建第二个实例。

不过现代 C++ 面试中,如果只是让写线程安全单例,通常优先写:

复制代码
static Singleton &getInstance() {
    static Singleton instance;
    return instance;
}

更加简单,而且从 C++11 开始局部静态变量初始化有线程安全保证。

所以面试时可以这样回答:

现代 C++ 中我更倾向使用 Meyers Singleton,通过函数内部的局部静态对象实现懒加载。C++11 起局部静态变量初始化是线程安全的,同时还能利用对象自动析构管理生命周期,比手写双重检查锁更加简单。

四、工厂模式:把对象创建过程封装起来

再来看工厂模式。

假设现在有:

复制代码
圆形
矩形
三角形

不同图形。

先定义统一接口:

复制代码
class Shape {
public:
    virtual ~Shape() = default;
    virtual void draw() = 0;
};

然后分别实现:

复制代码
class Circle : public Shape {
public:
    void draw() override {
        std::cout << "Draw Circle" << std::endl;
    }
};

class Rectangle : public Shape {
public:
    void draw() override {
        std::cout << "Draw Rectangle" << std::endl;
    }
};

如果没有工厂模式,业务代码可能:

复制代码
Circle circle;
circle.draw();

或者:

复制代码
Rectangle rectangle;
rectangle.draw();

这样业务代码必须知道:

复制代码
到底应该new哪个具体类

如果使用工厂:

复制代码
class ShapeFactory {
public:
    static std::unique_ptr<Shape> create(const std::string &type) {
        if (type == "circle") return std::make_unique<Circle>();
        if (type == "rectangle") return std::make_unique<Rectangle>();

        return nullptr;
    }
};

业务代码变成:

复制代码
auto shape = ShapeFactory::create("circle");

if (shape) {
    shape->draw();
}

业务层只需要告诉工厂:

复制代码
我要circle

至于:

复制代码
Circle怎么构造
Rectangle怎么构造

由工厂负责。

整个流程:

复制代码
业务代码
   ↓
ShapeFactory::create("circle")
   ↓
Factory判断类型
   ↓
创建Circle
   ↓
返回Shape指针
   ↓
业务代码调用draw()

完整代码:

复制代码
#include <iostream>
#include <memory>
#include <string>

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

class Circle : public Shape {
public:
    void draw() override {
        std::cout << "Draw Circle" << std::endl;
    }
};

class Rectangle : public Shape {
public:
    void draw() override {
        std::cout << "Draw Rectangle" << std::endl;
    }
};

class ShapeFactory {
public:
    static std::unique_ptr<Shape> create(const std::string &type) {
        if (type == "circle") return std::make_unique<Circle>();
        if (type == "rectangle") return std::make_unique<Rectangle>();

        return nullptr;
    }
};

int main() {
    auto shape1 = ShapeFactory::create("circle");
    auto shape2 = ShapeFactory::create("rectangle");

    if (shape1) shape1->draw();
    if (shape2) shape2->draw();

    return 0;
}

这里工厂返回:

复制代码
std::unique_ptr<Shape>

而不是:

复制代码
Circle *
Rectangle *

因为我们希望业务代码依赖:

复制代码
抽象接口Shape

而不是依赖具体实现。

所以:

复制代码
Circle
Rectangle

都可以通过:

复制代码
Shape*

进行多态调用。

这也正好和前面写过的:

复制代码
虚函数
多态
智能指针
RAII

这些知识串起来了。

五、单例模式和工厂模式面试怎么回答

首先最常见的问题:

单例模式适合什么场景?

一般适合:

复制代码
程序中逻辑上只需要一个实例

例如:

复制代码
配置管理器

日志管理器

全局资源管理器

某些线程池管理器

但是不要看到:

复制代码
全局使用

就全部使用单例。

因为单例本质上带有:

复制代码
全局状态

使用过多可能导致:

复制代码
模块耦合增加
测试困难
依赖关系隐藏

所以应该根据实际设计选择,而不是所有管理类都写成单例。


第二个问题:

为什么单例构造函数要private?

因为如果构造函数是:

复制代码
public

外部可以:

复制代码
Singleton a;
Singleton b;

这样就根本无法保证:

复制代码
只有一个实例

所以构造函数必须限制外部直接调用。

同时一般还需要:

复制代码
Singleton(const Singleton &) = delete;
Singleton &operator=(const Singleton &) = delete;

避免通过拷贝生成新的实例。


第三个问题:

怎么写线程安全单例?

现代 C++ 中可以直接写:

复制代码
static Singleton &getInstance() {
    static Singleton instance;
    return instance;
}

面试中可以回答:

从 C++11 开始,函数局部静态变量的初始化具有线程安全保证,因此 Meyers Singleton 是一种非常简单的线程安全懒加载实现。


第四个问题:

工厂模式最大的作用是什么?

可以回答:

工厂模式把对象的创建过程封装起来,调用者只依赖抽象接口,不直接依赖具体类。这样增加或替换具体实现时,可以减少业务代码对具体类型的耦合。

例如:

复制代码
没有工厂:

业务代码
   ↓
new Circle

变成:

复制代码
有工厂:

业务代码
   ↓
Factory
   ↓
具体对象

也就是:

复制代码
对象创建逻辑

从业务代码中被抽离出来。


第五个问题:

简单工厂和工厂方法有什么区别?

本文写的是:

复制代码
简单工厂

一个工厂:

复制代码
ShapeFactory

负责根据条件创建:

复制代码
Circle
Rectangle

如果类型越来越多:

复制代码
if (...)
else if (...)
else if (...)

工厂本身会越来越大。

工厂方法模式则进一步:

复制代码
不同产品
由不同工厂负责创建

例如:

复制代码
ShapeFactory
     ↑
     │
┌────┴─────┐
↓          ↓
CircleFactory
RectangleFactory

其中:

复制代码
CircleFactory

只负责:

复制代码
Circle

而:

复制代码
RectangleFactory

只负责:

复制代码
Rectangle

不过面试中如果只是要求:

手写一个简单工厂

那么写本文这种:

复制代码
ShapeFactory::create(type)

已经能够体现工厂模式最核心的思想。


最后把两种模式放在一起理解:

复制代码
单例模式
    ↓
重点控制
"对象有几个"
    ↓
整个程序只有一个实例

工厂模式
    ↓
重点控制
"对象怎么创建"
    ↓
调用者不直接依赖具体类

如果面试官问:

单例模式怎么实现?

可以回答:

单例模式需要限制外部创建对象,因此构造函数设为私有,同时禁止拷贝构造和拷贝赋值,再提供静态接口获取唯一实例。现代 C++ 中常用函数局部静态变量实现 Meyers Singleton,从 C++11 开始其初始化具有线程安全保证。

如果问:

工厂模式解决什么问题?

可以回答:

工厂模式主要用于封装对象创建逻辑,让业务层面向抽象接口编程,而不是直接 new 某个具体类,从而降低对象创建逻辑和业务逻辑之间的耦合。

这两种设计模式真正需要掌握的并不是把代码背下来,而是先知道:

复制代码
为什么需要它
      ↓
它解决了什么问题
      ↓
什么时候值得使用

这样在实际项目或者面试设计题中,才能根据场景选择合适的模式。

0voice · GitHub

相关推荐
估值探索者42 分钟前
【Python量化系统工程实战 #01】数据存储选型 CSVSQLiteMySQL 对比与 SQLite 实战建库
开发语言·jvm·python·sqlite·api接口·数据api接口·股票数据api接口
无名猿1 小时前
shared_ptr 的线程安全边界:计数安全不等于对象安全
c++·内存管理·并发编程·现代c++
根目录下的猫1 小时前
ROS 2 C++(rclcpp)版本,实现话题订阅、话题发布,以及完整编译运行流程
开发语言·c++·算法
无名猿1 小时前
shared_mutex 读写锁该不该用,以及 volatile 的三大误解
c++·性能优化·并发编程·现代c++
сокол1 小时前
【Python-基础-环境搭建】Ubuntu Python 开发环境搭建:编译、pipenv、pyenv、远程连接
开发语言·python·ubuntu
大侠归来3 小时前
C 语言 | 在函数中操作数组
c语言·c++·算法
liangshanbo12153 小时前
JavaScript 面试题:Object.keys() 的返回顺序是无序的吗?
开发语言·javascript·ecmascript
朝朝辞暮i9 小时前
C++ 第 23 课:class —— 开始真正进入面向对象
开发语言·c++·算法
小师兄吃牛肉10 小时前
C语言篇:宏
c语言·开发语言