WPF ListBox和带有变化哈希码的Items

6
我有一个绑定到一组具有用于生成GetHashCode()结果的ID的项的ListBox。当添加新项时,它的ID为0,直到首次保存到我们的数据库。这导致我的ListBox出现问题;我认为原因是因为当项首次被ListBox使用时,它存储在不期望哈希码更改的内部Dictionary中。
我可以通过从集合中删除未保存的项(我必须在此阶段通知UI将其从字典中删除),保存到数据库,然后将其添加回集合中来解决此问题。这很麻烦,而且我并不总是能够从我的Save(BusinessObject obj)方法访问该集合。是否有其他解决此问题的替代方案?
编辑:针对Blam的答案:
我正在使用MVVM,因此已修改代码以使用绑定。要重现问题,请单击添加,选择项目,单击保存,重复此过程,然后尝试进行选择。我认为这证明了ListBox仍然在其内部Dictionary中保留旧的哈希码,因此会出现冲突的键错误。
<Window x:Class="ListBoxHashCode.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <StackPanel HorizontalAlignment="Center" Orientation="Horizontal">
            <Button Click="Button_Click_Add" Content="Add"/>
            <Button Click="Button_Click_Save" Content="Save Selected"/>
        </StackPanel>
        <ListBox Grid.Row="1" ItemsSource="{Binding List}" DisplayMemberPath="ID" SelectedItem="{Binding Selected}"/> 
    </Grid>
</Window>

public partial class MainWindow : Window {

    public ObservableCollection<ListItem> List { get; private set; }        
    public ListItem Selected { get; set; }
    private Int32 saveId;

    public MainWindow() {
        this.DataContext = this;            
        this.List = new ObservableCollection<ListItem>();
        this.saveId = 100;
        InitializeComponent();
    }

    private void Button_Click_Add(object sender, RoutedEventArgs e) {
        this.List.Add(new ListItem(0));
    }

    private void Button_Click_Save(object sender, RoutedEventArgs e) {
        if (Selected != null && Selected.ID == 0) {
            Selected.ID = saveId;
            saveId++;
        }
    }
}

编辑2:在进行一些测试后,我发现了一些事情:

  • 更改 ListBox 中项目的哈希码似乎可以正常工作。

  • 更改 ListBox选定项目的哈希码会破坏其功能。

当进行选择时(单个或多个选择模式),IList ListBox.SelectedItems 会更新。添加到选择中的项目将添加到 SelectedItems,不再包括在选择中的项目将被删除。

如果在选定项目时更改项目的哈希码,则无法从 SelectedItems 中删除它。即使手动调用 SelectedItems.Remove(item)SelectedItems.Clear() 并将 SelectedIndex 设置为 -1,都没有效果,项目仍然位于 IList 中。这会导致在下一次选定它后抛出异常,因为我认为它又再次添加到了 SelectedItems 中。


什么类型的集合?你尝试过其他集合吗?为什么不在将其添加到集合之前先将其保存到数据库中? - paparazzo
通常使用List和ObservableCollection,但我认为对于任何IEnumerable来说问题都是一样的。完整的故事是我正在将一个大型应用程序从.NET 3.5升级到4.0,这引入了这个问题。因此,我不能轻易地进行结构性更改,并且在向ListBox添加未持久化的项时,在许多地方使用了这种模式。 - Coder1095
在这个简化的例子中,@sixlettervariables没有使用(我认为它不需要,因为绑定只是单向使用),但在实际应用中我会使用它。 - Coder1095
@Dan Puzey 是的,我已经重写了 Equals(这是首先重写 GetHashCode 的原因)。ListItem 的代码在 Blam 的回答中。 - Coder1095
你还在添加新的 ListItem(0) 零吗?正如我在我的回答中解释的那样,这会破坏 ListView。ListView 中的项必须基于 Equals 是唯一的。 - paparazzo
显示剩余3条评论
2个回答

