Czy zawsze powinienem całkowicie zawierać wewnętrzną strukturę danych?

11

Proszę rozważyć tę klasę:

class ClassA{

    private Thing[] things; // stores data

    // stuff omitted

    public Thing[] getThings(){
        return things;
    }

}

Ta klasa udostępnia tablicę, której używa do przechowywania danych, każdemu zainteresowanemu kodowi klienta.

Zrobiłem to w aplikacji, nad którą pracuję. Miałem ChordProgressionklasę, która przechowuje sekwencję Chords (i robi kilka innych rzeczy). Miał Chord[] getChords()metodę, która zwróciła tablicę akordów. Gdy struktura danych musiała się zmienić (z tablicy na ArrayList), cały kod klienta się zepsuł.

To sprawiło, że pomyślałem - może lepsze jest następujące podejście:

class ClassA{

    private Thing[] things; // stores data

    // stuff omitted

    public Thing[] getThing(int index){
        return things[index];
    }

    public int getDataSize(){
        return things.length;
    }

    public void setThing(int index, Thing thing){
        things[index] = thing;
    }

}

Zamiast ujawniać samą strukturę danych, wszystkie operacje oferowane przez strukturę danych są teraz oferowane bezpośrednio przez klasę ją otaczającą, przy użyciu publicznych metod delegowanych do struktury danych.

Kiedy zmienia się struktura danych, tylko te metody muszą się zmienić - ale po tym, cały kod klienta nadal działa.

Należy pamiętać, że kolekcje bardziej złożone niż tablice mogą wymagać, aby klasa otaczająca zaimplementowała więcej niż trzy metody tylko w celu uzyskania dostępu do wewnętrznej struktury danych.


Czy to podejście jest powszechne? Co o tym sądzisz? Jakie wady ma inne? Czy rozsądne jest, aby klasa otaczająca implementowała co najmniej trzy metody publiczne tylko w celu delegowania do wewnętrznej struktury danych?

Aviv Cohn
źródło

Odpowiedzi:

14

Kod jak:

   public Thing[] getThings(){
        return things;
    }

To nie ma większego sensu, ponieważ twoja metoda dostępu nie robi nic, tylko bezpośrednio zwraca wewnętrzną strukturę danych. Równie dobrze można po prostu zadeklarować Thing[] thingssię public. Ideą metody dostępu jest stworzenie interfejsu, który izoluje klientów od wewnętrznych zmian i uniemożliwia im manipulowanie rzeczywistą strukturą danych, z wyjątkiem dyskretnych sposobów, na jakie pozwala interfejs. Jak odkryłeś, kiedy cały kod klienta się zepsuł, twoja metoda dostępu tego nie zrobiła - to po prostu zmarnowany kod. Myślę, że wielu programistów ma tendencję do pisania takiego kodu, ponieważ dowiedzieli się gdzieś, że wszystko musi być otoczone metodami dostępu - ale to z powodów, które wyjaśniłem. Robienie tego tylko po to, by „podążać za formą”, gdy metoda dostępu nie służy żadnemu celowi, to po prostu hałas.

Zdecydowanie poleciłbym zaproponowane rozwiązanie, które realizuje niektóre z najważniejszych celów enkapsulacji: Zapewnienie klientom solidnego, dyskretnego interfejsu, który izoluje ich od wewnętrznych szczegółów implementacyjnych twojej klasy i nie pozwala im dotknąć wewnętrznej struktury danych oczekuj w sposób, który uznasz za odpowiedni - „prawo najmniej koniecznego przywileju”. Jeśli spojrzysz na duże popularne frameworki OOP, takie jak CLR, STL, VCL, zaproponowany przez ciebie wzorzec jest powszechny, właśnie z tego powodu.

Czy zawsze powinieneś to robić? Niekoniecznie. Na przykład, jeśli masz klasy pomocnika lub przyjaciela, które są zasadniczo składnikiem twojej głównej klasy robotniczej i nie są skierowane do przodu, nie jest to konieczne - to przesada, która doda wiele niepotrzebnego kodu. W takim przypadku w ogóle nie użyłbym metody dostępu - jak wyjaśniono - jest bez sensu. Po prostu zadeklaruj strukturę danych w sposób, który ogranicza się tylko do głównej klasy, która z niej korzysta - większość języków obsługuje takie sposoby - friendlub deklarując ją w tym samym pliku, co główna klasa robotnicza itp.

Jedynym minusem, jaki widzę w twojej propozycji, jest to, że kodowanie wymaga więcej pracy (a teraz będziesz musiał ponownie kodować swoje klasy konsumenckie - ale i tak musiałeś / musiałeś to zrobić). Ale to nie jest tak naprawdę wada - musisz to zrobić dobrze, a czasem wymaga to więcej pracy.

