对不起,来晚了,御姐趣讲设计模式

御姐力作,深入浅出,妙趣横生,值得一看!

## 引言

你好,欢迎来到设计模式的世界,这一篇我将用一种引导、启迪的思路去讲述设计模式。在程序员的世界里,设计模式就相当于武侠世界的剑招、套路。掌握了招式,你的武学修为会得到极大提升,最终达到无招胜有招的境界。

+ 首先,我会告诉大家设计模式是什么,不是什么。

+ 然后,简单介绍一下设计模式的分类,简单罗列一下各设计模式。

+ 接着,阐述面向对象设计一个非常重要的设计原则:**合成复用原则**,它是核心原则,提高复用一直是软件工程师的不懈追求,它贯穿于设计模式一书。

+ 最后,从实用出发,我会详细描述两个最经典最常用的设计模式:单例和观察者。我不只是介绍这两种模式的用途和实现方式,还会结合自己工作实践,抛出限制与约束,提醒注意点,以及跟其他模式的配合方式。

希望你学完这一节,可以触类旁通,在实际项目中用好设计模式,为社会做贡献。

## 什么是设计模式

一门工程一定会有很多实践性的经验总结。就好比造大桥,人们会总结拱桥有哪些部件组成,有什么特点,有什么适用场合,悬索桥又有什么部件、特点、使用场合。这些从实践中提炼出来的建筑模式又可以指导新出现的需求,比如去设计一个某市长江大桥,你会思考有哪个成熟的模式可以适用,在这个模式下,又要如何根据实际需求定制化地设计各个部件。

软件工程也是如此。

设计模式是设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案,是被反复使用,多数人知晓的,经过分类编目的代码设计经验的总结。

+ 设计模式是一般问题的解决方案。分析多种多样的具体需求,常常会发现结构上和行为上具有的共性,常常会产生相似的设计。设计模式是脱离了具体需求的,某类共性问题的解决方案。

+ 设计模式是程序设计的经验总结。在其适用范围内正确地使用设计模式通常会产生高质量的设计。

+ 设计模式弥补了编程语言的缺陷。设计模式实现了创建时多态、双重分派等在主流编程语言中不直接提供的功能。反过来,近年来设计思想和设计模式的发展也影响了新兴语言的语言规范。

+ 设计模式是软件工程师的一套术语。完整地描述一个设计通常要花费相当的篇幅,通过对设计归类,可以便于快速表达设计的特点。

## 设计模式不是什么

+ 不是普适原则。设计模式并不是如SOLID设计原则一样是放之四海而皆准的普适的原则。每个设计模式都有其适用场景,必须根据实际情况分析决定采用哪种设计模式或不使用设计模式。在一个软件项目中设计模式并不是用得越多越好,符合实际需求的高质量的独特设计也是好设计。

+ 不是严格规范。设计模式是经验的总结,允许根据实际需要改变和改进。采用了设计模式并不意味着类的结构甚至命名都要与模式严格符合。在应用设计模式时应着重吸取其设计思路,根据实际需求进行设计。尤其是很多设计模式中的名称过于宽泛,在实际项目中并不适合用作类名。

+ 不是具体类库。设计模式有助于代码复用,但模式本身并不是可直接复用的代码。在设计模式中担任特定角色的并不是特定的一个类,通常需要在具体设计中结合具体需求来实现。现代编程语言中的模板、泛型等语言特性有助于写出更加通用的代码,但对于很多设计模式,完全通用的代码库既难实现,又难使用。

+ 不是行业解决方案。并没有说哪个模式特别适合互联网、哪个模式专门针对自动化。设计模式关注软件结构内在的共性,而与具体的业务领域无关。

有工程师言必称设计模式,生搬硬套设计模式,之后又出现反设计模式的思潮,认为设计模式是骗局,无助于软件质量提升。我认为,无论是神化设计模式亦或是反设计模式都是走极端,都是错误的。设计模式为我们解决一些通用性的问题提供了良好借鉴,且在大多数情况下,行之有效。设计模式并不绝对通用,在实际项目中如何抉择用哪个设计模式或是不用设计模式,非常考验工程师的水平和经验。

