OOP vs. FP. Snaha o rozšiřitelnost část 1

Během několika posledních let jsem se zajímal o programovací jazyk Scala a slyšel jsem spoustu kritiky ohledně smíšené povahy programování funkčních a objektově orientovaných. Na druhé straně se funkční programování v poslední době stalo tak populárním, že OOP je nyní považován za staromódní metodu, která by měla být přeložena do FP co nejdříve.

V tomto příspěvku na blogu se pokusím porovnat oba přístupy z hlediska rozšiřitelnosti na základě problému exprese formalizovaného profesorem informatiky, profesorem Philipem Wadlerem, který významně přispěl k rozvoji funkčního programování.

Rozšiřitelnost

Všichni víme, že náš kód se neustále vyvíjí. Vždy existuje určité refaktorování, oprava chyb a (doufejme) přidání nových funkcí. Kromě toho většina chyb a problémy s kódem obecně pramení ze skutečnosti, že neustále provádíme změny.

Mnoho z těchto změn mohlo být napsáno vašimi spoluhráči (přítomnými nebo bývalými) již dávno. Neexistuje způsob, jak se vyhnout úpravě našeho kódu, ale můžeme optimalizovat počet nezbytných změn při přidávání nových funkcí jednoduše tím, že náš kód bude rozšiřitelný.

Rozšiřitelnost softwaru je životně důležitá, takže jeden ze slavných principů SOLID je věnován výhradně tomuto tématu, jak je formulováno v principu Bertrand Mayer Open-Close

SOFTWARE ENTITIES (TŘÍDY, MODULY, FUNKCE, ETC.)
MĚL BY BÝT OTEVŘENO PRO ROZŠÍŘENÍ, ALE UZAVŘENÉ
ÚPRAVA.

Jednoduše řečeno, musíme vytvořit kód takovým způsobem, že když chceme přidat něco nového, neměli bychom mít povinnost dotknout se něčeho psaného v minulosti.

Problém výrazu

Problém výrazu se zaměřuje na jednu z nejdůležitějších vlastností kódu: rozšiřitelnost. Toto lze popsat jako schopnost vyvíjet se bez:

  • ovlivňující jasnost existujícího kódu
  • vynaložit velké úsilí na přidávání nových funkcí
  • porušení kódu, který funguje správně

Dlouho jsem s rozšiřitelností nakládal jako s jednosměrnou jednosměrnou ctností kódu, ale když jsem se dozvěděl o problému s výrazem, uvědomil jsem si, že existují dva různé způsoby, jak můžeme kód rozšířit.

Podle profesora Wadlera je prvním směrem, který můžeme sledovat, schopnost rozšířit náš kód přidáním nových formulářů. To lze jednoduše popsat jako poskytnutí zcela nových implementací současných rozhraní.

Druhým směrem je přidání nových operací. Operace, jak pravděpodobně předpokládáte, vyjadřují funkčnost představovanou jako nová metoda v rozhraní.

První kroky

Abychom otestovali rozšiřitelnost našeho kódu, musíme nejprve něco rozšířit. Náš úvodní příklad bude sestávat z jedné operace, reprezentované jako vyhodnocení aritmetického výrazu. Reprezentujeme ji jako rekurzivní datovou strukturu tříd, které kódují výraz na každé úrovni a sdělují nám, co dělat s jeho operandy:

val výraz = Přidat (Přidat (číslo (2), číslo (3)), číslo (4)))

Zde modelujeme objekty typu Add a Number po aritmetickém výrazu:

(2 + 3) + 4

Naším úkolem je vyhodnotit nebo (matematicky vyjádřit) náš výraz na jedinou hodnotu Double.

Objektově orientované programování

Dovolte mi začít OOP způsobem provádění polymorfismu, tzv. Subtypovým polymorfismem.

Začněme tím, že výraz může mít více forem (můžeme říci, že je polymorfní); Pro propojení těchto forem musíme vytvořit společné abstrakční rozhraní, které je v Scale definováno jako zvláštnost.

zvláštnost Expr {
  def eval: Double
}

Náš znak definuje jednu operaci, kterou lze provést u každé formy výrazu. Tuto operaci jsme nazvali eval a jejím úkolem je redukovat celý výraz na konečný termín hodnoty Double. Několik odstavců výše jsme se rozhodli zpracovat dvě formy vyjádření: Number and Add.

