IEnumerable<IDisposable>:谁在何时处理它们 -- 我理解得对吗?

8

以下是一个假设的场景。

我有非常多的用户名(比如说10,000,000,000,000,000,000,000。是的,我们已经进入了星际时代 :))。每个用户都有自己的数据库。我需要遍历用户列表,并对每个数据库执行一些SQL语句,并打印结果。

由于我学习了函数式编程的好处,并且因为我处理了如此庞大的用户数量,我决定使用F#和纯序列(也称为IEnumerable)来实现这一点。然后我就开始了。

// gets the list of user names
let users() : seq<string> = ...

// maps user name to the SqlConnection
let mapUsersToConnections (users: seq<string>) : seq<SqlConnection> = ...

// executes some sql against the given connection and returns some result
let mapConnectionToResult (conn) : seq<string> = ...

// print the result
let print (result) : unit = ...

// and here is the main program
users()
|> mapUsersToConnections
|> Seq.map mapConnectionToResult
|> Seq.iter print

漂亮?优雅?绝对是。

但是!谁在什么时候处理SqlConnection?

我认为答案mapConnectionToResult不应该这样做是错误的,因为它对给定的连接的生命周期一无所知。而且,根据mapUsersToConnections的实现方式和其他各种因素,事情可能会工作或不工作。

由于mapUsersToConnections是唯一可以访问连接的地方,它必须负责处理SQL连接。

在F#中,可以这样做:

// implementation where we return the same connection for each user
let mapUsersToConnections (users) : seq<SqlConnection> = seq {
    use conn = new SqlConnection()
    for u in users do
        yield conn
}


// implementation where we return new connection for each user
let mapUsersToConnections (users) : seq<SqlConnection> = seq {
    for u in users do
        use conn = new SqlConnection()
        yield conn
}

相当于C#的语法:

// C# -- same connection for all users
IEnumerable<SqlConnection> mapUsersToConnections(IEnumerable<string> users)
{
    using (var conn = new SqlConnection())
    foreach (var u in users)
    {
        yield return conn;
    }
}

// C# -- new connection for each users
IEnumerable<SqlConnection> mapUsersToConnections(IEnumerable<string> user)
{
    foreach (var u in users)
    using (var conn = new SqlConnection())
    {
        yield return conn;
    }
}

我所执行的测试表明,对象在正确的时间点得到了正确的处理,即使在并行执行的情况下:对于共享连接,在整个迭代的结尾处执行一次;对于非共享连接,在每个迭代周期后执行。

所以,问题是:我理解得对吗?

编辑:

  1. 一些答案友好地指出了代码中的一些错误,我进行了一些更正。以下是完整的可编译示例。

  2. SqlConnection 的使用仅用于示例目的,实际上它可以是任何 IDisposable。


可编译示例

open System

// Stand-in for SqlConnection
type SimpeDisposable() =
    member this.getResults() = "Hello"
    interface IDisposable with
        member this.Dispose() = printfn "Disposing"

// Alias SqlConnection to our dummy
type SqlConnection = SimpeDisposable

// gets the list of user names
let users() : seq<string> = seq {
    for i = 0 to 100 do yield i.ToString()
}

// maps user names to the SqlConnections
// this one uses one shared connection for each user
let mapUsersToConnections (users: seq<string>) : seq<SqlConnection> = seq {
    use c = new SimpeDisposable()
    for u in users do
        yield c
}

// maps user names to the SqlConnections
// this one uses new connection per each user
let mapUsersToConnections2 (users: seq<string>) : seq<SqlConnection> = seq {
    for u in users do
        use c = new SimpeDisposable()
        yield c
}

// executes some "sql" against the given connection and returns some result
let mapConnectionToResult (conn:SqlConnection) : string = conn.getResults()

// print the result
let print (result) : unit = printfn "%A" result

// and here is the main program - using shared connection
printfn "Using shared connection"
users()
|> mapUsersToConnections
|> Seq.map mapConnectionToResult
|> Seq.iter print


// and here is the main program - using individual connections
printfn "Using individual connection"
users()
|> mapUsersToConnections2
|> Seq.map mapConnectionToResult
|> Seq.iter print

