LINQ Except运算符和对象相等性

14

当使用Except运算符时,我遇到了一个有趣的问题:

我有一个用户列表,想要从中排除一些用户:

用户列表来自一个XML文件:

代码实现如下:

interface IUser
{
     int ID { get; set; }
     string Name { get; set; }
}

class User: IUser
{

    #region IUser Members

    public int ID
    {
        get;
        set;
    }

    public string Name
    {
        get;
        set;
    }

    #endregion

    public override string ToString()
    {
        return ID + ":" +Name;
    }


    public static IEnumerable<IUser> GetMatchingUsers(IEnumerable<IUser> users)
    {
         IEnumerable<IUser> localList = new List<User>
         {
            new User{ ID=4, Name="James"},
            new User{ ID=5, Name="Tom"}

         }.OfType<IUser>();
         var matches = from u in users
                       join lu in localList
                           on u.ID equals lu.ID
                       select u;
         return matches;
    }
}

class Program
{
    static void Main(string[] args)
    {
        XDocument doc = XDocument.Load("Users.xml");
        IEnumerable<IUser> users = doc.Element("Users").Elements("User").Select
            (u => new User
                { ID = (int)u.Attribute("id"),
                  Name = (string)u.Attribute("name")
                }
            ).OfType<IUser>();       //still a query, objects have not been materialized


        var matches = User.GetMatchingUsers(users);
        var excludes = users.Except(matches);    // excludes should contain 6 users but here it contains 8 users

    }
}
当我调用User.GetMatchingUsers(users)时,我得到了两个预期的匹配结果。问题在于当我调用users.Except(matches)时,匹配的用户根本没有被排除!我期望得到6个用户,但是"excludes"包含了所有8个用户。
由于在GetMatchingUsers(IEnumerable<IUser> users)中,我所做的一切只是取出与ID匹配的IUsers(在此情况下为2个IUser), 因此,我的理解是,默认情况下Except将使用引用相等性来比较要排除的对象。这不是Except的行为吗?
更有趣的是,如果我使用.ToList()材料化对象,然后获取匹配的用户并调用Except,一切都按预期工作!
像这样:
IEnumerable<IUser> users = doc.Element("Users").Elements("User").Select
            (u => new User
                { ID = (int)u.Attribute("id"),
                  Name = (string)u.Attribute("name")
                }
            ).OfType<IUser>().ToList();   //explicity materializing all objects by calling ToList()

var matches = User.GetMatchingUsers(users);
var excludes = users.Except(matches);   // excludes now contains 6 users as expected

对于定义在 IEnumerable<T> 上的 Except 方法,我不明白为什么需要将对象具体化后才能调用它?

如果有任何建议/见解,将不胜感激。

3个回答

15

a) 您需要重写GetHashCode函数。它必须为相等的IUser对象返回相等的值。例如:

public override int GetHashCode()
{
    return ID.GetHashCode() ^ Name.GetHashCode();
}
b) 如果实现 IUser 接口的类需要使用 object.Equals(object obj) 函数,则需要进行重写。
public override bool Equals(object obj)
{
    IUser other = obj as IUser;
    if (object.ReferenceEquals(obj, null)) // return false if obj is null OR if obj doesn't implement IUser
        return false;
    return (this.ID == other.ID) && (this.Name == other.Name);
}

c) 作为 (b) 的替代方案,用户可以继承 IEquatable 接口:

interface IUser : IEquatable<IUser>
...

在这种情况下,User类需要提供bool Equals(IUser other)方法。

就是这样。现在它可以在不调用.ToList()方法的情况下工作。


10

我认为我知道为什么这个程序不能按照预期工作。因为最初的用户列表是一个LINQ表达式,每次迭代时都会重新评估它(一次用于GetMatchingUsers,另一次用于Except操作),所以新的用户对象被创建了。这将导致不同的引用,因此没有匹配项。使用ToList可以解决这个问题,因为它只迭代一次LINQ查询,所以引用是固定的。

我已经能够重现你遇到的问题,并且经过对代码的调查,这似乎是一个非常合理的解释。虽然我尚未证明它。

更新
我刚刚运行了测试,输出了调用GetMatchingUsers之前,其中和之后的users集合。每次输出对象的哈希码,它们确实每次都有不同的值,表明是新对象,正如我所怀疑的一样。

以下是每个调用的输出:

==> Start
ID=1, Name=Jeff, HashCode=39086322
ID=2, Name=Alastair, HashCode=36181605
ID=3, Name=Anthony, HashCode=28068188
ID=4, Name=James, HashCode=33163964
ID=5, Name=Tom, HashCode=14421545
ID=6, Name=David, HashCode=35567111
<== End
==> Start
ID=1, Name=Jeff, HashCode=65066874
ID=2, Name=Alastair, HashCode=34160229
ID=3, Name=Anthony, HashCode=63238509
ID=4, Name=James, HashCode=11679222
ID=5, Name=Tom, HashCode=35410979
ID=6, Name=David, HashCode=57416410
<== End
==> Start
ID=1, Name=Jeff, HashCode=61940669
ID=2, Name=Alastair, HashCode=15193904
ID=3, Name=Anthony, HashCode=6303833
ID=4, Name=James, HashCode=40452378
ID=5, Name=Tom, HashCode=36009496
ID=6, Name=David, HashCode=19634871
<== End

