Wzorzec iteratora - dlaczego nie należy ujawniać wewnętrznej reprezentacji?

24

Czytam C # Design Pattern Essentials . Obecnie czytam o wzorze iteratora.

W pełni rozumiem, jak wdrożyć, ale nie rozumiem znaczenia ani nie widzę przypadku użycia. W książce podano przykład, w którym ktoś musi uzyskać listę obiektów. Mogli to zrobić, ujawniając własność publiczną, taką jak IList<T>lub Array.

Książka pisze

Problem polega na tym, że wewnętrzna reprezentacja w obu tych klasach była narażona na zewnętrzne projekty.

Jaka jest wewnętrzna reprezentacja? Fakt, że to jest arraylub IList<T>? Naprawdę nie rozumiem, dlaczego jest to złe dla konsumenta (programisty nazywającego to) wiedzieć ...

Książka mówi następnie, że ten wzorzec działa, ujawniając jego GetEnumeratorfunkcję, dzięki czemu możemy wywoływać GetEnumerator()i wyświetlać „listę” w ten sposób.

Zakładam, że te wzorce mają miejsce (jak wszystkie) w pewnych sytuacjach, ale nie widzę, gdzie i kiedy.

MyDaftQuestions
źródło
1
Dalsza lektura: en.m.wikipedia.org/wiki/Law_of_Demeter
Jules
4
Ponieważ implementacja może chcieć zmienić z używania tablicy na używanie listy połączonej bez konieczności wprowadzania zmian w klasie konsumenta. Byłoby to niemożliwe, gdyby konsument wiedział dokładnie, jaką klasę zwrócono.
MTilsted,
11
Nie jest to zła rzecz dla konsumenta, aby wiedzieć , że jest to zła rzecz dla konsumenta, na której można polegać . Nie podoba mi się pojęcie ukrywania informacji, ponieważ prowadzi do przekonania, że ​​informacje są „prywatne” jak twoje hasło, a nie prywatne jak wnętrze telefonu. Wewnętrzne komponenty są ukryte, ponieważ nie mają znaczenia dla użytkownika, a nie dlatego, że są jakimś sekretem. Wszystko, co musisz wiedzieć, to wybrać numer tutaj, porozmawiać tutaj, posłuchać tutaj.
Captain Man,
Załóżmy, że mamy ładne „Interface” F, który daje mi wiedzę (sposobami) a, ba c. Wszystko jest dobrze i dobrze, może być wiele rzeczy, które są różne, ale tylko Fdla mnie. Pomyśl o metodzie jako o „ograniczeniach” lub klauzulach umowy, które Fzobowiązują klasy do działania. Załóżmy, że dodajemy djednak, ponieważ go potrzebujemy. Dodaje to dodatkowe ograniczenie, za każdym razem, gdy to robimy, nakładamy coraz więcej na Fs. W końcu (najgorszy przypadek) może być tylko jedna rzecz, Fwięc równie dobrze możemy jej nie mieć. I Fogranicza tak bardzo, że jest tylko jeden sposób, aby to zrobić.
Alec Teal
@captainman, co za cudowny komentarz. Tak, rozumiem, dlaczego „abstrakować” wiele rzeczy, ale zwrot wiedzy i polegania jest… Szczerze mówiąc… Genialny. I ważne rozróżnienie, którego nie wziąłem pod uwagę, dopóki nie przeczytałem twojego postu.
MyDaftQuestions

Odpowiedzi:

56

Oprogramowanie to gra obietnic i przywilejów. Nigdy nie jest dobrym pomysłem obiecywać więcej, niż możesz dostarczyć, lub więcej niż potrzebuje Twój współpracownik.