Jedną z rzeczy, które sprawiają, że dobry programista jest dobry, jest to, że wiedzą, kiedy dodatkowa praca jest tego warta, a kiedy nie. W dłuższej perspektywie wprowadzenie dodatkowej kwoty w przyszłości przyniesie duże dywidendy - jeśli nie na tym projekcie, to na innych. Naucz się kodować we właściwy sposób i używaj do tego swojej głowy, a nie tylko automatycznie wykonuj określone formularze.

Należy pamiętać, że kolekcje bardziej złożone niż tablice mogą wymagać, aby klasa otaczająca zaimplementowała więcej niż trzy metody tylko w celu uzyskania dostępu do wewnętrznej struktury danych.

Jeśli ujawniasz całą strukturę danych za pośrednictwem zawierającej klasy, IMO musisz pomyśleć, dlaczego ta klasa jest w ogóle enkapsulowana, jeśli nie chodzi tylko o zapewnienie bezpieczniejszego interfejsu - „klasy opakowania”. Mówisz, że klasa zawierająca nie istnieje w tym celu - więc być może coś jest nie tak z twoim projektem. Zastanów się nad podziałem swoich klas na bardziej dyskretne moduły i warstwowaniem ich.

Klasa powinna mieć jeden jasny i dyskretny cel oraz zapewniać interfejs do obsługi tej funkcjonalności - nie więcej. Być może próbujesz połączyć ze sobą rzeczy, które do siebie nie pasują. Gdy to zrobisz, wszystko się zepsuje za każdym razem, gdy będziesz musiał wprowadzić zmianę. Im mniejsze i bardziej dyskretne są twoje zajęcia, tym łatwiej jest to zmieniać: Pomyśl LEGO.

Wektor
źródło
1
Dzięki za odpowiedź. Pytanie: A jeśli wewnętrzna struktura danych ma być może 5 metod publicznych - to wszystko musi być dostępne w publicznym interfejsie mojej klasy? Na przykład, Java ArrayList ma następujące metody: get(index), add(), size(), remove(index), i remove(Object). Korzystając z zaproponowanej techniki, klasa zawierająca tę ArrayList musi mieć pięć publicznych metod tylko do delegowania do wewnętrznej kolekcji. A celem tej klasy w programie najprawdopodobniej nie jest hermetyzacja ArrayList, ale raczej zrobienie czegoś innego. ArrayList to tylko szczegół. [...]
Aviv Cohn
Wewnętrzna struktura danych to zwykły element członkowski, który przy użyciu powyższej techniki - wymaga, aby zawierała klasę i zawierała dodatkowe pięć metod publicznych. Twoim zdaniem - czy to rozsądne? A także - czy to jest powszechne?
Aviv Cohn
@Prog - A jeśli wewnętrzna struktura danych ma być może 5 metod publicznych ... IMO, jeśli okaże się, że trzeba spakować całą klasę pomocników w swojej klasie głównej i ujawnić ją w ten sposób, trzeba to przemyśleć design - twoja klasa publiczna robi za dużo i / lub nie prezentuje odpowiedniego interfejsu. Klasa powinna odgrywać bardzo dyskretną i jasno określoną rolę, a jej interfejs powinien obsługiwać tę rolę i tylko tę rolę. Pomyśl o zerwaniu i warstwowaniu swoich klas. Klasa nie powinna być „zlewem kuchennym”, który zawiera wszelkiego rodzaju obiekty w imię enkapsulacji.
Wektor
Jeśli eksponujesz całą strukturę danych za pomocą klasy opakowania, IMO musisz pomyśleć, dlaczego ta klasa jest w ogóle enkapsulowana, jeśli nie ma po prostu zapewnić bezpieczniejszego interfejsu. Mówisz, że klasa zawierająca nie istnieje w tym celu - więc coś jest nie tak z tym projektem.
Wektor
1
@ Phoshi - słowo kluczowe tylko do odczytu - mogę się z tym zgodzić. Ale OP nie mówi o tylko do odczytu. na przykład removenie jest tylko do odczytu. Rozumiem, że OP chce upublicznić wszystko - tak jak w oryginalnym kodzie przed proponowaną zmianą public Thing[] getThings(){return things;}To mi się nie podoba.
Wektor
2

Pytanie: Czy zawsze powinienem całkowicie zawierać wewnętrzną strukturę danych?

Krótka odpowiedź: Tak, przez większość czasu, ale nie zawsze .

