Wiele najpopularniejszych języków programowania (takich jak C ++, Java, Python itp.) Ma pojęcie ukrywania / cieniowania zmiennych lub funkcji. Kiedy spotkałem się z ukrywaniem lub zacieniowaniem, były przyczyną trudnych do znalezienia błędów i nigdy nie widziałem przypadku, w którym uważam za konieczne korzystanie z tych funkcji języków.
Wydaje mi się, że lepiej zabronić ukrywania się i cieniowania.
Czy ktoś wie o dobrym wykorzystaniu tych pojęć?
Aktualizacja:
Nie mam na myśli enkapsulacji członków klasy (członków prywatnych / chronionych).
programming-languages
language-design
Szymon, Szymek
źródło
źródło
Odpowiedzi:
Jeśli nie zezwalasz na ukrywanie i cieniowanie, masz język, w którym wszystkie zmienne są globalne.
Jest to wyraźnie gorsze niż dopuszczenie zmiennych lokalnych lub funkcji, które mogą ukrywać zmienne globalne lub funkcje.
Jeśli nie zezwalasz na ukrywanie i cieniowanie, ORAZ próbujesz „chronić” niektóre zmienne globalne, tworzysz sytuację, w której kompilator mówi programatorowi „Przykro mi, Dave, ale nie możesz użyć tej nazwy, jest już używana . ” Doświadczenie z COBOL pokazuje, że w tej sytuacji programiści niemal natychmiast uciekają się do wulgaryzmów.
Podstawowym problemem nie jest ukrywanie / cieniowanie, ale zmienne globalne.
źródło
Używanie dokładnych, opisowych identyfikatorów jest zawsze dobrym rozwiązaniem.
Mogę argumentować, że ukrywanie zmiennych nie powoduje wielu błędów, ponieważ posiadanie dwóch bardzo podobnych nazw zmiennych tego samego / podobnego typu (co byś zrobił, gdyby ukrywanie zmiennych zostało niedozwolone) prawdopodobnie spowoduje tyle błędów i / lub tak samo poważne błędy. Nie wiem, czy ten argument jest poprawny , ale przynajmniej można go poddać dyskusji.
Użycie jakiegoś węgierskiego zapisu do rozróżnienia pól od zmiennych lokalnych omija ten problem, ale ma swój wpływ na utrzymanie (i rozsądek programisty).
I (być może najprawdopodobniej powodem, dla którego ta koncepcja jest znana) języki znacznie łatwiej wdrażają ukrywanie / cieniowanie niż nie zezwalają na to. Łatwiejsza implementacja oznacza, że kompilatory rzadziej zawierają błędy. Łatwiejsza implementacja oznacza, że kompilatory zajmują mniej czasu na pisanie, co powoduje wcześniejsze i szersze przyjęcie platformy.
źródło
Aby upewnić się, że jesteśmy na tej samej stronie, metoda „ukrywa się” polega na tym, że klasa pochodna definiuje element członkowski o tej samej nazwie co element klasy podstawowej (który, jeśli jest metodą / właściwością, nie jest oznaczony jako wirtualny / nadpisywalny) ), a po wywołaniu z instancji klasy pochodnej w „kontekście pochodnym” używany jest element pochodny, natomiast jeśli jest wywoływany przez to samo wystąpienie w kontekście swojej klasy podstawowej, używany jest element klasy podstawowej. Różni się to od abstrakcji / zastępowania elementu, gdy element klasy podstawowej oczekuje, że klasa pochodna zdefiniuje zamiennik, oraz od modyfikatorów zakresu / widoczności, które „ukrywają” członka przed konsumentami poza pożądanym zakresem.
Krótka odpowiedź na pytanie, dlaczego jest to dozwolone, jest taka, że nie zmuszałoby to deweloperów do łamania kilku kluczowych założeń projektowania obiektowego.
Oto dłuższa odpowiedź; po pierwsze, rozważ następującą strukturę klas w alternatywnym wszechświecie, w którym C # nie pozwala na ukrywanie członków:
Chcemy anulować komentarz członka w pasku, a tym samym pozwolić Barowi na dostarczenie innego MyFooString. Nie możemy tego jednak zrobić, ponieważ stanowiłoby to naruszenie zakazu ukrywania członków w rzeczywistości alternatywnej. Ten konkretny przykład byłby powszechny dla błędów i jest doskonałym przykładem tego, dlaczego możesz chcieć go zakazać; na przykład, jakie dane wyjściowe konsoli uzyskałbyś, gdybyś zrobił następujące rzeczy?
Z czubka mojej głowy tak naprawdę nie jestem pewien, czy dostaniesz „Foo” czy „Bar” w tej ostatniej linii. Zdecydowanie dostaniesz „Foo” dla pierwszego wiersza i „Bar” dla drugiego, mimo że wszystkie trzy zmienne odnoszą się dokładnie do tego samego wystąpienia z dokładnie tym samym stanem.
Tak więc projektanci języka, w naszym alternatywnym wszechświecie, zniechęcają do tego oczywiście złego kodu, uniemożliwiając ukrywanie własności. Teraz, jako programista, naprawdę musisz dokładnie to zrobić. Jak obejść to ograniczenie? Jednym ze sposobów jest inna nazwa właściwości Bar:
Idealnie legalne, ale nie takie zachowanie chcemy. Instancja Bar zawsze będzie produkować „Foo” dla właściwości MyFooString, gdy chcemy, aby produkowała „Bar”. Musimy nie tylko wiedzieć, że nasz IFoo to konkretnie Bar, musimy także wiedzieć, jak korzystać z innego akcesorium.
Możemy również, całkiem prawdopodobne, zapomnieć o relacji rodzic-dziecko i wdrożyć interfejs bezpośrednio:
W tym prostym przykładzie jest to idealna odpowiedź, o ile zależy ci tylko na tym, że Foo i Bar to IFoos. Kod użycia, o którym mowa w kilku przykładach, nie zostałby skompilowany, ponieważ pasek nie jest Foo i nie może być przypisany jako taki. Jednakże, jeśli Foo miał jakąś przydatną metodę „FooMethod”, której potrzebował Bar, teraz nie można dziedziczyć tej metody; musisz albo sklonować jego kod w Bar, albo uzyskać kreatywność:
To oczywisty hack i choć niektóre implementacje specyfikacji języka OO stanowią niewiele więcej, to jest to błędne koncepcyjnie; jeśli konsumenci Bar konieczności narazić funkcjonalność foo, bar powinien być Foo, nie mają do Foo.
Oczywiście, jeśli kontrolujemy Foo, możemy uczynić go wirtualnym, a następnie zastąpić. Jest to najlepsza praktyka konceptualna w naszym obecnym wszechświecie, gdy oczekuje się, że członek zostanie zastąpiony, i obejmowałby każdy alternatywny wszechświat, który nie pozwalałby na ukrywanie:
Problem polega na tym, że dostęp do elementów wirtualnych jest, pod maską, stosunkowo droższy w wykonywaniu, dlatego zazwyczaj chcesz to zrobić tylko wtedy, gdy jest to konieczne. Jednak brak ukrywania zmusza cię do pesymizmu co do elementów, które inny koder, który nie kontroluje twojego kodu źródłowego, może chcieć ponownie wdrożyć; „najlepszą praktyką” dla każdej niezamkniętej klasy byłoby uczynienie wszystkiego wirtualnym, chyba że specjalnie tego nie chcesz. Jest też jeszcze nie daje dokładnie zachowanie ukryciu; ciąg zawsze będzie miał wartość „Bar”, jeśli instancja to Bar. Czasami naprawdę przydatne jest wykorzystanie warstw danych o stanie ukrytym na podstawie poziomu dziedziczenia, na którym pracujesz.
Podsumowując, umożliwienie ukrywania się członków to mniejsze zło. Nie posiadanie go generalnie prowadzi do gorszych okrucieństw popełnianych wbrew zasadom obiektowym niż pozwala na to.
źródło
IEnumerable
iIEnumerable<T>
interfejs, opisane w Erica Libberta na blogu na ten temat.Szczerze mówiąc, Eric Lippert, główny programista w zespole kompilatorów C #, wyjaśnia to całkiem dobrze (dzięki Lescai Ionel za link). .NET
IEnumerable
iIEnumerable<T>
interfejsy są dobrymi przykładami przydatności ukrywania członków.Na początku .NET nie mieliśmy generycznych. Więc
IEnumerable
interfejs wyglądał następująco:Ten interfejs pozwolił nam
foreach
prześcignąć zbiór obiektów, jednak musieliśmy rzucić wszystkie te obiekty, aby móc z nich prawidłowo korzystać.Potem przyszedł rodzajowy. Kiedy otrzymaliśmy ogólne, otrzymaliśmy również nowy interfejs:
Teraz nie musimy rzucać przedmiotami podczas iteracji! Woot! Teraz, jeśli ukrywanie członków nie było dozwolone, interfejs musiałby wyglądać mniej więcej tak:
Byłoby to trochę głupie, ponieważ
GetEnumerator()
iwGetEnumeratorGeneric()
obu przypadkach robią prawie dokładnie to samo , ale mają nieco inne zwracane wartości. Są tak bardzo podobne, że prawie zawsze chcesz domyślnie używać ogólnej formyGetEnumerator
, chyba że pracujesz ze starszym kodem, który został napisany przed wprowadzeniem generycznych do .NET.Czasami ukrywanie członków daje więcej miejsca na nieprzyjemny kod i trudne do znalezienia błędy. Jednak czasami jest to przydatne, na przykład gdy chcesz zmienić typ zwracany bez łamania starszego kodu. To tylko jedna z tych decyzji, które muszą podjąć projektanci języka: Czy przeszkadzamy programistom, którzy zgodnie z prawem potrzebują tej funkcji i pomijamy ją, czy też włączamy tę funkcję do języka i łapiemy strach przed ofiarami jej niewłaściwego użycia?
źródło
IEnumerable<T>.GetEnumerator()
ukrywaIEnumerable.GetEnumerator()
, jest tak tylko dlatego, że C # nie ma kowariantnych typów zwracanych podczas przesłonięcia. Logicznie jest to zastąpienie, w pełni zgodne z LSP. Ukrywanie ma miejsce, gdy masz zmienną lokalnąmap
w funkcji w pliku, który mausing namespace std
(w C ++).Twoje pytanie można odczytać na dwa sposoby: albo ogólnie pytasz o zakres zmiennej / funkcji, albo zadajesz bardziej szczegółowe pytanie dotyczące zakresu w hierarchii dziedziczenia. Nie wspomniałeś konkretnie o dziedziczeniu, ale wspomniałeś o trudnych do znalezienia błędach, które w kontekście dziedziczenia brzmią bardziej jak zakres, niż zwykły zakres, więc odpowiem na oba pytania.
Ogólnie zakres jest dobrym pomysłem, ponieważ pozwala nam skupić się na jednej konkretnej (miejmy nadzieję małej) części programu. Ponieważ pozwala zawsze wygrywać nazwy lokalne, jeśli czytasz tylko część programu, która jest w danym zakresie, wiesz dokładnie, które części zostały zdefiniowane lokalnie, a co gdzie indziej. Albo nazwa odnosi się do czegoś lokalnego, w którym to przypadku kod, który ją definiuje, znajduje się tuż przed tobą, lub jest odniesieniem do czegoś poza lokalnym zakresem. Jeśli nie ma żadnych nielokalnych odniesień, które mogłyby zmienić się pod nami (szczególnie zmienne globalne, które można zmienić z dowolnego miejsca), możemy ocenić, czy część programu w zasięgu lokalnym jest poprawna, czy nie bez odwoływania się do dowolnej części reszty programu .
Może to czasami prowadzić do kilku błędów, ale rekompensuje to więcej, zapobiegając ogromnej ilości możliwych błędów. Poza tworzeniem definicji lokalnej o tej samej nazwie co funkcja biblioteki (nie rób tego), nie widzę łatwego sposobu na wprowadzenie błędów w zakresie lokalnym, ale zasięg lokalny pozwala wielu częściom tego samego programu korzystać i jako licznik indeksu dla pętli bez zapychania się nawzajem i pozwala Fredowi napisać funkcję, która używa łańcucha o nazwie str, który nie zapycha łańcucha o tej samej nazwie.
Znalazłem interesujący artykuł Bertranda Meyera, który omawia przeciążanie w kontekście dziedziczenia. Wprowadza interesujące rozróżnienie między tym, co nazywa przeciążeniem składniowym (co oznacza, że istnieją dwie różne rzeczy o tej samej nazwie) i przeciążeniem semantycznym (co oznacza, że istnieją dwie różne implementacje tego samego abstrakcyjnego pomysłu). Przeciążenie semantyczne byłoby w porządku, ponieważ zamierzałeś wprowadzić go inaczej w podklasie; przeciążenie składniowe to przypadkowe zderzenie nazwy, które spowodowało błąd.
Różnica między przeładowaniem w sytuacji dziedziczenia, która jest zamierzona, a która jest błędem, jest semantyka (znaczenie), więc kompilator nie ma możliwości dowiedzenia się, czy to, co zrobiłeś, jest dobre, czy złe. W przypadku zwykłego zakresu właściwą odpowiedzią jest zawsze sprawa lokalna, więc kompilator może dowiedzieć się, co jest właściwe.
Sugestią Bertranda Meyera byłoby użycie języka takiego jak Eiffel, który nie pozwala na takie konflikty nazw i zmusza programistę do zmiany nazwy jednego lub obu, unikając w ten sposób problemu. Moją sugestią byłoby całkowite uniknięcie dziedziczenia, a także całkowite uniknięcie problemu. Jeśli nie możesz lub nie chcesz robić żadnej z tych rzeczy, możesz jeszcze zmniejszyć prawdopodobieństwo wystąpienia problemu z dziedziczeniem: postępuj zgodnie z LSP (Zasada podstawienia Liskowa), preferuj kompozycję zamiast dziedziczenia, zachowaj twoje hierarchie dziedziczenia są płytkie, a klasy w hierarchii spadkowej małe. Ponadto niektóre języki mogą wyświetlać ostrzeżenie, nawet jeśli nie spowodują błędu, tak jak w przypadku języka Eiffel.
źródło
Oto moje dwa centy.
Programy mogą być podzielone na bloki (funkcje, procedury), które są samodzielnymi jednostkami logiki programu. Każdy blok może odnosić się do „rzeczy” (zmiennych, funkcji, procedur) przy użyciu nazw / identyfikatorów. To odwzorowanie nazw na rzeczy nazywa się wiązaniem .
Nazwy używane przez blok dzielą się na trzy kategorie:
Rozważmy na przykład następujący program C.
Funkcja
print_double_int
ma nazwę lokalną (zmienna lokalna)d
i argumentn
oraz używa zewnętrznej, globalnej nazwyprintf
, która jest w zasięgu, ale nie jest zdefiniowana lokalnie.Zauważ, że
printf
można go również przekazać jako argument:Zwykle argument służy do określania parametrów wejściowych / wyjściowych funkcji (procedura, blok), podczas gdy nazwy globalne są używane w odniesieniu do rzeczy takich jak funkcje biblioteczne, które „istnieją w środowisku”, dlatego wygodniej jest o nich wspomnieć tylko wtedy, gdy są potrzebne. Używanie argumentów zamiast nazw globalnych jest główną ideą wstrzykiwania zależności , która jest używana, gdy zależności muszą być jawne, a nie rozwiązywane przez spojrzenie na kontekst.
Kolejne podobne zastosowanie nazw zewnętrznych można znaleźć w zamknięciach. W tym przypadku nazwa zdefiniowana w kontekście leksykalnym bloku może być użyta w obrębie bloku, a wartość powiązana z tą nazwą będzie (zwykle) istnieć tak długo, jak długo blok się do niej odwołuje.
Weźmy na przykład ten kod Scala:
Zwracaną wartością funkcji
createMultiplier
jest zamknięcie(m: Int) => m * n
, które zawiera argumentm
i nazwę zewnętrznąn
. Nazwęn
rozwiązuje się, patrząc na kontekst, w którym zdefiniowano zamknięcie: nazwa jest powiązana z argumentemn
funkcjicreateMultiplier
. Zauważ, że to powiązanie jest tworzone podczas tworzenia zamknięcia, tj. KiedycreateMultiplier
jest wywoływane. Zatem nazwan
jest powiązana z rzeczywistą wartością argumentu dla określonego wywołania funkcji. Porównaj to z przypadkiem funkcji biblioteki typuprintf
, która jest rozwiązywana przez linker, gdy budowany jest plik wykonywalny programu.Podsumowując, przydatne może być odwołanie się do zewnętrznych nazw wewnątrz lokalnego bloku kodu, abyś mógł
Zacienianie pojawia się, gdy weźmiesz pod uwagę, że w bloku interesują Cię tylko odpowiednie nazwy zdefiniowane w środowisku, np.
printf
Funkcja, której chcesz użyć. Jeśli przez przypadek chcesz użyć lokalnej nazwy (getc
,putc
,scanf
, ...), który został już użyty w środowisku, to proste chcesz zignorować (cień) nazwę globalnego. Więc myśląc lokalnie, nie chcesz brać pod uwagę całego (być może bardzo dużego) kontekstu.Z drugiej strony, myśląc globalnie, chcesz zignorować wewnętrzne szczegóły lokalnych kontekstów (enkapsulacja). Dlatego potrzebujesz shadowingu, w przeciwnym razie dodanie nazwy globalnej może uszkodzić każdy blok lokalny, który już używał tej nazwy.
Podsumowując, jeśli chcesz, aby blok kodu odwoływał się do powiązań zdefiniowanych zewnętrznie, potrzebujesz shadowingu, aby chronić lokalne nazwy przed nazwami globalnymi.
źródło