Dotyczy to szczególnie typów. Chodzi o to, aby napisać kolekcję iterowalną, ponieważ jej użytkownik może iterować nad nią - nie więcej, nie mniej. Ujawnienie konkretnego typu Arrayzwykle stwarza wiele dodatkowych obietnic, np. Że możesz posortować kolekcję według wybranej przez ciebie funkcji, nie wspominając o tym, że normalna Arrayprawdopodobnie pozwoli współpracownikowi na zmianę danych, które są w niej przechowywane.

Nawet jeśli uważasz, że jest to dobra rzecz („Jeśli moduł renderujący zauważy, że brakuje nowej opcji eksportu, może ją po prostu załatać! Dobrze!”), Ogólnie zmniejsza to spójność podstawy kodu, co utrudnia rozumowanie - a łatwość rozumowania kodu jest najważniejszym celem inżynierii oprogramowania.

Teraz, jeśli twój współpracownik potrzebuje dostępu do wielu rzeczy, aby zagwarantować, że nie umknie żadnej z nich, zaimplementuj Iterableinterfejs i ujawnisz tylko te metody, które deklaruje ten interfejs. W ten sposób, w przyszłym roku, gdy w twojej standardowej bibliotece pojawi się znacznie lepsza i bardziej wydajna struktura danych, będziesz mógł zmienić kod bazowy i korzystać z niego bez konieczności poprawiania kodu klienta wszędzie . Są jeszcze inne korzyści, że nie obiecujemy więcej niż potrzeba, ale ta sama jest tak duża, że ​​w praktyce nie są potrzebne żadne inne.

Kilian Foth
źródło
12
Exposing the concrete type Array usually creates many additional promises, e.g. that you can sort the collection by a function of your own choosing...- Znakomity. Po prostu o tym nie myślałem. Tak, przywraca „iterację” i tylko iterację!
MyDaftQuestions
18

Ukrywanie implementacji jest podstawową zasadą OOP i dobrym pomysłem we wszystkich paradygmatach, ale jest szczególnie ważne dla iteratorów (lub jakkolwiek są one nazywane w tym konkretnym języku) w językach, które obsługują leniwe iteracje.

Problem z ujawnieniem konkretnego typu iterable - a nawet podobnych interfejsów IList<T>- nie leży w obiektach, które je ujawniają, ale w metodach, które ich używają. Załóżmy na przykład, że masz funkcję drukowania listy Foos:

void PrintFoos(IList<Foo> foos)
{
    foreach (foo in foos)
    {
        Console.WriteLine(foo);
    }
}

Możesz użyć tej funkcji tylko do drukowania list foo - ale tylko jeśli się zaimplementują IList<Foo>

IList<Foo> foos = //.....
PrintFoos(foos);

Ale co, jeśli chcesz wydrukować każdą pozycję z indeksu parzystego ? Musisz utworzyć nową listę:

IList<Foo> everySecondFoo = new List<T>();
bool isIndexEven = true;
foreach (foo; foos)
{
    if (isIndexEven)
    {
        everySecondFoo.Add(foo);
    }
    isIndexEven = !isIndexEven;
}
PrintFoos(everySecondFoo);

Jest to dość długie, ale dzięki LINQ możemy to zrobić na jednej linijce, co w rzeczywistości jest bardziej czytelne:

PrintFoos(foos.Where((foo, i) => i % 2 == 0).ToList());

Czy zauważyłeś .ToList()na końcu? Konwertuje to leniwe zapytanie na listę, dzięki czemu możemy go przekazać PrintFoos. Wymaga to przydzielenia drugiej listy i dwóch przejść do pozycji (jeden na pierwszej liście, aby utworzyć drugą listę, a drugi na drugiej liście, aby ją wydrukować). A co, jeśli mamy to:

void Print6Foos(IList<Foo> foos)
{
    int counter = 0;
    foreach (foo in foos)
    {
        Console.WriteLine(foo);
        ++ counter;
        if (6 < counter)
        {
            return;
        }
    }
}

// ........

Print6Foos(foos.Where((foo, i) => i % 2 == 0).ToList());

