0

I need a HashSet I can edit in the Inspector.

I've found this solution...

Posted here...

using System;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public class SerializableHashSet<T> : HashSet<T>, ISerializationCallbackReceiver
{
    [SerializeField] 
    private List<T> values = new List<T>();

    public SerializableHashSet() : base() {}
    
    public SerializableHashSet(IEnumerable<T> collection) : base(collection) {}

    public void OnBeforeSerialize ()
    {
        var cur = new HashSet<T> (values);
        
        foreach (var val in this) {
            if (!cur.Contains (val)) {
                values.Add (val);
            }
        }
    }
    
    public void OnAfterDeserialize ()
    {
        Clear ();

        foreach (var val in values)
        {
            if (val != null)
            Add (val);
        }
    }
}

However it throws the following 2 errors...

NullReferenceException: Object reference not set to an instance of an object
System.Collections.Generic.HashSet`1+Enumerator[T].MoveNext () (at <351e49e2a5bf4fd6beabb458ce2255f3>:0)
SerializableHashSet`1[T].OnBeforeSerialize () (at Assets/Scripts/Utilities/Collections/SerializableHashSet.cs:23)

ArgumentNullException: Value cannot be null.
Parameter name: array
System.Array.Clear (System.Array array, System.Int32 index, System.Int32 length) (at <695d1cc93cca45069c528c15c9fdd749>:0)
System.Collections.Generic.HashSet`1[T].Clear () (at <351e49e2a5bf4fd6beabb458ce2255f3>:0)
SerializableHashSet`1[T].OnAfterDeserialize () (at Assets/Scripts/Utilities/Collections/SerializableHashSet.cs:32)

I'm also not sure I need the check on line 21 - Surely duplicates just wouldn't be in the internal HashSet in the first place?

PhilB
  • 1
  • 2

1 Answers1

1

In general it is a little bit dangerous to simply inherit from collection types.

Honestly not even sure but I think the issue here is related to the constructor not being called correctly by the serializer.

Instead I would (even though it is more work of course) have a wrapper class containing both fields, a List<T> and a HashSet<T> parallel and then implement the same interfaces, forwarding them to the HashSet field like e.g.

[Serializable]
public class SerializableHashSet<T> :
    ISerializationCallbackReceiver,
    ISet<T>,
    IReadOnlyCollection<T>
{
    [SerializeField] private List<T> values = new List<T>();
    private HashSet<T> _hashSet = new HashSet<T>();


    #region Constructors

    // empty constructor required for Unity serialization
    public SerializableHashSet() { }

    public SerializableHashSet(IEnumerable<T> collection)
    {
        _hashSet = new HashSet<T>(collection);
    }

    #endregion Constructors


    #region Interface forwarding to the _hashset

    public int Count => _hashSet.Count;
    public bool IsReadOnly => false;
    public bool ISet<T>.Add(T item) => _hashSet.Add(item);
    public bool ICollection<T>.Remove(T item) => _hashSet.Remove(item);
    public void ExceptWith(IEnumerable<T> other) => _hashSet.ExceptWith(other);
    public void IntersectWith(IEnumerable<T> other) => _hashSet.IntersectWith(other);
    public bool IsProperSubsetOf(IEnumerable<T> other) => _hashSet.IsProperSubsetOf(other);
    public bool IsProperSupersetOf(IEnumerable<T> other) => _hashSet.IsProperSupersetOf(other);
    public bool IsSubsetOf(IEnumerable<T> other) => _hashSet.IsSubsetOf(other);
    public bool IsSupersetOf(IEnumerable<T> other) => _hashSet.IsSupersetOf(other);
    public bool Overlaps(IEnumerable<T> other) => _hashSet.Overlaps(other);
    public bool SetEquals(IEnumerable<T> other) => _hashSet.SetEquals(other);
    public void SymmetricExceptWith(IEnumerable<T> other) => _hashSet.SymmetricExceptWith(other);
    public void UnionWith(IEnumerable<T> other) => _hashSet.UnionWith(other);
    public void Clear() => _hashSet.Clear();
    public bool Contains(T item) => _hashSet.Contains(item);
    public void CopyTo(T[] array, int arrayIndex) => _hashSet.CopyTo(array, arrayIndex);
    Collection<T>.Add(T item) => _hashSet.Add(item);
    public IEnumerator<T> GetEnumerator() => _hashSet.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    #endregion Interface forwarding to the _hashset


    #region ISerializationCallbackReceiver implemenation

    public void OnBeforeSerialize()
    {
        var cur = new HashSet<T>(values);

        foreach (var val in this)
        {
            if (!cur.Contains(val))
            {
                values.Add(val);
            }
        }
    }

    public void OnAfterDeserialize()
    {
        Clear();

        foreach (var val in values)
        {
            Add(val);
        }
    }

    #endregion ISerializationCallbackReceiver implemenation
}

