Search code examples
c#structmutable

Is it possible to write dirty flags with ref getters?


Is it possible to write dirty flags with ref returned get only properties in C#?

    public class ByRef<T> where T : struct
    {
        private bool _dirty;
        private T    _value;

        public ref T Value
        {
            get
            {
                var oldValue = _value;
                Task.Run(() => //Possible bad attempt at executing code after return.
                {
                    Task.Delay(TimeSpan.FromTicks(1));
                    if (!_value.Equals(oldValue))
                    {
                        _dirty = true;
                    }
                });
                return ref _value;
            }
        }

        public bool Dirty
        {
            get => _dirty;
            set => _dirty = value;
        }
    }
    public class Node2D : Node
    {
        private ByRef<          float   > _rotation;
        private ByRef<(float X, float Y)> _position;
        private ByRef<(float X, float Y)> _scale;

        public ref           float    Rotation => ref _rotation.Value;
        public ref (float X, float Y) Position => ref _position.Value;
        public ref (float X, float Y) Scale    => ref _scale   .Value;
        
        protected override void OnUpdate(NodeEventArgs args)
        {
            if (_rotation.Dirty || _position.Dirty || _scale.Dirty)
            {
                //Update
            }
        }

The main reason I want to do this is to allow mutable members in the tuples so I can modify X and Y separately.

I also don't want to be updating position, rotation and scale every frame, so I was wondering if its possible to get the best of both worlds?


Solution

  • Is it possible to write dirty flags with ref returned get only properties in C#?

    It sure is, and technically you did successfully implement something that does exactly that.

    That being said however, spinning up a Task, having it dispatched by the TaskScheduler, and checking if the value was changed is opens yourself to many issues.

    Normally I would not opine on implementation details if they work. But the way you implemented this feature will lead to race conditions and unexpected behavior which can either cause catastrophic bugs for your users and/or very difficult to debug timings and other synchronization issues down the line.

    For the small price of an additional backing field we can eliminate the TaskScheduler completely.

    To implement this change you have to understand how the CLR handles by ref value types. When you say for example:

    ref x = ref node.Rotation;
    

    what you're essentially saying is, "Go to node, then go to property Rotationthen go to field _rotation, return the managed memory address where _rotation is stored."

    This allows you to have a mutable struct in the same storage location, which it sounds like is your intention.

    With this knowledge we can derive a fairly reliable way to give them the &address and check to see if they changed the value at the &address. We can pull this off with another backing field to store a copy of what was at that &address when we gave it to them. Later, if we want to know if the object is 'dirty' we just compare the current value at the &address with what we stored earlier. If they are different, we know for a fact that the caller changed the value at the &address we gave them. (This is assuming no other callers are accessing it at the same time, if that was the case, we would know if it changed, but not which caller changed it, among other quirks with managed memory).

    public class ByRef<T> where T : struct
    {
        private T _value;
        private T oldValue;
    
        public ref T Value
        {
            get
            {
                // since the address to the backing field is being accessed we should store a copy of the value of the
                // backing field before it was accessed, so in the future, if dirty is checked, we can determine
                // if the value in the backing field has changed
                oldValue = _value;
                return ref _value;
            }
        }
    
        public bool Dirty => _value.Equals(oldValue) is false;
    
        // alternatively if you want the Dirty flag to auto-reset every time it's checked you could do
        public bool Dirty
        {
            get
            {
                bool wasDirty = _value.Equals(oldValue) is false;
    
                if (wasDirty)
                {
                    // since it was dirty, we should now save the current value, so subsequent calls to .Dirty are false
                    // this is optional, if this functionality is needed
                    oldValue = _value;
                }
    
                return wasDirty;
            }
        }
    }
    

    This implementation might seem fairly simple but we can test the validity of the mutability of the backing field to obtain proof that the objects were mutated in-place wherever they are stored in managed memory. (This is ignoring that immutable structs may have been copied, altered, and re-placed into the same address by the CLR, but this shouldn't make a difference).

    public class Node2D
    {
        private ByRef<float> _rotation = new();
        private ByRef<(float x, float y)> _position = new();
        private ByRef<(float X, float Y)> _scale = new();
    
        public ref float Rotation => ref _rotation.Value;
    
        public ref (float x, float y) Position => ref _position.Value;
    
        public ref (float x, float y) Scale => ref _scale.Value;
    
        public void DumpInfo()
        {
            Console.WriteLine($"Check Dirty Statuses of all Fields");
            Console.WriteLine($"Position ({_position.Dirty}) Rotation ({_rotation.Dirty}) Scale ({_scale.Dirty})");
            Console.WriteLine(string.Empty);
    
            Console.WriteLine($"Verifying the backing fields have not changed addresses and have not been moved by GC or CLR");
            unsafe
            {
                fixed (float* pointer = &_rotation.Value)
                {
                    DumpAddress(nameof(Rotation), (long)pointer, _rotation.Value);
                }
                fixed ((float x, float y)* pointer = &_position.Value)
                {
                    DumpAddress(nameof(Position), (long)pointer, _position.Value);
                }
                fixed ((float x, float y)* pointer = &_scale.Value)
                {
                    DumpAddress(nameof(Scale), (long)pointer, _scale.Value);
                }
            }
            Console.WriteLine(string.Empty);
        }
        private unsafe void DumpAddress(string Name, long pointer, object Value)
        {
            Console.WriteLine($"{Name}\n\r\t Address:{pointer:X} Value:{Value}");
        }
    }
    

    We can then use this to test that the fields are mutable, and we have up-to-date, but not atomic, information on if the values are different from the last time we checked.

    // create a node
    var node = new Node2D();
    
    // dump initial info for comparison
    node.DumpInfo();
    /*
    Position (False) Rotation (False) Scale (False)
    Rotation
             Address: 1F440C8DF10 Value:0
    Position
             Address: 1F440C8DF28 Value:(0, 0)
    Scale
             Address: 1F440C8DF48 Value:(0, 0)
    */
    
    // access field but do not change value
    ref float x = ref node.Rotation;
    
    _ = x * 2;
    
    // check to make sure nothing changed
    node.DumpInfo();
    /*
    Position (False) Rotation (False) Scale (False)
    Rotation
             Address: 1F440C8DF10 Value:0
    Position
             Address: 1F440C8DF28 Value:(0, 0)
    Scale
             Address: 1F440C8DF48 Value:(0, 0)
    */
    
    // change a single field
    x = 12f;
    
    // check to make sure the address is still the same, and the value changed
    node.DumpInfo();
    /*
    Position (False) Rotation (True) Scale (False)
    Rotation
             Address: 1F440C8DF10 Value: 12
    Position
             Address: 1F440C8DF28 Value:(0, 0)
    Scale
             Address: 1F440C8DF48 Value:(0, 0)
    */
    
    // change the tuples to ensure they are mutable as well
    node.Position.x = 1.22f;
    node.Scale.y = 0.78f;
    
    // check to make sure the address is still the same, and the value changed
    node.DumpInfo();
    /*
    Position (True) Rotation (False) Scale (True)
    Rotation
             Address:1F440C8DF10 Value:12
    Position
             Address:1F440C8DF28 Value:(1.22, 0)
    Scale
             Address:1F440C8DF48 Value:(0, 0.78)
    */
    
    // this is optional, but check again to see if the dirty flags have cleared
    node.DumpInfo();
    /*
    Position (False) Rotation (False) Scale (False)
    Rotation
             Address:1F440C8DF10 Value:12
    Position
             Address:1F440C8DF28 Value:(1.22, 0)
    Scale
             Address:1F440C8DF48 Value:(0, 0.78)
    */