Skip to content

Latest commit

 

History

History
412 lines (303 loc) · 36.8 KB

text.md

File metadata and controls

412 lines (303 loc) · 36.8 KB

Если вы используете свойства (property) — вам должно быть за это стыдно.

Почему свойства (property) в языках программирования — это плохая идея

Во многих языках программирования есть свойства (property) — способ определить член класса так, чтобы доступ к нему выглядел, как доступ к полю, но при этом вместо чтения и записи по полю вызывались определённые пользователем методы. Пример на C#:

using System;

class Example {
    private int _field;
    public int Field
    {
        get
        {
            Console.WriteLine($"Read {_field}");
            return _field;
        }
        set
        { 
            Console.WriteLine($"Wrote {value}");
            _field = value;
        }
    }
}

class Program
{
    static void Main()
    {
        var example = new Example();
        Console.WriteLine("Before assignment");
        example.Field = 42;
        Console.WriteLine("After assignment, before reading");
        var value = example.Field;
        Console.WriteLine($"After read, got {value}");
    }
}

Эта программа при запуске печатает следующее:

Before assignment
Wrote 42
After assignment, before reading
Read 42
After read, got 42

В этом посте я расскажу о том, что не так со свойствами, какие проблемы они приносят и как можно решить задачи, с которыми они якобы помогают.

Что же не так со свойствами

Свойства не являются первоклассными сущностями

Обычные поля можно (как правило) передать по ссылке в функцию аргументом, которая может их менять. Свойство же представляет из себя ссылку на класс (причём на класс целиком, а не только какое-то поле), метод-геттер и метод-мутатор. Так как это совсем разные вещи, одно нельзя передать вместо другого без очень большого усложнения ABI. Конечно, значение из свойства можно передать по ссылке в другую функцию, но присваивание и чтение этого значения не будут вызывать нужные методы.

Мои последующие претензии во многом вытекают из этого факта.

Свойства хуже поддаются оптимизации

Доступы к отдельным полям не пересекаются, поэтому компилятор может использовать информацию о зависимостях по данным для того, чтобы вынести доступ к полю из цикла или, наоборот, спекулятивно загрузить поле до того, как оно фактически понадобится. Свойства же являются полноценными методами, имеющими доступ к классу целиком. Если компилятор не может достаточно в них разобраться (читай, если не заинлайнит), то он не сможет производить подобные оптимизации. Это соображение особенно актуально для JIT-компиляторов, поскольку они, в отличие от AOT-компиляторов, не могут себе позволить потратить произвольно большое количество времени на оптимизацию кода.

Доступ к свойству синтаксически не выглядит, как вызов метода

Сила свойств в том, что туда можно записать произвольный код. Слабость свойств в том, что... Туда можно записать произвольный код.

У программистов есть определённые ожидания — или, можно сказать, ментальная модель — насчёт поведения полей. Эти ожидания не совпадают с ожиданиями насчёт поведения методов. Когда вызов методов выглядит визуально так же, как и доступ к полям, возникает возможность для путаницы. Далее я разберу подробнее отдельные несовпадения.

Со свойствами можно случайно написать бесконечную рекурсию

При реализации свойства можно случайно записать/считать в/из самого свойства вместо backing field. Да, звучит не особо реалистично, но я, пока писал пост, сделал ровно такую ошибку при написании одного из примеров.

Свойства усложняют написание exception-safe кода

Любой код должен работать устойчиво в присутствии ошибок. В языках с исключениями достичь этого особенно сложно из-за того, что исключения могут вылететь из любой функции. Обычно выделяют несколько уровней exception safety:

  1. No-throw guarantee — выброс исключения в процессе работы кода не сказывается на вызывающей стороне.
  2. Strong exception safety — операция либо завершается успешно, либо, в случае выброса исключения, не имеет никакого эффекта.
  3. Basic exception safety — операция может завершиться частично, вместе со всеми побочными эффектами, но важные инварианты сохранены.
  4. No exception safety — никаких гарантий при выбросе исключения.

В силу того, что языки с исключениями обычно являются управляемыми — т. е. с неким рантаймом, который предотвращает возможность совершения наиболее фатальных ошибок — подобные языки обеспечивают как минимум basic exception safety: выброс исключения не поломает вам структуры данных и не попортит левую память. Однако инварианты могут быть и пользовательскими, и управляемые языки с их поддержкой не особо помогают. Пример на C#:

