Карринг vs Часткове застосування функції
Загалом, я знаю, що деякі люди іноді плутають терміникарингічасткове застосування функції— використовують їх взаємозамінно, коли цього робити не слід. Це одна з тих тем (як, наприклад, монади), яку я певною мірою розумію, і я вирішив, що найкращим способом переконатися у своїх знаннях буде написати про це. Якщо це зробить цю тему доступнішою для інших розробників, то краще.
Ця посада не містить Haskell
Майже у всіх роз'ясненнях на цю тему, що я бачив, були дані приклади на «правильних» функціональних мовах, зазвичай на Haskell. Я нічого не маю проти Haskell, просто мені зазвичай легше зрозуміти приклади мовою, яку я добре знаю. Тим більше, менінабагатолегшеписатиприклади такою мовою, тому всі приклади в цьому пості будуть на C#. Власне, всі приклади доступні в одному файлі, щоправда, кілька змінних у ньому перейменовано. Просто скомпілюйте та запустіть.
C# насправді не є функціональною мовою — я знаю достатньо, щоб розуміти, що делегати не є повною заміною функцій вищого порядку. Проте вони досить хороші для демонстрації описуваних принципів.
Хоча можна продемонструвати каринг та часткове застосування, використовуючи функцію (метод) з невеликою кількістю аргументів, я вирішив використати три аргументи для ясності. Хоча мої методи виконання карингу та часткового застосування будуть узагальненими (тому всі типи параметрів і значення, що повертається довільні), з метою демонстрації я використовую просту функцію:
Поки що все просто. У цьому методі немає нічого хитрого, не шукайте в ньому нічого дивного.
Про що взагалі йдеться?
І каринг та часткове застосування це способи перетворення одного виду функції вінший. Ми будемо використовувати делегати як апроксимацію функцій, тому для роботи з методом SampleFunction як зі значенням, ми можемо написати:
Цей рядок корисний з двох причин:
Тепер ми можемо викликати делегат із трьома аргументами:
Або те саме:
(Компілятор C# перетворює першу коротку форму на другу. Згенерований IL буде тим самим.)
Добре, якщо нам доступні всі три аргументи одноразово, але що якщо ні? Для конкретного (хоча і кілька надуманого) прикладу, припустимо у нас є функція логування з трьома параметрами (джерело, серйозність, повідомлення) і в межах одного класу (я буду називати BusinessLogic), ми хочемо завжди використовувати одне і те ж значення для параметра «Джерело». Ми хочемо мати можливість легко логувати з будь-якої точки класу, вказуючи лише серйозність та повідомлення. У нас є кілька варіантів:
- Створити клас-адаптер, який приймає функцію логування (або навіть об'єкт-логгер) та значення параметра «джерело» у свій конструктор, зберігає їх у своїх полях та виставляє назовні метод із двома параметрами. Цей метод просто делегує виклик збереженому логеру, передаючи збережене джерело першим параметром функції логера. У класі BusinessLogic ми створюємо екземпляр адаптера і зберігаємо посилання на нього в полі, а далі просто викликаємо метод із двома параметрами, де хочемо. Мабуть, це оверкілл, якщо нам потрібен лише адаптер від BusinessLogic, але його можна перевикористовувати… доти, поки ми адаптуватимемо ту ж функцію, що логікує.
- Зберігати вихідний об'єкт логера в нашому класі BusinessLogic, але створити допоміжний метод із двома параметрами, всередині якого буде захардшкірено значення для параметра «джерело». Якщо нам требазробити так у кількох місцях, це починає дратувати.
- Використовувати функціональніший підхід — у цьому випадкучасткове застосування функції.
Я навмисне ігнорую різницю між зберіганням посилання на об'єкт-логгер і зберіганням посилання на функцію логування. Очевидно, є істотна відмінність, якщо нам потрібно використовувати більше однієї функції класу логера, але для того, щоб розмірковувати про каринг і часткове застосування, ми думатимемо про «логер» як про «функцію, яка приймає три параметри» (як наша функція в прикладі ).
Тепер, коли я дав псевдо-реальний конкретний кейс для мотивації, ми забудемо його до кінця статті і розглядатимемо лише функцію-приклад. Я не хочу писати весь клас BusinessLogic, який буде вдавати, що займається чимось корисним; я впевнений ви зможете зробити уявне перетворення з «функції-прикладу» на «щось, що ви насправді хотіли б зробити».
Часткове застосування функції
Часткове застосування набирає функції з N параметрами і значення для одного з цих параметрів і повертає функцію з N-1 параметрами, таку, що, будучи викликаною, вона збере всі необхідні значення (перший аргумент, переданий самої функції часткового застосування, та інші N-1 аргументи передані функції, що повертається). Таким чином, ці два виклики повинні бути еквівалентними нашому методу з трьома параметрами:
В даному випадку, я реалізував часткове застосування з єдиним параметром, першим за рахунком - виможетенаписати ApplyPartial, яка прийматиме більше аргументів або підставлятиме їх в інші позиції в остаточному виконанні функції. Очевидно, збирання параметрів по одному, починаючи з початку — звичайнісінький підхід.
Дякую анонімним функціям (в даному випадкулямбда-виразу, але анонімний метод не був би дуже багатослівним), реалізація ApplyPartial проста:
Узагальнення змушують цей метод виглядати складніше, ніж є насправді. Зверніть увагу, що відсутність типів вищого порядку (higher order types) в C# означає, що вам необхідна реалізація цього методу для кожного делегата, який ви хочете використовувати - якщо вам необхідна версія для функції з чотирма параметрами, вам необхідний Метод ApplyPartial і т.д. Вам, ймовірно, також знадобиться набір методів для сімейства делегатів Action.
Останнє, що необхідно відзначити, маючи всі ці методи, ми можемо виконувати часткове застосування знову, навіть потенційно до результуючої функції без параметрів, якщо захочемо:
Знову ж таки, тільки останній рядок викличе вихідну функцію.
Ок, це часткове застосування функції. Воновідноснопросте. Каррінг, на мій погляд, трохи складніший для розуміння.
У той час як часткове застосування перетворює функцію з N параметрами на функцію з N-1 параметрами, застосовуючи один аргумент, каринг декомпозує функцію на функції від одного аргументу. Ми не передаємо жодних додаткових аргументів у метод Curry, крім перетворюваної функції:
- Curry(f) повертає функцію f1, таку що.
- f1(a) повертає функцію f2, таку що.
- f2(b) повертає функцію f3, таку що.
- f3(с) викликає f(a, b, c)
(Знову ж таки, зверніть увагу, що це стосується тільки нашої функції з трьома параметрами — сподіваюся, очевидно, як це буде працювати з іншими сигнатурами.)
Для нашого «еквівалентного» прикладу ми можемо написати:
Тепер, коли ми знаємо, що має робити метод Curry, його реалізація напрочуд проста. Насправді, все,що нам потрібно зробити це транслювати пункти вище в лямбда вирази. Краса:
Не соромтеся додавати дужки, якщо хочете зробити код зрозумілішим для себе, особисто я думаю, що вони тільки додадуть безладу. У будь-якому випадку ми отримали те, що хотіли. (Варто подумати про те, як стомливо було б це написати без лямбда-виразів або анонімних методів. Неважко, просто втомлює.)
Це і є каринг. Я вважаю. Можливо.
Висновок
Я не можу сказати, що я колись використав карринг, тоді як деякі частини парсингу тексту для Noda Time фактично використовують часткове застосування. (Якщо хтось дійсно хоче щоб я це перевірив, я зроблю це.)