1.5 使用 HOF 避免重复
例如,与数据库的交互需要一些设置来获取和打开一个连接,并在交互之后进行一些清理以关闭连接并将其返回到底层连接池。在代码中,它看起来像下面这样。
清单1.12 连接到一个数据库需要进行一些设置和拆解
string connString = "myDatabase";
//设置:获取并打开连接
var conn = new SqlConnection(connString));
conn.Open(); // interact with the database...
//拆解:关闭并释放连接
conn.Close();
conn.Dispose()
无论你是读还是写数据库,或执行一个或多个操作,设置和拆除都是相同的。 前面的代码通常是用一个using块来写的,像这样:
using (var conn = new SqlConnection(connString))
{
conn.Open();
// interact with the database...
}
这既短又好,但本质上仍然相同。考虑以下简单 DbLogger 类的示例,该类具有几个与数据库交互的方法:Log 插入给定的日志消息,GetLogs 检索自给定日期以来的所有日志。
清单 1.13 设置/拆卸逻辑的重复
using Dapper; //将 Execute 和 Query 作为连接上的扩展方法公开
// ...
publicclass DbLogger {
string connString; //假设这是在构造函数中设置的。
publicvoid Log(LogMessage msg) {
using(var conn = new SqlConnection(connString)) {// 设置
//将日志消息持久化到数据库
int affectedRows = conn.Execute("sp_create_log",
msg, commandType: CommandType.StoredProcedure);
}//拆解作为 Dispose 的一部分执行
}
public IEnumerable < LogMessage > GetLogs(DateTime since) {
var sqlGetLogs = "SELECT * FROM [Logs] WHERE [Timestamp] > @since";
using(var conn = new SqlConnection(connString)) {// 设置
//查询数据库并反序列化结果
return conn.Query < LogMessage > (sqlGetLogs, new {
since = since
});
}//拆解
}
}
注意到这两个方法有一些重复,即setup和tealdown逻辑。我们能不能去掉这些重复?
与数据库交互的具体细节与本次讨论无关,但如果你感兴趣,代码使用了Dapper库(记录在GitHub上:https://github.com/StackExchange/dapper-dot-net),它是ADO.NET之上的一个薄层,允许你通过一个非常简单的API与数据库交互。
- Query 查询数据库并返回反序列化的 LogMessages。
- Execute 运行存储过程并返回受影响的行数(我们忽略)。
这两种方法都被定义为连接上的扩展方法。更重要的是,注意在这两种情况下,数据库交互如何依赖于获取的连接并返回一些数据。这将允许您将数据库交互表示为从 IDbConnection 到“某物”的函数。
异步I/O操作在现实世界中,我建议你总是异步执行I/O操作(所以,在这个例子中,GetLogss应该真正调用QueryAsync并返回一个Task<IEnumerable< LogMessage > >)。
但是,当你试图学习FP已经具有挑战性的思想时,异步性增加了一个复杂的层次,这对你没有帮助。 出于教学目的,我将等到第13章再来讨论异步。
如您所见,Dapper 公开了一个令人愉快的 API,它甚至会在必要时打开连接。但是你仍然需要创建连接,一旦你完成它,你应该尽快处理它。结果,您的数据库调用的内容最终夹在执行设置和拆卸的相同代码段之间。让我们看看如何通过将设置和拆卸逻辑提取到 HOF 中来避免这种重复。
1.5.1 将设置和拆卸封装到 HOF 中
您正在寻找一个函数来执行设置和拆卸,并且参数化了在两者之间做什么。这是 HOF 的完美场景,因为您可以用函数表示其间的逻辑。从图形上看,它看起来像图 1.8。
因为连接建立和拆除比 DbLogger 更通用,所以它们可以被提取到一个新的 ConnectionHelper 类中。
清单 1.14 将数据库连接的设置和拆卸封装到 HOF 中
using System;
using System.Data;
using System.Data.SqlClient;
public static class ConnectionHelper {
public static R Connect <R> (string connString, Func <IDbConnection, R> f) {
using(var conn = new SqlConnection(connString)) {//设置
conn.Open();
return f(conn);//中间发生的事情现在被参数化了。
}//拆解
}
}
Connect函数执行设置和拆除,它的参数是它在两者之间应该做什么。函数主体的签名很有意思;它接收一个IDb-Connection(通过它与数据库交互),并返回一个通用对象R。在我们看到的用例中,R在查询的情况下是IEnumerable< LogMessage >,在插入的情况下是int。现在你可以在DbLogger中使用连接函数,如下所示:
using Dapper;
using static ConnectionHelper;
publicclass DbLogger {
string connString;
public void Log(LogMessage message) => Connect(connString, c => c.Execute("sp_create_log", message, commandType: CommandType.StoredProcedure));
public IEnumerable < LogMessage > GetLogs(DateTime since) => Connect(connString, c => c.Query < LogMessage > (@ "SELECT * FROM [Logs] WHERE [Timestamp] > @since", new {
since = since
}));
}
您摆脱了 DbLogger 中的重复,DbLogger 不再需要了解有关创建、打开或处置连接的详细信息。
1.5.2 将 using 语句转换为 HOF
前面的结果是令人满意的。但是,为了把HOF的想法更进一步,让我们更激进一点。using语句本身不就是一个setup/teardown的例子吗?毕竟,一个using块总是做以下事情:
- 设置——通过评估给定的声明或表达式来获取 IDisposable 资源
- Body——执行块内的内容
- Teardown - 退出块,导致在设置中获取的对象上调用 Dispose
所以…是的,它是! 至少可以这么说。 设置并不总是相同的,所以它也需要被参数化。然后,我们可以写一个更通用的设置/拆解的HOF,执行使用仪式。
这就是属于库中的那种广泛可重用的函数。在本书中,我将向你展示许多这样的可重用结构,这些结构已经进入了我的LaYumba.Functional库,使你在进行功能编码时有更好的体验。
清单1.15 一个可以用来代替using语句的HOF
using System;
namespace LaYumba.Functional {
public static class F {
public static R Using < TDisp, R > (TDisp disposable,
Func < TDisp, R > f) where TDisp: IDisposable {
using(disposable) return f(disposable);
}
}
}
前面的列表定义了一个名为F的类,它将包含我们函数库的核心函数。我们的想法是,这些函数应该通过使用static来实现,正如下一个代码示例中所示。
这个Using函数需要两个参数:第一个是 disposable 的资源,第二个是在资源被disposed前要执行的函数。有了这个函数,你可以更简洁地重写Connect函数:
using static LaYumba.Functional.F;
public static class ConnectionHelper {
public static R Connect < R > (string connStr, Func < IDbConnection, R > f) => Using(new SqlConnection(connStr), conn => {
conn.Open();
return f(conn);
});
}
第一行的 using static 使你能够调用 Using 函数,作为 using 语句的一种全局替代。注意,与using语句不同,调用Using函数是一个表达式。
- 它允许你使用更紧凑的以表达式为主体的方法语法。
- 一个表达式有一个值,所以Using函数可以与其他函数组合。
我们将在第5.5.1节中更深入地探讨构成和语句与表达式的概念。
1.5.3 HOF 的权衡
让我们看看你通过比较DbLogger中的一个方法的初始版本和重构版本所取得的成果:
// initial implementation
public void Log(LogMessage msg) {
using(var conn = new SqlConnection(connString)) {
int affectedRows = conn.Execute("sp_create_log", msg, commandType: CommandType.StoredProcedure);
}
}
// refactored implementation
public void Log(LogMessage message) =>
Connect(connString, c => c.Execute("sp_create_log", message, commandType: CommandType.StoredProcedure));
这是一个很好的例子,说明你可以从使用以一个函数为参数的HOF中得到好处:
- 简洁性 —— 新版本显然更加简洁。 一般来说,越是错综复杂的设置和需要的范围越广,你把它抽象成一个HOF就越有好处。
- 避免重复 —— 整个设置/卸载逻辑现在在一个单独的地方执行。
- 分离关注点 —— 你已经设法将连接管理隔离到ConnectionHelper类中,所以DbLogger只需要关注特定的日志逻辑。
让我们看看调用栈是如何变化的。在原来的实现中,对Execute的调用发生在Log的堆栈帧上,而在新的实现中,它们相隔四个堆栈帧(见图1.9)。
当Log执行时,代码调用Connect,将回调函数传递给它,以便在连接准备好时调用。Connect反过来将回调重新打包成一个新的回调,并将其传递给Using。
所以,HOFs 也有一些缺点:
- 你增加了堆栈使用。有性能影响,但可以忽略不计。
- 由于回调,调试应用程序会更复杂一些。
总的来说,对DbLogger所做的改进使其成为一个值得权衡的选择。
你现在可能已经同意,HOF是非常强大的工具,尽管过度使用会使人难以理解代码在做什么。在适当的时候使用HOF,但要注意可读性:使用短的lambdas,清晰的命名,以及有意义的缩进。