适配器模式,简单来说,就是将一个类的接口转换为另外一个类的接口,使得原本由于接口不兼容而不能一起工作的那些类能够一起工作。在现实生活中,这种例子也很多,比如我们如果买的是港行的电器,比如港版的iPhone,英版的原版树莓派,那么自带的充电器插头可能就是英标,在国内不能直接使用,国标的插头间距跟英标不兼容,所以,就需要一个适配器。

▲ 不同标准插头的适配

    还有个现实生活中的,比如我的车是个低配的绒布座椅,可以换更高级一点车的座椅,但是座椅的宽度可能不一样,那么就需要加一个滑轨适配器,一头把滑轨固定到车子上,一头就可以连接新更宽的座椅。

▲ 不同宽度汽车座椅的兼容安装

    还有个更有意思的图,下面这个😂。

▲ 汽车到铁轨转换器,图片来自 https://refactoring.guru/

场景


    现在来说说软件开发中的一些场景,假设我们有一个基础绘图的类库,这个绘图库只能接受像素级别的输入,这里抽象为Point(x,y)二维坐标的点对象。现在我们手头有另外一个更高级的类库,它能处理线(Line),矩形(Rectangle)等其它几何对象,假设这些集合对象的基本单元是Line线条。要想将这些更高级的对象绘制出来,就需要一个将Line转换为Point的适配器,才能使用基础绘图的类库。

public class Point
{
    public int X, Y;
    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
}

public class Line
{
    public Point Start;
    public Point End;
    public Line(Point start, Point end)
    {
        Start = start;
        End = end;
    }
}

    现在可以对更高级的集合对象进行抽象建模,假定这些更高级的对象,都是有各种复杂的Line集合构成,可以定义一个抽象的集合类,如下:

public abstract class VectorObject : Collection<Line> { }

    现在,定义一个矩形Rectangle,如下:

public class VectorRectangle : VectorObject
{
    public VectorRectangle(int x, int y, int width, int height)
    {
        Add(new Line(new Point(x, y), new Point(x + width, y)));
        Add(new Line(new Point(x + width, y), new Point(x + width, y + height)));
        Add(new Line(new Point(x, y), new Point(x, y + height)));
        Add(new Line(new Point(x, y + height), new Point(x + width, y + height)));
    }
}

    就是四条直线,构成了一个矩形。

    现在假设我们需要将矩形绘制到图形界面上,但是我们手头上已经存在的绘图接口接受的参数是Point(x,y),不接受Line类型,如下:

public static void DrawPoint(Point p)
{
     bitmap.SetPixel(p.X, p.Y, Color.Black);
}

     上图只是一个例子,表示我们只有一个接受Point的API。

适配器


    假设我们需要绘制一系列矩形对象:

private static readonly List<VectorObject> vectorObjects = new List<VectorObject>
{
            new VectorRectangle(1, 1, 10, 10),
            new VectorRectangle(3, 3, 6, 6)
};

    要能够绘制这些对象,需要将这一系列的线条,转换为一系列点对象。现在就需要一个适配器,新建一个LineToPointAdapter。

public class LineToPointAdapter : Collection<Point>
{
    private static int count = 0;
    public LineToPointAdapter(Line line)
    {
        Console.WriteLine($"{++count}: Generating points for line"
        + $" [{line.Start.X},{line.Start.Y}]-"
        + $"[{line.End.X},{line.End.Y}] (no caching)");

        int left = Math.Min(line.Start.X, line.End.X);
        int right = Math.Max(line.Start.X, line.End.X);
        int top = Math.Min(line.Start.Y, line.End.Y);
        int bottom = Math.Max(line.Start.Y, line.End.Y);
        int dx = right - left;
        int dy = line.End.Y - line.Start.Y;

        if (dx == 0)
        {
            for (int y = top; y <= bottom; ++y)
            {
                Add(new Point(left, y));
            }
        }
        else if (dy == 0)
        {
            for (int x = left; x <= right; ++x)
            {
                Add(new Point(x, top));
            }
        }
    }
}

   为了简单,上述代码只处理了水平和垂直线条对点对象的转换,其他情况下忽略。对于矩形来说一定程度上够了。

   上面的适配器中,在构造函数里进行了线条对点对象的转换,有点激进,后面可以优化为为延迟转换。

    现在,绘制抽象线条对象的方法可以写为:

static void DrawPoints()
{
    foreach (VectorObject vector in vectorObjects)
    {
        foreach (Line line in vector)
        {
            var adapter = new LineToPointAdapter(line);
            adapter.ForEach(DrawPoint);
        }
    }
}

 优化


    上述LineToPointAdapter适配器有两个问题,一个是因为是在构造函数里执行的线条到点的转换,没有延迟到绘制方法调用时进行转换。二是没有对线条转换到点对象进行缓存,这个问题尤其突出,比如很多时候,界面刷新,如果没有缓存,那么线条会在每次绘制的时候,都需要进行到点的转换,非常低效。

    对于延迟适配,我们可以添加一个局部变量来表示是否已经是配过,另外,还需要定义全局变量来保存上一次适配后的结果。

