У який момент непорушні заняття стають тягарем?


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; }
    }
}

Іменовані речі можуть бути побудовані, запитані та скопійовані до нових іменованих речей, але ім’я неможливо змінити.

Це все добре, але що відбувається, коли я хочу додати ще один атрибут? Я повинен додати параметр до конструктора та оновити конструктор копій; що не надто багато роботи, але проблеми починаються, наскільки я бачу, коли я хочу зробити складний об'єкт незмінним.

Якщо клас містить атрибути та колекції, що містять інші складні класи, мені здається, що список параметрів конструктора став кошмаром.

Тож у який момент клас стає занадто складним, щоб бути незмінним?


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

1
Ви можете поглянути на схему Builder, запропоновану в цьому запитанні: stackoverflow.com/questions/1304154/…
Ant Ant

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

3
@Tony Якщо ваші колекції та все, що вони містять, також незмінні, вам не потрібна глибока копія, достатньо дрібної копії.
mjcopple

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

Відповіді:


23

Коли вони стануть тягарем? Дуже швидко (особливо якщо ваша обрана мова не забезпечує достатню синтаксичну підтримку незмінності.)

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

Не обов’язково можливо моделювати все в невеликих не складних класах. Тож для великих класів та структур ми штучно їх розділяємо - не тому, що це має сенс у нашій доменній моделі, а тому, що нам доводиться мати справу з їх складними інстанціями та конструкторами в коді.

Ще гірше, коли люди занадто далеко забирають ідею незміненості мовою загального призначення, як Java або C #, роблячи все непорушним. Потім, як результат, ви бачите людей, які змушують конструкти s-вираження мовами, які не підтримують такі речі легко.

Інжиніринг - це акт моделювання за допомогою компромісів і компромісів. Зробити все незмінним едіктом, оскільки хтось прочитав, що все незмінне на X або Y функціональній мові (зовсім інша модель програмування), це не прийнятно. Це не гарне машинобудування.

Невеликі, можливо, унітарні речі можна зробити непорушними. Більш складні речі можна зробити непорушними, коли це має сенс . Але незмінність - це не срібна куля. Здатність зменшувати помилки, підвищувати масштабованість та продуктивність - це не єдина функція незмінності. Це функція належної інженерної практики . Зрештою, люди написали гарне масштабоване програмне забезпечення без незмінності.

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

Я, наприклад, намагаюся цього уникати (якщо я не працюю мовою програмування з хорошою синтаксичною підтримкою.)


6
Луїс, ви помітили, як добре написані, прагматично правильні відповіді, написані з поясненням простих, але надійних інженерних принципів, як правило, не набирають стільки голосів, скільки тих, хто використовує найсучасніші кодування? Це чудова, чудова відповідь.
Huperniketes

3
Дякую :) Я вже помітив реп-тренди сам, але це нормально. Fad fanboys churn code, який ми згодом отримаємо для ремонту за кращими погодинними тарифами,
ха-ха

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>, навіть не тільки для читання ? Мені не зовсім зрозуміло, що є неоднозначним у моїх умовах: колекція читається, якщо можна прочитати дані всередині. Він доступний лише для читання, якщо він може пообіцяти, що містяться дані не можуть бути змінені, якщо якась зовнішня посилання не міститься в коді, який би змінив їх. Це незмінне, якщо може гарантувати, що його неможливо змінити, періодично.
supercat

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, ви будете клонувати кешоване значення, а потім зміните одного зі своїх членів, від якого залежить кешоване значення. Чистіше відокремити стан від кешу.
Скотт Вітлок

Можливо, варто згадати ще одну перевагу внутрішньої структури: це спрощує об'єкт скопіювати його стан на об'єкт, на який існують інші посилання . Поширеним джерелом неоднозначності в OOP є те, чи метод, який повертає посилання на об'єкт, повертає вигляд об'єкта, який може змінюватися поза контролем одержувача. Якщо замість повернення посилання на об'єкт, метод приймає посилання на об'єкт від абонента і копіює йому стан, право власності на об'єкт буде набагато зрозумілішим. Такий підхід не добре працює з вільно успадкованими типами, але ...
supercat

... це може бути дуже корисно для змінних власників даних. Цей підхід також дуже легко створює "паралельні" змінні та незмінні класи (похідні від абстрактної "читабельної" бази) і їх конструктори зможуть копіювати дані один в одного.
supercat