Długa odpowiedź: Myślę, że klasy dzielą się na następujące kategorie:

  1. Klasy, które zawierają proste dane. Przykład: punkt 2D. Łatwo jest tworzyć funkcje publiczne, które umożliwiają uzyskanie / ustawienie współrzędnych X i Y, ale można łatwo ukryć dane wewnętrzne bez większych problemów. W przypadku takich klas ujawnianie szczegółów wewnętrznej struktury danych jest nieuzasadnione.

  2. Klasy kontenerów, które hermetyzują kolekcje. STL ma klasyczne klasy kontenerów. Rozważam std::stringi std::wstringwśród nich też. Zapewniają bogaty interfejs do czynienia z abstrakcjami, ale std::vector, std::stringi std::wstringrównież dają możliwość, aby uzyskać dostęp do surowych danych. Nie spieszyłbym się z nazwaniem ich źle zaprojektowanymi zajęciami. Nie znam uzasadnienia dla tych klas, które ujawniają swoje surowe dane. Jednak w mojej pracy uznałem za konieczne ujawnienie surowych danych w kontaktach z milionami węzłów siatki i danych w tych węzłach siatki ze względu na wydajność.

    Ważną rzeczą w ujawnieniu wewnętrznej struktury klasy jest to, że musisz długo i intensywnie myśleć, zanim nadasz jej zielony sygnał. Jeśli interfejs jest wewnętrzny projektu, jego zmiana w przyszłości będzie kosztowna, ale nie niemożliwa. Jeśli interfejs jest zewnętrzny dla projektu (na przykład podczas opracowywania biblioteki, która będzie używana przez innych programistów aplikacji), zmiana interfejsu bez utraty klientów może być niemożliwa.

  3. Klasy, które mają głównie charakter funkcjonalny. Przykłady: std::istream, std::ostream, iteratory pojemników STL. Głupotą jest ujawnianie wewnętrznych szczegółów tych klas.

  4. Klasy hybrydowe. Są to klasy, które zawierają pewną strukturę danych, ale także zapewniają funkcjonalność algorytmiczną. Osobiście uważam, że są one wynikiem źle przemyślanego projektu. Jednak jeśli je znajdziesz, musisz zdecydować, czy sensowne jest ujawnianie ich wewnętrznych danych w poszczególnych przypadkach.

Podsumowując: Jedyny raz, kiedy uznałem za konieczne ujawnienie wewnętrznej struktury danych klasy, jest moment, w którym stała się ona wąskim gardłem wydajności.

R Sahu
źródło
Myślę, że najważniejszym powodem, dla którego STL ujawnia swoje dane wewnętrzne, jest kompatybilność ze wszystkimi funkcjami, które oczekują wskaźników, których jest dużo.
Siyuan Ren
0

Zamiast zwracać bezpośrednio surowe dane, spróbuj czegoś takiego

class ClassA {
  private Things[] things;
  ...
  public Things[] asArray() { return things; }
  public List<Thing> asList() { ... }
  ...
}

Zasadniczo zapewniasz niestandardową kolekcję, która przedstawia pożądaną twarz świata. W nowej implementacji

class ClassA {
  private List<Thing> things;
  ...
  public Things[] asArray() { return things.asArray(); }
  public List<Thing> asList() { return things; }
  ...
}

Teraz masz odpowiednią enkapsulację, ukrywasz szczegóły implementacji i zapewniasz wsteczną kompatybilność (za opłatą).

BobDalgleish
źródło
Sprytny pomysł na kompatybilność wsteczną. Ale: Teraz masz odpowiednią enkapsulację, ukryj szczegóły implementacji - niezupełnie. Klienci wciąż mają do czynienia z niuansami List. Metody dostępu, które po prostu zwracają elementy danych, nawet z obsadą, aby uczynić coś bardziej niezawodnym, nie jest tak naprawdę dobrym enkapsulacją IMO. Klasa robotnicza powinna zajmować się tym wszystkim, a nie klientem. „Głupszy” musi być klient, tym bardziej będzie solidny. Nawiasem mówiąc, nie jestem pewien, czy odpowiedziałeś na pytanie ...
Wektor
1
@Vector - masz rację. Zwrócona struktura danych jest wciąż zmienna, a skutki uboczne zabiją ukrywanie informacji.
BobDalgleish
Zwrócona struktura danych jest wciąż zmienna, a skutki uboczne zabiją ukrywanie informacji - tak, to też - jest niebezpieczne. Myślałem po prostu o tym, co jest wymagane od klienta, na czym skupiło się pytanie.
Wektor
@BobDalgleish: dlaczego nie zwrócić kopii oryginalnej kolekcji?
Giorgio
1
@BobDalgleish: O ile nie istnieją dobre powody do działania, rozważam zwrócenie odwołania do wewnętrznych struktur danych, aby umożliwić użytkownikom ich zmianę, ponieważ jest to bardzo zła decyzja projektowa. Wewnętrzny stan obiektu powinien być zmieniany tylko za pomocą odpowiednich metod publicznych.
Giorgio
0

