程序员阿沛
发布于 2026-06-27 / 0 阅读
0
0

面向对象和设计模式十七树形结构的组合模式 和 节省内存的享元模式

面向对象和设计模式(十七)树形结构的组合模式 和 节省内存的享元模式

01 组合模式

组合模式可以将一组对象组织成树形结构,以表示一种“部分 -
整体”的层次结构。因此,组合模式是一种用于树形结构数据的设计模式。组合模式跟之前讲的面向对象设计中的“组合关系(通过组合来组装两个类)”是两回事。

相比于前面说的几种设计模式,组合模式是一种用的比较少的模式,数据或者说业务对象必须能表示成树形结构才能用到组合模式,比如用来表示目录结构、部门子部门和员工、订单与下游采购单/出库单/入库单等这样的场景、产品一级二级三级分组等。

下面直接上例子,假设有个需求:设计一个类来表示文件系统中的目录,能方便地实现下面这些功能:

1.动态地添加、删除某个目录下的子目录或文件;

2.统计指定目录下的文件个数;

3.统计指定目录下的文件总大小。

<?php

class FileSystemNode { private $path; private $isFile; // 是否为文件 private $subNodes = []; // 当前节点的子节点 private $father; // 当前节点的父节点 private $fileNum ; // 当前文件下的文件个数,包含自身 private $fileSize; // 当前文件大小,对于空目录而言为0 public function __construct($path, $isFile) { $this->path = $path; $this->isFile = $isFile; $this->fileNum = 1; $this->fileSize = $this->isFile ? getFileSize($this->path) : 0; } protected function setFather(FileSystemNode $father){ $this->father = $father; $this->refreshFather($this->countSizeOfFiles(), $this->countNumOfFiles()); }
protected function removeFather(){ $this->refreshFather(-$this->countSizeOfFiles(), -$this->countNumOfFiles()); $this->father = null; }
// 更新父节点的文件数量和文件大小 public function refreshFather($changeSize, $changeNum){ if(!$this->father){ return; } $this->father->setFileSize($this->father->countSizeOfFiles() + $changeSize); $this->father->setFileNum($this->father->countNumOfFiles() + $changeNum);
$this->father->refreshFather($changeSize, $changeNum); // 递归 }
public function setFileNum($num){ $this->fileNum = $num; }
public function setFileSize($size){ $this->fileSize = $size; }
public function countNumOfFiles() { return $this->isFile ? $this->fileNum : $this->fileNum-1; } public function countSizeOfFiles() { return $this->fileSize; } public function getPath() { return $this->path; } public function addSubNode(FileSystemNode $fileOrDir) { $this->subNodes[] = $fileOrDir; $fileOrDir->setFather($this); } public function removeSubNode(FileSystemNode $fileOrDir) { foreach($this->subNodes as $i => $subNode){ if($subNode->getPath() == $fileOrDir->getPath()){ unset($this->subNodes[$i]); $fileOrDir->removeFather(); break; } } } // 遍历本目录下所有的文件路径(这里使用树的前序遍历) public function iterate(){ echo $this->getPath() . “\n”; foreach($this->subNodes as $subNode){ $subNode->iterate(); } } }

从上面的例子来看,只要满足以下条件就算符合组合模式:

1、类中包含一个数组属性,该数组中存放着多个和本类属于同一实例的其他实例,表示子节点。

2、类中的A方法需要委托子节点(或者父节点)调用子节点(或者父节点)的A方法,比如上述例子中的 iterate()方法。

组合模式的设计思路,与其说是一种设计模式,倒不如说是对业务场景的一种数据结构和算法的抽象。其中,数据可以表示成树这种数据结构,业务逻辑可以通过在树上的递归遍历算法来实现。

组合模式将一组对象组织成树形结构,将单个对象和组合对象都看做树中的节点,以统一处理逻辑,并且它利用树形结构的特点,递归地处理每个子树,依次简化代码实现。

使用组合模式的前提在于,你的业务场景必须能够表示成树形结构。所以,组合模式的应用场景也比较局限,它并不是一种很常用的设计模式。

02 享元模式概念

“享元”,顾名思义就是被共享的单元。享元模式的意图是复用对象节省内存,前提是享元对象是不可变对象。

我们将对象的信息分为两个部分: 内部状态和外部状态。

内部状态是对象内部共享的属性,不随环境的改变而改变。 外部状态不可以被共享的,会随环境改变而变化的属性。

享元模式具体来说就是当一个系统中存在大量相似对象的时候,可以将这些对象中相同且不变的部分(不可变的相同属性)提取出来,设计成享元类对象,让这些大量相似的对象引用这些享元对象。

这里所说的内部状态的属性是指通过构造函数初始化后,这些成员变量就不会再被修改了。

下面是享元模式的类图和角色如下(享元的英文就是FlyWeight,FlyWeight的原义是轻量的意思,可见享元模式在节省内存、轻量化对象的作用):

FlyWeight:享元类的接口,operation方法是一些操作外部状态属性的方法,当然外部状态属性需要作为参数传入operation中。

ConcreteFlyWeight:具体的享元类,实现接口FlyWeight,存储内部状态属性。

FlyWeightFactory:享元工厂,用来创建和管理享元对象,其主要思想就是用一个Map来保存已经创建的享元对象实例。它主要是用来确保合理的共享FlyWeight对象,当用户请求一个FlyWeight对象时(getFlyWeight()方法),工厂提供一个已经存在的FlyWeight实例,如果不存在则创建一个返回。享元工厂保证一个享元对象只会有一份从而节省内存。

下面我们举两个例子来看看享元模式如何使用。

象棋游戏中,我们可以抽象出两个对象:棋局和棋子。棋子的属性包括:棋子类型(将、相、士、炮、车、兵等)、棋子颜色(红方、黑方)、棋子在棋局中的位置。而棋局需要保存所有棋子对象。利用这些数据,我们就能显示一个完整的棋盘给玩家。

<?php  class ChessPiece {//棋子    private $id;        // 棋子id    private $text;    private $color;     // 棋子颜色    private $positionX;    private $positionY;
    const COLOR_RED = 1;    const COLOR_BLACK = 2;      public function __construct($id, $text, $color, $positionX, $positionY) {      $this->id = $id;      $this->text = $text;      $this->color = $color;      $this->positionX = $positionX;      $this->positionY = $positionY;    }    // ...省略其他属性和getter/setter方法...  }    class ChessBoard {//棋局    private $chessPieces = [];      public function __construct() {      $this->init();    }      private function init() {      $this->chessPieces[1] = new ChessPiece(1, "車", ChessPiece::COLOR_BLACK, 0, 0);      $this->chessPieces[2] = new ChessPiece(2,"馬", ChessPiece::COLOR_RED, 0, 1);      //...省略摆放其他棋子的代码...    }      public function move($chessPieceId, $toPositionX, $toPositionY) {      //...省略...    }  }

想想以前我们玩的QQ游戏,一个游戏厅里有成千上万个房间,一个房间有一个棋局,为了记录每个房间当前的棋局情况,我们需要给每个房间都创建一个
ChessBoard 棋局对象和几十个棋子对象,会消耗大量的内存。

此时可以用到享元模式将相似对象中相同且不变的属性提取出来,id、text、color 属性在不同的房间中是不变,唯独 positionX、positionY
不同。

因此可以将棋子的 id、text、color 属性拆分出来,设计成独立的类,并且作为享元供多个棋盘复用,棋盘只需记录每个棋子的位置信息就可以了。

具体的代码实现如下所示:

<?php

// 享元类 class ChessPieceUnit{ const COLOR_RED = 1; const COLOR_BLACK = 2; const CHESS_STYLE_MAP = [ 1 => [“text”=>“車”, “color” => self::COLOR_BLACK], 2 => [“text”=>“馬”, “color” => self::COLOR_RED], // …其他棋子的固定属性 ]; private $id; // 棋子id private $text; // 棋子类型 private $color; // 棋子颜色
public function __construct($id, $text, $color) { $this->id = $id; $this->text = $text; $this->color = $color; } }
class ChessPieceUnitFactory{ private static $piecesUnit = []; // 保存所有的棋子享元,并且所有棋局和棋子想获取享元对象只能通过该工厂类获取,而不能自己实例化ChessPieceUnit,否则是做不到一个享元是有一个实例的目的了
public static function get($id){ if(!isset(self::$piecesUnit[$id])){ $style = ChessPieceUnit::CHESS_STYPE_MAP[$id]; $piecesUnit[$id] = new ChessPieceUnit($id, $style[‘text’], $style[‘color’]); } return $piecesUnit[$id]; } }
class ChessPiece {//棋子 private $unit; private $positionX; private $positionY; public function __construct(ChessPieceUnit $unit, $positionX, $positionY) { $this->unit = $unit; $this->positionX = $positionX; $this->positionY = $positionY; } // …省略其他属性和getter/setter方法… } class ChessBoard {//棋局 private $chessPieces = []; public function __construct() { $this->init(); } private function init() { $this->chessPieces[1] = new ChessPiece(ChessPieceUnitFactory::get(1), 0, 0); $this->chessPieces[2] = new ChessPiece(ChessPieceUnitFactory::get(2), 0, 1); //…省略摆放其他棋子的代码… } public function move($chessPieceId, $toPositionX, $toPositionY) { //…省略… } }

它的代码实现非常简单,主要是通过工厂模式,用一个 Map 来缓存已经创建过的享元对象,来达到复用的目的。

我们再看第二个例子。

文本编辑器中,要在内存中表示一个文本文件,只需要记录文字和格式两部分信息就可以了,其中,格式又包括文字的字体、大小、颜色等信息。

文本文件中最小的组成单元是文字,一个文字就会包含属于自己的文字内容、字体、大小和颜色。但是按照正常的用户使用习惯,没有人会把一篇文章的所有文字设置成不同的格式。顶多就是标题是一个样式、正文是一个样式、正文中需要强调的内容加粗一下。

此时我们也可以使用享元模式,将相同样式的文字的样式作为享元对象存储起来。具体实现如下:

<?php

// 享元类class CharacterStyle { private $uniqueId; private $font; private $size; private $colorRGB; public function __construct(Font $font, $size, $colorRGB) { $this->font = $font; $this->size = $size; $this->colorRGB = $colorRGB; $this->uniqueId = $this->getUniqueId(); } // 判断传入的样式和本样式是否相同 protected function getUniqueId() { return md5(json_encode($this->font).$this->size.json_encode($this->colorRGB)); }
public function uniqueId(){ return $this->uniqueId; } } class CharacterStyleFactory { private static $styles = []; public static function getStyle(Font $font, $size, $colorRGB): CharacterStyle { $newStyle = new CharacterStyle($font, $size, $colorRGB); if(!isset(self::$styles[$newStyle->uniqueId()])){ self::$styles[$newStyle->uniqueId()] = $newStyle; }else{ $newStyle = self::$styles[$newStyle->uniqueId()]; } return $newStyle; } }
class Character { private $cnt; private $style; public function __construct($cnt, CharacterStyle $style) { $this->cnt = $cnt; $this->style = $style; } } class Editor { private $chars = []; public function appendCharacter($cnt, Font $font, $size, $colorRGB) { $character = new Character($cnt, CharacterStyleFactory::getStyle($font, $size, $colorRGB)); $this->chars[] = $character; } }

享元对象的优缺点

享元模式是一个非常简单的模式,
它可以大大减少应用程序创建的对象,减低程序内存的占用,增强程序的性能,但它同时也提高了系统复杂性,需要分离出外部状态和内部状态,
而且外部状态具有固化特性,不应该随内部状态改变而改变,否则导致系统的逻辑混乱。

享元对象与单例、缓存、对象池的区别

享元模式 VS 单例

在单例模式中,一个类只能创建一个对象,而在享元模式中,一个类可以创建多个对象,每个对象被多处代码引用共享。

实际上,享元模式有点类似于之前讲到的单例的变体——多例。

从设计意图上来看,享元模式和单例是完全不同的。应用享元模式是为了对象复用,单纯是为了节省内存;而应用单例/多例模式是因为某个实体对象在系统中只应该保存一份,顺便节省了内存。

享元模式 VS 缓存

在享元模式的实现中,我们通过工厂类来“缓存”已经创建好的对象。这里的“缓存”实际上是“存储”的意思,跟我们平时所说的“数据库缓存”“CPU
缓存”“MemCache 缓存”是两回事。

我们平时所讲的缓存,主要是为了提高访问效率,而非复用。

享元模式 VS 对象池

对象池、连接池(比如数据库连接池)、线程池等也是为了复用。

但池化技术中的“复用”可以理解为一种“独占式的重复使用”,主要目的是节省时间(比如从数据库池中取一个连接,不需要重新创建)。但在任意时刻,每一个对象、连接、线程,并不会在多个地方同时被使用,而是被一个使用者(线程、协程)独占(此时其他使用者无法占用这个连接),当使用完成之后,放回到池中,再由其他使用者重复利用。

享元模式中的“复用”可以理解为“共享使用”,在整个生命周期中,都是被所有使用者共享的,主要目的是节省空间。

故池化技术中的对象同一时刻只能被独占,其复用是为了节省初始化时间;而享元模式是同一时刻可被多个地方共享,其复用是为了节省内存。


评论