Яка реальна відповідальність класу?


42

Мені цікаво, чи законним є використання дієслів, заснованих на іменниках в ООП.
Я зіткнувся з цією блискучою статтею , хоча я все ще не згоден з моментом, який вона висуває.

Щоб пояснити проблему трохи більше, у статті зазначено, що FileWriterкласу не повинно бути, наприклад , але написання є дією, це має бути методом класу File. Ви зрозумієте, що це часто залежить від мови, оскільки програміст Ruby, ймовірно, буде проти використання FileWriterкласу (Ruby використовує метод File.openдля доступу до файлу), тоді як Java-програміст цього не зробив.

Моя особиста (і так, дуже скромна) точка зору полягає в тому, що це може порушити принцип єдиної відповідальності. Коли я програмував на PHP (адже PHP, очевидно, найкраща мова для OOP, правда?), Я часто використовував такий тип фреймворку:

<?php

// This is just an example that I just made on the fly, may contain errors

class User extends Record {

    protected $name;

    public function __construct($name) {
        $this->name = $name;
    }

}

class UserDataHandler extends DataHandler /* knows the pdo object */ {

    public function find($id) {
         $query = $this->db->prepare('SELECT ' . $this->getFields . ' FROM users WHERE id = :id');
         $query->bindParam(':id', $id, PDO::PARAM_INT);
         $query->setFetchMode( PDO::FETCH_CLASS, 'user');
         $query->execute();
         return $query->fetch( PDO::FETCH_CLASS );
    }


}

?>

Наскільки я розумію, що суфікс DataHandler не додає нічого релевантного ; але справа в тому, що принцип єдиної відповідальності диктує нам, що об'єкт, що використовується в якості моделі, що містить дані (їх можна назвати записи), також не повинен нести відповідальність за виконання SQL-запитів та доступу до бази даних. Це якось приводить до недійсності шаблону ActionRecord, який використовується, наприклад, Ruby on Rails.

Я натрапив на цей код C # (так, четверта мова об’єкта, що використовується в цій публікації) якраз днями:

byte[] bytes = Encoding.Default.GetBytes(myString);
myString = Encoding.UTF8.GetString(bytes);

І я мушу сказати, що для мене не має великого сенсу, що клас Encodingчи Charsetклас насправді кодує рядки. Це має бути лише уявленням про те, що таке кодування насправді.

Таким чином, я схильний би думати, що:

  • FileВідкриття, читання або збереження файлів не є відповідальністю за клас.
  • XmlСеріалізація себе не є класною відповідальністю.
  • UserЗапит на базу даних не несе відповідальність за клас.
  • тощо.

Однак, якщо ми екстраполюємо ці ідеї, навіщо Objectмати toStringклас? Це не відповідальність автомобіля чи собаки перетворити себе на струну, зараз це?

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

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

Яка відповідальність класу?


Об'єкти toString в основному зручні, а toString використовується, як правило, для швидкого перегляду внутрішнього стану під час налагодження
храповик Freak

3
@ratchetfreak Я згоден, але це був приклад (майже жарт), який показує, що одна відповідальність дуже часто порушується так, як я описав.
П'єр Арло

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

Відповіді:


24

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

Перш за все, так званий "Принцип єдиної відповідальності" - це рефлекс - явно заявлений - поняття згуртованості . Читаючи літературу того часу (близько 70-х років), люди намагалися (і досі перебувають) намагаються визначити, що таке модуль, і як їх побудувати таким чином, щоб зберегти приємні властивості. Отже, вони би сказали, "ось це купа структур і процедур, я з них буду модуль", але без критеріїв, чому цей набір довільних речей упакований разом, організація може в кінцевому підсумку мати мало сенсу - мало «згуртованості». Отже, виникли дискусії щодо критеріїв.

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

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

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

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

Однак, якщо ми екстраполюємо ці ідеї, чому б у Object був клас toString? Це не відповідальність автомобіля чи собаки перетворити себе на струну, тепер це?

Тепер дозвольте зупинитись, щоб сказати тут щось про те toString: існує принципова річ, яку зазвичай нехтують, коли можна зробити перехід думки від модулів до класів і задуматися про те, які методи повинні належати до класу. А річ у динамічній диспетчері (він же, пізнє зв’язування, "поліморфізм").

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

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


31