class SeparatedArray {
    // инвариант: длина foos равна длине bars
    private List<Foo> foos = new List<Foo>();
    private List<Bar> bars = new List<Bar>();

    public void Add(Foo foo, Bar bar) {
        foos.Add(foo);
        bars.Add(bar);
    }
}

Код выглядит достаточно просто, однако инвариант класса может быть сломан. Именно, если свободная ёмкость есть в foos, но отсутствует в bars, и свободной памяти для реаллокации недостаточно, то первая строка Add выполнится успешно, а вот вторая выбросит OutOfMemoryException, которое вызывающая сторона может перехватить. В этом случае значение SeparatedArray останется со сломанным пользовательским инвариантом. Basic exception safety наличествует, но вот strong exception safety — нет. Для достижения последнего требуется заранее преаллоцировать память в обоих массивах перед добавлением элементов.

Приведённый выше пример, возможно, не вполне реалистичный, но он демонстрирует, что писать exception safe код сложно. Для того, чтобы иметь возможность такой код писать, нужно иметь примитивы, которые гарантируют отсутствие выброса исключения. И свойства тут подкладывают свинью: запись и чтение из поля исключения не бросают, но вот запись и чтение из свойства — которые синтаксически выглядит точно так же — вызывают произвольный пользовательский код и потому могут кидать исключение. Таковым, например, является свойство Capacity у List в C#, которое может бросить исключение при присваивании. Таким образом, свойства мешают написанию exception-safe кода.

Технически, эту проблему можно до некоторой степени решить тулингом, который подсвечивает доступ к свойству, но, во-первых, я не знаю, есть ли такой вообще, а во-вторых, для того, чтобы воспользоваться таким решением, нужно вообще быть в курсе самого существования подобной проблемы, что не очень-то дружелюбно по отношению к пользователю.

Чтение свойства не является чистой (pure) операцией

Чтение поля является чистой операцией: это можно сделать сколько угодно раз и каждый раз — если значение не меняется между чтениями — чтение поля будет давать один и тот же результат. Также чтение поля не имеет побочных эффектов (технически оно может спровоцировать page fault, но в семантике ЯП этого не видно), и это в целом обычно крайне дешёвая операция. Более того, если читается поле foo и в промежутке меняется поле bar, то значение foo не меняется: разные поля не пересекаются. Эти вполне логичные ожидания вылетают в трубу при работе со свойствами.

Чтение свойства может поменять объект

Да, это плохая практика и так обычно никто не делает, но ничто не останавливает программиста от написания чего-то вроде этого:

using System;

class SneakyCounter {
    private int _i;
    public int i {
        get {
            _i += 1;
            return _i;
        }
        set { _i = value; }
    }
}

class Program
{
    static void Main()
    {
        var innocent = new SneakyCounter();
        Console.WriteLine($"i is {innocent.i}"); // i is 1
        Console.WriteLine($"i is {innocent.i}"); // i is 2
        Console.WriteLine($"i is {innocent.i}"); // i is 3
    }
}

Да, от этого конкретно в C# можно защититься, если поставить модификатор readonly на get... Только это почему-то работает лишь на структурах и не работает на классах.

Чтение свойство может скрывать тяжёлую операцию

Обычно программист при чтении поля ожидает, что значение поля уже материализовано в памяти, и потому чтение поля максимум скопирует память. Однако поле может быть вычисляемым, и его вычисление — как и любой вызов функции — может занять произвольное количество времени. Это может привести к неправильной оценке производительности — и за счёт синтаксиса, мимикрирующего под доступ к полю, подобный доступ выглядит не выглядит, как что-то, что может замедлить программу.

Более того, это представляет некоторую проблему и с точки зрения автора класса. Подобные поля обычно кешируются — а, как известно, в программировании есть только две сложные проблемы: наименование сущностей, инвалидация кеша и ошибка на единицу. Автору подобного класса нужно либо делать класс иммутабельным, либо следить за тем, когда поля, от которых вычисляется свойство, меняются, и соответствующим образом инвалидировать закешированное значение.

С вычисляемыми свойствами есть ещё одна проблема:

Чтение вычисляемого свойства ломает ссылочную семантику

