C#: 使用不同类型参数重载LINQ通用方法

3

我正在编写一组LINQ 2 SQL扩展方法。目前,我有许多重复的方法,一个适用于IEnumerable集合,另一个适用于IQueryable集合。例如:

public static IQueryable<X> LimitVisible(this IQueryable<X> items) {
    return items.Where(x => !x.Hidden && !x.Parent.Hidden);
}

public static IEnumerable<X> LimitVisible(this IEnumerable<X> items) {
    return items.Where(x => !x.Hidden && !x.Parent.Hidden);
}

我认为我需要这个重复的方法,因为在某些情况下我想保留IQueryable的行为,以便执行被推迟到数据库。 这是正确的假设吗?还是一个单一的IEnumerable方法(加上将输出强制转换为IQueryable)可以工作? 如果我确实需要两者,我不喜欢重复,所以我想使用通用方法将两者合并

public static T LimitVisible<T>(this T items) where T : IEnumerable<X> {
    return (T)items.Where(x => !x.Hidden && !x.Parent.Hidden);
}

这个方法是可行的,但我还有其他类型(比如说Y和Z),我也想写适用于它们的LimitVisible函数。但我无法写成这样:
public static T LimitVisible<T>(this T items) where T : IEnumerable<Y> {
    return (T)items.Where(y => !y.Hidden && !y.Parent.Hidden);
}

因为你可以基于泛型进行重载。我可以将这些方法放在不同的类中,但那似乎不是正确的解决方案。
有什么建议吗?也许我做出了错误的假设,根本没有必要首先创建一个特定于IQueryable的版本。
编辑:另一种选择
这是我过去使用的另一种模式,以避免重复。
private static readonly Expression<Func<X, bool>> F = x => !x.Hidden && !x.Parent.Hidden;

public static IEnumerable<X> LimitVisible(this IEnumerable<X> e) {
    return e.Select(W.Compile());
}

public static IQueryable<X> LimitVisible(this IQueryable<X> q) {
    return q.Select(W);
}

这有什么风险吗?

X、Y、Z是否有共同的界面或基类用于“Hidden”和“Parent”属性? - Magnus
不,它们是数据库中不同的表,只是碰巧都有隐藏字段。 - Jordan Crittenden
2个回答

2

实际上,有两种方法是不可避免的。 IQueryable 中的Where 方法将Expression<Func<T,bool>>作为参数,而 IEnumerable 则将Func<T,bool>作为参数。由于 Lambda 表达式的神奇特性,对调用者来说代码看起来相同,但编译器实际上对这两个代码片段进行了 vastly different 的处理。


抱歉,那是一个打错的字,我刚刚修正了。 - Jordan Crittenden
@JordanCrittenden 我已经删除了我的回答中的那一部分,但其余部分仍然相关。 - Servy

1
答案取决于您的使用情况:如果您不介意将数据集带入内存以执行查询,则仅保留 IEnumerable<T> 覆盖即可。
然而,这个决定的必然结果是,一旦使用 LimitVisible<T>,您的查询将被强制进入内存。这可能是您想要的,也可能不是,可能会迫使您采用您宁愿避免的编码模式。例如,
var firstHundred = ctx
    .SalesOrders
    .LimitVisible()                     // Happens in memory
    .Where(ord => ord.UserId == myUser) // Also happens in memory
    .Take(100);

使用单个 IEnumerable<T> 时,性能将大大降低,而使用 IQueryable<T> 则可覆盖此问题,因为在确定其可见性之前,所有销售订单都将逐个检索到内存中,而不是在服务器端检查条件。如果按用户 ID 进行过滤可以在服务器端消除大部分销售订单,则此等效查询的性能差异可能会好几个数量级:

var firstHundred = ctx
    .SalesOrders
    .Where(ord => ord.UserId == myUser) // Happens in RDBMS
    .LimitVisible()                     // Happens in memory
    .Take(100);

如果您计划仅自己使用代码,并且不介意在没有明显原因的情况下出现性能不佳的情况,请保留一个重载。如果您计划让其他人使用您的库,我强烈建议保留两个重载。

编辑:为了加快您的替代实现速度,请预编译您的表达式,像这样:

private static readonly Expression<Func<X, bool>> F = x => !x.Hidden && !x.Parent.Hidden;
private static readonly Predicate<X> CompiledF = (Predicate<X>)F.Compile();

public static IEnumerable<X> LimitVisible(this IEnumerable<X> e) {
    return e.Select(CompiledF);
}

public static IQueryable<X> LimitVisible(this IQueryable<X> q) {
    return q.Select(F);
}

你证实了我的猜想。我绝对不想把所有东西都加载到内存中。拥有两种方法的问题在于很容易意外更新其中一个而忘记另一个的代码。 - Jordan Crittenden
很遗憾,这可能是最简单的方法,而不需要编写大量复杂的代码来重用LINQ表达式在IQueryable<T>实现中。 - Sergey Kalinichenko
是的,我刚刚编辑了一下以展示另一种方法。不过我认为这种方法效率较低,因为需要进行编译调用。 - Jordan Crittenden
@JordanCrittenden 不过你可以轻松解决这个问题,只需创建一个编译后的 lambda 的静态只读版本即可(请查看编辑)。 - Sergey Kalinichenko
你确定这个答案吗?我刚刚进行了一个简单的测试,发现像ObjectQuery<T>和ObjectSet<T>这样的IQueryable直到最后调用GetEnumerator()才会访问数据库,因此仅具有IEnumerable<T>扩展应该是可以的,不会将所有内容加载到内存中。 - Eren Ersönmez
1
如果您使用IEnumerator方法,它不会立即加载它们,但重点是,当迭代时,它最终将加载所有项目到内存中,然后对其进行过滤并输出结果。如果您使用了IQueryable输入,则需要将过滤器的输出带入内存。关键在于,此Where过滤器过滤掉的所有项目都不会被带入内存(永远不会)。这也意味着后续调用发生在数据库中,而不是在内存中,并且它们可以执行各种复杂任务(例如分组、连接等)或进行更多的过滤。 - Servy

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