[Примітка. Тут я буду говорити про об'єкти. Об'єкти - це те, що стосується об'єктно-орієнтованого програмування, зрештою, а не класів.]

Яка відповідальність об’єкта залежить, головним чином, від вашої моделі домену. Зазвичай існує багато способів моделювання одного домену, і ви виберете той чи інший спосіб, виходячи з того, як система буде використовуватися.

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

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

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

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

Що стосується вашого конкретного питання toString, я згоден. Я дуже вважаю за краще рішення Haskell Showable класу типу . (Scala вчить нас, що типи класів прекрасно поєднуються з OO.) Те ж саме з рівністю. Рівність дуже часто є не властивістю об'єкта, а контекстом, в якому об'єкт використовується. Подумайте лише про числа з плаваючою комою: яким повинен бути епсилон? Знову ж, Haskell має Eqклас типу.


Мені подобаються ваші порівняння haskell, це робить відповідь трохи більше ... свіжою. Що б ви тоді сказали про конструкції, які дозволяють ActionRecordодночасно бути моделлю та запитувачем бази даних? Незалежно від контексту, здається, це порушує принцип єдиної відповідальності.
П'єр Арло

5
"... тому що так багато залежить від того, як ви формулюєте речення." Так! О, і я люблю ваш приклад банківської справи. Ніколи не знав, що ще у вісімдесяті роки мене вже навчали функціональному програмуванню :) (дизайн, орієнтований на дані та незалежне від часу зберігання, де баланси і т.д. обчислюються і не повинні зберігатися, а чиюсь адресу не слід змінювати, а зберігати як новий факт) ...
Мар'ян Венема

1
Я б не сказав, що методологія дієслова / іменника є "смішною". Я б сказав, що спрощені конструкції зазнають невдач і що дуже важко вийти з цього права. Тому що, контрприклад, не є API RESTful методом дієслова / іменника? Де для додаткового ступеня складності дієслова вкрай обмежені?
user949300

@ user949300: Той факт, що набір дієслів фіксований, точно уникає проблеми, про яку я згадував, а саме: ви можете формулювати речення різними способами, роблячи таким чином, що таке іменник, а що дієслово, залежно від стилю написання, а не аналізу домену .
Йорг W Міттаг

8

Занадто часто Принцип єдиної відповідальності стає Принципом нульової відповідальності, і ви закінчуєте анемічні класи , які нічого не роблять (крім сеттерів та гетерів), що призводить до катастрофи Королівства іменників .

Ви ніколи не отримаєте його ідеальним, але краще для класу зробити трохи занадто багато, ніж занадто мало.

У вашому прикладі з Encoding, IMO, воно, безумовно, повинно бути в змозі кодувати. Що ти робиш замість цього? Просто мати назву "utf" - це нульова відповідальність. Тепер, можливо, ім'я має бути Encoder. Але, як сказав Конрад, дані (які кодують) та поведінка (роблять це) належать разом .


7

Екземпляр класу - це закриття. Це воно. Якщо ви вважаєте, що таким чином все добре розроблене програмне забезпечення, на яке ви дивитесь, буде виглядати правильно, а все погано продумане програмне забезпечення не буде. Дозвольте мені розширити.

Якщо ви хочете щось написати, щоб записати у файл, подумайте про все, що вам потрібно вказати (в ОС): ім'я файлу, спосіб доступу (читати, записувати, додавати), рядок, який ви хочете записати.

Отже, ви створюєте об'єкт File з ім'ям файлу (та методом доступу). Об'єкт File тепер закритий над ім'ям файлу (у цьому випадку він, ймовірно, сприйняв його як значення readonly / const). Екземпляр File тепер готовий приймати виклики методу "write", визначеному в класі. Це просто бере строковий аргумент, але в тілі способу запису, реалізації, також є доступ до імені файлу (або файлової ручки, яка була створена з нього).

Таким чином, екземпляр класу File містить деякі дані в якусь складову частину, щоб згодом використовувати цей примірник простіше. Коли у вас є об'єкт File, вам не потрібно турбуватися про те, що таке ім'я файлу, вас турбує лише те, яку рядок ви введете як аргумент у виклик запису. Ще раз повторюю - об’єкт File інкапсулює все, що вам не потрібно знати про те, куди насправді йде рядок, який ви хочете написати.

Більшість проблем, які ви хочете вирішити, є шаруватими таким чином - речі, створені на початку, речі, створені на початку кожної ітерації певного циклу процесу, потім різні речі, оголошені у двох половинах, якщо інше, то речі, створені в початок якихось підциклів, потім речі, оголошені як частина алгоритму в цьому циклі і т. д. Коли ви додаєте все більше і більше викликів функцій до стеку, ви робите все більш точний код, але кожен раз у вас буде Дані в шарах, розташованих нижче в стеку, отримують якусь хорошу абстракцію, яка полегшує доступ. Подивіться, що ви робите, використовуючи "OO" як своєрідну структуру управління закриттям для функціонального підходу програмування.

Швидке вирішення деяких проблем передбачає змінений стан об'єктів, який не настільки функціональний - ви маніпулюєте закриттями ззовні. Деякі речі вашого класу ви робите «глобальними», принаймні, на межі вище. Кожен раз, коли ви пишете сетер, намагайтеся уникати цього. Я заперечую, що саме тут ви невірно відповідали за свій клас чи інші класи, в яких він працює. Але не переживайте занадто сильно - іноді прагматично розмішувати трохи в незмінному стані, щоб швидко вирішити певні проблеми, без зайвого когнітивного навантаження, і насправді все зробити.

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

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


4

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

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

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

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


4

Я натрапив на цей код C # (так, четверта мова об’єкта, що використовується в цій публікації) якраз днями:

byte[] bytes = Encoding.Default.GetBytes(myString);
myString = Encoding.UTF8.GetString(bytes);