Во многих ООП-языках ссылочные типы по умолчанию алиасятся. В частности, если присвоить переменной значение поля объекта ссылочного типа, а затем вызвать на переменной мутирующий метод, то изменения у поля будут видны. Свойства, однако, позволяют получить совсем новое значение, которое не будет алиасится с полем — потому что поля нет! Пример:

using System;
using System.Linq;
using System.Collections.Generic;

class Numbers {
    public List<int> Lucky = new List<int>();
    public List<int> Unlucky = new List<int>();
    public List<int> All {
        get => Lucky.Concat(Unlucky).ToList();
    }
}

class Program
{
    static void PrintList(List<int> list)
    {
        Console.WriteLine("[" + string.Join(", ", list) + "]");
    }

    static void Main()
    {
        var numbers = new Numbers();

        var lucky = numbers.Lucky;
        lucky.Add(7);
        PrintList(numbers.Lucky); // [7]
        
        var unlucky = numbers.Unlucky;
        unlucky.Add(13);
        PrintList(numbers.Unlucky); // [13]
        
        var all = numbers.All;
        all.Add(101);
        PrintList(numbers.All); // [7, 13]
                                //  ^--- где 101?
    }
}

Иными словами, для того, чтобы узнать, отразится ли изменение отдельно вынесенного значения на исходном поле, недостаточно знать, является тип ссылочным или значимым — нужно ещё и знать, как оно конструируется. Особенно коварный код может выдавать значение, которое алиасится в зависимости от рантайм-условия:

using System;
using System.Collections.Generic;

class Evil {
    public List<int> _contents = new List<int>();
    public bool AliasField = true;
    public List<int> Contents {
        get => AliasField ? _contents : new List<int>(_contents);
    }
}

class Program
{
    static void PrintList(List<int> list)
    {
        Console.WriteLine("[" + string.Join(", ", list) + "]");
    }

    static void Main()
    {
        var evil = new Evil();

        var list = evil.Contents;
        list.Add(13);
        PrintList(evil._contents); // [13]

        evil._contents = new List<int>();
        evil.AliasField = false;

        list = evil.Contents;
        list.Add(666);
        PrintList(evil._contents); // []
    }
}

Чтение свойства может давать другое значение при записи в другое поле

Больше актуально для вычислимых свойств, но применимо и к свойствам с backing field. Ничто не удерживает программиста от того, чтобы записать в несколько полей в сеттере!

using System;

class Twins {
    private int a = 0;
    private int b = 0;
    public int Tweedledum {
        get => a;
        set {
            a = value;
            b = value;
        }
    }
    public int Tweedledee {
        get => b;
        set {
            a = value;
            b = value;
        }
    }
}

class ThroughTheLookingGlass
{
    static void Main()
    {
        var twins = new Twins();
        twins.Tweedledum = 10;
        Console.WriteLine(twins.Tweedledum); // 10
        twins.Tweedledee = 20;
        Console.WriteLine(twins.Tweedledum); // 20
        // В Зазеркалье и не такое увидишь...
    }
}

Запись свойства не идемпотентна

Запись обычного поля несколько раз подряд, без каких-либо промежуточных операций — идемпотентная операция: после первой записи эффект будет таким же, как и после нескольких записей. В сеттере свойства может быть произвольный код, так что в общем случае для свойств это не выполняется. Далее разберу отдельные моменты.

Многократное присваивание может иметь побочные эффекты

Если N раз вызвать функцию, то вызванные ей побочные эффекты будут произведены N раз. Аналогично с записью в свойство:

using System;

class Phantom {
    public int Field {
        set => Console.WriteLine($"{value} was assigned");
    }
}

public class Program
{
    public static void Main()
    {
        var foo = new Phantom();
        foo.Field = 0; // 0 was assigned
        foo.Field = 1; // 1 was assigned
        foo.Field = 2; // 2 was assigned
    }
}

Запись в промежуточную переменную и перезапись поля не эквивалентны

Если нам нужно провести много манипуляций над некоторым полем объекта, то для того, чтобы не упоминать имя объекта на каждой строчке, можно вынести это значение в отдельную переменную, а потом записать значение обратно. К сожалению, в применении к свойствам подобный рефакторинг в общем случае может поменять поведение:

using System;

class CounterString {
    public int Count { get; private set; }

    private string str = "";
    public string Value {
        get => str;
        set {
            str = value;
            Count += 1;
        }
    }
}