Co jeśli foosma tysiące wpisów? Będziemy musieli przejrzeć je wszystkie i przydzielić ogromną listę tylko po to, aby wydrukować 6 z nich!

Enter Enumerators - wersja C # wzoru Iterator. Zamiast akceptować listę przez naszą funkcję, przyjmujemy ją Enumerable:

void Print6Foos(Enumerable<Foo> foos)
{
    // everything else stays the same
}

// ........

Print6Foos(foos.Where((foo, i) => i % 2 == 0));

Teraz Print6Foosmożemy leniwie powtarzać pierwsze 6 pozycji na liście, a my nie musimy dotykać reszty.

Kluczem jest tutaj nieujawnianie wewnętrznej reprezentacji. Po Print6Foosprzyjęciu listy musieliśmy dać jej listę - coś, co obsługuje losowy dostęp - i dlatego musieliśmy przydzielić listę, ponieważ podpis nie gwarantuje, że będzie się nad nią powtarzał. Ukrywając implementację, możemy łatwo stworzyć bardziej wydajny Enumerableobiekt, który obsługuje tylko to, czego faktycznie potrzebuje funkcja.

Idan Arye
źródło
6

Ujawnienie wewnętrznej reprezentacji praktycznie nigdy nie jest dobrym pomysłem. Utrudnia to rozumowanie kodu, ale także utrudnia utrzymanie. Wyobraź sobie, że wybrałeś jakąkolwiek wewnętrzną reprezentację - powiedzmy IList<T>- i odsłoniłeś to wewnętrzne. Każdy, kto używa Twojego kodu, może uzyskać dostęp do Listy, a kod może polegać na wewnętrznej reprezentacji będącej Listą.

Z jakiegokolwiek powodu decydujesz się na zmianę wewnętrznej reprezentacji IAmWhatever<T>na później. Zamiast tego po prostu zmieniając elementy wewnętrzne swojej klasy, będziesz musiał zmienić każdy wiersz kodu i metodę, zależnie od typu reprezentacji wewnętrznej IList<T>. Jest to co najwyżej kłopotliwe i podatne na błędy, gdy jesteś jedynym, który korzysta z klasy, ale może złamać kod innych ludzi korzystających z tej klasy. Jeśli po prostu odsłoniłeś zestaw metod publicznych bez ujawnienia żadnych elementów wewnętrznych, mógłbyś zmienić wewnętrzną reprezentację bez zauważenia jakiegokolwiek wiersza kodu poza klasą, działając tak, jakby nic się nie zmieniło.

Dlatego enkapsulacja jest jednym z najważniejszych aspektów każdego nietrywialnego projektu oprogramowania.

Paul Kertscher
źródło
6

Im mniej mówisz, tym mniej musisz powtarzać.

Im mniej pytasz, tym mniej musisz mówić.

Jeśli kod tylko ujawnia IEnumerable<T>, który obsługuje tylko GetEnumrator(), można go zastąpić dowolnym innym kodem, który może obsługiwać IEnumerable<T>. To dodaje elastyczności.

Jeśli Twój kod używa tylko IEnumerable<T>, może być obsługiwany przez dowolny kod, który implementuje IEnumerable<T>. Ponownie istnieje dodatkowa elastyczność.

Na przykład wszystkie linie do obiektów zależą tylko od IEnumerable<T>. Chociaż przyspiesza ścieżki, niektóre jego konkretne implementacje IEnumerable<T>robią to w sposób testowy i użytkowy, który zawsze może wrócić do samego użycia GetEnumerator()i IEnumerator<T>implementacji, która powraca. Daje to znacznie większą elastyczność niż gdyby był zbudowany na tablicach lub listach.

Jon Hanna
źródło
Ładna, jasna odpowiedź. Bardzo trafne, ponieważ krótko mówiąc, i dlatego postępuj zgodnie z radą w pierwszym zdaniu. Brawo!
Daniel Hollinrake
0