这是修改后的代码以显示问题:

using System.Xml.Linq;
using System.Collections.Generic;
using System.Linq;
using System;

interface IUser
{
    int ID
    {
        get;
        set;
    }
    string Name
    {
        get;
        set;
    }
}

class User : IUser
{

    #region IUser Members

    public int ID
    {
        get;
        set;
    }

    public string Name
    {
        get;
        set;
    }

    #endregion

    public override string ToString()
    {
        return ID + ":" + Name;
    }


    public static IEnumerable<IUser> GetMatchingUsers(IEnumerable<IUser> users)
    {
        IEnumerable<IUser> localList = new List<User>
         {
            new User{ ID=4, Name="James"},
            new User{ ID=5, Name="Tom"}

         }.OfType<IUser>();

        OutputUsers(users);
        var matches = from u in users
                      join lu in localList
                          on u.ID equals lu.ID
                      select u;
        return matches;
    }

    public static void OutputUsers(IEnumerable<IUser> users)
    {
        Console.WriteLine("==> Start");
        foreach (IUser user in users)
        {
            Console.WriteLine("ID=" + user.ID.ToString() + ", Name=" + user.Name + ", HashCode=" + user.GetHashCode().ToString());
        }
        Console.WriteLine("<== End");
    }
}

class Program
{
    static void Main(string[] args)
    {
        XDocument doc = new XDocument(
            new XElement(
                "Users",
                new XElement("User", new XAttribute("id", "1"), new XAttribute("name", "Jeff")),
                new XElement("User", new XAttribute("id", "2"), new XAttribute("name", "Alastair")),
                new XElement("User", new XAttribute("id", "3"), new XAttribute("name", "Anthony")),
                new XElement("User", new XAttribute("id", "4"), new XAttribute("name", "James")),
                new XElement("User", new XAttribute("id", "5"), new XAttribute("name", "Tom")),
                new XElement("User", new XAttribute("id", "6"), new XAttribute("name", "David"))));
        IEnumerable<IUser> users = doc.Element("Users").Elements("User").Select
            (u => new User
            {
                ID = (int)u.Attribute("id"),
                Name = (string)u.Attribute("name")
            }
            ).OfType<IUser>();       //still a query, objects have not been materialized


        User.OutputUsers(users);
        var matches = User.GetMatchingUsers(users);
        User.OutputUsers(users);
        var excludes = users.Except(matches);    // excludes should contain 6 users but here it contains 8 users

    }
}

如果是这样的话,那么“new”对象不是每次都会传递到GetMatchingUsers中吗?此外,该方法返回一个查询作为结果而不是对象。这只是我的个人看法... - Abhijeet Patel
不行,因为表达式每次使用时都会被评估。在我的代码中,它在调用GetMatchingUsers之前由我的输出评估,然后再次在调用GetMatchingUSers时评估,最重要的是,在Except期间再次评估。 - Jeff Yates
由于GetMatchingUsers和Except的评估都会生成自己的实例,因此Except无法按预期工作。 - Jeff Yates
在这种情况下,是的,看起来是这样。 - Jeff Yates
没问题。这很有趣。 :) - Jeff Yates
显示剩余4条评论

2
我认为你应该实现IEquatable<T>来提供自己的Equals和GetHashCode方法。
从MSDN(Enumerable.Except)中可以看到:

如果您想要比较某个自定义数据类型的对象序列,您必须在您的类中实现IEqualityComparer<(Of <(T>)>)泛型接口。以下代码示例展示了如何在自定义数据类型中实现此接口并提供GetHashCode和Equals方法。


CMS: 我已经在我的生产代码中实现了IEqualtable<T>,它确实起作用。但我不明白的是,为什么在调用GetMatchingUsers之前显式调用query的ToList()方法会产生期望的效果,而不是将users变量保留为query。 - Abhijeet Patel
Jeff:我没有从GetMatchingUser内部创建的本地列表中返回IUsers。该方法应该从原始的IEnumerable<IUser>返回IUsers,因此引用应该仍然指向幕后的原始IUser对象,因此引用相等性应该按预期工作! - Abhijeet Patel
@Abhijeet:是的,我看到了。因此删除了我的答案。我自己正在仔细查看。我已经复制了你所看到的内容。 - Jeff Yates
quo;Users> <User id="1" name="Jack"/> <User id="2" name="Jim"/> <User id="3" name="Joe"/> <User id="4" name="James"/> <User id="5" name="Tom"/> <User id="6" name="Matt"/> </Users> - Abhijeet Patel
这个答案将隐藏多次运行查询的低效率。 - Jeff Yates

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