Чи є смисловий контракт інтерфейсу (OOP) більш інформативним, ніж підпис функції (FP)?


16

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

Зі статті:

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

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

public interface IMessageQuery
{
    string Read(int id);
}

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

Порівняйте це з залежністю від його еквівалентної функціональної підпису int -> string. Без додаткових підказів ця функція може бути простою ToString(). Якщо ви реалізовуєте IMessageQuery.Read(int id)з ToString()I, я можу звинуватити вас у навмисному підриві!

Отже, що можуть зробити функціональні програмісти, щоб зберегти семантику добре названого інтерфейсу? Це звичайно, наприклад, створити тип запису з одним членом?

type MessageQuery = {
    Read: int -> string
}

5
Інтерфейс OOP більше схожий на клас FP типу , а не на одну функцію.
9000

2
Ви можете отримати дуже багато корисного, застосувавши провайдер «суворо», щоб у кінцевому підсумку, 1 метод на інтерфейс просто надто далеко.
gbjbaanb

3
@gbjbaanb фактично більшість моїх інтерфейсів мають лише один метод із багатьма реалізаціями. Чим більше ви застосовуєте принципи SOLID, тим більше ви бачите переваг. Але це поза темою для цього питання
AlexFoxGill

1
@jk. Ну, у Haskell це класи типів, в OCaml ви використовуєте модуль або функтор, в Clojure ви використовуєте протокол. У будь-якому випадку, ви зазвичай не обмежуєте аналогію інтерфейсу однією функцією.
9000

2
Without any additional clues... можливо, саме тому документація є частиною договору ?
SJuan76

Відповіді:


8

Як каже Теластин, порівнюючи статичні визначення функцій:

public string Read(int id) { /*...*/ }

до

let read (id:int) = //...

Ви насправді нічого не втратили, переходячи від OOP до FP.

Однак це лише частина історії, оскільки функції та інтерфейси не згадуються лише у своїх статичних визначеннях. Вони також пройшли навколо . Отже, скажімо, наш MessageQueryбув прочитаний іншим фрагментом коду, a MessageProcessor. Тоді ми маємо:

public void ProcessMessage(int messageId, IMessageQuery messageReader) { /*...*/ }

Тепер ми не можемо безпосередньо бачити назву методу IMessageQuery.Readчи його параметр int id, але ми можемо потрапити туди дуже легко через наш IDE. Загалом, той факт, що ми передаємо IMessageQueryшвидше, ніж будь-який інтерфейс із методом, функцією від int до string, означає, що ми це зберігаємоid метадані параметра, пов'язані з цією функцією.

З іншого боку, для нашої функціональної версії ми маємо:

let read (id:int) (messageReader : int -> string) = // ...

То що ми зберегли і втратили? Ну, у нас все ще є ім'я параметра messageReader, що, ймовірно, робить ім'я типу (еквівалентно IMessageQuery) зайвим. Але тепер ми втратили ім’я параметра idв нашій функції.


Є два основні способи цього:

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

  • По-друге, в багатьох функціональних мовах вважається ідіоматичним дизайном для створення малих типів для загортання примітивів. У цьому випадку відбувається навпаки - замість заміни імені типу ім'ям параметра ( IMessageQueryдо messageReader) ми можемо замінити ім'я параметра на ім'я типу. Наприклад, intможе бути загорнуто у тип, який називається Id:

    type Id = Id of int
    

    Тепер наш readпідпис стає:

    let read (id:int) (messageReader : Id -> string) = // ...
    

    Що так само інформативно, як і раніше.

    Як бічне зауваження, це також дає нам певний захист компілятора, який ми мали в OOP. Якщо версія OOP гарантувала, що ми взяли конкретно, IMessageQueryа не будь-яку стару int -> stringфункцію, тут ми маємо схожий (але різний) захист, який ми приймаємо, Id -> stringа не будь-який старий int -> string.


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


1
Це моя улюблена відповідь - що FP заохочує використання смислових типів
AlexFoxGill

10

Роблячи ФП, я схильний використовувати більш конкретні семантичні типи.

Наприклад, ваш метод для мене став би таким:

read: MessageId -> Message

Це спілкується набагато більше, ніж ThingDoer.doThing()стиль OO (/ java)


2
+1. Крім того, ви можете кодувати набагато більше властивостей у системі типів на введених мовах FP, таких як Haskell. Якби це був тип haskell, я б знав, що він взагалі не робить ніякого IO (і, таким чином, швидше за все, десь посилається на деяке відображення в пам’яті ідентифікаторів із повідомленнями). У мовах OOP у мене немає такої інформації - ця функція може задзвонити базу даних, як частину її виклику.
Джек

1
Мені не зовсім зрозуміло, чому це буде спілкуватися більше, ніж стиль Java. Що read: MessageId -> Messageвам каже, що string MessageReader.GetMessage(int messageId)ні?
Бен Аронсон

@BenAaronson краще співвідношення сигнал / шум. Якщо це метод екземпляра OO, я поняття не маю, що він робить під кришкою, він може мати будь-яку залежність, яка не виражена. Як згадує Джек, коли ти маєш сильніші типи, ти маєш більше гарантій.
Daenyth