结果如下:

共享连接: "hello" "hello" ... "正在释放"

个体连接: "hello" "正在释放" "hello" "正在释放"


1
100万亿用户。我认为你的系统中有一些多账户用户。 - jball
1
只是提醒一下:在这种情况下不要忘记函数式编程的爱好:根据定义,纯函数式编程是没有副作用的编码。IDisposable.Dispose()(以及几乎任何返回void的东西)仅为其副作用而执行,因此根据定义,它是纯函数式编程的相反 - Dax Fohl
8个回答

7

我建议避免使用这种方法,因为如果你的库的用户不小心做了像下面这样的操作,结构就会失败:

users()
|> Seq.map userToCxn
|> Seq.toList() //oops disposes connections
|> List.map .... // uses disposed cxns 
. . .. 

我不是这个问题的专家,但我认为最好的做法是在序列/可枚举对象产生结果后不要对它们进行修改。原因是中间的ToList()调用将产生与直接操作序列不同的结果——DoSomething(GetMyStuff())将不同于DoSomething(GetMyStuff().ToList())。

实际上,为什么不直接使用序列表达式来完成整个过程呢?这样可以完全避免这个问题:

seq{ for user in users do
     use cxn = userToCxn user
     yield cxnToResult cxn }

(其中,userToCxn和cxnToResult都是简单的一对一非可释放函数)。这似乎比任何东西都更易读,且应产生所需的结果,可并行化,并适用于任何可释放的对象。可以使用以下技术将其转换为C# LINQ:http://solutionizing.net/2009/07/23/using-idisposables-with-linq/
from user in users
from cxn in UserToCxn(user).Use()
select CxnToResult(cxn)

然而,另一种方法是先定义你的“获取用户数据并且释放资源”函数,然后将其作为你的基础构建块:

let getUserResult selector user = 
    use cxn = userToCxn user
    selector cxn

一旦您掌握了这个,那么您就可以从此轻松构建:

 //first create a selector
let addrSelector cxn = cxn.Address()
//then use it like this:
let user1Address1 = getUserResult addrSelector user1
//or more idiomatically:
let user1Address2 = user1 |> getUserResult addrSelector
//or just query dynamically!
let user1Address3 = user1 |> getUserResult (fun cxn -> cxn.Address())

//it can be used with Seq.map easily too.
let addresses1 = users |> Seq.map (getUserResult (fun cxn -> cxn.Address()))
let addresses2 = users |> Seq.map (getUserResult addrSelector)

//if you are tired of Seq.map everywhere, it's easy to create your own map function
let userCxnMap selector = Seq.map <| getUserResult selector
//use it like this:
let addresses3 = users |> userCxnMap (fun cxn -> cxn.Address())
let addresses4 = users |> userCxnMap addrSelector 

这样你就不必检索整个序列,如果你只想要一个用户。我想这里得到的教训是使你的核心函数简单化,这样就更容易在其上构建抽象。请注意,在中间某个地方执行 ToList 不会导致任何选项失败。


+1 对于将整个内容包装在序列表达式中。这样可以保持它的惰性和资源生命周期的确定性。 - Daniel
Seq.toList()非常有用,感谢您指出这一点。它实际上回答了最初的问题“我理解得对吗”。看来我没有理解正确。 - Philip P.

4
// C# -- new connection for each users
IEnumerable<SqlConnection> mapUserToConnection(string user)
{
    while (true)
    using (var conn = new SqlConnection())
    {
        yield return conn;
    }
}

这对我来说看起来不太对- 每当下一个用户(下一次迭代周期)请求新连接时,您就会处理该连接 - 这意味着这些连接只能依次独占地使用 - 一旦用户B开始使用他的连接,用户A的连接就被处理了。这真的是你想要的吗?

4

您的F#示例无法进行类型检查(即使您为函数添加一些虚拟实现,例如使用failwith)。我假设您的userToConnectionconnectionToResult函数实际上将一个用户转换为一个连接,再转换为一个结果(而不是像您的示例中那样使用序列):

