dotnet icon indicating copy to clipboard operation
dotnet copied to clipboard

Generate observable properties based on nested record

Open TFTomSun opened this issue 3 years ago • 3 comments

One disadvantage that I see with the classic observable pattern implementation is that the backing fields are accessible and visible in the intellisense even if nobody should access them directly. Have you considered to generate the observable properties based on a different input than the backin fields (e. g. a nested record)

example:


class MyObsevablePerson
{
[ObservableFields] 
private record Fields(string FirstName, string LastName);
} 

would generate

class MyObsevablePerson
{
  private Fields  _fields =new();

  public string FirstName
{
get=> _fields.FirstName;
set ....
} 

.... 

TFTomSun avatar Apr 08 '22 19:04 TFTomSun

One disadvantage to your approach, as given, is that your setter would create a new object each time you set a property.

You could, in fact, accomplish your proposed approach right now, using this code:

public class MyObservablePerson : ObservableObject
{

    private Fields _fields = new();
    
    public string FirstName
    {
        get => this._fields.FirstName;
        set => this.SetProperty(
            this._fields.FirstName,
            value, 
            this._fields, 
            static (person, value) => person._fields = person._fields with 
            {
                FirstName = value,
            }
        );
    }
    
    private record Fields(string FirstName);
}

But - since you're using a record, with positional syntax, it is immutable. So, each time you set a property's value, you need to use a with expression - which creates a new object - which needs to be garbage collected.

Since you're intending to mutate your fields, a more efficient alternative would be to maintain mutable state (if needed, you could optionally provide an immutable representation of the state).

public class MyObservablePerson : ObservableObject
{
    private Fields _fields = new();
    
    public string FirstName
    {
        get => this._fields.FirstName;
        set => this.SetProperty(
            this._fields.FirstName,
            value, 
            this._fields, 
            static (person, value) => person._fields = person._fields with 
            {
                FirstName = value,
            }
        );
    }
    
    private class Fields
    {
        public string FirstName { get; set; }
    }
}

But this is still a lot of code to write, for not much benefit, for there's a much easier approach, that gives you the same benefit.

  • Implement your ObservableObject the way you always would, with private fields - but in a private class. Use the new source generation features, if you'd like.
  • The public class will simply forward the property getters and setters to an instance of the private class
  • Subscribe to the PropertyChanged event on the private class, and raise notifications on the public class.
public class MyObservablePerson : ObservableObject
{
    public MyObservablePerson()
    {
        this._fields.PropertyChanged += (sender, args) => this.OnPropertyChanged(args.PropertyName);
    }
    private Fields _fields = new();
    
    public string FirstName
    {
        get => this._fields.FirstName;
        set => this._fields.FirstName = value;
    }
    
    private class Fields : ObservableObject
    {
        [ObservableProperty]
        private string firstName;
    }
}

Edit: To be clear - I have nothing against your feature suggestion - in fact, it's a grand idea. I just think that instead of your suggested implementation, it should take the form of my last suggested implementation ☝️

mikechristiansenvae avatar May 31 '22 15:05 mikechristiansenvae

Another thought. If the private class is being generated, you could also avoid the whole PropertyChanged event.

First off, I like this code, for what you would enter to trigger the source generator...

  • You're specifying the actual property names you want
    • Normally, you'd create a field, with a name of firstName, with an attribute, to generate a property with a name of FirstName. We do this because if we specify a property - then the source generator can't create/change that property.
    • Here, we're using constructor initialization syntax to indicate a property name - but not create the property itself - so the source generator is free to create the property.
  • We specify a default value for the property.
public partial class MyObservablePerson : ObservableObject
{
    [ObservableFields]
    private Fields _fields = new
    {
        FirstName = string.Empty,
        LastName = string.Empty,
        Age = -1,
    };
}

Which could then generate (as the other part):

public partial class MyObservablePerson
{
    private Fields _fields = new();
    
    public string FirstName
    {
        get => this._fields.FirstName;
        set => this.SetProperty(this._fields.FirstName, value, this._fields, static (obj, value) => obj._fields.FirstName = value);
    }
    public string LastName
    {
        get => this._fields.LastName;
        set => this.SetProperty(this._fields.LastName, value, this._fields, static (obj, value) => obj._fields.LastName = value);
    }
    public string Age
    {
        get => this._fields.Age;
        set => this.SetProperty(this._fields.Age, value, this._fields, static (obj, value) => obj._fields.Age = value);
    }
    
    private class Fields
    {
        public string FirstName  { get; set; }
        public string LastName  { get; set; }
        public string Age { get; set; }
    }
}

mikechristiansenvae avatar May 31 '22 16:05 mikechristiansenvae

@mikechristiansenvae yes, forgot about the immutability of the record, when I wrote it. I like your ideas as well. Another idea:

Simply write the public interface yourself:


public partial class MyObservablePerson : ObservableObject
{
    public string FirstName
    {
        get => this._fields.FirstName;
        set => this._fields.FirstName = value;
    }
}

and let the generator do the rest:


public partial class MyObservablePerson
{
    private Fields __fields;
    private Fields _fields => (__fields ??= new(this));

    
    private class Fields
    {
ObservableObject Owner {get;}
public Fields(ObservableObject observable)
{
Owner = obsverable;
}

private string firstName;
 public string FirstName
    {
        get => this.firstName;
        set {
if(this.firstName != value){
   this.firstName = value;
this.Owner.NotifyChange("FirstName");
}
}
 ...
}


That would have the advantage that you would always take the same approach to define a observable property even for special situations / properties which might need a bit more than just the standard behavior around.

TFTomSun avatar Jun 01 '22 10:06 TFTomSun