Powinieneś używać interfejsów do tych rzeczy. Nie pomogłoby to w twoim przypadku, ponieważ tablica Java nie implementuje tych interfejsów, ale powinieneś to zrobić od teraz:

class ClassA{

    public ClassA(){
        things = new ArrayList<Thing>();
    }

    private List<Thing> things; // stores data

    // stuff omitted

    public List<Thing> getThings(){
        return things;
    }

}

W ten sposób możesz zmienić ArrayListna LinkedListlub cokolwiek innego, i nie złamiesz żadnego kodu, ponieważ wszystkie kolekcje Java (oprócz tablic), które mają (pseudo?) Losowy dostęp, prawdopodobnie zostaną zaimplementowane List.

Możesz również użyć Collection, które oferują mniej metod niż, Listale mogą obsługiwać kolekcje bez losowego dostępu, lub Iterablektóre mogą nawet obsługiwać strumienie, ale nie oferują wiele pod względem metod dostępu ...

Idan Arye
źródło
-1 - słaby kompromis i niezbyt bezpieczny IMO: ujawniasz wewnętrzną strukturę danych klientowi, po prostu maskujesz ją i liczysz na najlepsze, ponieważ „kolekcje Java ... prawdopodobnie zaimplementują Listę”. Jeśli twoje rozwiązanie było naprawdę oparte na polimorfii / dziedziczeniu - że wszystkie kolekcje niezmiennie implementują się Listjako klasy pochodne, miałoby to większy sens, ale po prostu „nadzieja na najlepsze” nie jest dobrym pomysłem. „Dobry programista patrzy w obie strony na ulicę jednokierunkową”.
Wektor
@Vector Tak, zakładam, że przyszłe kolekcje Java zostaną zaimplementowane List(lub Collectionprzynajmniej Iterable). To jest sedno tych interfejsów i szkoda, że ​​tablice Java ich nie implementują, ale są one oficjalnym interfejsem dla kolekcji w Javie, więc nie jest zbyt daleko idące przypuszczenie, że zaimplementuje je dowolna kolekcja Java - chyba że ta kolekcja jest starsza niż List, i w takim przypadku bardzo łatwo jest owinąć go AbstractList .
Idan Arye
Mówisz, że twoje założenie jest praktycznie zagwarantowane, więc OK - usunę głos negatywny (kiedy mi wolno), ponieważ byłeś wystarczająco przyzwoity, aby wyjaśnić, a ja nie jestem facetem z Javy z wyjątkiem osmozy. Mimo to nie popieram tego pomysłu ujawnienia wewnętrznej struktury danych, niezależnie od tego, jak się to robi, i nie odpowiedziałeś bezpośrednio na pytanie OP, które tak naprawdę dotyczy enkapsulacji. tj. ograniczenie dostępu do wewnętrznej struktury danych.
Wektor
1
@Vector Tak, użytkownicy mogą przesyłać Listdo ArrayList, ale nie jest tak, że implementacja może być w 100% chroniona - zawsze możesz użyć refleksji, aby uzyskać dostęp do prywatnych pól. Takie postępowanie jest marszczone, ale rzucanie czuje się również (choć nie tak bardzo). Istotą enkapsulacji nie jest zapobieganie złośliwemu hakowaniu - raczej uniemożliwia użytkownikom uzależnienie się od szczegółów implementacji, które możesz chcieć zmienić. Korzystanie z Listinterfejsu robi dokładnie to - użytkownicy klasy mogą polegać na Listinterfejsie zamiast konkretnej ArrayListklasy, która może się zmienić.
Idan Arye
zawsze możesz użyć refleksji, aby uzyskać dostęp do prywatnych pól - jeśli ktoś chce napisać zły kod i obalić projekt, może to zrobić. raczej ma to na celu zapobieganie użytkownikom ... - to jeden z powodów enkapsulacji. Innym jest zapewnienie integralności i spójności wewnętrznego stanu twojej klasy. Problemem nie jest „złośliwe hakowanie”, ale zła organizacja, która prowadzi do paskudnych błędów. „Prawo najmniej niezbędnych przywilejów” - daj konsumentowi swojej klasy tylko to, co jest obowiązkowe - nie więcej. Jeśli upublicznienie całej wewnętrznej struktury danych jest obowiązkowe, masz problem z projektem.
Wektor
-2

Jest to dość częste ukrywanie wewnętrznej struktury danych przed światem zewnętrznym. Czasami jest to przesada, szczególnie w DTO. Polecam to dla modelu domeny. Jeśli w ogóle wymagane jest ujawnienie, zwróć niezmienną kopię. Wraz z tym sugeruję utworzenie interfejsu zawierającego takie metody jak get, set, remove itp.

VGaur
źródło
1
wydaje się, że nie oferuje nic istotnego w porównaniu do 3 wcześniejszych odpowiedzi
komara