Wstęp
Rozumiem, że dla niektórych to może być szok! Przecież dynamiczne typowanie jest flaky i mniej bezpieczne, a statyczne typowanie jest safe, sound i bezpieczne. Owszem — ale czasem statyczne typowanie zmusza nas żeby gdzieś dodać coupling, nawet pomiędzy rzeczami pomiędzy którymi tego nie chcemy. I dlatego czasem statyczne typowanie zmusza nas do złamania DI.
Rozwinięcie
Oczywiście możemy bawić się w Ridlyzmy czyli kłócić co to jest "poprawnie zrobione Dependency Inversion"
Poprawnie zrobione dependency inversion to takie, w którym zależność w kodzie źródłowym jest w jedną stronę (tylko jedną), ale kontrola przepływu jest w drugą (tylko w drugą).
- Bez DI — zależność w kodzie i kontrola przepływu idzie w jedną stronę (obie w jedną, albo obie w drugą). Czyli kierunek kontroli przepływu jest taki sam jak kierunek zależności.
- "Dependency Inversion" dlatego nazywa się "inversion", bo pozwala obrócić te dwa kierunki w przeciwne strony. Czyli kierunek kontroli przepływu jest odwrotny niż kierunek zależności - stąd "odwrócenie zależności".
Innymi słowy, jeśli pomiędzy dwoma modułami jest DI, to jeden z tych modułów da się skompilować bez drugiego (czyt. sprawdzić typy w jednym, bez drugiego).
dlaczego enum lamie DI? I czemu nie moge go zastapic interfejsem?
To ja może powiem jeszcze raz.
Masz abstrakcyjną fabrykę w głównej części programu. Dołaczasz plugin który implementuje tą fabrykę i zwraca jeden rodzaj obiektów. Super. Wszystko działa, static typing działa, DI działa. Zwykły polimorfizm. Teraz — co w sytuacji, w którym ta fabryka ma stworzyć kilka różnych rodzajów obiektów?
Masz kilka sposobów żeby ją sparametryzować:
- Jeśli piszesz w dynamicznie typowanym języku:
- po prostu dodajesz metodę i tyle. Wszystko gra. Interpreter wymusi istnienie tej metody w runtime.
- Jeśli piszesz w statycznie typowanym języku, to:
- Albo z aplikacji do pluginu przekażesz prymityw: integer albo string — tylko wtedy nie ma statycznego typowania, bo kompilator nie jest w stanie tego sprawdzić podczas kompilacji
- Albo z aplikacji do pluginu przekażesz
enum
(czy tam nawet implementację interfejs). Tylko że taki enum albo interfejs musiałby zostać zadeklarowany w głównej części programu, a nie w pluginie — tylko wtedy nie ma DI. Bo wtedy chcąc dodać nową wartość w pluginie, musiałbyś zmodyfikować moduł aplikacji.
- Nie możesz zawołać niezadeklarowanej metody bo kompilator, który sprawdza statycznie typ, Ci na to nie pozwoli.
Oczywiście — jeśli nie musisz parametryzować tej fabryki, to wtedy problemu nie ma — i zarówno static typing jak i DI są możliwe równocześnie.
To raczej bzdura. Może lepiej podaj przykład konkretny w kodzie, ale generalnie nie widzę problemu.
Dobrze, napiszę w Javie, może być?
Mamy dwa osobne moduły, server.jar
oraz client.jar
. Chcemy żeby client.jar
jar był pluginem od server.jar
(czyli server.jar
nie wie o istnieniu client.jar
). Zwykłe Dependency Inversion.
server.jar
package server;
interface FruitShop {
Fruit buy(String fruitName);
}
interface Fruit {
void doStuff();
}
class Application {
FruitShop shop;
public Application(FruitShop shop) {
this.shop = shop;
}
public void run() {
Fruit obj1 = factory.buy("apple"); // tutaj nie ma sprawdzania typów,
Fruit obj2 = factory.buy("orange"); // np można wywołac .create("banana");
// i żaden kompilator tego nie sprawdzi
obj1.doStuff();
}
}
client.jar
package client;
import server.FruitShop;
import server.Fruit;
class ClientShop implements FruitShop {
Fruit buy(String something) {
if (something == "apple") {
return new Apple();
}
return new Orange();
// jeśli ktoś przekaże złą wartość, np "banana",
// to system typów tego nie sprawdzi.
// oczywiście, można rzucić wyjątek typu `new InvalidType(something)`, ale to
// jest praktycznie dynamiczne typowanie, bo nie będzie sprawdzone w compile time,
// błąd poleci w runtime.
}
}
class Apple implements Fruit {}
class Orange implements Fruit {}
Jakie kryteria musi spełniać ta aplikacja, żeby można było powiedzieć że spełnia DI
- W
server.jar
ma nie być żadnego import client
, bo wtedy client
nie byłby pluginem, a więc łamie DI.
- W
client.jar
może być dowolna ilość import server
.
- W
client.jar
chcę móc dodawać nowe implementacje Fruit
do woli.
- W
client.jar
chce móc sprawić, że ClientShop.buy()
może zwracać te nowe owoce.
- Kod
server.jar
chcę móc skompilować bez udziału client.jar
.
I teraz to czego się nie da zrobić - to nie da się w compile time sprawdzić poprawności kodu w server.jar
pod względem typów, dlatego że w compile-time server nawet nie wie, jakie typy są w client.jar
. Da się to zrobić tylko i wyłącznie w runtime - dlatego że w runtime ten drugi moduł jest, w compiletime go nie ma.
Dlaczego:
- Dla przykładu, gdybyś chciał zrobić że
FruitShop.buy()
przymuje enum
, to ten enum musiałby być zadeklarowany w server.jar
. Jeśli wtedy client.jar
chciałby dodać nowy typ owocu, to wtedy musiałby zmienić ten enum
, łamiąc DI.
- Jeśli chciałbyś zamiast parametrów mieć różne metody w
FruitShop
, to jeśli plugin chciałby dodać nowy obiekt, to musiałby dodać nową funkcję do FruitShop
, tym samym łamiąc DI.
Dlaczego w dynamicznym typowaniu to działa?
- Bo jeśli mielibyśmy dynamiczne typowanie, to
FruitShop
nie jest być zadeklarowany w server.jar
, i można do niego dowolnie dodawać funkcje i enumy.
Czemu statyczne typowanie czasem wyklucza DI?
Dlatego że dowolna forma statycznego typowania (takiego sprawdzanego w compiletime) musi mieć te typy w server.jar
, tym samym "wiążąc je" z zależnością w kodzie źródłowym.
Jedyny sposób żeby osiągnąć DI zawsze, to jest nie mieć typów w server.jar
, a skoro tak, to nie można przeprowadzić statycznego sprawdzania typów (jedynie dynamiczne).
Zakończenie
Kluczem do Dependency Inversion jest polimorfizm - który istnieje zarówno w statycznie i dynamicznie typowanych językach. Różnica polega na tym, że w statycznie typowanych językach deklaracje polimorficznych calli muszą być znane podczas kompilacji (np musi być zadeklarowany interfejs), a w dynamicznie typowanych nie - i przez to czasem dependency inversion w statycznie typowanych językach czasem nie jest możliwe. Bo jeśli zajdzie potrzeba modyfikacji tego typu - to w dynamicznym typowaniu, zmiana modułu aplikacji nie jest konieczna, a w statycznie typowanych jest.
Jak więc osiągnąć sprawdzanie typów? Tylko dynamicznie, czyli w runtime'ie.
PS: Jak by to wyglądało w dynamicznie typowanym języku?
server.py
class Application:
def __init__(shop):
this.shop = shop
def run() {
obj1 = factory.buyApple()
obj2 = factory.buyOrange() # interpreter pilnuje istnienia metody
# nie da się wywołac self.buyBanana()
obj1.doStuff();
client.py
class ClientShop:
def buyApple(self):
return Apple()
def buyOrange(self):
return Orange()
class Apple:
pass
class Orange:
pass
Teraz mogę do woli dodawać nowe funkcje i typy w client.py
, a server.py
się nie zmieni - osiągnięte Dependency Inversion
Oczywiście - większość dynamicznie typowanych języków nie jest tak rzetelna