Як переробити програму OO у функціональну?


26

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

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


6
я не думаю, що це можливо. вам доведеться переробити (і переписати) все заново.
Брайан Чен

18
-1, ця публікація упереджена помилковим припущенням, що ООП та функціональний стиль суперечать. Це здебільшого ортогональні поняття, і ІМХО - це міф, що їх немає. "Функціональний" більше протистоїть "Процедурному", і обидва стилі можна використовувати спільно з OOP.
Doc Brown

11
@DocBrown, OOP занадто сильно покладається на стан, що змінюється. Об'єкти без громадянства не вписуються в сучасну практику проектування ООП.
SK-логіка

9
@ SK-логіка: ключовими є не об'єкти без громадянства, а незмінні об'єкти. І навіть коли об'єкти є змінними, вони часто можуть використовуватися у функціональній частині системи до тих пір, поки вони не будуть змінені в заданому контексті. Крім того, я думаю, ви знаєте, що об'єкти та закриття є взаємозамінними. Отже, все це показує, що ООП та "функціонал" не суперечать.
Док Браун

12
@DocBrown: Я думаю, що мовні конструкції є ортогональними, тоді як настрій мислення, як правило, стикається. Люди OOP схильні запитувати "що це за об'єкти та як вони співпрацюють?"; функціональні люди схильні запитувати "що це за мої дані та як я хочу їх перетворити?". Це не однакові питання, і вони призводять до різних відповідей. Я також думаю, що ви неправильно прочитали питання. Це не "OOP drools та FP правила, як я позбудусь OOP?", Це "Я отримую OOP і я не отримую FP, чи є спосіб перетворити програму OOP у функціональну, щоб я міг отримати якесь розуміння? ".
Майкл Шоу

Відповіді:


31

Визначення функціонального програмування

Вступ до «Радості Клоджура» говорить про наступне:

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

Функціональне програмування стосується та полегшує застосування та склад функцій ... Щоб мова вважалася функціональною, її поняття функції повинно бути першокласним. Функції першого класу можна зберігати, передавати та повертати, як і будь-який інший фрагмент даних. Поза межами цієї основної концепції [визначення FP може включати] чистоту, незмінність, рекурсію, лінь та референтну прозорість.

Програмування в Scala 2nd Edition p. 10 має таке визначення:

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

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

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

Функції першого класу

Уявіть, що ви зараз отримуєте Список пасажирів від об'єкта Автобус, і ви повторюєте його, зменшуючи банківський рахунок кожного пасажира на суму тарифу на автобус. Функціональним способом виконання цієї самої дії було б мати метод на Bus, який може бути названий forEachPassenger, який приймає функцію одного аргументу. Тоді Автобус повторить своїх пасажирів, але найкраще це зробити, і код вашого клієнта, який стягує вартість проїзду, буде введений у функцію та переданий ForEachPassenger. Вуаля! Ви використовуєте функціональне програмування.

Обов’язкові:

for (Passenger p : Bus.getPassengers()) {
    p.debit(fare);
}

Функціональний (використовуючи анонімну функцію або "лямбда" в Scala):

myBus = myBus.forEachPassenger(p:Passenger -> { p.debit(fare) })

Більш цукриста версія Scala:

myBus = myBus.forEachPassenger(_.debit(fare))

Не першокласні функції

Якщо ваша мова не підтримує першокласні функції, це може стати дуже потворним. У Java 7 або новіших версіях ви повинні надати інтерфейс "Функціональний об'єкт", наприклад такий:

// Java 8 has java.util.function.Consumer, but in earlier
// versions you have to roll your own:
public interface Consumer<T> {
    public void accept(T t);
}

Тоді клас Bus забезпечує внутрішній ітератор:

public void forEachPassenger(Consumer<Passenger> c) {
    for (Passenger p : passengers) {
        c.accept(p);
    }
}

Нарешті, ви передаєте об'єкт анонімної функції на Шину:

