保持代码单元的接口简单
限制每个代码单元的参数不能超过4个。将多个参数提取成对象。
为了保持代码的可维护性,需要限制参数的个数,避免使用过多的参数(也称为代码单元接口)
之前的JPacman项目中,BoardPanel类的render方法,拥有许多参数的典型,此方法在一个由x,y,w,h表示的矩形中绘制一个方块以及方块的占有者(例如表示一个幽灵或一个豆丸)
/// <summary>
/// 在一个指定的矩形上绘制一个方块
/// </summary>
/// <param name="squere">要绘制的方块</param>
/// <param name="g">需要进行绘制的图形上下文</param>
/// <param name="x">开始绘制的x位置</param>
/// <param name="y">开始绘制的y位置</param>
/// <param name="w">方块的宽度</param>
/// <param name="h">方块的高度</param>
private void Render(Square squere, Graphics g, int x, int y, int w, int h)
{
squere.Sprite.Draw(g, x, y, w, h);
foreach(Unit unit in squere.Occupants)
{
uint.Sprite.Draw(g, x, y, w, h);
}
}
方法超过了4个参数,由于后四个参数x,y,w,h都是相关联的,并且Render方法没有单独去操作每个变量,所以应将它们封装为一个对象,下面代码展示了重构的Render方法
public class Point
{
public int X { get; set; }
public int Y { get; set; }
}
public class Rectangle
{
public Point Position { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public Rectangle(Point position, int width, int height)
{
this.Position = position;
this.Width = width;
this.Height = height;
}
}
private void Render(Square squere, Graphics g, Rectangle r)
{
Point position = r.Position;
squere.Sprite.Draw(g, position.X, position.Y, r.Width, r.Height);
foreach (Unit unit in squere.Occupants)
{
uint.Sprite.Draw(g, position.X, position.Y, r.Width, r.Height);
}
}
相比之前的6个参数,限制render方法只有3个参数,进一步简化Draw方法的接口参数
private void Render(Square squere, Graphics g, Rectangle r)
{
squere.Sprite.Draw(g, r);
foreach (Unit unit in squere.Occupants)
{
uint.Sprite.Draw(g, r);
}
}
接口参数较少的方法能够保持简单的上下文,更容易被人理解。并不过于依赖来自外部的输入,也更易于重用和修改。
短接口更易于理解和重用:随着代码库规模的增长,核心类会逐渐成为其他代码所依赖的API,为了避免代码量迅速膨胀以及开发速度下降,核心类必须有一个清晰、短的接口定义,
假设想在数据库中存储一个ProductOrder对象,你是喜欢这个ProductOrderDao.store(ProductOrder order),还是喜欢这个ProductOrderDao.store(ProductOrder order,string databaseUser,string databaseName, bool validateBeforeStore, bool closeDbConnection)
短接口的方法更易于修改:过长的接口不仅让方法变得难以理解,还表示它承担着多重的职责(尤其是当你感觉真的无法将这些对象组合在一起的时候)。接口长短与代码单元大小和复杂度有关,如果你的某个方法有8个参数,方法内部有很多代码,你就很难将它拆分成多个独立的部分,但是一旦拆分成功,这几个方法就能够各司其职,并且每个方法只有很少的几个参数,这样可以很容易定位到需要修改的代码。
根据SIG经验,参数个数上限为4比较合理,在演示如何将接口从长变短之前,应该清楚,过长的接口本身不是问题,代码中可能存在设计不合理的数据模型。
假设有9个参数的方法用来构造并发送一封电子邮件:
public void DoBuildAndSendMail(MailMan m,string firstName, string lastName, string division,string subject, MailFont font, string message1, string message2, string message3)
{
//格式化电子邮件地址
string mId = $"{firstName[0]}.{lastName.Substring(0, 7)}" + $"@{division.Substring(0, 5)}.coma.ny";
//根据指定的内容类型和原始消息进行格式化
MailMessage mMessage = FormatMessage(font, message1 + message2 + message3);
//发送消息
m.Send(mId, subject, mMessage);
}
这个方法拥有太多的职责,生成邮件地址与发送具体邮件没有关系,重构后的代码如下:
public void DoBuildAndSendMail(MailMan m, MailAddress mAddress, MailBody mBody)
{
//创建电子邮件
Mail mail = new Mail(mAddress, mBody);
//发送消息
m.Send(mail);
}
public class Mail
{
public MailAddress Address { get; set; }
public MailBody Body { get; set; }
public Mail(MailAddress mAddress, MailBody mBody)
{
this.Address = mAddress;
this.Body = mBody;
}
}
public class MailBody
{
public string Subject { get; set; }
public MailMessage Message { get; set; }
public MailBody(string subject, MailMessage message)
{
this.Subject = subject;
this.Message = message;
}
}
public class MailAddress
{
public string MsgId { get; set; }
public MailAddress(string firstName, string lastName, string division)
{
this.MsgId = $"{firstName[0]}.{lastName.Substring(0, 7)}" + $"@{division.Substring(0, 5)}.coma.ny";
}
}
现在DoBuildAndSendMail方法的复杂度降低了很多,这里的程序都将参数包装成了对象,通常被称为数据传输对象(DTO)
这些实际上代表了对应的领域对象,一个点,一个长度和宽度表示了一个矩形,同样一个姓,一个名,一个地区表示了一个地址,抽取成了名为MailAddress的类。
封装这些类不只是减少方法参数的数量,还因为它们都是对实际领域对象的通用抽象,在代码中被频繁地重用。
如果方法的各个参数无法组合在一起,仍然可以封装成一个类,但是这个类可能只能使用一次,例如,正在创建一个可以在Drawing.Graphics画布上绘制图表的库,柱形图和饼图,需要绘制的区域大小、横纵轴的配置信息,以及实际的数据集等,一种提供这些信息的方式如下:
public static void DrawBarChart(Graphics g,
CategoryItemRendererState state,
Rectangle graphArea,
CategoryPlot plot,
CategoryAxis domainAxis,
ValueAxis rangeAxis,
CategoryDataset dataset)
{
//..
}
这个方法已经有7个参数,对此方法的调用都需要提供这7个参数,如果希望图表库提供默认值,一种实现方式是重载方法,定义一个只有2个参数的DrawBarChart方法
public static void DrawBarChart(Graphics g, CategoryDataset dataset)
{
Charts.DrawBarChart(g,
CategoryItemRendererState.DEFAULT,
new Rectangle(new Point(0, 0), 100, 100),
CategoryPlot.DEFAULT,
CategoryAxis.DEFAULT,
ValueAxis.DEFAULT,
dataset);
}
但是还是存在着7个参数的方法,另一种解决方式是使用之前提到过的“使用方法对象替换方法”,首先定义一个BarChart类
public class BarChart
{
private CategoryItemRendererState state = CategoryItemRendererState.DEFAULT;
private Rectangle graphArea = new Rectangle(new Point(0, 0), 100, 100);
private CategoryPlot plot = CategoryPlot.DEFAULT;
private CategoryAxis domainAxis = CategoryAxis.DEFAULT;
private ValueAxis rangeAxis = ValueAxis.DEFAULT;
private CategoryDataset dataset = CategoryDataset.DEFAULT;
public BarChart Draw(Graphics g)
{
//..
return this;
}
public ValueAxis GetValueAxis()
{
return rangeAxis;
}
public BarChart SetRangeAxis(ValueAxis rangeAxis)
{
this.rangeAxis = rangeAxis;
return this;
}
//更多的getter和setter
}
在这个类里DrawBarChart被替换成了Draw,原先的7个参数就只剩下了1个,其余6个都转换为了类的私有成员,且有默认值。所有的setter方法都返回this,从而可以创建一个流式的接口,一种级联方式来进行调用,比如:
private void ShowMyBarChart()
{
Graphics g = this.CreateGraphics();
BarChart b = new BarChart().SetRangeAxis(myValueAxis).SetDateset(myDataset).Draw(g);
}
常见反对意见:
构造参数对象过于复杂:如果一切顺利,你已经在重构过长接口的过程中,把一组参数封装到了一个对象中,这里可能会出现该对象拥有很多参数的情况,通常意味着需要在对象内部进行更细粒度的划分。拿第一个例子Rectangle的重构来说,虽然定义矩形的参数都已经被组合到一起,但我们没有采用一个四个参数的构造函数,而是将x和y封装到了一个Point对象中,因此,你应当引入另一个参数对象,更应该思考一下这个对象的结构以及它与其他代码的关系。
重构长接口并没有带来任何改善:想要避免长接口不是一件容易的事,都不是仅靠重构方法就能解决,应该持续取分离方法中的各个职责,只在需要时去访问最主要的几个参数,比如,Render方法经过重构后,由于Draw方法的原因,需要访问Rectangle对象中的所有参数,当然你也可以对Draw方法进行重构,在方法体访问x,y,w,h参数,这样也许会更好,你不需要在开始绘制前去实际操纵他的类成员变量,只需向Render方法传递一个Rectangle对象就可以了。
一些框架或库已经规定了长参数列表的接口:实现或者重载这些方法会让你的代码中也出现长列表参数的方法,无法避免,但是可以限制它们的影响,最好的办法是使用包装类或适配器类对它们进行隔离。
SIG评估代码单元的接口程度,根据系统中每个代码单元的参数数量,划分4个风险分类,超过7个参数,5个以上参数,3个以上参数,2个以下参数。