Коли в об'єктно-орієнтованих мовах, коли об’єкти повинні робити операції над собою та коли операції слід робити на об'єктах?


11

Припустимо, існує Pageклас, який представляє набір вказівок для рендерінгу сторінки. І припустимо, існує Rendererклас, який знає, як візуалізувати сторінку на екрані. Можна структурувати код двома різними способами:

/*
 * 1) Page Uses Renderer internally,
 * or receives it explicitly
 */
$page->renderMe(); 
$page->renderMe($renderer); 

/*
 * 2) Page is passed to Renderer
 */
$renderer->renderPage($page);

Які плюси і мінуси кожного підходу? Коли буде краще? Коли інший буде кращим?


Передумови

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

$pdf = new TCPDF();
$html = "some text";
$pdf->writeHTML($html);

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

/*
 * A representation of the PDF page snippet:
 * a template directing how to render a specific PDF page snippet
 */
class PageSnippet
{    
    function runTemplate(TCPDF $pdf, array $data = null): void
    {
        $pdf->writeHTML($data['html']);
    }
}

/* To be used like so */
$pdf = new TCPDF();
$data['html'] = "some text";
$snippet = new PageSnippet();
$snippet->runTemplate($pdf, $data);

1) Зауважте тут, що $snippet працює сам , як у моєму першому прикладі коду. Він також повинен знати і бути знайомим $pdfі з будь-яким, $dataщоб він працював.

Але я можу створити такий PdfRendererклас:

class PdfRenderer
{
    /**@var TCPDF */
    protected $pdf;

    function __construct(TCPDF $pdf)
    {
        $this->pdf = $pdf;
    }

    function runTemplate(PageSnippet $template, array $data = null): void
    {
        $template->runTemplate($this->pdf, $data);
    }
}

і тоді мій код перетворюється на це:

$renderer = new PdfRenderer(new TCPDF());
$renderer->runTemplate(new PageSnippet(), array('html' => 'some text'));

2) Тут $rendererприймаються PageSnippetі всі $dataнеобхідні для нього роботи. Це схоже на мій другий приклад коду.

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


2
На жаль, ви блукали тут у світі програмного забезпечення "релігійні війни", по лінії того, чи слід використовувати пробіли чи вкладки, який стиль дужки використовувати та ін. Тут немає "кращого", просто сильні думки з обох сторін. Проведіть пошук в Інтернеті про переваги та недоліки як багатих, так і анемічних моделей доменів та формуйте свою власну думку.
Девід Арно

7
@DavidArno Використовуйте пробіли, які ви погані ! :)
candied_orange

1
Га, я часом серйозно не розумію цей сайт. Ідеально хороші запитання, на які отримують хороші відповіді, закриваються як раз на основі думки. І все-таки очевидне питання, що ґрунтується на думці, виникає, і звичайних підозрюваних ніде не знайти. Ну добре, якщо ви не можете їх перемогти і все таке ... :)
Девід Арно

@Erik Eidt, чи не могли б ти скасувати свою відповідь, будь ласка, оскільки я вважаю це дуже гарною відповіддю "вгорі".
Девід Арно

1
Окрім принципів SOLID, ви можете поглянути на GRASP , особливо на експертну частину. Питання в тому, яка інформація для вас відповідає?
OnesimusUnbound

Відповіді:


13

Це повністю залежить від того, що ви думаєте, що є ОО .

Для OOP = SOLID операція повинна бути частиною класу, якщо вона є частиною Єдиної відповідальності класу.

Для OO = віртуальна диспетчеризація / поліморфізм, операція повинна бути частиною об'єкта, якщо вона повинна динамічно відправлятися, тобто якщо вона викликається через інтерфейс.

Для OO = інкапсуляція, операція повинна бути частиною класу, якщо вона використовує внутрішній стан, який ви не хочете виставляти.

Для OO = "Мені подобаються вільні інтерфейси", питання полягає в тому, який варіант читається більш природно.

Для ОО = моделювання реальних сутностей, який суб'єкт реального світу виконує цю операцію?


Усі ці точки зору, як правило, неправильно поодинокі. Але іноді одна або кілька з цих перспектив корисні для прийняття дизайнерського рішення.

Наприклад, використовуючи точку зору поліморфізму: якщо у вас різні стратегії візуалізації (наприклад, різні формати виводу або різні двигуни візуалізації), це $renderer->render($page)має багато сенсу. Але якщо у вас різні типи сторінок, які мають бути відображені по-різному, $page->render()можливо, буде і краще. Якщо результат залежить як від типу сторінки, так і від стратегії візуалізації, ви можете зробити подвійну розсилку через шаблон відвідувача.