## GOF设计模式

设计模式的流行源于一本叫《设计模式:可复用面向对象软件的基础》的书,这本书的作者是4个博士,也叫GOF(Gang of Four),软件设计模式一词由作者从建筑设计领域引入计算机科学。

书中介绍了 23 种设计模式。这些模式可以分为三大类:

+ 创建型模式:单例、原型、工厂方法、抽象工厂、建造者

+ 结构型模式:代理、适配、桥接、装饰、外观、享元、组合

+ 行为型模式:模板方法、策略、命令、职责链、状态、观察者、中介者、迭代器、访问者、备忘录、解释器

## 合成复用原则

对于软件复用来说,组合优于继承,在软件复用时,优先考虑组合关系,其次才考虑继承关系。

面向对象设计的特点之一是继承,子类包含父类的所有属性和方法,因此一个很自然的想法是为了复用父类的代码而继承。但是实践发现,用继承关系来实现软件的复用有很多缺点,一般来说更为合理的方式是,用多个对象的组合关系来实现复用。

+ 继承关系是子类“是一个”父类的关系,但如果是为了复用父类的已有功能来实现子类的新功能,常常会违反里氏替换原则。

+ 组合关系更容易处理有多个可复用模块的情况。多重继承会导致结构复杂不易维护。

+ 组合关系更灵活易扩展,只要使用适当的设计模式,使用者和被使用者都可被修改、扩展、替换。

+ 组合关系可以提供运行时的灵活性。可以在运行时指定一个模块的底层实现,或者运行时替换一个对象的内部实现。

为了体现它的重要性,这里我们看一个具体的例子。

我们知道,队列是一种先进先出的数据结构。在队列上可以执行添加和删除一个元素的操作,添加元素称为入队,删除元素称为出队,并且元素出队的顺序与入队的顺序相同。显然,队列可以用双向链表来实现,那么,我们要不要把队列设计成双向链表的子类呢?

咋一看,可以让queue私有继承list,隐藏掉list所有的方法,然后实现队列的push方法调用list的push_pack方法,队列的pop方法调用list的pop_front方法。非常简单直接。

但是,这种实现方式是有问题的。到底啥问题?一言两语也讲不清楚,你自己想去吧。

因此,C++和Java的标准库都没有采用这种继承的方式实现队列。

在C++的stl中,queue被设计成一个容器适配器。只要是是实现了push_back、pop_front的容器,都可以作为queue的底层容器。stl中就提供了2种可以套用queue的容器,是list和deque。list就是双向链表。deque的实现是数组指针的数组,与list相比减少了内存分配的次数。

在JDK中,Queue是一个interface,实现了Queue接口的有LinkedList、ArrayDeque、ConcurrentLinkedQueue、LinkedBlockingQueue等许多具体类。

为了体现它的重要性,这里我将用一个实例来加深你对它的印象。如果设计一个网络组件库,HttpConnection应该继承TcpConnection吗?

HttpConnection不再能够提供符合TcpConnection的功能,不能当作TcpConnection使用。考虑read方法,若直接暴露TcpConnection的read方法,则破坏内部结构;若提供基于HTTP协议的read方法,又无法做到功能跟父类一致。

Http协议能够使用不同的下层协议,例如TCPv6。继承自TcpConnection就失去了这种扩展性。

如果设计另一个类"HttpOverTcp6Connection",会导致二者有大量的重复代码,而这些代码恰恰是实现HTTP协议本身的功能,应复用为好。

如果希望一个程序在IPv4和IPv6网络下都可使用,需要做很多的工作来实现在运行时(而非编译时)根据配置文件或用户输入选择HttpConnection或HttpOverTcp6Connection。

继承关系表达类的对外提供的功能,而非类的内部实现。Java中HttpURLConnection继承URLConnection,与之并列的是JarURLConnection,二者都提供了根据URL建立连接并通信的功能。