public class Program
{
    public static void Main()
    {
        {
            var cs = new CounterString();
            cs.Value += "hello";
            cs.Value += ", ";
            cs.Value += "world";
            Console.WriteLine(cs.Count); // 3
        }
        {
            var cs = new CounterString();
            var value = cs.Value;
            value += "hello";
            value += ", ";
            value += "world";
            cs.Value = value;
            Console.WriteLine(cs.Count); // 1
        }
    }
}

Запись поля может поменять другие поля

Отсылаю к примеру выше с Tweedledum и Tweedledee.

Свойства даже не решают заявленных проблем

Именно, не смотря на то, что свойства, по идее, должны решать некоторые проблемы, они или не решаются свойствами, или решаются свойствами хуже альтернатив. Рассмотрим конкретные проблемы, решением которых свойства якобы являются.

Прозрачный доступ к данным из базы данных

Да, это очевидно плохой пример, но я всё-таки обязан это упомянуть, потому что это приводится примером в руководстве по использованию свойств в C# от Mircosoft. Почему это плохо? Да потому что база данных — это большое мутабельное состояние, независимое от состояния программы, и притворяться, что это не так — значит закладывать фундамент проблем с производительностью и логических ошибок.

Очевидны проблемы с нечистой операцией чтения, низкой производительностью и возможными исключениями из-за ошибок запросов к базе данных. Чуть менее очевидны проблемы с временем жизни соединения. Получение значения из базы данных подразумевает наличие живого соединения с ней. Соединения с БД обычно дорогой ресурс, поэтому навряд ли каждое конкретное значение получает своё соединение. Это значит, что время жизни объекта и время жизни соединения (которое является одним из его полей, напрямую или через некий сервис ресурсов) отвязаны друг от друга. Мы или рискуем получить закрытое соединение в произвольный момент, или оставляем соединение живым/невозвращённым в пул дольше, чем надо. Более того, даже если соединение остаётся открытым, запись с соответствующим ID может быть удалена в БД или, что ещё хуже, удалена и перезаписана новым значением.

Ещё одна проблема с этим подходом — возможные N+1 запросы. Наиболее прямолинейный способ получить значение поля и/или записать его с таким подходом — это пройтись по всей коллекции в цикле и вызвать один из аксессоров свойства. Мало того, что это менее эффективно, чем работать с БД пачками данных, так ещё и не позволяет нормально проводить подобные запросы транзакционно. Оборачивать каждый отдельный доступ в транзакцию бессмысленно, поскольку композиция отдельных транзакций, очевидно, не является атомарной операцией.

Обратная совместимость

Один из главных доводов, которые приводят сторонники свойств: мол, свойства позволяют поменять поле на свойство с сохранением обратной совместимости. И хотя этот аргумент и выглядит разумным на первый взгляд, при ближайшем рассмотрении он не выдерживает никакой критики.

Первый случай: мы меняем поле на свойство и добавляем логику в геттер и/или сеттер. Поздравляю, мы только что сломали обратную совместимость! Теперь вызывающий код в состоянии получить поведение, которого не было ранее.

Второй случай: мы меняем поле на свойство и не добавляем логику в геттер и сеттер. Отлично, мы ничего не добились по сравнению с полем! Теперь код вызывает функции на ровном месте и ровным счётом ничего не получает взамен.

Даже без всего этого свойства обычно не имеют некоторых возможностей, которые есть у полей, особенно в языках без позднего связывания. Скажем, в C# невозможно передать свойство как ref или out аргумент, а в Python свойство, созданное встроенным декоратором @property, не будет включено в __dict__. Поэтому смена поля на свойство даже с тривиальной логикой является ломающим изменением.

Ещё одна тонкость: даже без этих тонкостей в языках с ранним связыванием свойство компилируется иначе, чем просто поле, а потому "совместимость" в них есть только на уровне исходников, но не на уровне скомпилированного кода.

Валидация полей

Ещё один частый аргумент сторонников: в сеттер свойства можно поместить валидацию полей объекта.

Так вот, обычные методы справляется с этим ничуть не хуже! Даже лучше, поскольку, в отличие от свойств, вызов метода не выглядит синтаксически, как присваивание полю, а потому даёт понять, что тут может быть выброшено исключение. Более того, наличие возможности вернуть значение позволяет сообщить об ошибке более внятным способом, чем через исключение.