Не забувайте, що в багатьох мовах функції не повинні бути методами. Проста функція, яка, як render($page)правило, ідеально прекрасне (і на диво просто) рішення.


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

@CandiedOrange Це гарний момент, але я б зарезервував ваш аргумент під SRP: вирішити, як це буде відображено, це може бути Сторінка, яка відповідає капіталу R, можливо, використовуючи якусь стратегію поліморфного візуалізації.
амон

Я подумав, що $rendererбуде вирішувати, як зробити рендер. Коли $pageпереговори з $rendererусіма сказаними - це що робити. Не як. Поняття $pageне має, як. Це втручає мене в проблеми SRP?
candied_orange

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

Хм добре. Ти правий. Те, про що я говорив, слідкував за тим, щоб не говорити. Тепер виправте мене, якщо я помиляюся. Інша стратегія, де рендерінг посилається на посилання на сторінку, означає, що рендерінг повинен буде розгорнутись і попросити сторінку про речі, використовуючи сторінку для отримання сторінок.
candied_orange

2

За словами Алана Кей , об’єкти є самодостатніми, «дорослими» та відповідальними організмами. Дорослі роблять справи, на них не оперують. Тобто, фінансова транзакція відповідає за збереження себе , сторінка відповідає за візуалізацію тощо, тощо. Більш коротко, інкапсуляція - це велика річ у ООП. Зокрема, це проявляється через знаменитий Телль не питай принцип (що @CandiedOrange любить згадувати весь час :)) і громадський осуд з видобувачів і сеттерів .

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

Отже, враховуючи ваш приклад, моя OOP-версія виглядатиме так:

class Page
{
    private $data;
    private $renderer;

    public function __construct(ICanRender $renderer, $data)
    {
        $this->renderer = $renderer;
        $this->data = $data;
    }

    public function render()
    {
        $this->renderer->render($this->data);
    }
}

У випадку, якщо вас цікавить, Девід Вест розповідає про оригінальні принципи ООП у своїй книзі « Об’єктне мислення» .


1
Якщо говорити прямо, кого хвилює те, що хтось сказав про щось, що стосується розробки програмного забезпечення, 15 років тому, крім історичного інтересу?
Девід Арно

1
" Мені все одно, що людина, яка винайшла об'єктно-орієнтовану концепцію, сказала про те, що таке об'єкт ". Чому? Окрім примхливості у використанні помилок "звернення до влади" у ваших аргументах, яке можливе відношення може мати думка винахідника терміну щодо застосування цього терміну через 15 років?
Девід Арно

2
@Zadadlo: Ви не представляєте аргументу, чому повідомлення переходить від Page to Renderer, а не навпаки. Вони обидва об'єктні, а значить, і обидва дорослі, правда?
ЖакБ

1
" Звернення до помилок владних повноважень тут не можна застосовувати " ... " Отже, набір понять, які, на вашу думку, представляють ООП, насправді помилковий [тому, що це спотворення вихідного визначення] ". Я вважаю, ви не знаєте, що таке заклик до помилок влади? Підказка: ви тут використали один. :)
Девід Арно

1
@David Arno Отже, чи всі звернення до влади невірні? Чи хотіли б ви "Звернутися до моєї думки?" Кожен раз, коли хтось цитує дядька Бобізму, ви будете скаржитися на звернення до влади? Західьо надав добре поважне джерело. Ви можете погодитись або посилатись на конфліктні джерела, але повторювач, який скаржиться, що хтось надав цитування, не є конструктивним
user949300

2

$page->renderMe();

Тут ми pageнесемо повну відповідальність за себе. Можливо, він поставляється з візуалізацією через конструктор, або він може мати вбудовану функціональність.

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

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

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

$page->renderMe($renderer);

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

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

$renderer->renderPage($page);

Тут ми повністю поважали СРП. Об'єкт сторінки несе відповідальність за зберігання інформації на сторінці, а рендер відповідає за візуалізацію цієї сторінки. Однак зараз ми повністю ослабили інкапсуляцію об’єкта сторінки, оскільки це потрібно, щоб зробити весь її державний, загальнодоступним.

Також ми створили нову проблему: рендерінг тепер щільно пов'язаний із класом сторінки. Що відбувається, коли ми хочемо відобразити щось інше на сторінці?