**下面以2个常用的设计模式为例,说明它们的应用场景和应用价值,让大家有一个比较直观具体的感受。**

## 单例模式

单例模式是指,某个类负责创建自己的对象,同时确保只能创建单个对象。单例模式最简单的设计模式,也是最容易用错的设计模式。

### 如何实现单例模式

单例模式非常简单,这个模式中只包含一个类。实现单例模式的重点是管理单例实例的创建。

+ C++,可以通过static局部变量的方式,也可以通过static指针成员变量的条件创建方式做到(即每次GetInstance的时候判空,如果为空则new,否则直接返回)。Java可以用static指针成员变量的方式。

+ 通常为了避免使用者错误创建多余的对象,单例的构造函数和析构函数声明为私有函数。

+ 多线程环境下,创建单例的代码需要谨慎处理并发的问题。一般做法是双重检查加锁(即每次判空的时候先判空一次,如果为空则加锁再次判空)。C++的静态局部变量可以保证线程安全,java要使用synchronized实现。

+ 多种单例,如果有依赖关系,需要仔细处理构建顺序。C++的静态局部变量在程序首次运行到变量声明处时执行其构造函数。Java的静态变量初始化发生在类被加载时。

### 单例模式的好处

+ 使用简单,任何需要用到类实例的地方,直接用类的GetInstance()方法就便利的获取到实例。

+ 可以避免使用全局变量,让开发者有更好的OOP感,且可以让程序员更好地控制初始化顺序。

+ 它隐藏了对象的构建细节,且能避免多次构建引起的错误。

### 单例模式的探讨

从原则上说,一个类应努力提供它应有的功能,而不应对它的使用者做出过多限制。而单例模式限制这个类的对象只存在唯一实例。因此单例模式只应在确有必要的情况下使用:

+ 技术上必须保证此对象全局唯一,例如代表应用本身、对象管理器、全局服务等。

+ 程序中多处依赖此对象,采用单例模式能使代码得到极大简化,例如全局配置选项。

避免根据一时的具体需求将某类设计为单例,而极大地限制了可扩展性。例如一个选课系统如果把学校信息设计为单例,将来想要支持跨校选课时就比较困难。

尤其注意,一旦某个类设计为单例,就会形成在程序各处随意地引用这个对象的一种倾向。这正是单例模式的便利之处,但如果并不希望一个类有如此广泛的耦合关系,则应避免将其设计为单例。

此外,由这种便利性会引发更不利的倾向。在未经仔细设计的系统中,随着需求变更和系统演进,单例类可能会无节制地扩展,包含各种难以归类的数据成员和各个模块的中转方法。

### 替代方案

通常有以下方法可以避免使用单例模式:

+ 元模式。例如Android SDK使用activity.getApplication() ,避免“Application.getSingleton() ”。这样取得Application实例并不像单例模式那么方便,从而限制了Application的耦合性。而通过Activity获取Application是符合逻辑的设计,大多数真正需要用到Application的场合并不影响使用。

+ 静态方法。例如Unity引擎的物体查询接口是GameObject.Find(name) ,而不是由比如“GameObjectManager”的单例类提供。静态方法只提供单一的功能,并且调用时的写法比单例模式更加简洁。但须注意,只有逻辑上与某个类有紧密联系的功能才适合作为静态方法。静态方法如果滥用,会导致软件结构实际上变成了面向过程的设计。

## 观察者模式

观察者模式,当一个对象发生改变时,把这种改变通知给其他多个对象,从而影响其他对象的行为。又称订阅模式、事件模式等。

### 观察者模式的组成

观察者模式中包含两个角色:

+ 被观察者,它维护观察者列表,并在自身发生改变时通知观察者。也可称为发布者、事件源等。

+ 观察者,它将自身注册到被观察者维护的观察者列表,并在接收到被观察者的通知时做出响应。观察者也称订阅者。

### 如何实现观察者模式

