В какой момент неизменные классы становятся бременем?


16

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

Например, вот неизменяемый класс для представления именованной вещи (я использую синтаксис C #, но этот принцип применим ко всем языкам OO)

class NamedThing
{
    private string _name;    
    public NamedThing(string name)
    {
        _name = name;
    }    
    public NamedThing(NamedThing other)
    {
         this._name = other._name;
    }
    public string Name
    {
        get { return _name; }
    }
}

Именованные вещи можно создавать, запрашивать и копировать в новые именованные вещи, но имя нельзя изменить.

Это все хорошо, но что происходит, когда я хочу добавить еще один атрибут? Я должен добавить параметр в конструктор и обновить конструктор копирования; Это не слишком большая работа, но проблемы начинаются, насколько я вижу, когда я хочу сделать сложный объект неизменным.

Если класс содержит атрибуты may и коллекции, содержащие другие сложные классы, мне кажется, что список параметров конструктора стал бы кошмаром.

Итак, в какой момент класс становится слишком сложным, чтобы быть неизменным?


Я всегда стараюсь сделать классы в моей модели неизменными. Если у вас огромные, длинные списки параметров contructor, то, возможно, ваш класс слишком большой и его можно разделить? Если ваши объекты более низкого уровня также неизменны и следуют той же схеме, то ваши объекты более высокого уровня не должны страдать (слишком сильно). Мне гораздо сложнее изменить существующий класс, чтобы он стал неизменным, чем сделать модель данных неизменной, когда я начинаю с нуля.
Никто

1
Вы можете посмотреть на шаблон Builder, предложенный в этом вопросе: stackoverflow.com/questions/1304154/…
Ant

Вы смотрели на MemberwiseClone? Вам не нужно обновлять конструктор копирования для каждого нового члена.
Кевин Клайн

3
@Tony Если ваши коллекции и все, что они содержат, также неизменны, вам не нужна глубокая копия, достаточно мелкой копии.
mjcopple

2
Кроме того, я обычно использую поля "set-Once" в классах, где класс должен быть "достаточно" неизменным, но не полностью. Я считаю, что это решает проблему огромных конструкторов, но обеспечивает большинство преимуществ неизменяемых классов. (а именно, ваш внутренний код класса не должен беспокоиться об изменении значения)
Earlz

Ответы:


23

Когда они становятся бременем? Очень быстро (особенно если выбранный вами язык не обеспечивает достаточной синтаксической поддержки неизменности.)

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

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

Еще хуже, когда люди слишком далеко зацикливаются на идее неизменности в языке общего назначения, таком как Java или C #, делая все неизменным. Затем, в результате, вы видите людей, форсирующих конструкции s-выражений в языках, которые не поддерживают такие вещи с легкостью.

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

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

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

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


6
Луис, вы заметили, как хорошо написанные, прагматически правильные ответы, написанные с объяснением простых, но здравых инженерных принципов, как правило, не получают столько голосов, сколько те, которые используют самые современные приёмы кодирования? Это отличный ответ.
Гуперникетес

3
Спасибо :) Я сам заметил тенденции повторения, но это нормально. Причудливый фанатский отток кода, который мы позже восстановим по лучшей почасовой ставке, хахах :) jk ...
luis.espinal

4
Серебряная пуля? нет. Стоит немного неловкости в C # / Java (это не так уж и плохо)? Абсолютно. Кроме того, роль многоядерности в неизменяемости весьма незначительна ... реальная выгода - простота рассуждений.
Маурисио Шеффер

@Mauricio - если вы так говорите (что неизменность в Java не так уж и плоха). Проработав на Java с 1998 по 2011 год, я позволю себе не согласиться, это не тривиальное исключение в простых основах кода. Однако у людей разный опыт, и я признаю, что мой POV не свободен от субъективности. Извините, не могу согласиться. Однако я согласен с тем, что простота рассуждений является самой важной вещью, когда речь идет об неизменности.
luis.espinal

13

