Diversity w polskim IT
Charles Scalfani
Charles ScalfaniCTO @ Panoramic Software

Żegnaj, programowanie obiektowe

Sprawdź, jakie problemy wiążą się z filarami programowania obiektowego, czyli dziedziczeniem, polimorfizmem i enkapsulacją.
9.04.20219 min
Żegnaj, programowanie obiektowe

Od kilku dekad programuję w językach obiektowych. Pierwszym językiem OO, którego użyłem, był C ++. Następnie Smalltalk, a wreszcie .NET i Java. Byłem w stanie wykorzystywać zalety dziedziczenia, enkapsulacji i polimorfizmu. Trzech filarów paradygmatu. Chciałem ziścić obietnicę ponownego wykorzystania mądrości tych, którzy byli tu przede mną. Nie mogłem powstrzymać podekscytowania na myśl o odwzorowaniu rzeczywistych obiektów w klasach i oczekiwałem, że cały świat ułoży się w logiczną całość.

Nie mogłem się bardziej pomylić.

Dziedziczenie, pierwszy krok ku upadkowi

Na pierwszy rzut oka dziedziczenie wydaje się być największą zaletą paradygmatu obiektowego. Wszystkie uproszczone przykłady kształtu hierarchii, które są przedstawiane jako przykłady dla nowo indoktrynowanych, wydają się być logiczne.

A “ponowne wykorzystanie” to sformułowanie dnia. Nie... może lepiej roku albo i jeszcze dłuższego okresu czasu. “Łyknąłem” to w całości i rzuciłem się w świat z moim nowym odkryciem.

Problem banana, małpy i dżungli

Z religią w sercu i problemami do rozwiązania, zacząłem budować hierarchię klas i pisać kod. Wszystko grało.

Nigdy nie zapomnę tego dnia, kiedy byłem gotowy spieniężyć obietnicę “ponownego wykorzystania”, dziedzicząc z istniejącej klasy. To był moment, na który czekałem. Pojawił się nowy projekt i mogłem powrócić do klasy, którą tak lubiłem w moim ostatnim projekcie. Nic trudnego. Ponowne wykorzystywanie przyjdzie na ratunek. Wszystko, co muszę zrobić, to po prostu wziąć tę klasę z innego projektu i jej użyć.

Cóż… właściwie… nie tylko tę jedną klasę. Potrzebujemy klasy nadrzędnej. Ale… to na pewno wszystko. Czekaj… Wygląda na to, że również będziemy potrzebować klasy nadrzędnej do obecnie nadrzędnej… A potem… Będziemy potrzebować WSZYSTKICH rodziców. Dobra… poradzę sobie z tym. Nie ma problemu.

I świetnie. Teraz się nie skompiluje. Czemu?? Rozumiem… Ten obiekt zawiera inny obiekt. Potrzebuję też jego. Nie ma problemu. Zaczekaj… Nie potrzebuję tego obiektu. Potrzebuję nadrzędnego obiektu i nadrzędnego do niego. Z każdym zawartym obiektem i WSZYSTKIMI wyżej od tego...

Jest świetny cytat Joe Armstronga, twórcy Erlanga:
Problem z językami zorientowanymi obiektowo polega na tym, że mają całe to ukryte środowisko, które niosą ze sobą. Chciałeś banana, ale masz małpę trzymającą banana i jeszcze całą dżunglę.

Rozwiązanie problemu banana, małpy i dżungli

Potrafię rozwiązać ten problem, nie tworząc zbyt skomplikowanych hierarchii. Ale jeśli dziedziczenie jest kluczem do ponownego wykorzystywania, wszelkie ograniczenia, które nakładam na ten mechanizm, z pewnością ograniczą korzyści wynikające z początkowego założenia.

Co więc robi biedny programista obiektowy? Używa podejścia‘Contain and delegate’. Więcej o tym później.

Problem diamentowy

Prędzej czy później następujący problem będzie coraz bardziej uciążliwy i, w zależności od języka, może stać się nierozwiązywalny.

