观察者模式

观察者模式

观察者模式

某对象一旦执行某操作,一个或多个其他对象就执行各自的某种行为,我们将某对象称为被观察者,其他对象称为观察者。直觉的实现方式是,让观察者轮询被观察者。但是这种实现会让程序在大部分时间都处于空转状态,消耗了大量的资源。高效的方式是让被观察者执行某些操作时,主动通知观察者对象,让他们执行相应的行为。这种方式直觉的实现是,把所有的观察者对象都添加到被观察者的成员列表中,但这造成了不同类之间的依赖和耦合。依赖倒置原则(Dependence Inversion Principle,DIP)告诉我们,程序要依赖于抽象接口,而不是依赖于具体实现。所以这个问题就变成了,如何对观察者和被观察者之间的关系进行抽象。

观察者模式就是在符合设计原则下对这种关系进行的抽象建模。观察者模式从事件的依赖关系进行抽象,观察者和被观察者的具体实现在这种抽象里消失了,就像在阶级叙事中A公司和B公司的工人没什么区别一样。在观察者模式中,这种依赖关系完全由观察者接口Observer和被观察者抽象类Subject所描述。被观察者继承Subject抽象类,观察者实现Observer接口。【接口是一组方法声明,任何实现该接口的类都必须提供这些方法的具体实现。接口不包含任何实现代码,只有方法签名。抽象类是不能直接实例化的类,通常包含一个或多个抽象方法(不提供实现的函数)以及一些具体的方法(有实现的函数),允许状态保存。】

在观察者模式中,核心就是被观察者维护一个观察者列表并实现notify()方法,观察者实现update()方法。每当被观察者执行某些操作需要通知观察者时,都会调用notify()方法,在notify()方法中调用所有观察者的update()方法。被观察者也可以注册新的观察者对象或删去旧的观察者对象。这样,虽然体现出来是观察者注视着被观察者的行为执行相应的操作,实际上是被观察者主动通知它的观察者们实现的。

classDiagram
    class Subject {
        <>
        + attach(observer: Observer)
        + detach(observer: Observer)
        + notify()
        - observers: List~Observer~
    }
    
    class ConcreteSubject {
        + getState(): int
        + setState(state: int)
        - state: int
    }
    
    class Observer {
        <>
        + update()
    }
    
    class ConcreteObserver {
        + update()
        - subject: ConcreteSubject
        - observerState: int
    }
    
    Subject o-- Observer : aggregates
    Subject <|-- ConcreteSubject
    Observer <|.. ConcreteObserver
    Subject --> Observer : notifies
    ConcreteObserver --> ConcreteSubject : observes

其他

UML图

UML,全称Unified Modeling Language,标准建模语言,可以使用于说明各类复杂系统的结构和关系。其中UML类图可以描述代码中各个类的关系。其中每个方框代表一个类,方框上半部分显示类的名称,下半部分显示类的属性和方法。在方法中,-代表私有方法, +代表公有方法,#代表保护方法。有些方框的上面还会有<<...>>修饰,这表示这个类是一个接口或者抽象类。比如在上面的UML图中,Observer类就是一个接口。

在UML图中,用线和箭头去描述类之间的关系,常见的有泛化、实现、关联、依赖、聚合、组合等。

undefined

泛化表示类的继承关系(is-a)。狗是动物,DogAnimal的子类,那么它们之间就是泛化关系:

classDiagram
    Animal <|-- Dog

实现表示类实现了接口的行为(can-do)。狗可以跑,Dog实现了Runnable的接口,那么他们之间就是实现关系:

classDiagram
    Runnable <|.. Dog

关联表示类之间的长期关系(has-a)。狗有一个主人,DogOwner之间有一个关联关系:

classDiagram
    class Dog {
        +bark()
    }
    
    class Owner {
        +name
    }
    
    Dog --> Owner : has

依赖表示类与类之间的临时使用关系(uses-a)。狗用球来玩耍,Dog使用Ball

classDiagram
    class Dog {
        +bark()
        +fetchBall(ball: Ball)
    }
    
    class Ball {
        +roll()
    }
    
    Dog ..> Ball : uses

聚合表示类之间的部分-整体关系,部分可以独立存在(whole-part)。假设有一个 Pack 类,它表示一群狗的集合,每只狗都属于一个群体,但即使群体不存在,狗仍然可以独立存在。

classDiagram
    class Pack {
        +leader
    }
    
    class Dog {
        +bark()
    }
    
    Pack o-- Dog : contains

合表示类之间的强依赖关系,部分不能独立存在(strong whole-part)。 Dog 类有一个 Heart 类,表示狗的心脏。心脏不能脱离狗独立存在,所以这是组合关系。