If this is the optimal approach I can't tell but it works without exceptions ;)

Note though

In the Inspector you still can have duplicate entries which will be simply ignored by the HashSet. Also entries that are removed in the HashSet will remain in the List!

This limitation is from your ISerializationCallbackReceiver implementation. t was done like this due to the Inspector simply copying the last entry when adding elements so otherwise it would simply not be possible at all to add elements via the Inspector. There is not really a way around this except implementing a custom drawer...

derHugo
  • 83,094
  • 9
  • 75
  • 115
  • I'm getting a warning for this line... public IEnumerator GetEnumerator() => _hashSet.GetEnumerator(); That says... Boxing allocation: conversion from 'HashSet.Enumerator' to 'IEnumerator' requires boxing of value type – PhilB Oct 23 '21 at 09:49
  • I'm also getting the following compiler errors... Assets/Scripts/Progression/ProgressController.cs(20,48): error CS1061: 'SerializableHashSet' does not contain a definition for 'Remove' and no accessible extension method 'Remove' accepting a first argument of type 'SerializableHashSet' could be found (are you missing a using directive or an assembly reference?) – PhilB Oct 23 '21 at 09:56
  • Assets/Scripts/UI/Balance of Power Adjustments Screen/BalanceOfPowerAdjustmentsScreenController.cs(1027,39): error CS0173: Type of conditional expression cannot be determined because there is no implicit conversion between 'System.Collections.Generic.HashSet' and 'SerializableHashSet' – PhilB Oct 23 '21 at 09:56
  • NullReferenceException: Object reference not set to an instance of an object System.Collections.Generic.HashSet`1+Enumerator[T].MoveNext () (at <351e49e2a5bf4fd6beabb458ce2255f3>:0) SerializableHashSet`1[T].OnBeforeSerialize () (at Assets/Scripts/Utilities/Collections/SerializableHashSet.cs:21) UnityEditor.HostView:OnInspectorUpdate() (at /Users/bokken/buildslave/unity/build/Editor/Mono/HostView.cs:354) – PhilB Oct 23 '21 at 09:56
  • NullReferenceException: Object reference not set to an instance of an object System.Collections.Generic.HashSet`1+Enumerator[T].MoveNext () (at <351e49e2a5bf4fd6beabb458ce2255f3>:0) SerializableHashSet`1[T].OnBeforeSerialize () (at Assets/Scripts/Utilities/Collections/SerializableHashSet.cs:21) UnityEngine.GUIUtility:ProcessEvent(Int32, IntPtr, Boolean&) (at /Users/bokken/buildslave/unity/build/Modules/IMGUI/GUIUtility.cs:189) – PhilB Oct 23 '21 at 09:57
  • Thanks for your help so far. Any suggestions for how to address these would be great. Thanks :) – PhilB Oct 23 '21 at 09:57
  • For the remove: fixed it .. had to make the implementation public .. I don't know what you are doing in `BalanceOfPowerAdjustmentsScreenController` ..and strange .. I didn't get any exception in my project even after closing and reopening etc ... – derHugo Oct 23 '21 at 10:16
  • I'm still getting some warnings in Rider. For line 31 which says... ```public bool ISet.Add(T item) => _hashSet.Add(item);``` The warnings are... #1 The modifier 'public' is not valid for explicit interface implementation. #2 Method 'Add' cannot implement method from interface 'System.Collections.Generic.ICollection'. Return type should be 'void'. – PhilB Oct 24 '21 at 02:20
  • For line 32, which is ```public bool ICollection.Remove(T item) => _hashSet.Remove(item);``` it says... The modifier 'public' is not valid for explicit interface implementation. – PhilB Oct 24 '21 at 02:21
  • For line 46, which is ```Collection.Add(T item) => _hashSet.Add(item);``` it says... #1 Cannot resolve symbol 'Add', #2 Unexpected token (just after '.Add(T item)'), #3 Cannot resolve symbol '_hashSet', #4 Cannot resolve symbol item. – PhilB Oct 24 '21 at 02:24
  • Do I actually need to specify the interfaces when I give these method implementations? – PhilB Oct 24 '21 at 02:25
  • So I managed to get rid of those warnings by doing the following... #1 Removing 'public' for both the 'ISet.Add(T item)' definition and 'ICollection.Remove(T item)' definition. #2 For line 46 I made it ICollection instead of Collection. #3 I also had to add a line... ```public bool Add(T item) => _hashSet.Add(item);``` for methods outside this class that use Add on SerializableHashset directly. I also had to do the same for Remove. – PhilB Oct 24 '21 at 02:36
  • In BalanceOfPowerAdjustmentsController I'm doing this... ```var subLocationsNotAffected = ReferenceController.Instance.devSettings.areSubLocationsAffectedByThePlayerExcludedFromRandomAdjustments ? s.subLocationsEnabled.Except(affectedSubLocations).ToHashSet(): s.subLocationsEnabled;``` - where s.subLocationsEnabled is a SerializableHashSet I'm trying to perform a '.Except' operation on then convert the result back to a HashSet. – PhilB Oct 24 '21 at 02:50
  • Ah I think I need to somehow set up a type conversion as it doesn't know how to exclude elements from a normal HashSet from a SerializableHashSet. Does that sound right? I'm not sure how to proceed...? – PhilB Oct 24 '21 at 03:06
  • I removed '.ToHashSet' and it stopped complaining about that. – PhilB Oct 24 '21 at 03:25
  • So, after resolving those extra issues, I've found that the OnAfterSerialize error in my initial post is now resolved. However I'm still getting the original OnBeforeSerialize error, 3 variations of it in fact, one after the other... – PhilB Oct 24 '21 at 03:32
  • ```NullReferenceException: Object reference not set to an instance of an object System.Collections.Generic.HashSet`1+Enumerator[T].MoveNext () (at <351e49e2a5bf4fd6beabb458ce2255f3>:0) SerializableHashSet`1[T].OnBeforeSerialize () (at Assets/Scripts/Utilities/Collections/SerializableHashSet.cs:21) UnityEditor.HostView:OnInspectorUpdate() (at /Users/bokken/buildslave/unity/build/Editor/Mono/HostView.cs:354)``` – PhilB Oct 24 '21 at 03:32
  • ```NullReferenceException: Object reference not set to an instance of an object System.Collections.Generic.HashSet`1+Enumerator[T].MoveNext () (at <351e49e2a5bf4fd6beabb458ce2255f3>:0) SerializableHashSet`1[T].OnBeforeSerialize () (at Assets/Scripts/Utilities/Collections/SerializableHashSet.cs:21) UnityEngine.GUIUtility:ProcessEvent(Int32, IntPtr, Boolean&) (at /Users/bokken/buildslave/unity/build/Modules/IMGUI/GUIUtility.cs:189) ``` – PhilB Oct 24 '21 at 03:32
  • ```NullReferenceException: Object reference not set to an instance of an object System.Collections.Generic.HashSet`1+Enumerator[T].MoveNext () (at <351e49e2a5bf4fd6beabb458ce2255f3>:0) SerializableHashSet`1[T].OnBeforeSerialize () (at Assets/Scripts/Utilities/Collections/SerializableHashSet.cs:21)``` – PhilB Oct 24 '21 at 03:33