І я мушу сказати, що для мене не має великого сенсу, що клас Encodingчи Charsetклас насправді кодує рядки. Це має бути лише уявленням про те, що таке кодування насправді.

Теоретично, так, але я вважаю, що C # робить виправданий компроміс заради простоти (на відміну від більш суворого підходу Java).

Якщо Encodingтільки представлено (або ідентифікується) певне кодування - скажімо, UTF-8 - то, очевидно, вам теж потрібен Encoderклас, щоб ви могли його реалізувати GetBytes- але тоді вам потрібно керувати співвідношенням Encoders і Encodings, тому ми в кінцевому підсумку з нашим добрим ol '

EncodersFactory.getDefaultFactory().createEncoder(new UTF8Encoding()).getBytes(myString)

так блискуче висловлювались у тій статті, з якою ви посилаєтесь (до речі, чудово читайте).

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

Ну, на протилежному боці спектру - процедурний підхід, який не важко реалізувати в мовах OOP із використанням статичних класів:

HelpfulStuff.GetUTF8Bytes(myString)

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

Повинен чи я шукати його в HelpfulStuff, Utils, StringHelper?

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

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

EncodersFactory.getDefaultFactory().createEncoder(new UTF8Encoding()).getBytes(myString)

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

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

Я думаю, це проходить як фасадний малюнок, який допомагає змастити шестерні. Чи порушує цей законний зразок SOLID? Питання, пов'язане з цим: чи порушує фасадний малюнок SRP?

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

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


1

У Java абстракція, що використовується для представлення файлів, є потоком, деякі потоки є односпрямованими (лише для читання або запису), інші - двонаправленими. Для Ruby the File клас - абстракція, яка представляє файли ОС. Тож стає питанням, яка його єдина відповідальність? У Java відповідальність FileWriter полягає у наданні однонаправленого потоку байтів, підключеного до файлу ОС. У Ruby відповідальність File полягає у наданні двостороннього доступу до системного файлу. Вони обидва виконують СРП, вони просто несуть різні обов'язки.

Це не відповідальність автомобіля чи собаки перетворити себе на струну, тепер це?

Звісно, ​​чому б ні? Я очікую, що клас Авто повністю представляє автомобільну абстракцію. Якщо має сенс використовувати автомобільну абстракцію там, де необхідна рядок, я повністю очікую, що клас Авто підтримує перетворення на рядок.

Відповісти на велике запитання безпосередньо відповідальність класу - це виступати в якості абстракції. Ця робота може бути складною, але в цьому полягає суть, абстракція повинна ховати складні речі за більш простим у використанні та менш складним інтерфейсом. SRP - це орієнтир проектування, тому ви зосереджуєтесь на питанні "що собою представляє ця абстракція?". Якщо для того, щоб повністю абстрагувати концепцію, необхідно кілька різних форм поведінки, то так і нехай буде.


0

Клас може мати кілька обов'язків. Принаймні, у класичному OOP, орієнтованому на дані.

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

Я думаю, що немає нічого поганого в тому, щоб витягти частину складності з базового класу, як у випадку з File та FileWriter. Перевага полягає в тому, що ви отримуєте чітке відокремлення коду, який відповідає за певну операцію, а також, що ви можете замінити (підклас) саме той фрагмент коду, а не переосмислити весь базовий клас (наприклад, Файл). Тим не менш, застосовувати це систематично, здається, для мене надмір. Ви просто отримуєте набагато більше класів для обробки і для кожної операції потрібно викликати відповідний клас. Це гнучкість, яка поставляється за певну ціну.

Ці видобувні класи повинні мати дуже описові імена , засновані на операціях вони містять, наприклад <X>Writer, <X>Reader, <X>Builder. Імена на кшталт <X>Manager, <X>Handlerзовсім не описують операцію, і якщо вони називаються так, оскільки вони містять більше однієї операції, то не ясно, чого ви навіть досягли. Ви відокремили функціонал від його даних, ви все ще порушуєте єдиний принцип відповідальності навіть у тому вилученому класі, і якщо ви шукаєте певний метод, ви можете не знати, де його шукати (якщо є <X>чи <X>Manager).

Тепер ваш випадок з UserDataHandler відрізняється тим, що немає базового класу (класу, який містить фактичні дані). Дані цього класу зберігаються у зовнішньому ресурсі (базі даних), і ви використовуєте api для доступу до них та маніпулювання ними. Ваш клас користувача представляє користувача, який працює під час виконання, який насправді є чимось іншим, ніж постійний користувач, і розділення обов'язків (проблем) може стати тут корисним, особливо якщо у вас є багато логіки, пов’язаної з виконанням користувачів.

Ви, ймовірно, так назвали UserDataHandler, оскільки в цьому класі є лише функціональність і немає реальних даних. Однак ви можете також абстрагуватися від цього факту і вважати, що дані в базі даних належать до класу. За допомогою цієї абстракції ви можете назвати клас на основі того, що він є, а не за тим, що він робить. Ви можете дати йому таке ім'я, як UserRepository, яке досить гладке, а також пропонує використання шаблону сховища .

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