9

Отже, що можуть зробити функціональні програмісти, щоб зберегти семантику добре названого інтерфейсу?

Використовуйте добре названі функції.

IMessageQuery::Read: int -> stringпросто стає ReadMessageQuery: int -> stringчи щось подібне.

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

Чи є смисловий контракт інтерфейсу (OOP) більш інформативним, ніж підпис функції (FP)?

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

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

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


2
Що з іменами параметрів?
Бен Ааронсон

@BenAaronson - що з ними? Як OO, так і функціональні мови дозволяють вказувати назви параметрів, так що це поштовх. Я просто використовую тут скорочення підписів типу, щоб відповідати питанню. На справжній мові це виглядало б приблизно такReadMessageQuery id = <code to go fetch based on id>
Теластин

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

1
Так, @BenAaronson - це одна з частин інформації, яка втрачається в підписі функції. Наприклад у let consumer (messageQuery : int -> string) = messageQuery 5, idпараметр просто an int. Я здогадуюсь, один аргумент полягає в тому, що вам слід передавати а Id, а не анкету int. Насправді це дало б гідну відповідь.
AlexFoxGill

@AlexFoxGill Насправді я щойно писав щось саме по цьому напрямку!
Бен Ааронсон

0

Я не погоджуюся, що одна функція не може мати «смисловий контракт». Розглянемо ці закони для foldr:

foldr f z nil = z
foldr f z (singleton x) = f x z
foldr f z (xn <> ys) = foldr f (foldr f z ys) xn

У якому сенсі це не семантичний, чи не контрактний? Вам не потрібно визначати тип для "папки", тим більше, що foldrце однозначно визначається цими законами. Ви точно знаєте, що це буде робити.

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

-- The first argument `f` must satisfy for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
sort :: forall 'a. (a -> a -> bool.t) -> list.t a -> list.t a;

Ім'я та захоплення цього типу потрібно лише в тому випадку, якщо вам потрібен один і той же контракт кілька разів:

-- Type of functions `f` satisfying, for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
type comparison.t 'a = a -> a -> bool.t;

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


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

@AlexFoxGill - це не визначення функції, хоча це однозначно визначає foldr. Підказка: визначення foldrмає два рівняння (чому?), А специфікація, наведена вище, має три.
Джонатан У ролях

Що я маю на увазі, що foldr - це функція, а не підпис функції. Закони чи визначення не є частиною підпису
AlexFoxGill

-1

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

Наприклад, скажімо, що клієнт хотів реалізувати запит повідомлення, який було підкріплено списком. У Haskell реалізація може бути такою ж простою:

messages = ["Message 0", "Message 1", "Message 2"]
messageQuery = (messages !!)

Використовувати newtype Message = Message Stringце було б набагато менш просто, залишаючи цю реалізацію приблизно такою:

messages = map Message ["Message 0", "Message 1", "Message 2"]
messageQuery (Id index) = messages !! index

Це може не здатися великою справою, але вам доведеться або робити це перетворення типу скрізь і назад , або встановити прикордонний шар у своєму коді, де все вище, Int -> Stringтоді ви перетворите його Id -> Messageна перехід на шар нижче. Скажіть, я хотів додати інтернаціоналізацію, або відформатувати її у всіх великих обмеженнях, або додати контекст ведення журналу, або будь-що інше. Ці операції всі мертві, просто їх скласти Int -> Stringі дратувати з Id -> Message. Справа не в тому, що ніколи не буває випадків, коли підвищені типи обмежень бажані, але роздратування краще варто було б скасувати.

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

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


1
Це більше схоже на коментар до відповідей Бен / Деніта - що ви можете використовувати семантичні типи, але ви цього не робите через незручності? Я вас не заявив, але це не відповідь на це питання.
AlexFoxGill

-1 Це взагалі не зашкодило б використанню або повторному використанню. Якщо Idі Messageпрості обгортки для Intі String, то тривіально конвертувати між ними.
Майкл Шоу

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

Це здається річчю розміру. У невеликих проектах такі види обгортки, мабуть, не так корисні. У великих проектах закономірно, що межі становлять невелику частку загального коду, тому здійснення цих типів перетворень на кордоні, а потім проходження навколо загорнутих типів скрізь ще не є надто складним. Крім того, як сказав Алекс, я не бачу, як це відповідь на питання.
Бен Аронсон

Я пояснив, як це зробити, то чому функціональні програмісти цього не роблять. Це відповідь, навіть якщо це не той, кого хотіли люди. Це не має нічого спільного з розміром. Ця думка про те, що якось добре навмисно ускладнити повторне використання коду, - це, безумовно, концепція OOP. Створення типу продукту, який додає певної цінності? Весь час. Створення типу саме як широко використовуваний тип, за винятком того, що ви можете використовувати його лише в одній бібліотеці? Практично нечувано, за винятком, можливо, FP-коду, який сильно взаємодіє з кодом OOP, або написаного кимось, переважно знайомим з ідіомами OOP.
Карл Білефельдт
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.