Flyweight,fly是苍蝇的意思,拳击里有个“蝇量级”,翻译也是“flyweight”,在设计模式中,Flyweight被翻译成了“享元”,意思是“共享元素”。在一些需要大量小的对象的应用场景,如果想要减少内存占用,可以考虑享元模式。下面举两个例子说明。
Example 1:人名的存储
比如,在英语国家,有很多人叫“John Smith”,如果我们在系统里,就要存储这个名字很多次,那么就需要很多额外的内存来存储相同的名字。相反,如果我们能够只存储某个名字一次,然后其余的都引用这个名字,这样就会节省很多空间。
再比如,可能“Smith”这个姓有很多人用,那么就可以将名字“John”和姓“Smith”分开存储,而不是将“John Smith”整个作为FullName存储。可以将名和姓“John”和“Smith”存在一个数组中Names,然后要存储“John Smith”时,只需要存储“John”和"Smith“在数组Names中的两个下标即可。
下面这个User类是通常的做法,直接将“John Smith”整个存储成单个字符串。这就意味着“John Smith”和“Jane Smith”都是分别的字符串,他们都有自己的内存分配。
public class User
{
public string FullName { get; }
public User(string fullName)
{
FullName = fullName;
}
}
现在根据上述思路,将User类构造一下,姓和名分别存储到公共的对象中。
public class User2
{
private static List<string> allNames = new List<string>();
private int[] names;
public User2(string fullName)
{
int getOrAdd(string s)
{
int idx = allNames.IndexOf(s);
if (idx != -1)
{
return idx;
}
else
{
allNames.Add(s);
return allNames.Count - 1;
}
}
names = fullName.Split(' ').Select(getOrAdd).ToArray();
}
public string FullName => string.Join(" ", names.Select(i => allNames[i]));
}
这里,可以看到,定义了一个私有的静态的allNames的List集合,用来存储所有的“名”和“姓”。随后定义了一个私有的int数组,来定义本对象的“名”和“姓”在静态allNames列表里的下标。可以看到整个逻辑就完了,没有实际存储Full name字符串。只是在有需要访问到FullName的时候,才动态的根据names下标,结合allNames“姓”,“名”列表,来拼接成Full name。
这里的主要逻辑在构造函数中,在方法体内定义了一个获取“名”或者“姓”在allNames中下标的方法,如果有,直接返回下标,如果没有,追加到allNames里,并返回下标。然后将传进来的fullName,用空格分隔成“名”和“姓”,然后通过select方法,传入查找下标的委托,得到“名”和“姓‘’在整个allNames下标中的位置。
在FullName只读属性里,将下标names结合allNames大集合,用空格把“名”和“姓”拼接起来了。
这里有两个思想,非常重要,一是不存储实际字符串,而是存储在集合中的位置,二是不直接提前实现FullName,而是在需要的时候,根据存储的位置动态生成。
下面来测试一下,原文用的是dotMemory,需要安装Resharper,我试了一下很不幸,第二种方法内存占用更多🙄
using NUnit.Framework;
using JetBrains.dotMemoryUnit;
using System;
using System.Collections.Generic;
using System.Linq;
using Flyweight;
namespace Tests
{
[TestFixture]
public class Tests
{
public void ForceGC()
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
public static string RandomString()
{
Random rand = new Random();
return new string(
Enumerable.Range(0, 10).Select(i => (char)('a' + rand.Next(26))).ToArray());
}
[Test]
public void TestUser()
{
var users = new List<User>();
var firstNames = Enumerable.Range(0, 100).Select(_ => RandomString());
var lastNames = Enumerable.Range(0, 100).Select(_ => RandomString());
foreach (var firstName in firstNames)
foreach (var lastName in lastNames)
users.Add(new User($"{firstName} {lastName}"));
ForceGC();
dotMemory.Check(memory =>
{
Console.WriteLine(memory.SizeInBytes);
});
}
[Test]
public void TestUser2()
{
var users = new List<User2>();
var firstNames = Enumerable.Range(0, 100).Select(_ => RandomString());
var lastNames = Enumerable.Range(0, 100).Select(_ => RandomString());
foreach (var firstName in firstNames)
foreach (var lastName in lastNames)
users.Add(new User2($"{firstName} {lastName}"));
ForceGC();
dotMemory.Check(memory =>
{
Console.WriteLine(memory.SizeInBytes);
});
}
}
}
以上是完整代码,需要新建一个NUnit测试项目,引用JetBrains.dotMemoryUnit包,然后在测试文件里,右键,在弹出的菜单里选择“Run Unit Tests under dotMemory Unit”,就会打印出两者的内存占用,非常不幸,原始方法只占用了:7447493bit,改进方法占用了:8205631 bit。
我再次使用BenchMark.DotNet来测试,代码如下:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Flyweight
{
[RankColumn]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[MemoryDiagnoser]
public class Test
{
private static string RandomString()
{
Random rnd = new Random();
return new string(Enumerable.Range(0, 10).Select(i => (char)('a' + rnd.Next(26))).ToArray());
}
private List<string> firstNames = Enumerable.Range(0, 100).Select(i => RandomString()).ToList();
private List<string> lastNames = Enumerable.Range(0, 100).Select(i => RandomString()).ToList();
[Benchmark]
public void TestUser()
{
var user = new List<User>();
foreach (var firstName in firstNames)
{
foreach (var lastName in lastNames)
{
user.Add(new User($"{firstName} {lastName}"));
}
}
}
[Benchmark]
public void TestUser2()
{
var user = new List<User2>();
foreach (var firstName in firstNames)
{
foreach (var lastName in lastNames)
{
user.Add(new User2($"{firstName} {lastName}"));
}
}
}
}
}
项目中,引用BenchmarkDotNet包,然后在要测试的地方运行如下:
using BenchmarkDotNet.Running;
using System;
namespace Flyweight
{
class Program
{
static void Main(string[] args)
{
var summary = BenchmarkRunner.Run<Test>();
Console.ReadLine();
}
}
}
运行结果如下:
可以看到,在.NET Core 3.1下,改进后的方法,不仅占用内存多,而且运行时间也长,产生的垃圾也多。我不知道是什么问题,可能作者是用.NET来测试的,代码在Github上有,新书Design Patterns in .NET Core 3还没发布,到时候出来了可以再看下,反正意思就是这样,翻车了😂。
Example 2:字符串格式化
现在价格有个编辑器,需要将某个字符串格式化,比如,让某些字符加粗,斜体,大写等。一个方法是将这些字符串的每一个字符当做一个对象,如果某段文字包含X个字符串,就会有一个X长度的bool数组来表示某个特性,如果某个字符需要格式化,则置为true。比如,方法如下:
public class FormattedText
{
private string plainText;
private bool[] capitalize;
public FormattedText(string plainText)
{
this.plainText = plainText;
capitalize = new bool[this.plainText.Length];
}
public void Capitalize(int start, int end)
{
if ((start < 0 || start > capitalize.Length - 1) || (end < 0 || end > capitalize.Length - 1))
{
return;
}
for (int i = start; i <= end; i++)
{
capitalize[i] = true;
}
}
public override string ToString()
{
StringBuilder s = new StringBuilder(plainText.Length);
for (int i = 0; i < plainText.Length; i++)
{
var c = plainText[i];
s.Append(capitalize[i] ? char.ToUpper(c) : c);
}
return s.ToString();
}
}
然后写个测试验证一下。
[TestFixture]
public class FormattedTextTest
{
[Test]
public void TestCapitalize()
{
var ft = new FormattedText("this is a new world");
ft.Capitalize(0, 3);
Assert.AreEqual(ft.ToString().Substring(0, 4), "THIS");
}
}
可以看到,测试通过,通过调用Captalize,将前面三个字符,成功变成了大写。但这种方式很浪费内存,及时没有任何格式化需求,也会初始化一个capitalize数组,诚然我们可以用Lazy延迟初始化的方式,但在第一次使用时,对于特别大的文本,仍然比较费内存。
上面这个例子可以看到,我们保存了所有的字符的格式化信息,不管它有没有被用到。这种情况就特别适合享元模式,在这个例子中,我们可以定义一个Range类,来保存所有待处理的字符串的开始和结尾信息,以及需要进行格式化处理的操作。
public class TextRange
{
public int Start, End;//需要格式化的起始位置
public Func<char, char> ProcessFunc;//需要格式化的行为
public bool Covers(int position)
{
return position >= Start && position <= End;
}
}
这个TextRange可以作为内部类。现在上述的Capitalize方法,我们换为GetRange方法,并返回一个TextRange类,用户可以设置一些列格式化操作,然后将这些设置好的操作保存在TextRange的集合中。当用户调用ToString的时候,一并应用上去。
public class BetterFormattedText
{
private readonly string plainText;
public readonly List<TextRange> formatting = new List<TextRange>();
public BetterFormattedText(string txt)
{
plainText = txt;
}
public TextRange GetRange(int start, int end)
{
var range = new TextRange() { Start = start, End = end };
formatting.Add(range);
return range;
}
public override string ToString()
{
var sb = new StringBuilder();
for (int i = 0; i < plainText.Length; i++)
{
var c = plainText[i];
foreach (var f in formatting)
{
if (f.Covers(i) && f.ProcessFunc != null)
{
c = f.ProcessFunc(c);
}
sb.Append(c);
}
}
return sb.ToString();
}
}
需要重点看的是,ToString()里面,我们逐个检查字符串中的字符是否在格式化对象中,如果在,则应用带格式化的操作。使用方式如下:
public void TestCapitalize()
{
var bft = new BetterFormattedText("This is a brave new world");
bft.GetRange(10, 15).ProcessFunc = char.ToUpper;
Assert.AreEqual(bft.ToString(), "This is a BRAVE new world");
}
非常方便,用起来也很简洁。上面的ToString()方法可能一个字符一个字符遍历可能还不够高效,但是这种方式能够节省大量内存空间。以下是对上述ToString()的改进。
public override string ToString()
{
char[] c = plainText.ToCharArray();
foreach (var f in formatting)
{
if (f.ProcessFunc != null)
{
for (int j = f.Start; j <= f.End; j++)
{
c[j] = f.ProcessFunc(c[j]);
}
}
}
return new string(c);
}
总结
针对大量的细粒度对象,使用享元模式能够减少内存空间占用。享元模式有多种表现形态,比如在例1存储用户名的例子中,外部使用者根本不知道内部实现。还有一种是作为API方式返回给用户自己处理,比如BetterFormattedText里的TextRange那样。
在.NET中,类似享元模式思想的对象就是ArraySegment<T>和Span<T>了。类似前面例子中在处理字符串时的TextRange对象,Span<T>表示T数组的一部分,包含起始位置和长度。在C# 7.0中,跟ref相关的API中对Span有着大量应用。
参考