Иногда в качестве конкретного примера приводят что-то вроде "проверить, что передаваемый строковой аргумент не пустой". Иными словами, проверяемое свойство зависит только от передаваемого аргументом значения и не зависит от полей объекта. Если у вас в коде есть подобные проверки, велик шанс, что они нужны не только в классе, где объявлено свойство. Наличие независимых мест, проверяющих какое-либо свойство, затрудняет проверку того, что они все меняются синхронно. Чтобы избежать рассинхронизации, куда лучше ввести новый отдельный тип для таких полей и проводить валидацию ровно в одном месте: в конструкторе. Даже если такое поле используется только в одном типе, таким образом всё равно можно уменьшить количество проверок, проводя их лишь во время создания значений этого нового типа вместо каждого присваивания. Подробнее про этот подход можно прочитать в прекрасной статье Parse, don't validate.

Депрекейт поля

Один из немногих более-менее валидных примеров: удаление поля без того, чтобы немедленно делать его недоступным. Именно, замена поля на свойство позволяет при доступе, скажем, печатать в лог предупреждение, что помогает пользователю кода перейти на новую версию, где поля уже не будет.

Неплохое решение, но по сути это костыль, связанный с инвалидностью неспособностью языка надёжно отловить доступ к полю. В языках с ранним связыванием с этим проблем нет, поскольку компилятор попросту не даст скомпилировать (и, соответственно, запустить) код, который обращается к несуществующему или приватному полю. Разумеется, это не поможет, если доступ к полю осуществляется при помощи рефлексии, но это уже ССЗБ со стороны программиста (о рефлексии тоже хочется поговорить, но уже в другой раз).

Readonly/writeonly поля

Свойства позволяют сделать поля с ограниченным для внешнего кода взаимодействием.

Я не согласен с этим аргументом и по букве, и по духу. Как я уже демонстрировал, свойство — не поле. Ограничение доступа можно точно так же разрулить при помощи обычных методов, это не требует новых сущностей в языке. Более того, в языках, где есть свойства, ограничения видимости, как правило, либо отсутствует (Python), либо завязаны на область видимости класса вместо модуля (C#), что недостаточно гибко.

Свойства могут быть использованы для достижения данной цели, но я всё же считаю это неподходящим инструментом: в более совершенном ЯП, на мой взгляд, поддержка ограничение доступа на чтение и запись должна быть напрямую.

Когда свойства действительно являются хорошей идеей

Не смотря на то, что я считаю свойства в целом плохой идеей, есть практические применения, которые сильно выигрывают от свойств.

Реактивный GUI

Программирование GUI (поверх готового набора графических примитивов) во многом является кодом, который меняет свойства UI-элементов: позиция, размеры, масштаб, активирован ли сейчас элемент, наличие фокуса ввода. Использование свойств позволяет отслеживать изменения в модели элементов и автоматически отражать их в представлении. Использование методов, хоть и технически эквивалентно в выразительной силе, ухудшило бы читаемость подобного кода.

Вместе с тем надо отметить, что подобный подход не очень хорошо работает с массовыми апдейтами. Наивное применение свойств для отслеживания изменений может привести к избыточному количеству перерисовки интерфейса, что сказывается на отзывчивости.

ORM

Более конкретно — Entity framework для платформы .NET. Этот фреймворк позволяет описать данные в виде, удобном для обработки, и описать декларативно, как отношения в коде отображаются на таблицы в базе данных, без явного упоминания в коде вещей вроде отдельных таблиц и внешних ключей. Программист просто меняет объекты в коде, а затем явно просит фреймворк наложить изменения на базу данных. Построение необходимых запросов и их выполнение происходит автомагически.

Регистрировать изменения для последующего запроса можно либо вручную, либо при помощи генерируемых прокси-объектов. Второй подход существенным образом полагается на виртуальные свойства. Программист пишет поля в виде свойств, а прокси-классы наследуются от них и добавляют в переопределяемые свойства код, который регистрирует изменения.

Разумеется, этот подход не идеален: изменения в рамках одного контекста, регистрирующего изменения, можно применить в виде запроса к БД только целиком. Даже если изменения и можно было бы разбить на разные наборы изменений, которые можно применять независимо, синтаксис свойств не оставляет места для того, чтобы указать, к какому набору изменений должно относиться отдельное присваивание.