Większość języków tego nie wspiera, mimo że wydawałoby się, że ma to ma sens. Co jest trudnego we wspieraniu tego w językach OO? Wyobraź sobie następujący pseudokod:

Class PoweredDevice {
}

Class Scanner inherits from PoweredDevice {
function start() {
}
}

Class Printer inherits from PoweredDevice {
function start() {
}
}

Class Copier inherits from Scanner, Printer {
}


Zauważ, że klasy Scanner i Printer implementują funkcję o nazwie start.

Która funkcja startowa dziedziczy klasę Copier? Na pewno nie obydwie.

Rozwiązanie

Rozwiązanie jest proste. Nie rób tego. Większość języków OO nie pozwala na to. A co, jeśli będę musiał to modelować? Chcę opcji ponownego użycia!

Wtedy musisz użyć contain i delegate.

Class PoweredDevice {
}

Class Scanner inherits from PoweredDevice {
function start() {
}
}

Class Printer inherits from PoweredDevice {
function start() {
}
}

Class Copier {
Scanner scanner
Printer printer
function start() {
printer.start()
}
}


Zauważ tutaj, że klasa Copier zawiera teraz instancje Printer i Scanner. Deleguję funkcję start do implementacji klasy Printer. Mogłoby to być równie łatwo delegowane do Scanner.

Ten problem jest kolejnym pęknięciem w filarze dziedziczenia.

Fragile base class, czyli problem delikatnej klasy bazowej

Robię więc płytkie hierarchie i pilnuję, by nie były cykliczne. Dzięki temu unikam problemu diamentowego. I wszystko już było w porządku. Do czasu..

Pewnego dnia mój kod działał i następnego dnia przestał działać. I co ciekawe - nic w nim nie zmieniałem. Cóż, może to błąd… Ale poczekaj… Coś się zmieniło… Ale nie w moim kodzie. Okazuje się, że zmiana dotyczyła klasy, z której dziedziczyłem.

Jak zmiana w klasie bazowej może psuć mój kod? Wyobraź sobie następującą klasę bazową (napisaną w Javie, ale zrozumiale, nawet jeśli nie znasz Javy):

import java.util.ArrayList;
 
public class Array
{
  private ArrayList<Object> a = new ArrayList<Object>();
 
  public void add(Object element)
  {
    a.add(element);
  }
 
  public void addAll(Object elements[])
  {
    for (int i = 0; i < elements.length; ++i)
      a.add(elements[i]); // this line is going to be changed
  }
}


WAŻNE: Zwróć uwagę na linię kodu z komentarzem. Ta linia zostanie później zmieniona, co zepsuje wszystko.

Ta klasa ma dwie funkcje w swoim interfejsie, add () i addAll(). Funkcja add() doda pojedynczy element, a addAll() doda wiele elementów, wywołując funkcję add. A oto klasa pochodna:

public class ArrayCount extends Array
{
  private int count = 0;
 
  @Override
  public void add(Object element)
  {
    super.add(element);
    ++count;
  }
 
  @Override
  public void addAll(Object elements[])
  {
    super.addAll(elements);
    count += elements.length;
  }
}


Klasa ArrayCount to specjalizacja ogólnej klasy Array. Jedyną różnicą w zachowaniu jest to, że ArrayCount utrzymuje liczbę elementów. Przyjrzyjmy się szczegółowo obu tym klasom.

Array add() dodaje element do lokalnej tablicy ArrayList.
Array addAll() wywołuje lokalne dodanie ArrayList dla każdego elementu.
ArrayCount add() wywołuje add() swojej nadrzędnej funkcji, a następnie zwiększa liczbę.
ArrayCount addAll() wywołuje addAll() swojego rodzica, a następnie zwiększa liczbę o liczbę elementów.

I wszystko gra. A jeśli chodzi o zmianę, która psuje program. Skomentowana linia kodu w klasie bazowej zostaje zmieniona na następującą:

 public void addAll(Object elements[])
  {
    for (int i = 0; i < elements.length; ++i)
      add(elements[i]); // this line was changed
  }