public class LineToPointAdapterLazy : IEnumerable<Point>
{
    private static int count = 0;
    static Dictionary<Line, List<Point>> cache = new Dictionary<Line, List<Point>>();
    private Line line;

    public LineToPointAdapterLazy(Line line)
    {
        this.line = line;
    }

    private void Prepare()
    {
        if (cache.ContainsKey(line))
            return; // we already have it
        Console.WriteLine($"{++count}: Generating points for line [{line.Start.X},{line.Start.Y}]-[{line.End.X},{line.End.Y}] (with caching)");
        List<Point> points = new List<Point>();
        int left = Math.Min(line.Start.X, line.End.X);
        int right = Math.Max(line.Start.X, line.End.X);
        int top = Math.Min(line.Start.Y, line.End.Y);
        int bottom = Math.Max(line.Start.Y, line.End.Y);
        int dx = right - left;
        int dy = line.End.Y - line.Start.Y;
        if (dx == 0)
        {
            for (int y = top; y <= bottom; ++y)
            {
                points.Add(new Point(left, y));
            }
        }
        else if (dy == 0)
        {
            for (int x = left; x <= right; ++x)
            {
                points.Add(new Point(x, top));
            }
        }
        cache.Add(line, points);
    }

    public IEnumerator<Point> GetEnumerator()
    {
        Prepare();
        return cache[line].GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

    首先,定义了一个全局私有的静态变量cache,用来存储Line->Points的转换,Line作为key,另外因为在构造函数里,只是保存了带转换的线条,并没有立即进行线条到点对象的转换。在进行一次适配后,将适配器的记过保存到cache中,下一次如果同样的Line对象进来,直接返回保存好的对象。

     其次,实现了IEnumerable对象,该对象本身具有延迟加载的特性,在遍历的时候,会调用IEnumerable接口的GetEnumerator方法,在该方法里,会调用真正的适配方法Prepare。

     这里还有一个地方需要注意,cache的key是Line,字典将对象作为key的时候,在比较的时候,是存储的哈希值,所以这里就需要对Line对象和Point对象实现IEqual接口。

public class Point : IEquatable<Point>
{
    public int X, Y;
    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public bool Equals(Point other)
    {
        return X == other.X && Y == other.Y;
    }
    public override bool Equals(object obj)
    {
        if (null == obj) return false;
        if (this == obj) return true;
        if (obj.GetType() != this.GetType()) return false;
        return Equals((Point)obj);
    }

    public override int GetHashCode()
    {
        return (X << 32) | Y;
    }
}

    Line对象也要处理。

public class Line : IEquatable<Line>
{
    public Point Start;
    public Point End;
    public Line(Point start, Point end)
    {
        Start = start;
        End = end;
    }
    public bool Equals(Line other)
    {
        return Equals(Start, other.Start) && Equals(End, other.End);
    }
    public override bool Equals(object obj)
    {
        if (null == obj) return false;
        if (this == obj) return true;
        if (obj.GetType() != this.GetType()) return false;
        return Equals((Line)obj);
    }

    public override int GetHashCode()
    {
        unchecked
        {
            return ((Start != null ? Start.GetHashCode() : 0) * 397) ^ (End != null ? End.GetHashCode() : 0);
        }
    }
}

.NET Framework里面的适配器


    在.NET Framework里面有一些用到适配器模式的地方:

  • System.Data 里存在ADO.NET Provider,比如SqlCommand,适配了数据库命令或者查询来执行SQL语句,每一种ADO.NET针对特定数据库类型提供了一种适配。
  • 数据库数据类型适配——所有继承自DbDataAdapter的类型,提供了一个类似的,更高级的操作。在内部,这些适配器维护了一系列数据命令和对特定数据源的连接。这些适配器的目的就是用来填充DataSet以及用来更新数据源。
  • LINQ Provider也是适配器,每一种都适配了一些底层的存储技术,这些技术可以用通用的LINQ语句,比如Select,Where等来操作。表达式树用来将C# lambda函数翻译成其他的查询语句,比如SQL语句。
  • 数据流适配器,比如TextReader,StreamWriter,将一种类型的数据,比如二进制文件,文本文件,适配为特定的对象。比如StringWrite会将数据写入到StringBuilder内部的缓存中。

总结


    适配器模式的想法非常简单:使用适配器是的能够将已存在的接口能够被用到其他需要的地方。适配器实现过程中,需要注意的地方在于,在适配到目标对象的过程中,有时候需要进行缓存来提升性能。当对某个对象适配后,将其存在内部缓存中,如果之前缓存过,直接取出来返回,否则才进行真正的适配工作。另外,在保存适配对象的缓存的时候,需要设计好的哈希缓存来避免哈希冲突。

    另外,在实现适配器模式的时候,还有一个可以优化的地方是延迟适配,在真正需要用到适配对象的时候才去适配。