Я прошел этап настаивания на том, чтобы классы были неизменными, где это возможно. У меня были строители практически для всего, неизменяемых массивов и т. Д., И т. Д. Я нашел ответ на ваш вопрос простым: в какой момент неизменяемые классы становятся бременем? Очень быстро. Как только вы хотите сериализовать что-то, вы должны быть в состоянии десериализовать, что означает, что это должно быть изменяемым; как только вы захотите использовать ORM, большинство из них настаивают на том, чтобы свойства были изменяемыми. И так далее.

В конце концов я заменил эту политику неизменными интерфейсами к изменяемым объектам.

class NamedThing : INamedThing
{
    private string _name;    
    public NamedThing(string name)
    {
        _name = name;
    }    

    public NamedThing(NamedThing other)
    {
        this._name = other._name;
    }

    public string Name
    {
        get { return _name; }
        set { _name = value; }
    }
}

interface INamedThing
{
    string Name { get; }
}

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


8
Не говоря уже о незначительных пристрастиях, я согласен, что неизменяемые объекты могут очень быстро стать проблемой при программировании на императивном языке, но я не совсем уверен, действительно ли неизменный интерфейс решает ту же проблему или любую проблему вообще. Основная причина использования неизменяемого объекта заключается в том, что вы можете выбросить его куда угодно в любое время и никогда не беспокоиться о том, что кто-то другой может испортить ваше состояние. Если базовый объект является изменяемым, то у вас нет такой гарантии, особенно если причина, по которой вы сохранили его, была в том, что различные вещи должны мутировать его.
Aaronaught

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

1
@Aaronaught: Точно так же IComparable<T>гарантирует, что если X.CompareTo(Y)>0и Y.CompareTo(Z)>0, то X.CompareTo(Z)>0. Интерфейсы имеют контракты . Если в контракте IImmutableList<T>указано, что значения всех предметов и свойств должны быть «установлены в камне», прежде чем какой-либо экземпляр подвергнется воздействию внешнего мира, то все законные реализации будут делать это. Ничто не мешает IComparableреализации нарушать транзитивность, но реализации, которые делают это, незаконны. Если SortedDictionaryнеисправности, когда дано незаконно IComparable, ...
суперкат

1
@Aaronaught: Почему я должен доверять реализации IReadOnlyList<T> будут неизменными, учитывая, что (1) такое требование не указано в документации интерфейса, и (2) самая распространенная реализация List<T>, даже не только для чтения ? Я не совсем понимаю, что двусмысленно в моих терминах: коллекция читаема, если данные внутри могут быть прочитаны. Он доступен только для чтения, если он может пообещать, что содержащиеся в нем данные не могут быть изменены, если какой-либо внешний источник не содержит код, который изменит его. Он неизменен, если может гарантировать, что его нельзя изменить, точка.
суперкат

1
@supercat: Кстати, Microsoft согласна со мной. Они выпустили пакет неизменяемых коллекций и заметили, что все они являются конкретными типами, потому что вы никогда не сможете гарантировать, что абстрактный класс или интерфейс действительно неизменны.
Aaronaught

8

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

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

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


5

Вы можете избежать проблемы копирования, если храните все свои неизменяемые поля во внутренней части struct. Это в основном вариация картины на память. Затем, когда вы хотите сделать копию, просто скопируйте сувенир:

class MyClass
{
    struct Memento
    {
        public int field1;
        public string field2;
    }

    private readonly Memento memento;

    public MyClass(int field1, string field2)
    {
        this.memento = new Memento()
            {
                field1 = field1,
                field2 = field2
            };
    }

    private MyClass(Memento memento) // for copying
    {
        this.memento = memento;
    }

    public int Field1 { get { return this.memento.field1; } }
    public string Field2 { get { return this.memento.field2; } }

    public MyClass WithNewField1(int newField1)
    {
        Memento newMemento = this.memento;
        newMemento.field1 = newField1;
        return new MyClass(newMemento);
    }
}

Я думаю, что внутренняя структура не нужна. Это просто еще один способ сделать MemberwiseClone.
Кодизм