Z punktu widzenia klasy bazowej, wszystko działa nadal tak, jak trzeba. Wszystkie testy automatyczne przechodzą. Ale klasa bazowa nie wie nic o klasach pochodnych. I osobę korzystającą z klasy pochodnej czeka niemiła niespodzianka.


Teraz ArrayCount addAll() wywołuje addAll() rodzica, który wewnętrznie wywołuje add(), który został NADPISANY przez klasę pochodną. Powoduje to zwiększanie licznika za każdym razem, gdy wywoływana jest funkcja add() klasy pochodnej, a następnie jest zwiększana ponownie o liczbę elementów dodanych w addAll() klasy pochodnej.

Liczy się dwukrotnie. Jeśli tak się stanie, a tak się stanie, autor klasy pochodnej musi wiedzieć, w jaki sposób zaimplementowano klasę bazową. I musi być informowany o każdej zmianie w klasie bazowej, ponieważ może to zepsuć klasę pochodną w nieprzewidywalny sposób. To zawsze zagraża stabilności cennego filaru dziedziczenia.

Rozwiązanie

Ponownie, Contain and Delegate na ratunek.

Korzystając z opcji zawrzyj i deleguj, przechodzimy od programowania White Box do programowania Black Box. W programowaniu White Box musimy znać implementację klasy bazowej. Przy programowaniu Black Box możemy być całkowicie nieświadomi implementacji, ponieważ nie możemy wprowadzić kodu do klasy Base, zastępując jedną z jego funkcji. Musimy myśleć tylko o interfejsie. Ten trend jest niepokojący…

Dziedziczenie miało być znacznie ułatwić ponowne wykorzystanie kodu. Języki zorientowane obiektowo nie sprawiają, że łatwo jest wykorzystywać podejście "zawrzyj i deleguj". Zostały zaprojektowane tak, aby ułatwić dziedziczenie.

Jeśli jesteś podobny do mnie, zaczynasz się zastanawiać nad częścią z dziedziczeniem. A co ważniejsze, powinno to zachwiać pewnością w moc klasyfikacji poprzez hierarchię.

Problem hierarchii

Za każdym razem, gdy rozpoczynam pracę w nowej firmie, zmagam się z problemem, gdy tworzę miejsce do umieszczenia moich dokumentów, np. Podręcznik dla pracowników. Czy tworzę folder o nazwie Dokumenty, a następnie tworzę w nim folder o nazwie Firma? Czy mogę utworzyć folder o nazwie Firma, a następnie utworzyć w nim folder o nazwie Dokumenty? Obydwie opcje są ok, ale która będzie lepsza?

Ideą hierarchii kategorii było to, że istniały klasy podstawowe (rodzice), które były bardziej ogólne, a klasy pochodne (dzieci) były bardziej wyspecjalizowanymi wersjami tych klas. I jeszcze bardziej wyspecjalizowana, gdy schodzimy w dół łańcucha dziedziczenia. Ale jeśli rodzic i dziecko mogliby dowolnie zamienić się miejscami, to w tym modelu coś jest nie tak.

Rozwiązanie

Co jest nie tak? Hierarchie kategorii nie działają. Do czego zatem służą hierarchie? Do zawierania. Jeśli spojrzysz na rzeczywisty świat, wszędzie zobaczysz hierarchię zawierania (lub wyłącznej własności).

To, czego nie znajdziesz, to hierarchie kategorii. Paradygmat zorientowany obiektowo był oparty na rzeczywistym świecie, wypełnionym obiektami. Ale wtedy używa zepsutego modelu, a mianowicie hierarchii kategorii, która nie ma analogii ze światem rzeczywistym. Ale rzeczywisty świat jest pełen hierarchii zawierania. Doskonałym przykładem hierarchii zawierania są Twoje skarpety. Znajdują się one w szufladzie na skarpety, która jest zawarta w jednej szufladzie w komodzie, która jest zawarta w sypialni, która znajduje się w domu, itp.