classDiagram
    class Dog {
        +bark()
    }
    
    class Heart {
        +beat()
    }
    
    Dog *-- Heart : contains

所以再去分析观察者模式的UML类图,Subject是一个抽象类,ConcreteSubject是这个抽象类的一个具体实现。Observer是被观察者的接口,ConcreteObserver具体实现了这个接口。Subject类包含多个实现了Observer抽象接口的成员变量,且观察者对象可以独立存在,因此二者之间为聚合关系。Subjectnotify()方法需要调用Observerupdate()方法,因此存在依赖关系。此外,ConcreteObserver通常需要根据ConcreteSubject的状态及具体行为,决定如何执行update()操作,因此这里也存在一个依赖关系。

编程语言的执行流程

编程语言按照执行机制来分类,以我目前知识储备来看大致可以分成三类,一类是像C/C++这种纯编译的,一类是像Python/Shell这类纯解释的,中间的是C#/Java这样的,一半编译一半解释。下面具体说明一下这三类语言的执行流程。


对于C语言这种典型的编译型语言,代码在运行之前需要完全编译成机器码。编译结果是二进制文件,可以直接由操作系统加载并执行。这种方式的优点是运行效率高,因为它直接转换为机器可执行的代码。但是编译后的程序只能在特定类型的操作系统和硬件上运行,不同平台需要各自编译,程序的可移植性受限。具体执行流程主要包括:

预处理:处理源代码文件中以#开头的预处理指令,如#include#define等,生成一个扩展了所有宏定义和包含指令的“纯”源代码文件。

编译:将预处理后的源代码转换成汇编语言,生成相应的.s文件,包含汇编语言代码。

汇编:将汇编语言代码转换为机器语言的目标代码(通常是二进制格式),生成.o.obj格式的目标文件。

链接:将一个或多个目标文件与库文件一起合并,解析各种符号引用,生成.exe(在Windows平台)或可执行格式(在Unix-like系统如Linux)程序。


Python和Shell这类都是解释型语言,因为它的代码在运行时由解释器直接逐行执行。比如Python代码实际上是Python.exe(在Windows平台)执行的,Shell代码也由各类Shell解释器执行的,比如/bin/sh, /bin/bash, /bin/csh等等。Python的标准实现是CPython,它将Python代码先编译成字节码,然后由Python虚拟机执行这些字节码。虽然这个过程包括了一个“编译”步骤,但这种编译是到字节码而不是直接到机器码,执行仍然需要解释器。这种实现的好处是同一套代码可以在任何有相应解释器的平台上运行,而且代码在运行时可以被修改,支持动态类型。此外,还易于进行环境管理。具体执行流程主要包括:

编译:Python解释器首先将源代码编译成字节码(Bytecode)。这一过程涉及语法分析和编译,但不涉及机器语言,生成与平台无关的.pyc文件。

解释执行:Python解释器(或Python虚拟机)执行字节码。在这个过程中,字节码逐条解释执行。

但是这整个过程都是在背后自动进行的,用户不需要手动编译和解释执行,用户看到的就是Python.exe执行了.py文件。


Java结合了编译型语言和解释型语言的特点。Java源代码首先被编译成Java字节码(中间代码),这一过程由Java编译器(javac)完成。然后,字节码可以在任何安装了Java虚拟机(JVM)的平台上运行。在运行时,JVM可以通过解释执行字节码或者进一步将字节码编译成机器码(即所谓的即时编译,JIT编译)来执行,这使得Java程序运行效率大大提高。通过“编写一次,到处运行”(WORA)的方式,Java代码在不同平台上都能运行。此外,通过JIT编译器在运行时将字节码转换为机器码,提高了执行效率。具体执行流程主要包括:

编译:Java编译器(javac)将.java源文件编译为Java字节码(.class文件)。这些字节码是平台无关的中间代码。

解释执行:Java字节码在Java虚拟机(JVM)上运行,即采用java.exe运行。JVM执行字节码时有两种方式:解释执行:JVM逐条解释执行字节码;即时编译(JIT):为提高性能,JVM将热点代码(经常执行的代码)编译成本地机器代码。


所以从上面的讨论可以看到,Java系列工具分成两块,一块是编译工具,一块是解释执行工具。

JDK(Java Development Kit)是开发 Java 程序必不可少的工具包,提供了开发 Java 应用程序所需的所有工具。它包括:

  • 编译器(javac):将 Java 源代码编译成字节码。
  • 调试工具、文档生成工具(javadoc)、打包工具(jar)等。
  • JRE(Java Runtime Environment):用于运行 Java 程序。