Sformułowanie, które lubię używać mirrorów, komentarz @CaptainMan: „Konsumentowi dobrze jest znać wewnętrzne szczegóły. Źle jest, gdy klient musi znać wewnętrzne szczegóły”.

Jeśli klient musi wpisać arraylub wpisać IList<T>w swoim kodzie, gdy te typy były szczegółami wewnętrznymi, konsument musi je odpowiednio skorygować. Jeśli się mylą, konsument może się nie skompilować, a nawet uzyskać błędne wyniki. Daje to takie same zachowania, jak inne odpowiedzi „zawsze ukryj szczegóły implementacji, ponieważ nie powinieneś ich ujawniać”, ale zaczyna kopać na korzyść braku ujawnienia. Jeśli nigdy nie ujawnisz szczegółów implementacji, Twój klient nigdy nie będzie mógł się związać, używając go!

Lubię o tym myśleć w tym świetle, ponieważ otwiera to koncepcję ukrywania implementacji w odcieniach znaczenia, a nie drakońskiego „zawsze ukryj szczegóły implementacji”. Istnieje wiele sytuacji, w których ujawnianie implementacji jest całkowicie ważne. Rozważmy przypadek sterowników urządzeń w czasie rzeczywistym, w których zdolność pomijania przeszłych warstw abstrakcji i umieszczania bajtów w odpowiednich miejscach może być różnicą między ustalaniem czasu lub nie. Lub rozważ środowisko programistyczne, w którym nie masz czasu na tworzenie „dobrych interfejsów API” i musisz natychmiast z nich korzystać. Często ujawnianie bazowych interfejsów API w takich środowiskach może być różnicą między ustaleniem terminu, czy nie.

Jednak gdy zostawiasz te specjalne przypadki za sobą i patrzysz na bardziej ogólne przypadki, zaczyna być coraz ważniejsze ukrywanie implementacji. Ujawnianie szczegółów implementacji jest jak lina. Użyte właściwie, możesz robić z nim różne rzeczy, takie jak skalowanie wysokich gór. Używana nieprawidłowo i może wiązać cię w pętlę. Im mniej wiesz o tym, jak będzie używany Twój produkt, tym bardziej musisz być ostrożny.

Studium przypadku, które widzę od czasu do czasu, to takie, w którym programista tworzy interfejs API, który nie bardzo ostrożnie ukrywa szczegóły implementacji. Drugi programista, korzystający z tej biblioteki, stwierdza, że ​​API brakuje niektórych kluczowych funkcji, które chcieliby. Zamiast pisać prośbę o funkcję (która może potrwać kilka miesięcy), zamiast tego decydują się nadużyć dostępu do ukrytych szczegółów implementacji (co zajmuje kilka sekund). Samo w sobie nie jest to złe, ale często deweloper API nigdy nie planował, że będzie częścią API. Później, kiedy udoskonalą interfejs API i zmienią ten podstawowy szczegół, okaże się, że są przywiązani do starej implementacji, ponieważ ktoś użył go jako części interfejsu API. Najgorsza wersja tego jest taka, że ​​ktoś użył twojego API, aby zapewnić funkcję zorientowaną na klienta, a teraz Twoja firma wypłata jest powiązana z tym błędnym API! Po prostu ujawniając szczegóły implementacji, gdy nie wiedziałeś wystarczająco dużo o swoich klientach, okazało się, że wystarczy liny, aby zawiązać pętlę wokół twojego API!