@ Кодизм - да и нет. Временами вам могут понадобиться другие участники, которых вы не хотите клонировать. Что, если вы использовали ленивую оценку в одном из ваших получателей и кэшировали результат в члене? Если вы выполните MemberwiseClone, вы клонируете кэшированное значение, а затем измените один из ваших членов, от которого зависит кэшированное значение. Чище отделить состояние от кеша.
Скотт Уитлок

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

... это может быть очень полезно с изменчивыми держателями данных. Этот подход также позволяет очень легко иметь «параллельные» изменяемые и неизменяемые классы (производные от абстрактной «читаемой» базы), и их конструкторы могут копировать данные друг из друга.
суперкат

3

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

  • Есть ли деловая причина, по которой объект может изменить свое состояние? Например, пользовательский объект, хранящийся в базе данных, является уникальным в зависимости от его идентификатора, но он должен иметь возможность изменять состояние с течением времени. С другой стороны, когда вы меняете координаты на сетке, она перестает быть исходной координатой, поэтому имеет смысл сделать координаты неизменными. То же самое со строками.
  • Можно ли вычислить некоторые атрибуты? Короче говоря, если другие значения в новой копии объекта являются функцией некоторого базового значения, которое вы передаете, вы можете либо вычислить их в конструкторе, либо по требованию. Это уменьшает объем обслуживания, поскольку вы можете инициализировать эти значения одинаково при копировании или создании.
  • Сколько значений составляют новый неизменный объект? В какой-то момент сложность создания объекта становится нетривиальной, и в этот момент наличие большего количества экземпляров объекта может стать проблемой. Примеры включают неизменяемые древовидные структуры, объекты с более чем тремя, переданными в параметрах, и т. Д. Чем больше параметров, тем больше вероятность испортить порядок параметров или обнулить неправильный.

В языках, которые поддерживают только неизменяемые объекты (например, Erlang), если есть какая-либо операция, которая, по-видимому, изменяет состояние неизменяемого объекта, конечным результатом является новая копия объекта с обновленным значением. Например, когда вы добавляете элемент в вектор / список:

myList = lists:append([[1,2,3], [4,5,6]])
% myList is now [1,2,3,4,5,6]

Это может быть нормальным способом работы с более сложными объектами. Например, когда вы добавляете узел дерева, в результате получается новое дерево с добавленным узлом. Метод в приведенном выше примере возвращает новый список. В примере в этом параграфе tree.add(newNode)будет возвращено новое дерево с добавленным узлом. Для пользователей становится легко работать. Для авторов библиотеки становится утомительно, когда язык не поддерживает неявное копирование. Этот порог зависит от вашего собственного терпения. Для пользователей вашей библиотеки самый вменяемый предел, который я нашел, составляет около трех-четырех параметров вершин.


Если кто-то склонен использовать в качестве значения ссылку на изменяемый объект [имеется в виду, что никакие ссылки не содержатся внутри его владельца и никогда не раскрываются], то создание нового неизменяемого объекта, который содержит желаемое «измененное» содержимое, эквивалентно непосредственной модификации объекта, хотя, вероятно, медленнее. Однако изменяемые объекты также можно использовать как объекты . Как сделать вещи, которые ведут себя как сущности, без изменяемых объектов?
суперкат

0

Если у вас есть несколько конечных членов класса, и вы не хотите, чтобы они были доступны всем объектам, которые должны его создать, вы можете использовать шаблон builder:

class NamedThing
{
    private string _name;    
    private string _value;
    private NamedThing(string name, string value)
    {
        _name = name;
        _value = value;
    }    
    public NamedThing(NamedThing other)
    {
        this._name = other._name;
        this._value = other._value;
    }
    public string Name
    {
        get { return _name; }
    }

    public static class Builder {
        string _name;
        string _value;

        public void setValue(string value) {
            _value = value;
        }
        public void setName(string name) {
            _name = name;
        }
        public NamedThing newObject() {
            return new NamedThing(_name, _value);
        }
    }
}

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


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

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