Що найкраще? Жоден з них. Усі вони мають свої вади.


Не згоден, що V3 поважає SRP. У рендері є щонайменше дві причини зміни: якщо Сторінка змінюється, або якщо змінюється спосіб її відображення. І третє, яке ви покриваєте, якщо Renderer потребує візуалізації інших об'єктів, ніж Pages. Інакше приємний аналіз.
user949300

2

Відповідь на це питання однозначна. Саме це $renderer->renderPage($page);і є правильною реалізацією. Щоб зрозуміти, як ми дійшли цього висновку, нам потрібно зрозуміти інкапсуляцію.

Що таке сторінка? Це зображення відображення, яке хтось буде споживати. Що "хтось" може бути людиною або ботами. Зауважте, що Pageце представлення, а не сам дисплей. Чи існує представництво без його представлення? Сторінка чимось без рендерінга? Відповідь - Так, представництво може існувати без представлення. Представляти - це пізніший етап.

Що таке візуалізація без сторінки? Чи може візуалізатор відображати без сторінки? Ні. Отже, інтерфейс Renderer дійсно потребує renderPage($page);методу.

Що не так $page->renderMe($renderer);?

Справа в тому, що renderMe($renderer)все одно доведеться внутрішньо телефонувати $renderer->renderPage($page);. Це порушує Закон Деметера, який стверджує

Кожен підрозділ повинен мати лише обмежені знання про інші підрозділи

PageКлас не хвилює , чи існує Rendererу Всесвіті. Дбає лише про те, щоб бути представленням сторінки. Тож клас чи інтерфейс Rendererніколи не слід згадувати в a Page.


ОНОВЛЕНА ВІДПОВІДЬ

Якщо я правильно поставив ваше запитання, PageSnippetклас повинен стосуватися лише фрагмента сторінки.

class PageSnippet
{    
    /** string */
    private $html;

    function __construct($data = ['html' => '']): void
    {
        $this->html = $data['html'];
    }

   public function getHtml()
   {
       return $this->html;
   }
}

PdfRenderer стосується візуалізації.

class PdfRenderer
{
    /**@var TCPDF */
    protected $pdf;

    function __construct(TCPDF $pdf = new TCPDF())
    {
        $this->pdf = $pdf;
    }

    function runTemplate(string $html): void
    {
        $this->pdf->writeHTML($html);
    }
}

Використання клієнта

$renderer = new PdfRenderer();
$snippet = new PageSnippet(['html' => '<html />']);
$renderer->runTemplate($snippet->getHtml());

Кілька пунктів, які слід врахувати:

  • Його погана практика передавати себе $dataяк асоціативний масив. Це повинен бути екземпляр класу.
  • Те, що формат сторінки міститься у htmlвластивості $dataмасиву, є деталями, характерними для вашого домену, і PageSnippetзнає про ці відомості.

Але що робити, якщо, окрім Сторінок, у вас є "Малюнки", "Статті" та "Триптихи"? У вашій схемі рендер повинен знати про них. Це багато витоку. Просто їжа для роздумів.
user949300

@ user949300: Ну, якщо Рендерер повинен вміти візуалізувати зображення тощо, то, очевидно, він повинен знати про них.
ЖакБ

1
Кент Бек представляє схему методу зворотного перегляду, в якій обидва підтримуються. Зв'язана стаття показує, що об’єкт підтримує printOn:aStreamметод, але все, що він робить, - це сказати потоку для друку об'єкта. Аналогія з вашою відповіддю полягає в тому, що немає жодної причини, щоб у вас не було як сторінки, яка може бути надана рендереру, так і рендерінга, яка може надати сторінку, з однією реалізацією та вибором зручних інтерфейсів.
Грем Лі

2
У будь-якому випадку вам доведеться зламати / погладити SRP, але якщо Renderer повинен знати, як зробити багато різних речей, це дійсно "багато відповідальності", і, якщо можливо, цього слід уникати.
user949300

1
Мені подобається ваша відповідь, але я спокушаюся думати, що Pageне бути обізнаним про $ renderrer неможливо. Я додав якийсь код у своє запитання, дивіться PageSnippetклас. Це фактично сторінка, але вона не може існувати, не роблячи жодного посилання на цей $pdf, який насправді є стороннім PDF-рендером у цьому випадку. .. Однак я припускаю, що хоч я міг би створити такий PageSnippetклас, який містить лише масив текстових вказівок у PDF, а інший клас інтерпретувати ці інструкції. Таким чином , я можу уникнути ін'єкційного $pdfв PageSnippet, за рахунок додаткової складності
Денніс