// Java 8 has syntactic sugar to make this look more like
// the Scala solution, but earlier versions require manually
// instantiating a "Function Object," in this case, a
// Consumer:
Bus.forEachPassenger(new Consumer<Passenger>() {
    @Override
    public void accept(final Passenger p) {
        p.debit(fare);
    }
}

Java 8 дозволяє захоплювати локальні змінні сферою дії анонімної функції, але в більш ранніх версіях будь-які такі варіації повинні бути оголошені остаточними. Щоб обійти це, можливо, вам доведеться скласти обгортковий клас MutableReference. Ось цілочисельний клас, який дозволяє додавати лічильник циклу до вищевказаного коду:

public static class MutableIntWrapper {
    private int i;
    private MutableIntWrapper(int in) { i = in; }
    public static MutableIntWrapper ofZero() {
        return new MutableIntWrapper(0);
    }
    public int value() { return i; }
    public void increment() { i++; }
}

final MutableIntWrapper count = MutableIntWrapper.ofZero();
Bus.forEachPassenger(new Consumer<Passenger>() {
    @Override
    public void accept(final Passenger p) {
        p.debit(fare);
        count.increment();
    }
}

System.out.println(count.value());

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

Ця потворність була зафіксована в Java 8, але обробка перевірених винятків усередині функції першого класу все ще по-справжньому потворна, і Java все ще несе припущення про незмінність у всіх своїх колекціях. Що підводить нас до інших цілей, часто пов'язаних з ПП:

Незмінюваність

Пункт 13 Джоша Блоха - "Віддавайте перевагу незмінності". Незважаючи на поширені розмови про сміття, навпаки, OOP можна робити з незмінними предметами, і це робить це набагато краще. Наприклад, рядок у Java незмінний. StringBuffer, OTOH потрібно змінювати, щоб створити незмінний рядок. Деякі завдання, як-от робота з буферами, по суті потребують змін.

Чистота

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

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

Висновок

Дійсно, з усіх вищезазначених ідей незмінність стала найбільшою єдиною виграшею з точки зору практичних застосувань для спрощення мого коду - будь то OOP чи FP. Передача функцій ітераторам - це другий за величиною виграш. Документація Java 8 Lambdas має найкраще пояснення того, чому. Рекурсія чудово підходить для обробки дерев. Лінь дозволяє працювати з нескінченними колекціями.

Якщо вам подобається JVM, я рекомендую вам поглянути на Scala та Clojure. Обидва є проникливими інтерпретаціями функціонального програмування. Scala є безпечним для типу з дещо схожим на C синтаксисом, хоча він справді має стільки ж синтаксису спільного з Haskell, як і з C. Clojure не є безпечним для типу, і це Lisp. Нещодавно я опублікував порівняння Java, Scala та Clojure щодо однієї конкретної проблеми рефакторингу. Порівняння Логана Кемпбелла, використовуючи гру «Життя життя», включає також Haskell і також набрав Clojure.

PS

Джиммі Хоффа зазначив, що мій клас "Автобус" є змінним. Замість того, щоб виправити оригінал, я думаю, що саме це буде демонструвати саме те, яке рефакторинг стосується цього питання. Це можна виправити, зробивши кожен метод на Автобусі заводом для виробництва нової Автобус, кожен метод на Пасажирі - фабрикою для виробництва нового Пасажира. Таким чином, я додав тип повернення до всього, що означає, що я скопіюю java.util.function.Function Java 8 замість споживчого інтерфейсу:

public interface Function<T,R> {
    public R apply(T t);
    // Note: I'm leaving out Java 8's compose() method here for simplicity
}

Потім у автобусі:

public Bus mapPassengers(Function<Passenger,Passenger> c) {
    // I have to use a mutable collection internally because Java
    // does not have immutable collections that return modified copies
    // of themselves the way the Clojure and Scala collections do.
    List<Passenger> newPassengers = new ArrayList(passengers.size());
    for (Passenger p : passengers) {
        newPassengers.add(c.apply(p));
    }
    return Bus.of(driver, Collections.unmodifiableList(passengers));
}

Нарешті, об’єкт анонімної функції повертає модифікований стан речей (нова шина з новими пасажирами). Це передбачає, що p.debit () тепер повертає нового незмінного Пасажира з меншими грошима, ніж оригінал:

Bus b = b.mapPassengers(new Function<Passenger,Passenger>() {
    @Override
    public Passenger apply(final Passenger p) {
        return p.debit(fare);
    }
}

Сподіваємось, ви тепер можете самостійно прийняти рішення про те, наскільки функціональним ви хочете зробити свою імперативну мову, і вирішити, чи було б краще переробити проект за допомогою функціональної мови. У Scala або Clojure колекції та інші API розроблені для спрощення функціонального програмування. Обидва мають дуже хороший інтероп Java, тож ви можете змішувати та співставляти мови. Насправді, для інтероперабельності Java Scala збирає свої функції першого класу в анонімні класи, майже сумісні з функціональними інтерфейсами Java 8. Про подробиці ви можете прочитати в Scala в глибині секти. 1.3.2 .


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

І як тільки функція не повертає значення, раптом функція не може бути складена з іншими, і ви втрачаєте всю абстракцію функціональної композиції. Ви можете змусити функцію змінити об'єкт на місці, а потім повернути об'єкт, але якщо це робиться, то чому б не просто змусити функцію взяти об'єкт як параметр і звільнити його від меж його батьківського об'єкта? Звільнений від батьківського об'єкта, він зможе працювати і над іншими типами, що є ще однією важливою частиною FP, якої ви не вистачаєте: Абстракція типу ваш forEachPasenger працює лише проти пасажирів ...
Джиммі Хоффа

1
Причина, по якій ви абстрагуєте речі для відображення та зменшення, і ці функції не пов'язані з вмістом об'єктів, полягає в тому, що їх можна використовувати безліч типів через параметричний поліморфізм. Саме спалах цих різноманітних абстракцій, яких ви не знайдете в мовах OOP, дійсно визначає FP і приводить його до значення. Справа не в тому, що лінь, референтна прозорість, незмінність або навіть система типу HM необхідні для створення FP, ці речі є скоріше побічними ефектами створення мов, призначених для функціональної композиції, де функції можуть абстрагуватися по типах взагалі
Джиммі Хоффа

@JimmyHoffa Ви зробили дуже чесну критику мого прикладу. Мене спокусив інтерфейс для споживачів інтерфейс Java8. Крім того, визначення Fouser / Fogus FP не включало незмінність, і я додав визначення Odersky / Spoon / Venners пізніше. Я залишив оригінальний приклад, але додав нову незмінну версію в розділі "PS" внизу. Це некрасиво. Але я думаю, що це демонструє функції, що діють на об'єкти, щоб створювати нові об'єкти, а не змінювати внутрішні оригінали. Чудовий коментар!
GlenPeterson

1
Ця розмова триває на Білій
GlenPeterson

12

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

  • Перетворити весь зовнішній стан у параметр функції. EG: якщо метод об'єкта модифікується x, зробіть так, щоб метод отримав xзамість виклику this.x.
  • Видаліть поведінку з об’єктів.
    1. Зробити загальнодоступними дані об’єкта
    2. Перетворити всі методи у функції, які викликає об'єкт.
    3. Код клієнта, який викликає об'єкт, викликає нову функцію, передаючи дані об'єкта. EG: Перетворити x.methodThatModifiesTheFooVar()вfooFn(x.foo)
    4. Видаліть оригінальний метод з об’єкта
  • Замінити як багато ітераційних циклів , як ви можете з функцій вищого порядку подобається map, reduce, filterі т.д.

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

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

Редагувати

Як ви бачите тут , неможливо створити справді незмінний об’єкт у JavaScript. Якщо ви старанно і керуєте тим, хто називає ваш код, ви можете це зробити, завжди створюючи новий об'єкт, а не мутувати поточний. Мені не варто було докладати зусиль.

Але якщо ви використовуєте Java, ви можете використовувати ці методи, щоб зробити ваші заняття незмінними.


+1 Залежно від того, що саме ви намагаєтеся зробити, це, мабуть, настільки далеко, наскільки ви дійсно можете піти, не вносячи змін дизайну, які могли б вийти за рамки просто "рефакторингу".
Евікатос

@Evicatos: Я не знаю, якби у JavaScript була краща підтримка непорушного стану, я думаю, що моє рішення було б таким же функціональним, як ви отримаєте динамічну функціональну мову, як Clojure. Що є прикладом чогось, що вимагатиме чогось, крім простого рефакторингу?
Даніель Каплан

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

@Evicatos дивись мою редакцію
Daniel Kaplan

1
@tieTYT так, це сумно з приводу того, що JS є такою мінливою, але принаймні Clojure може компілювати на JavaScript: github.com/clojure/clojurescript
GlenPeterson

3

Я не думаю, що реально переробити програму повністю можливо - вам доведеться переробити та переробити у правильну парадигму.

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

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


Додам, що першим хорошим знаком є ​​прагнення до прозорості. Після цього ви отримуєте ~ 50% переваг функціонального програмування.
Даніель Гратцер

3

Я думаю, що ця серія статей саме те, що ви хочете:

Чисто функціональні ретрогри

http://prog21.dadgum.com/23.html Частина 1

http://prog21.dadgum.com/24.html Частина 2

http://prog21.dadgum.com/25.html Частина 3

http://prog21.dadgum.com/26.html Частина 4

http://prog21.dadgum.com/37.html Подальші дії

Підсумок:

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

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


2

Ймовірно, вам доведеться перетворити весь код назовні, оскільки OOP і FP мають два протилежні підходи до організації коду.

OOP організовує код навколо типів (класів): різні класи можуть реалізувати одну операцію (метод з однаковою підписом). Як результат, OOP є більш доцільним, коли набір операцій не сильно змінюється, тоді як нові типи можна додавати дуже часто. Наприклад, розглянемо бібліотеку GUI , в якому кожен віджет має фіксований набір методів ( hide(), show(), paint(), move(), і так далі) , але нові віджети можуть бути додані як бібліотека розширена. В OOP легко додати новий тип (для заданого інтерфейсу): потрібно лише додати новий клас та реалізувати всі його методи (зміна локального коду). З іншого боку, додавання нової операції (методу) до інтерфейсу може зажадати зміни всіх класів, які реалізують цей інтерфейс (навіть незважаючи на те, що успадкування може зменшити обсяг роботи).

FP організовує код навколо операцій (функцій): кожна функція реалізує певну операцію, яка може обробляти різні типи по-різному. Зазвичай це досягається диспетчеризацією за типом за допомогою узгодження шаблону чи іншого механізму. Як наслідок, ПП є більш доцільним, коли набір типів стабільний і нові операції додаються частіше. Візьмемо для прикладу фіксований набір форматів зображень (GIF, JPEG тощо) та деякі алгоритми, які потрібно реалізувати. Кожен алгоритм може бути реалізований функцією, яка поводиться по-різному залежно від типу зображення. Додавання нового алгоритму є простим, оскільки вам потрібно лише реалізувати нову функцію (зміна локального коду). Додавання нового формату (типу) вимагає зміни всіх функцій, які ви реалізували до цього часу, для його підтримки (нелокальні зміни).

Підсумок: OOP та FP принципово відрізняються тим, як вони організовують код, і зміна OOP-дизайну на FP-дизайн передбачає зміну всього коду для відображення цього. Це може бути цікавою вправою. Дивіться також ці конспекти лекцій до книги SICP, цитовані mikemay, зокрема слайди 13.1.5 до 13.1.10.

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