Katalogi na dysku twardym są kolejnym przykładem hierarchii zawierania. Zawierają pliki. Jak zatem kategoryzujemy? Cóż, jeśli myślisz o dokumentach firmowych, to nie ma znaczenia, gdzie je umieściłem. Mogę je umieścić w folderze dokumentów lub folderze o nazwie Stuff. Kategoryzuję go za pomocą tagów. Taguję plik następującymi tagami: Document, Company, Handbook.

Tagi nie mają porządku ani hierarchii (to rozwiązuje również problem diamentowy). Tagi są analogiczne do interfejsów, ponieważ z dokumentem można skojarzyć wiele typów. Ale przy tak wielu “pęknięciach” wygląda na to, że upadł filar dziedziczenia.

Żegnaj, dziedziczenie

Enkapsulacja, drugi krok ku upadkowi

Na pierwszy rzut oka, enkapsulacja wydaje się być drugą co do wielkości zaletą programowania obiektowego. Zmienne stanu obiektu są chronione przed dostępem z zewnątrz, tzn. enkapsulowane w obiekcie. Nie będziemy już musieli martwić się o zmienne globalne, do których dostęp uzyskuje nie wiadomo kto. Enkapsulacja jest bezpieczna dla zmiennych. Enkapsulacja jest świetna! Dopóki...

Problem referencji

Ze względu na wydajność, obiekty są przekazywane do funkcji nie przez wartość, ale przez referencję. Oznacza to, że funkcje nie będą przekazywać obiektu, ale zamiast tego przekazują referencję lub wskaźnik do obiektu. Jeśli obiekt jest przekazywany przez referencję do konstruktora obiektu, konstruktor może umieścić to referencję do obiektu w zmiennej prywatnej, która jest chroniona przez enkapsulację. Ale przekazany obiekt NIE jest bezpieczny!

Dlaczego nie? Ponieważ jakiś inny fragment kodu ma wskaźnik do obiektu, a mianowicie, kod, który wywołał konstruktor. Musi mieć referencję do obiektu, w przeciwnym razie nie może przekazać go konstruktorowi.

Rozwiązanie problemu referencji

Konstruktor będzie musiał sklonować przekazany obiekt. I to nie przez płytkie, ale przez głębokie klonowanie. Każdy obiekt zawarty w przekazanym obiekcie i każdy obiekt w tych obiektach i tak dalej.

A oto problem. Nie wszystkie obiekty można klonować. Niektóre z nich mają związane z nimi zasoby systemu operacyjnego, co sprawia, że klonowanie jest w najlepszym razie bezużyteczne lub w najgorszym razie niemożliwe. I KAŻDY pojedynczy główny nurt języka OO ma ten problem.

Żegnaj, enkapsulacjo.

Polimorfizm, trzeci krok ku upadkowi

Polimorfizm był rudym pasierbem Trójcy zorientowanej obiektowo. Jest wszędzie, ale tylko jako postać wspierająca. Nie jest tak, że polimorfizm nie jest wspaniały. Po prostu nie potrzebujesz języka zorientowanego obiektowo, aby go uzyskać. Interfejsy Ci to zapewnią. Bez całego bagażu OO.

W przypadku interfejsów nie ma limitu liczby różnych zachowań, które można mieszać. Więc bez zbędnego hałasu, żegnamy się z polimorfizmem OO i witamy się z polimorfizmem opartym na interfejsie.

Złamane obietnice

Cóż, programowanie obiektowe obiecało wiele na początku. Obietnice te są nadal składane naiwnym programistom, którzy siedzą w klasach, czytają blogi i biorą udział w kursach online.

Wiele lat zajęło mi uświadomienie sobie, jak OO mnie okłamało. Ja też byłem otwarty, niedoświadczony i ufny. I nadzieje okazały się być wydmuszką.

Żegnaj, programowanie obiektowe

I co teraz?

Witaj, programowanie funkcyjne. Tak miło było z Tobą pracować w ciągu ostatnich kilku lat. Chociaż nadal będę czujny, żeby znowu się tak bardzo nie zawieść!


Oryginał artykułu w języku angielskim możesz przeczytać tutaj.

<p>Loading...</p>