třída případu Number (value: Double) rozšiřuje Expr {
  přepsat def eval = hodnota
}
třída případu Add (a: Expr, b: Expr) rozšiřuje Expr {
  přepsat def eval = a.eval + b.eval
}

Číslo právě vrací svou zabalenou hodnotu a můžeme s ní zacházet jako s koncovým stavem našeho rekurzivního schématu.

Přidat vyhodnotí jeho levou a pravou stranu a pak sečte vypočítané hodnoty. To je pěkné a snadné: je to jen obvyklá rekurze, ale vyjádřená spíše v vyvolání metody než ve funkcích.

Máme vše, co potřebujeme k testování naší implementace. Dovolte mi připomenout, jak náš referenční výraz vypadá a zkuste zkontrolovat jeho výsledek v REPL.

val výraz = Přidat (Přidat (číslo (2), číslo (3)), číslo (4)))
expression.eval
// res0: Double = 9.0

Skvělý! Zdá se, že se jedná o platný výsledek, a proto naše implementace funguje. Nyní je čas implementovat totéž ve způsobu FP.

Přístup funkčního programování

Ve funkčním programování se snažíme oddělit data a operace, takže začínáme definicí našeho ADT (algebraický datový typ), který definuje model našeho aritmetického výrazu.

zvláštnost Expr
třída třídy Number (value: Double) rozšiřuje Expr
třída případu Add (a: Expr, b: Expr) rozšiřuje Expr

Po zavedení abstraktní definice musíme implementovat operace, které by se měly použít na model. Implementace je téměř stejná jako v OOP, takže není třeba mnoho vysvětlovat. Právě jsme nahradili metody pro každý formulář jednou funkcí, která odpovídá jednomu výrazu a určuje, co by se mělo udělat nyní.

def Evaluation (expression: Expr): Double = match match {
  
  číslo případu (a) => a
  případ Add (a, b) => vyhodnotit (a) + vyhodnotit (b)
}

Náš výchozí bod je definován z hlediska FP a OOP. Dále otestujeme rozšiřitelnost každého z těchto paradigmat.

Rozšíření přidáním nové operace

Představme si, že chceme přidat schopnost tisknout náš výraz. Abychom mohli tisknout, musíme přidat další formu reprezentace výrazu (stále máme pouze Přidat a číslo), ale zcela nový způsob vyhodnocení ADT našeho výrazu. Může být navržen takto:

val výraz = Přidat (Přidat (číslo (2), číslo (3)), číslo (4)))
val print = print (expression)
// res1: String = ((2.0 + 3.0) + 4.0)

Funkční programovací způsob rozšíření operací

Tentokrát začneme s FP způsobem. To lze provést pouze přidáním nové funkce (operace) níže, definované na začátku eval:

def tisk (výraz: Expr): String = shoda výrazu {
  číslo případu (a) => a.toString
  případ Add (a, b) => s ”($ {print (a)} + $ {print (b)})”
}

A to je vše! Právě jsme přidali novou funkci, která přijímá objekty Expr. V FP nevyžadují rozšiřovací operace editaci dříve napsaného kódu, takže množství práce a riziko zavedení regrese v kódu je velmi nízké.

Vidíme, že funkční programování prošlo testem exprese na základě schopnosti rozšířit se ve směru operací. Přidali jsme zcela novou operaci, která nevyžaduje změnu stávajícího kódu.

OOP způsob rozšíření operací

Do našeho výrazu chceme přidat novou schopnost tisku. Už jsme definovali chování výrazu pomocí znaku Expr, což naznačuje, že každá forma výrazu by měla mít implementovaný způsob, jak se redukovat na jednu dvojitou hodnotu.

Proto musíme přidat další omezení, aby náš výraz mohl být vytisknutelný:

zvláštnost Expr {
  def eval: Double
  def tisk: String
}

Jak vidíte, tento příklad vyžaduje mnohem více změn ve stávající kódové základně, protože povaha subtypového polymorfismu vyžaduje, aby každá abstraktní metoda, která je zděděná od svých rodičů, byla implementována uvnitř rozšiřující třídy:

třída případu Number (value: Double) rozšiřuje Expr {
  přepsat def eval = hodnota
  přepsat def print = value.toString
}
třída případu Add (a: Expr, b: Expr) rozšiřuje Expr {
  přepsat def eval = a.eval + b.eval
  přepsat def print = s ”($ {a.print} + $ {b.print})”
}

