[.NET, C#, ООП, Промышленное программирование] Lazy Properties Are Good. That Is How You Are to Use Them

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
08-Мар-2021 12:30

Properties have always been a subject of numerous discussions and arguments, and I am not the one to resolve all of them. I am here to suggest to you an approach which I like and use.Problems of parameterless methodsEven though I have no objective to fight mutability, I only consider immutable objects for this article.Consider a type Integer defined as follows:
public sealed record Integer(int Value);
It is some artificial abstract object. For example, in math every integer number inevitably has its tripled value. In today's world many people would still define it a "classical" way with a method, which computes the tripled value and returns it:
public sealed record Integer(int Value)
{
    public Integer Triple() => new Integer(Value * 3);
}
Now, let me show the downsides of this fairly naive approach. That is a piece of code addressing this method:
public int SomeMethod(Integer number)
{
    var tripled = number.Triple();
    if (tripled.Value > 5)
        return tripled.Value;
    else
        return 1;
}
In the fourth and fifth line we address the tripled's Value. In fact, I could have written it in one line, but then I would have to call Triple twice, which might hurt the performance significantly{ Value: > 5 }You might know that in C#9 with all these advanced pattern matching techniques you can write number.Triple() is { Value: > 5 } res ? res : 1, and it is true.However, you have to agree that you cannot guarantee how many times this method is going to be called. You will need to cache your SomeMethod by creating an additional table of values, otherwise, it may be called multiple times. In either case the performance and readability and maintainability of your code is likely to be negative affected.Now, property's move. That is what it should look like:
public int SomeMethod(Integer number)
    => number.Tripled.Value > 5 ? number.Tripled.Value : 1;
As you can see, I address the Tripled property without any worry that I call it too many times. In fact, this property will return the same object, but it will spend time and memory only when it is addressed the first time.As a code's user, you should not have to care about the cost of performance of such properties. But you would if it was a method, performing an active action (that is, an action which guaranteedly consumes some remarkable CPU time or memory).What is the solution?First of all, as a type's author, I must care about the experience of the programmer using my type. I never want to call some outside library's methods too many times, so as a user, I would cache them somewhere ruining my code. What I should see instead are properties which I never want to cache, let them be fields for me. Looks like a field, acts like a field.Second, now we need to guarantee that our property is indeed not an active action. Internally, it will call the binded method, but only once. You may think of it as of a lazy unnecessary initialization. Because the object itself is responsible for this property, the user cannot check whether the property has already been initialized or not, which helps to avoid a potential for useless optimizations.It sounds bad that the user is limitedWith those properties, when addressing one you cannot tell whether it will return immediately or consume a few milliseconds to process.However, if it was not a property, it would be a method which you would still call. The first time the property will be as expensive as calling a method, so at least you do not lose performance.If you could check whether a property is initialized AND would change the behaviour of your code depending on it, there is probably something very strange with your code.Third, I still care about my own comfort, given that I am the designer of my type. I cannot have decorators in C#, at most I have source generators, which, as they cannot modify the code, seem useless for this.What I found the most convenient is having a private field next to every public property where your cache would be stored. Not only that, the initializer method should not be in the cache field's constructor (because you cannot address fields of a type when initializing a new field). Instead, it must be in the property (so that we could address type's fields).That is it about this pattern. Now I am going to share my thoughts about the implementation of this pattern in real life.Down into detailsHere I am going to cover my thoughts rather than find the best way of implementing this pattern.Approach 1. Fake property:
public sealed record Number(int Value)
{
    public int Number Tripled => new Number(@this.Value * 3);
}
To me it looks like an anti-pattern. Imagine profiling your code and then discover that addressing a property consumed so much CPU time and RAM, despite that a property syntactically looks the same as a field. Instead, if you do not want to cache it, use a method.Now we are going to cover approaches with permanent caching/lazy initialization.Approach 2. Lazy<T>:
public sealed record Number : IEquatable<Number>
{
    public int Value { get; init; }  // we have to overload the ctor, so it anyway makes no sense to put it as a record's parameter
    public int Number Tripled => tripled.Value;
    private Lazy<Number> tripled;
    public Number(int value)
    {
        Value = value;
        tripled = new(() => value * 3);  // we cannot do it when assigning a field because you cannot address other fields from a field's initialization
    }
    // because Equals, generated for records, is generated based on its fields, hence, Lazy<T> affects the comparison
    public bool Equals(Number number) => Value == number.Value;
    // same logic with GetHashCode
    public override int GetHashCode() => Value.GetHashCode();
}
To me it looks awful. Not only that we now cannot use the records' features like auto-implemented Equals and GetHashCode, I barely can imagine adding a new property, because this would only increase the "entropy" of this messy code. Also, every time you add a field which has to affect the equality/hash code, you will need to add it to both Equals and GetHashCode.As we can see, we have to put the Lazy<T>'s initialization in a place different from the field. That is, while declaring the field in one place, we assign to it in way other place (in the constructor). Assume you want to add a new property, then you will need to add a lazy somewhere, and its initialization in the constructor.Another problem with this code is the with operator, which clones all your fields aside from those you explicitly reassign. This operator will clone your Lazy<T> field as well, despite that it was only valid for your first instance, but might be not for new values of fields. This implies that with should also be overriden, and every time you add a real field, which should be copied, you will have to add it to the override as well.Approach 3. Using ConditionalWeakTable:
public sealed record Number(int Value)
{
    public Number Tripled => tripled.GetValue(this, @this => new Integer(@this.Value * 3));
    private static ConditionalWeakTable<Number, Number> tripled = new();
}
It looks fairly concise. In terms of design it implements what I want to see: a private field carring a lambda-initalizer, which will "hit" once the property is addressed the first time.There are a couple of minor problems. First, it only accepts reference types, so you will need to wrap a ValueType with a class or record. Second, it is also a bit slower than it could be, especially when using primitive types (takes 6x as much time as my naive implementation).My naive implementationI only provide this for the sake of completeness of the information, so it is more of an example of what it could look like.Let me consider the key points:
  • This private container will be a struct (because why having another unnecessary allocation?)
  • Equals and GetHashCode will now return true and 0 respectively. Although it is a workaround, this fairly simple trick allows us to avoid overriding these two methods. That is, you will still have a correct comparison and hash code with Roslyn's generated Equals and GetHashCode even if you have private fields dedicated to caches.
  • Let there be any type for <T> (unlike what we had in Approach 3). We are going to lock the field's holder, not the field itself.
  • We will pass the factory in the property itself, so that we could address any field without needing to override the contructor (like we had to in Approach 2).
  • When internally checking whether our property is initalized, we shall compare references of holders. If, say, you applied the with operator, even though this private field is copied along with others, your property will be reinitialized once addressed the first time in the new instance.
I called my struct for permanent caching/lazy initialization as FieldCache. That is what its fields look like:
public struct FieldCache<T> : IEquatable<FieldCache<T>>
{
    private T value;
    private object holder; // we will only compare references, hence there is no need to make it generic
    // like I said earlier, to avoid affects of the field on Equals and GetHashCode we make it permanently true and 0
    public bool Equals(FieldCache<T> _) => true;
    public override int GetHashCode() => 0;
}
Now, that is what a naive implementation of the method GetValue looks like:
public struct FieldCache<T> : IEquatable<FieldCache<T>>
{
        public T GetValue<TThis>(Func<TThis, T> factory, TThis @this)
          // record is class internally. We need the holder to
          // be a reference type so that we could safely compare by it
          where TThis : class
        {
            // if the holder's reference has changed or is null
            if (!ReferenceEquals(@this, holder))
                lock (@this)
                {
                    if (!ReferenceEquals(@this, holder))
                    {
                        // we pass this to the factory, so that the
                        // property using FieldCache could address
                        // local properties/fields or methods
                        // without the need to recreate a capturing lambda
                        value = factory(@this);
                        holder = @this;
                    }
                }
            return value;
        }
}
Now, that is how my type is designed a the end:
public sealed record Number(int Value)
{
    public int Number Tripled => tripled.GetValue(@this => new Number(@this.Value * 3), @this);
    private FieldCache<Number> tripled;
}
It is significantly faster than ConditionalWeakTable, although slower than Lazy<T>:MethodMeanBenchFunction4600 nsLazy<T>0.67 nsFieldCache<T>3.67 nsConditionalWeakTable25 nsIn conclusionIf, in your immutable type, you have a method with no parameters but some computations, you may want to replace it with a cacheable property to make sure that it is called once at most, as well as encapsulate those computations into a field-like looking property.Nonetheless, you may experience problems with the existing approaches, so the problem, in a sense, remains open.Like I said, I do use this pattern, so I implemented it for my projects. The source code is available on GitHub. I do not pose it as the best solution, so instead, you are likely to develop your own type, or take an existing one which fits your needs.The main point of this article - use cacheable properties instead of parameterless methods for immutable objects.Thank you for your attention, I hope I helped some people to reconsider their view on this problem.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_.net, #_c#, #_oop (ООП), #_promyshlennoe_programmirovanie (Промышленное программирование), #_csharp, #_oop, #_industrial_programming, #_property, #_properties, #_lazy_initialization, #_.net, #_c#, #_oop (
ООП
)
, #_promyshlennoe_programmirovanie (
Промышленное программирование
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 14:10
Часовой пояс: UTC + 5