1

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

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

renderer.renderLine(x, y, w, h, Color.Black)
renderer.renderText(a, b, Font.Helvetica, Color.Black, "bla bla...")
etc...

Так було б $page->renderMe($renderer), оскільки Сторінка потребує посилання на рендерінга.

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

[
  Line(x, y, w, h, Color.Black), 
  Text(a, b, Font.Helvetica, Color.Black, "bla bla...")
]

У цьому випадку фактичний Рендер отримає цю структуру даних зі сторінки та обробить її, виконавши відповідні інструкції з надання. При такому підході залежності буде перетворено - Сторінка не повинна знати про Рендера, але Рендерінгу повинна бути надана Сторінка, яку він може потім надати. Отже варіант два:$renderer->renderPage($page);

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

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

renderPage($page, $renderer)

Єдиний підхід, який я не рекомендую, - $page->renderMe()це припускати, що на сторінці може бути лише один рендер. Але що робити, якщо у вас є ScreenRendererі додати PrintRenderer? Одна і та ж сторінка може бути надана обома.


У контексті EPUB або HTML концепція сторінки не існує без рендерінга.
mouviciel

1
@mouviciel: Я не впевнений, що розумію, що ти маєш на увазі. Напевно у вас може бути HTML-сторінка, не надаючи її? Наприклад, обробляти сторінки сканера Google, не надаючи їх.
ЖакБ

2
Існує інше поняття сторінки слова: результат процесу пагинації, коли HTML-сторінка відформатована для друку, можливо, це те, що має на увазі @mouviciel. Однак у цьому питанні a pageявно є вклад для рендерінга, а не вихід, до якого поняття явно не підходить.
Док Браун

1

D частина SOLID говорить

"Абстракції не повинні залежати від деталей. Деталі повинні залежати від абстракцій."

Отже, між Page і Renderer, яка, швидше за все, буде стійкою абстракцією, рідше змінюється, можливо, представляє інтерфейс? Навпаки, яка «деталь»?

На мій досвід, абстракція - це, як правило, рендер. Наприклад, це може бути простий потік або XML, дуже абстрактний і стабільний. Або якийсь досить стандартний макет. Більше ймовірно, що Ваша Сторінка - це власний бізнес-об’єкт, "деталь". І у вас є інші бізнес-об’єкти, які потрібно рендерувати, наприклад "фотографії", "звіти", "діаграми" тощо ... (Мабуть, не "триптих", як у моєму коментарі)

Але це очевидно залежить від вашого дизайну. Сторінка може бути абстрактною, наприклад, еквівалентом <article>тегу HTML зі стандартними підчастинами. І у вас є безліч різноманітних спеціальних звітів про бізнес "звітування". У такому випадку рендер повинен залежати від сторінки.


0

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

  • Класи, що містять дані (змінні або незмінні значення не мають значення)

Це класи, які майже не залежать ні від чого іншого. Зазвичай вони є частиною вашого домену. Вони не повинні містити ніякої логіки або лише логіки, яка може бути виведена безпосередньо з її стану. Клас Співробітник може мати функцію isAdult, яка може бути отримана безпосередньо з його, birthDateале не функцію hasBirthDay, яка вимагає зовнішньої інформації (поточна дата).

  • Класи, які надають послуги

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

Ваш приклад

У вашому прикладі Pageбуде клас, що містить дані. Він повинен мати функції, щоб отримати ці дані та, можливо, змінити їх, якщо клас повинен бути змінним. Тримайте його німим, тому його можна використовувати без великої кількості залежностей.

Дані, або в цьому випадку, ваші Pageможуть бути представлені безліччю способів. Це може бути представлено у вигляді веб-сторінки, записаної на диск, збереженої в базі даних, перетвореної в JSON, як би там не було. Ви не хочете додавати методи до такого класу для кожного з цих випадків (і створювати залежності від усіх видів інших класів, навіть якщо ваш клас повинен містити дані).

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

Наприклад, ви можете мати a MobileRendererі a StandardRenderer, обидві реалізації Rendererкласу, але з різними налаштуваннями.

Так , як Pageмістить дані і повинні бути німим, чистісінька рішення в цьому випадку повинен був би пройти Pageдо Renderer:

$renderer->renderPage($page)

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