Doposud je FP konečně efektivnější, pokud jde o rozšiřitelnost; nicméně, jak byste mohli mít podezření, realita není tak růžová, jak potřebujeme vyzkoušet druhý typ rozšiřitelnosti - formy.

Rozšíření formulářů

Nyní zkontrolujeme rozšiřitelnost v rámci druhé dimenze přidáním nových forem výrazu do našeho kódu. Chceme přidat nový formulář představující schopnost negovat výraz.

V předchozí části jsme viděli, že když chceme rozšířit přidáním nové operace (tisk), FP funguje velmi dobře a splňuje požadavek Expression Problem, kterým je rozšířit kód bez provedení jakékoli změny dříve napsané funkce. Podívejme se, jak FP zvládne přidání nové formy výrazu.

Nejprve začneme jako dříve se specifikací:

val výraz = Přidat (Přidat (číslo (2), Neg (číslo (3))), číslo (4)))

Vidíme, že forma našeho výrazu se změnila. Nyní představuje následující:

(2 + (-3)) + 4

Takže nyní bude náš výsledek 3 místo předchozích 9. Máme tedy vše, co bychom měli začít kódovat FP způsobem:

třída případů Neg (a: Expr) rozšiřuje Expr
def Evaluation (expression: Expr): Double = match match {
  číslo případu (a) => a
  případ Add (a, b) => vyhodnotit (a) + vyhodnotit (b)
  případ Neg (a) => - vyhodnotit (a)
}
def tisk (výraz: Expr): String = shoda výrazu {
  číslo případu (a) => a.toString
  případ Add (a, b) => s ”($ {print (a)} + $ {print (b)})”
  případ Neg (a) => s ”- $ {print (a)}”
}

Uvědomuji si, že jsem mohl zklamat mnoho fanoušků FP: Funkční programování je hrozné v přidávání nových forem. Stejně jako v případě OOP, i při přidávání nových operací je třeba upravit každý kousek našeho kódu, aby poskytoval další způsob, jak reprezentovat náš výraz.

Nyní se podíváme, jak si OOP v tomto ohledu vede. Můžeme začít pouhým přidáním nové třídy a implementací dříve deklarovaných operací:

třída případů Neg (a: Expr) rozšiřuje Expr {
  přepsat def eval = - a.eval
  přepsat def print = s ”- $ {a.print}”
}

A to je vše. Přidali jsme nový formulář, aniž bychom se dotkli jakéhokoli existujícího kódu. V tomto případě vyhraje OOP, takže se zdá, že máme remízu.

Závěry

V tomto příspěvku na blogu jsem uvedl jeden z nejdůležitějších problémů ve vývoji softwaru: rozšiřitelnost. Všichni víme, jak cenné je psát dobré, rozšiřitelné programy a jak to vypadá, když přidáváme novou funkci a nemusíme se kopat do celé kódové základny. Tento příklad Expression Problem je jednoduchý, ale dotýká se obou aspektů rozšiřitelnosti a ukazuje, jak se dva hlavní programovací paradigmata (FP a OOP) zabývají tímto problémem.

Viděli jsme, že neexistuje žádná stříbrná kulka pro problémy rozšiřitelnosti, pokud jde o techniky vývoje FP a OOP. Doufejme, že Scala umožňuje použití obou paradigmat a my můžeme přistupovat k problému rozšíření našeho kódu pouhým výběrem správného stylu při implementaci funkcí. Je však velmi obtížné předvídat budoucí rozšiřitelnost při zahájení vývoje.

Scala sama o sobě tedy není řešením tohoto problému, ale díky vzoru známému jako Type Class můžeme zcela vyřešit Expression Problem (zavádění nových formulářů a operací bez doteku existujícího kódu). Představím vám to v příštím blogovém příspěvku!

Ale pokud se chcete dozvědět, jak mohou být třídy Class implementovány do Scaly, najdete je v mém předchozím blogu

PS. Právě jsem uveřejnil druhou část příběhu, kde se zabývám problémem rozšiřitelnosti pomocí vzoru Typové třídy.

https://medium.com/virtuslab/oop-vs-fp-the-pursuit-of-extensibility-part-2-22a37a33d1a0