被观察者的接口应包含3个方法:增加观察者、删除观察者、向观察者发送通知。其中,增加观察者、删除观察者通常由观察者调用,用于表明哪些观察者对象需要得到通知。发送通知方法通常由被观察者调用,因此可以考虑定义为protected方法。发送通知方法应遍历自身的观察者列表,逐一调用观察者的接收通知方法。这3个方法功能较为明确,可以用抽象类、模板、泛型等技术提供通用实现。

观察者的接口需要提供接收通知方法,以供被观察者调用。不同的具体观察者类型实现各自的接收通知方法,实现当被观察者发生改变时,观察者应做出的响应。

由于观察者接口只有一个方法,在C#语言中deligate来代替,在C++中可以用std::function代替,这样进一步解耦了不同类型的观察者,其不必派生自同一个公共接口。当然,当系统中的观察者的确有所联系时,则不应该过度追求解耦,显式定义一个观察者接口或抽象类可以使结构更为清晰、严谨。

观察者模式常常与命令模式配合使用。命令模式是,将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开。采用命令模式,将通知或事件封装成对象,可以使观察者和被观察者之间进一步解耦。例如,如果不希望在被观察者的运行过程中穿插执行观察者的函数,则可以保存命令稍后执行。

### 观察者模式的特点和适用场景

每种设计模式都有其最适合的应用场景,如果正确使用,可以帮助理清复杂的耦合关系,简化设计。但如果在不合适的场景中生搬硬套,则会把原本简单的事情搞复杂,并不能真正解决需求。观察者模式也不例外,在实际项目中,必须具体问题具体分析,考察需求是否符合观察者模式的特点,决定是否选用观察者模式。

+ 观察者模式适合一对多的关联关系。一个被观察者可以有零个或多个观察者。当然,一个程序中被观察者可以有多个,每个被观察者都有自己的一对多关系,而相互之间没有关联。

+ 逻辑上的依赖关系是单向的。被观察者往往可以独立运行,并不依赖观察者。而观察者的顺利运行依赖于被观察者的推动,离开被观察者就运行不起来了。

+ 调用关系与逻辑关系是反向的。逻辑上被观察者不依赖观察者,但有事件发生时却是被观察者调用了观察者的方法。

下面我们用一个例子来看如何应用观察者模式来解决具体的需求,以及使用观察者模式带来的好处。

我们假设需求是这样:某个应用程序中有多处要用到定时执行的功能,就是到一个固定的时间需要执行一个特定的函数。很自然,多处要用到的功能应该提炼出来作为一个子模块。但另一方面,我们又不希望这个定时模块与每一个用到了定时功能的其他模块都有很强的耦合。

观察者模式可以帮助我们设计定时模块,既能服用,又有低耦合性。这里我们的示例实现如下。为了突出展示观察者模式,我对需求做了一定简化,我们的定时模块固定在每天上午9点触发,不支持自定义时间。

+ [C++语言实现AlarmClock](AlarmClock.c++)

#include <iostream>
#include <list>


//简单闹钟,每天早上9点响
class AlarmClock {
    public:
    class Alarm {
    public:
        virtual ~Alarm() {}
        virtual void onClockAlarmed() = 0;
    };
    
    private:
    static const int TimeZone = 8; // 北京时间东8区
    static const int AlarmHour = 9;
    
    std::list<Alarm*> alarms;
    time_t tomorrow;
    
    public:
    
    AlarmClock() {
        //将tomorrow设置为明天9点钟
        time_t now = time(0);
        tomorrow = now - now % 86400 - TimeZone * 3600 + AlarmHour * 3600;
        if (tomorrow < now)
            tomorrow += 86400;
    }
    
    AlarmClock(AlarmClock&) = delete;


    void setAlarm(Alarm* alarm) {
        alarms.push_back(alarm);
    }
    
    void unsetAlarm(Alarm* alarm) {
        alarms.remove(alarm);
    }
    
    void advance() {
        tomorrow += 86400;
        for (auto alarm : alarms) {
            alarm->onClockAlarmed();
        }
    }
    
    void update(time_t now) {
        while (now >= tomorrow) {
            advance();
        }
    }
};