// gets the list of user names
let users() : seq<string> = failwith "!"

// maps user name to the SqlConnection
let userToConnection (user:string) : SqlConnection = failwith "!"

// executes some sql against the given connection and returns some result
let connectionToResult (conn:SqlConnection) : string = failwith "!"

// print the result
let print (result:string) : unit = ()

现在,如果您想将连接的处理保持私密性到userToConnection,您可以更改它,使其不返回连接SqlConnection。相反,它可以返回一个高阶函数,该函数提供连接给某个函数(在下一步中指定),并在调用函数后处理连接。类似这样:

let userToConnection (user:string) (action:SqlConnection -> 'R) : 'R = 
  use conn = new SqlConnection("...")
  action conn

您可以使用柯里化,这样当您编写userToConnection user时,您将得到一个期望函数并返回结果的函数:(SqlConnection -> 'R) -> 'R。然后,您可以像这样组合您的总体函数:

// and here is the main program
users()
|> Seq.map userToConnection
|> Seq.map (fun f -> 
     // We got a function that we can provide with our specific behavior
     // it runs it (giving it the connection) and then closes connection
     f connectionToResult)
|> Seq.iter print

我不确定你是否想将单个用户映射到单个连接等,但即使你使用集合的集合,也可以使用完全相同的原理(使用返回函数)。


这看起来是一个非常好的技巧,唯一的问题是你不能多次调用那个高阶函数,因为第二次调用将使用已释放的对象。而且问题越来越严重如果那个函数被包装并传播到其他东西中等等,直到我们在程序的完全不相关部分得到奇怪的错误。所以这并不是一个理想的解决方案。除非我漏掉了什么? - Philip P.

3
我认为这方面有很大的改进空间。看起来你的代码不应该编译,因为mapUserToConnection返回一个序列,而mapConnectionToResult接受一个连接(将你的map更改为collect可以解决这个问题)。
我不确定一个用户是否应该映射到多个连接,或者每个用户是否只有一个连接。在后一种情况下,为每个用户返回单例序列似乎过于复杂了。
通常,从序列中返回IDisposable是一个坏主意,因为你不能控制项目何时被处理。更好的方法是将IDisposable的作用域限制在一个函数内。这个“控制”函数可以接受一个回调函数,使用资源,并在回调函数触发后处理资源(using函数就是一个例子)。在你的情况下,结合mapUserToConnectionmapConnectionToResult可能完全避免这个问题,因为函数可以控制连接的生命周期。
你最终会得到像这样的东西:
users
|> Seq.map mapUserToResult
|> Seq.iter print

这里的 mapUserToResult 是一个接受用户并返回结果的函数,其类型为 string -> string,因此它可以控制每个连接的生命周期。


是的,你说得对,这些函数应该返回SqlConnection和string,而不是seq<SqlConnection>和seq<string>。哎呀,我当时在想什么呢。我猜后面的示例可能已经不能正常工作了...我会更正这篇文章的。谢谢! - Philip P.

1

我觉得这些都不太对 - 例如,为什么你要为单个用户名返回一系列连接?你的签名不是想要像这样(作为 Linq-ness 的扩展方法编写):

IEnumerable<SqlConnection> mapUserToConnection(this IEnumerable<string> Usernames)

无论如何,继续往下看 - 在第一个例子中:

using (var conn = new SqlConnection())
{
    while (true)
    {
        yield return conn;
    }
}

这个方法是可行的,但仅当整个集合被枚举时才有效。如果(例如)只枚举第一个项目,则连接将不会被释放(至少在C#中),请参见Yield and usings - your Dispose may not be called!

第二个示例对我来说似乎很好用,但我曾经遇到过类似的问题,导致连接在不应该释放时被释放了。

一般来说,我发现结合disposeyield return是一个棘手的问题,我倾向于避免使用它,而是实现自己的枚举器,显式实现IDisposableIEnumerable。这样你就可以确定对象何时被释放。


对于实现可枚举的一次性资源的建议,那是在自找麻烦...我想不出有什么情况下没有更好的解决方案。我认为“正确”的答案是告诉提问者这是一个严重的代码异味。 - Daniel
@kragen:我已经进行了编辑以更正代码,请查看。 - Philip P.
@Daniel:但是函数式编程和使用map等技术呢?有时候也别无选择。 - Philip P.
@Komrade:我认为您不应该从序列中返回IDisposables。在这个问题的答案中提到了几种可行的替代方案。 - Daniel

1

Dispose 应该由能够保证对象不再使用的人调用。如果您可以做出这个保证(比如说,对象仅在您的方法内使用),那么您就需要负责处理它的释放。如果您无法保证对象已经完成了任务(比如说,您正在公开一个带有对象的迭代器),那么您就不需要担心它的释放,让其他人来处理。

一个潜在的设计决策是,您可以遵循 CLR 对于 Stream 实例的处理方式。许多接受 Stream 的构造函数也接受一个 bool 参数。如果这个参数为 true,那么对象知道它需要在完成任务后释放 Stream。如果您正在返回一个迭代器,您可以返回一个类型为 Disposable,bool 的元组。

然而,我建议您深入研究实际问题所在。也许您需要改变架构以避免这些问题。例如,不要为每个用户设置一个数据库,而是只使用一个数据库。或者,您可能需要使用连接池来减轻活动但非活跃连接的负担(关于最后一个问题,我不是100%确定,请自行研究相关选项)。


好观点。虽然我不同意在Stream示例中使用bool值来控制谁处理什么是最佳方法的演示。是的,这样可以工作,但对我来说,这突显了问题而非解决了问题。当然,这里我们谈论的是包装其他对象的对象,而不是纯独立函数。 - Philip P.
@Komrade P:我不认为这是最好的演示,但我也认为,如果需要处理,则需要从架构角度确定谁负责处理对象,而不是试图将该事实未定义。另一个选择是使用代理,在调用方不负责时忽略Dispose调用的实例。 - Guvante

0

仅使用函数构造来解决这个问题,在我看来是 F# 的一个陷阱的好例子。纯函数式语言通常使用不可变数据结构。而基于 .NET 的 F# 通常不会,这在某些情况下对性能非常有利。

我解决这个问题的方法是将创建和销毁 SqlConnection 对象的命令式部分隔离到自己的函数中。在这种情况下,我们将使用 useUserConnection

let users() : seq<string> = // ...

/// Takes a function that uses a user's connection to the database
let useUserConnection connectionUser user =
    use conn = // ...
    connectionUser conn

let mapConnectionToResult conn = 
    // ... *conn is not disposed of here* 

// Function currying is used here
let mapUserToResult = useUserConnection mapConnectionToResult

let print result = // ...

// Main program 
users() 
    |> Seq.map mapUserToResult 
    |> Seq.iter print

0

我认为这里存在一个设计问题。如果你看一下问题陈述,它是关于获取有关用户的一些信息。用户被表示为字符串,信息也被表示为字符串。因此,我们需要一个像这样的函数:

let getUserInfo (u:string) : string = <some code here>

使用方法非常简单:

users() |> Seq.map getUserInfo 

现在这个函数如何获取用户信息取决于它使用SqlConnection、文件流或其他可释放或不可释放的对象,该函数负责创建连接和进行适当的资源处理。在您的代码中,您已完全分离了连接创建和获取信息部分,这导致了关于谁处理连接的困惑。

如果您希望使用单个连接来供所有getUserInfo方法使用,那么您可以将此方法设置为

let getUserInfoFromConn (c:SqlConnection) (u:string) : string = <some code here>

现在这个函数接受一个连接对象(或其他可释放对象)。在这种情况下,这个函数不会释放连接对象,而是由调用者来释放它。我们可以像这样使用:

use conn = new SqlConnection()
users() |> Seq.map (conn |> getUserInfoFromConn)

所有这些都清楚地表明了谁处理资源。


是的,你说得对。在这个特定的例子中,你的建议可能是正确的。我的问题是关于更一般地使用IDisposable,特别是在迭代器和独立函数产生/消耗迭代器的情况下。 - Philip P.

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接