Cort Ammon - Przywróć Monikę
źródło
1
Odp: „Lub weź pod uwagę środowisko programistyczne, w którym nie masz czasu na tworzenie„ dobrych interfejsów API ”i musisz natychmiast użyć różnych rzeczy”: jeśli znajdziesz się w takim środowisku i z jakiegoś powodu nie chcesz rezygnować ( lub nie jesteś pewien, czy to zrobisz), polecam książkę Death March: The Complete Software Developer's Guide to Surviving 'Mission Impossible' . Ma doskonałe porady na ten temat. (Również polecam zmienić zdanie na temat nie rzucania palenia.)
ruakh
@ruakh Zauważyłem, że zawsze ważne jest, aby zrozumieć, jak pracować w środowisku, w którym nie masz czasu na tworzenie dobrych interfejsów API. Szczerze mówiąc, o ile uwielbiamy podejście do wieży z kości słoniowej, prawdziwy kod jest tym, co faktycznie płaci rachunki. Im szybciej możemy to przyznać, tym szybciej możemy zacząć zbliżać się do oprogramowania jako równowagi, równoważąc jakość interfejsu API z wydajnym kodem, aby dopasować się do naszego dokładnego środowiska. Widziałem zbyt wielu młodych programistów uwięzionych w bałaganie ideałów, nie zdając sobie sprawy, że muszą je zgiąć. (Byłem również tym młodym programistą ... wciąż wychodzącym z 5 lat projektowania „dobrego API”)
Cort Ammon - Przywróć Monikę
1
Prawdziwy kod płaci rachunki, tak. Dobry projekt interfejsu API gwarantuje, że nadal będziesz w stanie generować prawdziwy kod. Błędny kod przestaje się zwracać, gdy projekt staje się niemożliwy do utrzymania i niemożliwe jest naprawianie błędów bez tworzenia nowych. (W każdym razie jest to fałszywy dylemat. Dobry kod wymaga nie tyle czasu , ile kręgosłupa .)
ruakh
1
@ruakh Zauważyłem, że można stworzyć dobry kod bez „dobrych API”. Jeśli daje się taką możliwość, dobre API są fajne, ale traktowanie ich jako konieczności powoduje więcej konfliktów niż jest to warte. Zastanów się: API dla ludzkich ciał jest przerażające, ale nadal uważamy, że są całkiem niezłe, małe osiągnięcia inżynieryjne =)
Cort Ammon - Przywróć Monikę
Innym przykładem, który podam, jest ten, który był inspiracją do sporu o robienie czasu. Jedna z grup, z którymi pracuję, miała kilka sterowników do urządzeń, które musieli opracować, z twardymi wymaganiami w czasie rzeczywistym. Mieli 2 interfejsy API do pracy. Jeden był dobry, drugi mniej. Dobry interfejs API nie mógł ustalić czasu, ponieważ ukrył implementację. Mniejszy interfejs API ujawnił implementację, umożliwiając w ten sposób wykorzystanie technik specyficznych dla procesora do stworzenia działającego produktu. Dobre API zostało grzecznie umieszczone na półce i nadal spełniały wymagania dotyczące czasu.
Cort Ammon - Przywróć Monikę
0

Niekoniecznie wiesz, jaka jest wewnętrzna reprezentacja twoich danych - lub może stać się w przyszłości.

Załóżmy, że masz źródło danych, które reprezentujesz jako tablicę, możesz iterować je za pomocą zwykłej pętli for.

Teraz powiedz, że chcesz refaktoryzować. Twój szef poprosił, aby źródłem danych był obiekt bazy danych. Ponadto chciałby mieć opcję pobierania danych z interfejsu API, mapy skrótów lub powiązanej listy, obracającego się bębna magnetycznego lub mikrofonu lub zliczania ilości wiórów jak w Mongolii Zewnętrznej w czasie rzeczywistym.

Cóż, jeśli masz tylko jedną pętlę dla, możesz łatwo zmodyfikować kod, aby uzyskać dostęp do obiektu danych w sposób, w jaki oczekuje on dostępu.

Jeśli masz 1000 klas próbujących uzyskać dostęp do obiektu danych, teraz masz duży problem z refaktoryzacją.

Iterator to standardowy sposób dostępu do źródła danych opartego na liście. To wspólny interfejs. Użycie go sprawi, że Twój kod źródłowy danych będzie agnostyczny.

superluminarny
źródło