// 资深程序员张三
class TestZhangSan : public AlarmClock::Alarm {
    public:
    ~TestZhangSan() {}
    TestZhangSan(AlarmClock& clock) {
        clock.setAlarm(this);
    }
    
    // 开始了996的一天
    void onClockAlarmed() {
        std::cout << "Zhang San is going to work..." << std::endl;
    }
};


// 隔壁上夜班的王叔叔
class TestLaoWang : public AlarmClock::Alarm {
    public:
    ~TestLaoWang(){}
    TestLaoWang(AlarmClock& clock) {
        clock.setAlarm(this);
    }
    
    // 下班回家睡觉
    void onClockAlarmed() {
        std::cout << "Lao Wang is going to bed..." << std::endl;
    }
};


int main(int argc, char **argv)
{
    AlarmClock clock;
    TestZhangSan zhang(clock);
    TestLaoWang wang(clock);
    time_t now = time(0);
    now -= now % 3600;
    for (int i = 0; i < 24; i++) {
        std::cout << "Now:" << ctime(&now);
        clock.update(now);
        now += 3600;
    }
    return 0;
}

+ [Java语言实现AlarmClock](AlarmClock.java)

import java.util.Calendar;
import java.util.List;
import java.util.LinkedList;


//简单闹钟,每天早上9点响
public class AlarmClock {
    public static interface Alarm {
        void onClockAlarmed();
    }


    private static final int AlarmHour = 9;
    
    private final List<Alarm*> alarms = new LinkedList<>();
    private Calendar tomorrow;
    
    public AlarmClock() {
        //将tomorrow设置为明天9点钟
        tomorrow = Calendar.getInstance();
        boolean addDay = tomorrow.get(Calendar.HOUR_OF_DAY) >= AlarmHour;
        tomorrow.set(Calendar.HOUR_OF_DAY, AlarmHour);
        tomorrow.set(Calendar.MINUTE, 0);
        tomorrow.set(Calendar.SECOND, 0);
        tomorrow.set(Calendar.MILLISECOND, 0);
        if (addDay) {
            tomorrow.add(Calendar.DAY_OF_MONTH, 1);
        }
    }


    public void setAlarm(Alarm alarm) {
        alarms.add(alarm);
    }
    
    public void unsetAlarm(Alarm alarm) {
        alarms.remove(alarm);
    }
    
    public void advance() {
        tomorrow += 86400;
        for (Alarm alarm : alarms) {
            alarm.onClockAlarmed();
        }
    }
    
    public void update(Calendar now) {
        while (now >= tomorrow) {
            advance();
        }
    }


    // 资深程序员张三
    private class TestZhangSan : public Alarm {
        public:
        TestZhangSan(AlarmClock& clock) {
            clock.setAlarm(this);
        }
        
        // 开始了996的一天
        public void onClockAlarmed() {
            System.out.println("Zhang San is going to work...");
        }
    }


    // 隔壁上夜班的老王
    private class TestLaoWang : public Alarm {
        public TestLaoWang(AlarmClock& clock) {
            clock.setAlarm(this);
        }
        
       // 下班回家睡觉
        public void onClockAlarmed() {
            System.out.println("Lao Wang is going to bed...");
        }
    }


    public static void main(String []args){
        AlarmClock clock = new AlarmClock();
        TestZhangSan zhang = new TestZhangSan(clock);
        TestLaoWang wang = new TestLaoWang(clock);
        Calendar now = Calendar.getInstance();
        now.set(Calendar.MINUTE, 0);
        now.set(Calendar.SECOND, 0);
        now.set(Calendar.MILLISECOND, 0);
        //假装时间经过了24小时
        for (int i = 0; i < 24; i++) {
            System.out.println("Now:" + now.getTime());
            clock.update(now);
            now.add(Calendar.HOUR_OF_DAY, 1);
        }
    }
}

在这个例子中,AlarmClock类是被观察者,Alarm接口及其具体子类是观察者。按照观察者模式,被观察者AlarmClock维护了它的观察者的列表。当时间进行到新一天的早晨,AlarmClock的状态发生变化,也就是产生了一个事件,这时AlarmClock调用每个Alarm的方法。这样,Alarm的具体子类对象,即每个希望定时执行的模块,就能够在正确的时间得到执行。

