Golang Flyweight享元模式对象复用实践
在Go语言开发中,当我们频繁创建大量相似的对象时,会不可避免地带来内存占用过高、垃圾回收压力增大等问题。此时可以借助享元模式(Flyweight Pattern)的思想,通过共享已经创建的对象来减少对象创建数量,达到提升性能、降低资源消耗的目的。享元模式的核心就是将对象的状态拆分为内部状态(可共享、不随环境变化)和外部状态(不可共享、随环境变化),只缓存内部状态相同的对象,重复使用时直接获取缓存即可。
享元模式的适用场景
并不是所有场景都适合使用享元模式,通常在以下情况下可以考虑引入该模式:
- 系统中需要创建大量相似的对象,且这些对象的创建成本较高
- 对象的大部分状态是可以共享的内部状态,只有少部分状态是随场景变化的外部状态
- 对象的使用频率很高,重复创建会造成明显的资源浪费
比如在游戏开发中,大量的同类型小兵、子弹,或者业务系统中频繁使用的配置对象、连接对象等,都可以通过享元模式实现复用。
Go语言实现享元模式的示例
下面我们以游戏中的小兵对象为例,实现一个简单的享元模式对象池。小兵的类型、基础属性属于内部状态,可以共享;而小兵的当前位置、血量属于外部状态,需要跟随场景变化。
package main
import (
"fmt"
"sync"
)
// 享元对象:小兵的内部状态(可共享部分)
type SoldierFlyweight struct {
Type string // 小兵类型:步兵、弓兵、骑兵
Attack int // 基础攻击力
Defend int // 基础防御力
}
// 设置小兵的外部状态(位置、当前血量),外部状态不存储在享元对象中
type SoldierExternalState struct {
X int // x坐标
Y int // y坐标
HP int // 当前血量
}
// 享元工厂:负责管理享元对象的创建和缓存
type SoldierFactory struct {
pool map[string]*SoldierFlyweight
mu sync.RWMutex
}
// 单例获取享元工厂实例
var (
factoryInstance *SoldierFactory
factoryOnce sync.Once
)
func GetSoldierFactory() *SoldierFactory {
factoryOnce.Do(func() {
factoryInstance = &SoldierFactory{
pool: make(map[string]*SoldierFlyweight),
}
})
return factoryInstance
}
// 获取享元对象:如果缓存中存在则直接返回,不存在则创建后缓存
func (f *SoldierFactory) GetSoldier(soldierType string) *SoldierFlyweight {
f.mu.RLock()
flyweight, exists := f.pool[soldierType]
f.mu.RUnlock()
if exists {
return flyweight
}
// 不存在则创建新的享元对象,设置内部状态后缓存
f.mu.Lock()
defer f.mu.Unlock()
// 再次检查,避免并发创建重复对象
if old, ok := f.pool[soldierType]; ok {
return old
}
var newSoldier *SoldierFlyweight
switch soldierType {
case "步兵":
newSoldier = &SoldierFlyweight{
Type: "步兵",
Attack: 50,
Defend: 30,
}
case "弓兵":
newSoldier = &SoldierFlyweight{
Type: "弓兵",
Attack: 70,
Defend: 15,
}
case "骑兵":
newSoldier = &SoldierFlyweight{
Type: "骑兵",
Attack: 60,
Defend: 25,
}
default:
return nil
}
f.pool[soldierType] = newSoldier
fmt.Printf("创建新的小兵享元对象,类型:%s\n", soldierType)
return newSoldier
}
// 业务中使用的小兵对象,组合享元内部状态和外部状态
type Soldier struct {
flyweight *SoldierFlyweight
state SoldierExternalState
}
// 创建小兵:从工厂获取享元对象,搭配外部状态组成完整小兵
func NewSoldier(soldierType string, x, y, hp int) *Soldier {
factory := GetSoldierFactory()
flyweight := factory.GetSoldier(soldierType)
if flyweight == nil {
return nil
}
return &Soldier{
flyweight: flyweight,
state: SoldierExternalState{
X: x,
Y: y,
HP: hp,
},
}
}
// 小兵移动:修改外部状态
func (s *Soldier) Move(newX, newY int) {
s.state.X = newX
s.state.Y = newY
fmt.Printf("小兵类型:%s,移动到坐标(%d,%d)\n", s.flyweight.Type, newX, newY)
}
// 小兵受击:修改外部状态血量
func (s *Soldier) UnderAttack(damage int) {
s.state.HP -= damage
if s.state.HP < 0 {
s.state.HP = 0
}
fmt.Printf("小兵类型:%s,受到%d点伤害,剩余血量:%d\n", s.flyweight.Type, damage, s.state.HP)
}
// 打印小兵完整信息
func (s *Soldier) ShowInfo() {
fmt.Printf("小兵类型:%s,攻击力:%d,防御力:%d,位置:(%d,%d),血量:%d\n",
s.flyweight.Type, s.flyweight.Attack, s.flyweight.Defend, s.state.X, s.state.Y, s.state.HP)
}
func main() {
// 创建多个同类型小兵,观察享元对象的复用情况
soldier1 := NewSoldier("步兵", 10, 20, 100)
soldier2 := NewSoldier("步兵", 15, 25, 100)
soldier3 := NewSoldier("弓兵", 30, 40, 80)
fmt.Println("===== 初始状态 =====")
soldier1.ShowInfo()
soldier2.ShowInfo()
soldier3.ShowInfo()
fmt.Println("===== 操作小兵 =====")
soldier1.Move(12, 22)
soldier2.UnderAttack(20)
soldier3.Move(35, 45)
fmt.Println("===== 验证享元对象是否共享 =====")
// 两个步兵的享元对象指针相同,说明是同一个共享对象
fmt.Printf("soldier1和soldier2的享元对象是否相同:%v\n", soldier1.flyweight == soldier2.flyweight)
fmt.Printf("soldier1和soldier3的享元对象是否相同:%v\n", soldier1.flyweight == soldier3.flyweight)
}上面的代码实现中,我们首先定义了SoldierFlyweight作为享元对象,只存储可共享的内部状态(小兵类型、基础攻击力、基础防御力)。然后通过SoldierFactory享元工厂管理这些享元对象,使用map作为缓存容器,同时加入读写锁保证并发场景下的安全性,还使用了sync.Once实现工厂的单例,避免重复创建工厂实例。
业务层的Soldier结构体组合了享元对象和不可共享的外部状态(坐标、血量),创建小兵时只需要从工厂获取对应的享元对象,再搭配外部状态即可,不需要每次都重新创建完整的小兵对象。从运行结果可以看到,同类型的步兵只会创建一次享元对象,后续的步兵都复用这个对象,大大减少了对象创建的数量。
享元模式的注意事项
虽然享元模式可以有效减少对象数量,但使用时也需要注意一些问题:
- 享元对象的内部状态必须是不可变的,否则一旦修改会影响所有使用该享元对象的其他实例,引发逻辑错误
- 需要合理拆分内部状态和外部状态,如果外部状态占比过高,享元模式的收益会非常有限
- 缓存的享元对象如果长时间不用,需要考虑淘汰策略,避免缓存占用过多内存;如果是临时场景使用,也可以在场景结束后清空缓存
- 并发场景下一定要做好缓存的线程安全控制,Go语言中通常使用
sync.RWMutex或者sync.Map来实现安全的共享缓存
总的来说,享元模式是一种以时间换空间的设计模式,适合对象创建成本高、使用频繁的场景。在Go语言中,我们还可以结合sync.Pool来实现更灵活的对象复用,不过sync.Pool更适合临时对象的复用,而享元模式更侧重相同内部状态对象的长期共享,实际使用时可以根据场景选择合适的方案。