3

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

  • Чи є бізнес-причина, чому об’єкт може змінити свій стан? Наприклад, об’єкт користувача, що зберігається в базі даних, є унікальним на основі його ідентифікатора, але він повинен мати можливість змінювати стан з часом. З іншого боку, коли ви змінюєте координати на сітці, вона перестає бути вихідною координатою, і тому є сенс зробити координати незмінними. Те саме зі струнами.
  • Чи можна обчислити деякі атрибути? Якщо коротко, якщо інші значення в новій копії об'єкта є функцією якогось основного значення, яке ви передаєте, ви можете їх обчислити в конструкторі або на вимогу. Це зменшує кількість обслуговування, оскільки ви можете ініціалізувати ці значення однаково під час копіювання чи створення.
  • Скільки значень складає новий незмінний об’єкт? У якийсь момент складність створення об'єкта стає нетривіальною, і в цей момент наявність більшої кількості примірників об'єкта може стати проблемою. Приклади включають незмінні дерева структури, об’єкти з більш ніж трьома переданими параметрами і т. Д. Чим більше параметрів, тим більше можливість псувати порядок параметрів або викреслювати неправильну.

У мовах, які підтримують лише незмінні об'єкти (наприклад, Erlang), якщо є якась операція, яка, здається, змінює стан незмінного об'єкта, кінцевим результатом є нова копія об'єкта з оновленим значенням. Наприклад, додаючи елемент у вектор / список:

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

Це може бути розумним способом роботи зі складнішими об'єктами. Додаючи, наприклад, вузол дерева, результатом є нове дерево з доданим вузлом. Метод у наведеному вище прикладі повертає новий список. У прикладі в цьому пункті tree.add(newNode), повертається нове дерево з доданим вузлом. Користувачам з ним стає легко працювати. Для бібліотечних письменників стає нудно, коли мова не підтримує неявне копіювання. Цей поріг залежить від вашого власного терпіння. Для користувачів вашої бібліотеки найвищий розмір, який я знайшов, - це приблизно три-чотири вершини параметрів.


Якщо хтось схильний використовувати посилання на об'єкт, що змінюється, як значення [означає, що жодні посилання не містяться в його власнику і ніколи не піддаються впливу], побудова нового незмінного об'єкта, який містить бажаний "змінений" вміст, еквівалентно безпосередньо зміні об'єкта, хоча, ймовірно, повільніше. Однак мінливі об'єкти також можуть використовуватися як сутності . Як можна зробити речі, які поводяться як сутності без змінних об'єктів?
supercat

0

Якщо у вас є кілька членів остаточного класу, і ви не хочете, щоб вони були піддані впливу всіх об'єктів, які потребують його створення, ви можете використовувати шаблон конструктора:

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. Приналежність до стійкої структури даних, яка є незмінною і знаходиться за інтерфейсом, що дозволяє лише доступ лише для читання, елементи, що належать до контейнера, стають незмінними без класу елементів ( 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;
    }
}

На даний момент безпечна для винятків змінна версія - навіть обчислювально дорожча і, можливо, навіть важче написати правильно, ніж незмінна версія, використовуючи "builder". І багато розробників C ++ просто нехтують безпекою винятків, і, можливо, це добре для їхнього домену, але в моєму випадку я хотів би переконатися, що мій код працює правильно навіть у випадку винятку (навіть написання тестів, які навмисно викидають винятки, щоб перевірити виняток безпека), і це робить його таким, що я повинен мати можливість відкатати будь-які побічні ефекти, яка функція викликає на півдорозі функції, якщо щось кине.

Якщо ви хочете бути захищеними від винятків і вигадувати помилки вишукано, без вашої програми та збоїв, тоді вам доведеться повернути / скасувати будь-які побічні ефекти, які функція може спричинити у разі помилки / виключення. І там будівельник може фактично заощадити більше програмного часу, ніж це коштує разом з обчислювальним часом, тому що: ...

Вам не доведеться турбуватися про відміну побічних ефектів у функції, яка не викликає жодної!

Тож повернемось до основного питання:

У який момент непорушні заняття стають тягарем?

Вони завжди навантажують мови, які обертаються більше навколо незмінність, ніж незмінність, і саме тому, я думаю, ви повинні використовувати їх там, де користь значно перевищує витрати. Але на досить широкому рівні для достатньо великих структур даних я вважаю, що є багато випадків, коли це гідна компромісія.

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

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.