3
有没有其他解决这个问题的替代方案?
在对象的生命周期中,对象的哈希码不应更改。您不应使用可变数据计算哈希码。
更新。
我没想到我的回答会引起这样的讨论。下面是一些详细的解释,或许能帮助OP。
让我们看一些在您的代码中定义的可变实体类型,该类型重写了 GetHashCode 并且当然也重写了 Equals。相等性基于 Id 相等:
class Mutable : IEquatable<Mutable>
{
    public int Id { get; set; }

    public override int GetHashCode()
    {
        return Id.GetHashCode();
    }

    public override bool Equals(object obj)
    {
        if (obj == null)
        {
            return false;
        }

        var mutable = obj as Mutable;
        if (mutable == null)
        {
            return false;
        }

        return this.Equals(mutable);
    }

    public bool Equals(Mutable other)
    {
        return Id.Equals(other.Id);
    }
}

在你的代码中,你创建了这个类型的多个实例:

        // here's some mutable entities with hash-code, calculated using mutable data:
        var key1 = new Mutable { Id = 1 };
        var key2 = new Mutable { Id = 2 };
        var key3 = new Mutable { Id = 3 };

以下是一些使用 Dictionary<Mutable, string> 进行内部操作的外部代码:

        // let's use them as a key for the dictionary:
        var dictionary = new Dictionary<Mutable, string>
        {
            { key1, "John" },
            { key2, "Mary" },
            { key3, "Peter" }
        };

        // everything is ok, all of the keys are located properly:
        Console.WriteLine(dictionary[key1]);
        Console.WriteLine(dictionary[key2]);
        Console.WriteLine(dictionary[key3]);

再次看你的代码。假设你已经改变了key1Id。哈希码也会随之改变:

        // let's change the hashcode of key1:
        key1.Id = 4;

再次提到外部代码。这里它试图通过key1来定位一些数据:

Console.WriteLine(dictionary[key1]); // ooops! key1 was not found in dictionary

当然,你可以设计可变类型,重写 GetHashCode 和 Equals,并在可变数据上计算哈希码。但是,除非你确切知道自己在做什么,否则你不应该这样做。
你无法保证任何外部代码不会在内部使用 Dictionary 或 HashSet。

5
虽然您的回答可以解决问题,但作为一般指导原则并���正确:据微软文档所述,“对于一个对象而言,只要没有修改对象状态,其GetHashCode方法必须始终返回相同的哈希码[...]”,因此状态的改变可以(并且通常应该)更改哈希码。尽管如此,我长期以来认为这是“ListBox”实现中的失败之处,似乎依赖于“HashCode”进行选择逻辑。 - Dan Puzey
2
微软在MSDN (msdn.microsoft.com/en-us/library/system.object.gethashcode.aspx)中指出,当对象状态发生改变可能影响Equals()的返回值时,GetHashCode的值必须更改,即使在其示例中,它也展示了完全依赖于公开可变值的GetHashCode实现。 - Coder1095
1
@DanPuzey:你可以遵循文档。但是在这种情况下,当你得到像OP一样的行为时,请不要感到惊讶。不幸的是,这种情况下,文档与现实生活中的实现相矛盾。当然,你可能会说“这些实现是错误的”,但这会对你有所帮助吗? :) - Dennis
2
@Dennis:任何实现非引用“Equals”的对象必须具有基于其可变数据变化的哈希码才能正常工作。我认为文档在这里没有矛盾之处,但是ListBox存在一个未记录的依赖关系。 - Dan Puzey
好的,所以你已经发现如果你改变一个“属性”(在一般意义上)而你的集合没有跟踪它的方式,那么它就会出错...听起来你应该遵循Dennis的建议。 - user7116
显示剩余4条评论