由于采用了观察者模式,AlarmClock与其它模块之间只通过Alarm接口交互,AlarmClock只引用Alarm,而不需要关心每个Alarm到底是哪个具体类,也不关心调用Alarm后究竟会执行哪些操作。如果Alarm的具体子类需要修改,我们并不需要修改AlarmClock类。如果有新的模块需要用到定时功能,只需要让新模块实现Alarm接口即可。这就是观察者模式降低耦合性的作用。

因为这个例子中被观察者只有一个,因此被观察者的抽象接口被省略了。并且我们没有使用Observer、Subject等非常宽泛的名字,而是结合实际情况,观察目标就是具体类AlarmClock类,观察者被称为Alarm。这样使得整个设计非常自然,没有生搬硬套设计模式的痕迹,哪怕是没有学过设计模式的人也能够看懂。这就是在具体应用设计模式时常常应该做的剪裁和调整。

需要指出,这个例子是为了能够清晰演示观察者模式而专门假设的场景。你可以尝试把例子进行扩展。如果希望支持为每个Alarm指定不同的执行时间,应如何设计?如果张三多件事情需要分别定时执行,又应如何设计?

在实际项目中,业务需求一定会更为复杂,工程师需要在复杂需求中识别出在哪里使用哪种设计模式能够带来好处,这是需要锻炼提升的能力。实际项目的设计也会根据需求做出更多的调整,多一些类或少一些类,常常看起来跟最初学习设计模式时看到的很不一样。因此学习设计模式重在掌握思想,不能生搬硬套。无招胜有招。

## 总结

短短一篇文章,想要讲清设计模式的所有内容几乎是不可能完成的任务,所以我没有逐一讲解,而是结合我自己工作中遇到过的问题,来带你重新认识设计模式,为你树立它的重要性的观念,避免陷入细节泥潭,欢乐的时间过太快,又是时候说拜拜,最后,恭喜大家,你已经掌握了设计模式,去干一番对人类有益的事业吧。

本篇由御姐供稿,版权和解释权归御姐所有,文章内容代表御姐意见,本农夫自媒体对文章观点不持立场,喜欢的话就关注点赞转发吧,“码砖杂役”,你身边的土味砖家。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
下面是一个基于分治思想的求解n元素数组中最大元素的位置的算法的伪代码: ``` def find_max_index(array, start, end): if start == end: return start else: mid = (start + end) // 2 left_max_index = find_max_index(array, start, mid) right_max_index = find_max_index(array, mid+1, end) if array[left_max_index] >= array[right_max_index]: return left_max_index else: return right_max_index ``` 该算法的基本思想是将数组分成两个部分,分别递归求解每个子数组的最大元素,然后将两个子数组的最大元素进行比较,返回较大者的下标作为整个数组的最大元素的下标。 对于问题1,如果数组中的若干个元素都具有最大值,该算法的输出是不确定的,因为在比较过程中可能会选择其中一个最大值,而不是所有最大值。 对于问题2,建立该算法的键值比较次数的递推关系式如下: - 当n=1时,比较次数为0。 - 当n>1时,假设左右子数组的长度分别为n1和n2,则总比较次数为T(n) = T(n1) + T(n2) + 1。因为每次比较都可以将问题规模减半,所以n1和n2的最大值为n/2,因此有n1 + n2 = n,代入上式得到T(n) = T(n/2) + T(n/2) + 1 = 2T(n/2) + 1。 根据主定理,可以得到该算法的时间复杂度为O(nlogn)。 对于问题3,该分治算法的比较次数较蛮力算法要少,因为每次将问题规模减半,因此总比较次数为O(nlogn),而蛮力算法的总比较次数为O(n)。但是,该分治算法需要递归调用函数,因此需要额外的空间来存储每个递归调用的返回值,空间复杂度为O(logn),而蛮力算法的空间复杂度为O(1)。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值