Search Unity

Impossible Null Reference?

Discussion in 'Scripting' started by Dextozz, Jul 7, 2020.

  1. Dextozz

    Dextozz

    Joined:
    Apr 8, 2018
    Posts:
    493
    I'm using ISerializationCallbackReceiver to display a HashSet in the editor. For some weird reason, I'm seeing this NullReferenceException error:
    Code (CSharp):
    1. ArgumentNullException: Value cannot be null.
    2. Parameter name: array
    3. System.Array.Clear (System.Array array, System.Int32 index, System.Int32 length) (at <437ba245d8404784b9fbab9b439ac908>:0)
    4. System.Collections.Generic.HashSet`1[T].Clear () (at <351e49e2a5bf4fd6beabb458ce2255f3>:0)
    Here's my code:
    Code (CSharp):
    1. [System.Serializable]
    2. public class SHashSet<TKey> : HashSet<TKey>, ISerializationCallbackReceiver
    3. {
    4.     [SerializeField] private List<TKey> keys = new List<TKey>();
    5.  
    6.     public void OnBeforeSerialize()
    7.     {
    8.         keys.Clear();
    9.  
    10.         foreach (TKey key in this)
    11.         {
    12.             keys.Add(key);
    13.         }
    14.     }
    15.  
    16.     public void OnAfterDeserialize()
    17.     {
    18.         this.Clear();
    19.  
    20.         foreach (var key in keys)
    21.         {
    22.             this.Add(key);
    23.         }
    24.     }
    25. }
    26.  
    This shouldn't happen at all. It happens whenever I save my changes in a script and switch back to Unity (when it reloads or whatever)
     
  2. Baste

    Baste

    Joined:
    Jan 24, 2013
    Posts:
    6,338
    That's probably not going to work.

    Your sHashSet inherits from HashSet. HashSet stores it's data, internally, in an array.

    When your SHashSet gets deserialized by Unity, that inner array is not going to automatically be initialized correctly. Some of the data will be set up correctly, some won't. Reading through the decompiled version of Clear(), it looks like this:

    Code (csharp):
    1.  
    2.     [__DynamicallyInvokable]
    3.     public void Clear()
    4.     {
    5.       if (this.m_lastIndex > 0)
    6.       {
    7.         Array.Clear((Array) this.m_slots, 0, this.m_lastIndex);
    8.         Array.Clear((Array) this.m_buckets, 0, this.m_buckets.Length);
    9.         this.m_lastIndex = 0;
    10.         this.m_count = 0;
    11.         this.m_freeList = -1;
    12.       }
    13.       ++this.m_version;
    14.     }
    15.  
    Both m_slots or m_buckets are not initialized before they're needed, and Unity won't be able to automatically serialize them. It seems like m_lastIndex is automatically serialized, though, causing the if-statement to pass.


    Two suggestions for fixing this;
    - HashSet<T> implements IDeserializationCallback, which has an OnDeserialization method. You could try invoking that at the start of OnAfterDeserialize.
    - The way we usually implement serializable versions of collections is not to inherit from them, but instead wrap the collection, and expose the methods that are needed. Then we new() the wrapped collection and add elements to it as needed in OnAfterDeserialize.
     
    Bunny83 and Dextozz like this.
  3. Dextozz

    Dextozz

    Joined:
    Apr 8, 2018
    Posts:
    493
    @Baste Thanks for the response. Everything is a bit clearer now and I'll try the first suggestion tomorrow.
    Could you please give me an example of the second suggestion? I'm having trouble understanding what you meant exactly. Thanks again!
     
  4. Baste

    Baste

    Joined:
    Jan 24, 2013
    Posts:
    6,338
    Code (csharp):
    1.  
    2. [System.Serializable]
    3. public class SHashSet<TKey> : ISerializationCallbackReceiver
    4. {
    5.     [SerializeField] private List<TKey> keys = new List<TKey>();
    6.    
    7.     private HashSet<TKey> hashSet;
    8.  
    9.     public void OnBeforeSerialize()
    10.     {
    11.         keys.Clear();
    12.  
    13.         if (hashSet != null)
    14.             foreach (TKey key in hashSet)
    15.             {
    16.                 keys.Add(key);
    17.             }
    18.     }
    19.  
    20.     public void OnAfterDeserialize()
    21.     {
    22.         if (hashSet == null)
    23.             hashSet = new HashSet();
    24.         else
    25.            hashSet.Clear();
    26.  
    27.         foreach (var key in keys)
    28.         {
    29.             hashSet.Add(key);
    30.         }
    31.     }
    32.  
    33.     public void Add<TKey>(TKey value) => hashSet.Add(value);
    34.     // etc. etc. for all methods
    35. }
    We do this with out SerializableDictionary implementation.

    The biiiig downside here is that you can't send this to a method that expects a HashSet. If you have SHashSet implement ISet and the other things that HashSet implements, you get a bit more flexibility.

    So I'd for sure go for suggestion 1, since that doesn't have those downsides.
     
    Dextozz likes this.
  5. mnotarnicola-unity

    mnotarnicola-unity

    Unity Technologies

    Joined:
    May 9, 2019
    Posts:
    1
    @Dextozz
    One other workaround that seems to work correctly: add a default constructor that calls a base constructor from HashSet<T> that calls the Initialize method. Here's my implementation:

    Code (CSharp):
    1.  
    2. [Serializable]
    3. public class SerializedHashSet<T> : HashSet<T>, ISerializationCallbackReceiver
    4. {
    5.     #region Properties
    6.  
    7.     [field: SerializeField]
    8.     private List<T> SerializedValues { get; set; } = new();
    9.  
    10.     #endregion
    11.  
    12.     #region Constructors
    13.  
    14.     /*
    15.      * Without a call to specific base constructors,
    16.      * some underlying arrays won't be initialized correctly.
    17.      * The constructor that takes a capacity calls Initialize, which fixes this issue.
    18.      */
    19.     public SerializedHashSet() : base(4) { }
    20.  
    21.     #endregion
    22.  
    23.     #region Methods
    24.  
    25.     /// <inheritdoc />
    26.     void ISerializationCallbackReceiver.OnBeforeSerialize()
    27.     {
    28.         SerializedValues.Clear();
    29.         SerializedValues.AddRange(this);
    30.     }
    31.  
    32.     /// <inheritdoc />
    33.     void ISerializationCallbackReceiver.OnAfterDeserialize()
    34.     {
    35.         Clear();
    36.         this.AddRange(SerializedValues);
    37.         SerializedValues.Clear();
    38.     }
    39.  
    40.     #endregion
    41. }
    42.  
     
    CodeRonnie, SisusCo and Bunny83 like this.