1
我怀疑你的代码问题在于没有覆盖 EqualsListBox 使用 Equals 来查找项目,因此如果有多个返回 true 的 Equals,则会匹配多个项目并且会出现问题。 ListBox 中的项目必须基于 Equals 是唯一的。 如果您尝试将 ListBox 绑定到 List Int32 或 List string 并重复任何值,则会出现相同的问题。
当你说抱怨时,它是如何抱怨的?
在下面的简单示例中,即使更改了 GetHashCodeListView 也没有崩溃。
你实现了 INotifyPropertyChanged 吗?
<Window x:Class="ListViewGetHashCode.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <StackPanel Grid.Row="0" Orientation="Horizontal">
            <Button Click="Button_Click" Content="Button"/>
            <Button Click="Button_Click2" Content="Add"/>
            <Button Grid.Row="0" Click="Button_Click_Save" Content="Save"/>
        </StackPanel>
        <ListBox Grid.Row="1" ItemsSource="{Binding BindingList}" DisplayMemberPath="ID" SelectedItem="{Binding Selected}" VirtualizingStackPanel.VirtualizationMode="Standard"/>
        <!--<ListBox Grid.Row="1" x:Name="lbHash" ItemsSource="{Binding}" DisplayMemberPath="ID"/>--> 
    </Grid>
</Window>

using System.ComponentModel;
using System.Collections.ObjectModel;

namespace ListViewGetHashCode
{
    public partial class MainWindow : Window
    {
        ObservableCollection<ListItem> li = new ObservableCollection<ListItem>();
        private Int32 saveId = 100;
        private Int32 tempId = -1;
        public MainWindow()
        {
            this.DataContext = this;
            for (Int32 i = 1; i < saveId; i++) li.Add(new ListItem(i));

            InitializeComponent();

        }
        public ObservableCollection<ListItem> BindingList { get { return li; } }
        public ListItem Selected { get; set; }
        private void Button_Click(object sender, RoutedEventArgs e)
        {
            Int32 counter = 0;
            foreach (ListItem l in li)
            {
                l.ID = -l.ID;
                counter++;
                if (counter > 100) break;
            }
        }
        private void Button_Click2(object sender, RoutedEventArgs e)
        {          
            //li.Add(new ListItem(0)); // this is where it breaks as items were not unique
            li.Add(new ListItem(tempId));
            tempId--;
        }   
        private void Button_Click_Save(object sender, RoutedEventArgs e)
        {
            if (Selected != null && Selected.ID <= 0)
            {
                Selected.ID = saveId;
                saveId++;
            }
        }
    }
    public class ListItem : Object, INotifyPropertyChanged
    {
        private Int32 id;
        public event PropertyChangedEventHandler PropertyChanged;
        protected void NotifyPropertyChanged(String info)
        {
            if (PropertyChanged != null)
            {
                PropertyChanged(this, new PropertyChangedEventArgs(info));
            }
        }  
        public Int32 ID 
        {
            get 
            { 
                return (id < 0) ? 0 : id;
                //if you want users to see 0 and not the temp id 
                //internally much use id
                //return id;
            }
            set
            {
                if (id == value) return;
                id = value;
                NotifyPropertyChanged("ID");
            }
        }
        public override bool Equals(object obj)
        {
            if (obj is ListItem)
            {
                ListItem comp = (ListItem)obj;
                return (comp.id == this.id);
            }
            else return false;
        }
        public bool Equals(ListItem comp)
        {
            return (comp.id == this.id);
        }
        public override int GetHashCode()
        {
            System.Diagnostics.Debug.WriteLine("GetHashCode " + id.ToString());
            return id;
            //can even return 0 as the hash for negative but it will only slow 
            //things downs
            //if (id > 0) return id;
            //else return 0;
        }
        public ListItem(Int32 ID) { id = ID; }
    }
}

非常感谢您抽出时间来测试这个。我已经修改了您的代码并重现了我的错误。很快会发布。 - Coder1095
这看起来很不错,使用基本的GetHashCode作为ID 0的解决方案听起来非常完美。我明天回到工作岗位后会试用你的修复方法。 - Coder1095
刚刚测试了一下 .NET 3.5,没有任何问题(就像我正在尝试升级到4.0的实际应用程序一样)。 - Coder1095
我也在两台机器上尝试过(VS2012 .NET 4.0),并且刚刚在VS2010中尝试,但没有成功。我完全按照上面的内容复制,而不是使用问题中的示例。 - Coder1095
哇,除了它对我有效之外,我不知道该说什么。然后只需给它一个唯一的哈希码,并且不要更改哈希码。 - paparazzo
显示剩余6条评论

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