其中JRE(Java Runtime Environment)是 Java 运行时环境,包含运行 Java 程序所需的所有库和类。JRE 主要用于运行已经编译好的 Java 程序,而不是用于开发。JRE 包括以下组件:

  • JVM(Java Virtual Machine):Java 虚拟机,是执行 Java 字节码的核心组件,负责将字节码翻译成机器代码,以便在不同的平台上运行。
  • 核心类库:Java API 提供了各种库,用于开发 Java 程序,如数据结构、IO、网络、GUI 等。
  • Java 类加载器:负责加载 Java 类文件到内存中,并执行它们。

Java基础

Java和其他编程语言很不一样的地方是,所有的Java代码都必须写在类中,不支持全局函数或像Python一样在顶级作用域中直接运行代码,所有的函数(在Java中称为方法)和变量都必须存在于类的上下文中。如果类不加任何关键字,则默认具有包访问级别。在Java中,一个包提供了一个全局的命名空间。类的访问级别和类中各个方法可以有不同的访问级别,类方法的默认访问级别也是包。在 Java 中,包的结构是严格基于目录结构的,一个文件夹内的Java文件被认为是在一个包中。如果在 mypackage 下有一个子文件夹,那么子文件夹内的 Java 文件不会动属于 mypackage 包,除非在这些文件中明确指定包的完整名称。

1
2
3
4
5
src/
└── mypackage/
├── MyClass.java
└── subpackage/
└── MySubClass.java

可以通过public关键字来将某一个类声明为公共的,此时这个类可以被任何其他类访问。要导入这个公共类,可以使用 import 语句导入这个类。每个Java文件最多有一个类被声明公共的,且文件名必须和公共类完全一致。比如公共类名为MyClass,那么源文件就应该相应命名为MyClass.java。此外,公共类中可以定义一个主函数(main方法),作为程序的程序的入口点,用于启动应用程序。main方法必须是publicstatic的。public确保 main 方法可以从任何其他类、包或程序中被访问,以确保 JVM能够在任何环境下无障碍地访问并调用它。static关键字用于表示类的某个成员(变量、方法、块或内部类)属于类本身,而不属于类的某个特定实例。main方法必须是static的,因为它是程序的入口点。如果main方法不是static的,JVM就需要创建其所属类的实例,这不符合Java程序入口点的设计规范。

把上面的内容都合并起来,我们可以创建下面这个小例子。我们编写两个Java文件,其中一个是 HelloWorld 类,它包含一个打印 "Hello World" 的方法;另一个是 MainApplication 类,它的 main 方法调用 HelloWorld 类的方法。

1
2
3
src/
├── HelloWorld.java
└── MainApplication.java
1
2
3
4
5
6
7
8
9
10
11
12
13
// HelloWorld.java
public class HelloWorld {
public static void sayHello() {
System.out.println("Hello World");
}
}

// MainApplication.java
public class MainApplication {
public static void main(String[] args) {
HelloWorld.sayHello(); // 调用 HelloWorld 的 sayHello 方法
}
}

首先,我们通过javac编译这两个Java文件

1
2
javac HelloWorld.java
javac MainApplication.java

之后,运行MainApplication就可以获得"Hello World"的输出。

1
java MainApplication

Java的一个主要特点是接口。在Java中,接口中的方法默认是公开的,并且是抽象的,实现接口的类需要提供这些方法的具体实现。可以简单地改造原始示例来说明接口的使用。我们定义一个接口,这个接口声明一个方法用于输出文本。然后,让 HelloWorld 类实现这个接口,并在 MainApplication 中调用这个方法。新建一个接口Speakable

1
2
3
4
// Speakable.java
public interface Speakable {
void say();
}

HelloWorld类实现Speakable接口并提供say方法的实现,关键字 implements 表示它实现了 Speakable 接口。注意尽管接口本身可以包含静态方法,但这些方法是作为接口的一部分提供的,并不是由实现类来实现的。实现接口的类必须提供非静态方法的具体实现,这些方法符合接口定义的实例方法契约。也就是说实现接口的方法不能声明为 static

1
2
3
4
5
6
// HelloWorld.java
public class HelloWorld implements Speakable {
public void say() {
System.out.println("Hello World");
}
}

之后,可以在MainApplication中创建一个HelloWorld实例对象来调用它的say方法,这里使用接口作为引用类型,来满足DIP实现程序运行时多态。

1
2
3
4
5
6
7
// MainApplication.java
public class MainApplication {
public static void main(String[] args) {
Speakable speaker = new HelloWorld();
speaker.say();
}
}

Reference

  1. https://design-patterns.readthedocs.io/zh-cn/latest/read_uml.html
  2. https://zh.wikipedia.org/wiki/%E9%A1%9E%E5%88%A5%E5%9C%96
  3. https://refactoringguru.cn/design-patterns/observer
  4. https://www.runoob.com/design-pattern/observer-pattern.html