Я понимаю, что вы говорите, я просто представляю проблему с этим, потому что это не приводит разработчика к "провалу в яме успеха". Тот факт, что он использует статические переменные, означает, что при Builderповторном использовании a существует реальный риск того, что произойдет, о чем я упоминал. Кто-то может создать множество объектов и решить, что, поскольку большинство свойств одинаковы, просто повторно использовать объект Builder, и фактически сделаем его глобальным синглтоном, в который вводится зависимость! Упс. Введены основные ошибки. Так что я думаю, что эта модель смешанного инстанцированного против статического - это плохо.
ErikE

1
@Salandur Внутренние классы в C # всегда «статичны» в смысле внутреннего класса Java.
Себастьян Редл

0

Итак, в какой момент класс становится слишком сложным, чтобы быть неизменным?

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

Постоянные структуры данных

Где я нахожу личное использование для неизменности, так это для больших центральных структур данных, которые объединяют кучу маленьких данных, таких как экземпляры класса, который вы показываете, например, тот, который хранит миллион NamedThings. Принадлежность к постоянной структуре данных, которая является неизменной, и находящаяся за интерфейсом, который разрешает только доступ только для чтения, элементы, которые принадлежат контейнеру, становятся неизменяемыми, и элементу class ( NamedThing) не приходится иметь дело с ним.

Дешевые копии

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

Благодаря постоянной структуре данных это позволяет вам написать такую ​​функцию и избежать копирования всей структуры данных, требуя только около мегабайта дополнительной памяти для вывода, если ваша функция только преобразовала объем памяти в мегабайтах.

обременять

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

void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
     // Transform stuff in the range, [first, last).
     for (; first != last; ++first)
          transform(stuff[first]);
}

... тогда должно быть написано так:

ImmList<Stuff> transform_stuff(ImmList<Stuff> stuff, int first, int last)
{
     // Grab a "transient" (builder) list we can modify:
     TransientList<Stuff> transient(stuff);

     // Transform stuff in the range, [first, last)
     // for the transient list.
     for (; first != last; ++first)
          transform(transient[first]);

     // Commit the modifications to get and return a new
     // immutable list.
     return stuff.commit(transient);
}

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

Исключение-Безопасность или Восстановление после ошибок

Не все могут получить такую ​​же выгоду, как я, от постоянных структур данных в подобных контекстах (я нашел их столь полезными в системах отмены и неразрушающем редактировании, которые являются центральными понятиями в моей области VFX), но одна вещь применима только к каждый должен учитывать исключительную безопасность или исправление ошибок .

Если вы хотите сделать исходную функцию мутации исключительной, то для нее требуется логика отката, для которой простейшая реализация требует копирования всего списка:

void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
    // Make a copy of the whole massive gigabyte-sized list 
    // in case we encounter an exception and need to rollback
    // changes.
    MutList<Stuff> old_stuff = stuff;

    try
    {
         // Transform stuff in the range, [first, last).
         for (; first != last; ++first)
             transform(stuff[first]);
    }
    catch (...)
    {
         // If the operation failed and ran into an exception,
         // swap the original list with the one we modified
         // to "undo" our changes.
         stuff.swap(old_stuff);
         throw;
    }
}

На этом этапе изменяемая на исключение изменяемая версия еще более затратна в вычислительном отношении и, возможно, даже сложнее написать правильно, чем неизменяемая версия, использующая «конструктор». И многие разработчики на C ++ просто пренебрегают безопасностью исключений и, возможно, это хорошо для их домена, но в моем случае я хотел бы убедиться, что мой код работает правильно даже в случае исключения (даже при написании тестов, которые намеренно генерируют исключения для проверки исключений). безопасность), и это делает меня таким, чтобы я был в состоянии откатить любые побочные эффекты, которые функция вызывает на полпути в функцию, если что-то выбрасывает.

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

Вам не нужно беспокоиться об откате побочных эффектов в функции, которая не вызывает их!

Итак, вернемся к основному вопросу:

В какой момент неизменные классы становятся бременем?

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

Кроме того, у меня есть только несколько неизменных типов данных, и все они представляют собой огромные структуры данных, предназначенные для хранения огромного количества элементов (пикселей изображения / текстуры, объектов и компонентов ECS, а также вершин / ребер / многоугольников). сетка).

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.