Jak ostrzec innych programistów przed implementacją klasy

96

Piszę zajęcia, które „muszą być używane w określony sposób” (chyba wszystkie klasy muszą…).

Na przykład, utworzyć fooManagerklasę, która wymaga połączenia do, powiedzmy Initialize(string,string). I, idąc dalej za przykładem, klasa byłaby bezużyteczna, gdybyśmy nie słuchali jej ThisHappenedakcji.

Chodzi mi o to, że klasa, którą piszę, wymaga wywołań metod. Ale skompiluje się dobrze, jeśli nie wywołasz tych metod i skończy się pustym nowym FooManager. W pewnym momencie albo nie zadziała, albo może się zawiesi, w zależności od klasy i tego, co robi. Programista, który implementuje moją klasę, oczywiście zajrzy do niej i zrozumie „Och, nie zadzwoniłem do Initialize!”, I byłoby dobrze.

Ale mi się to nie podoba. Idealnie chciałbym, aby kod NIE kompilował się, gdyby metoda nie została wywołana; Zgaduję, że to po prostu niemożliwe. Lub coś, co natychmiast byłoby widoczne i jasne.

Niepokoi mnie obecne podejście, które mam tutaj:

Dodaj prywatną wartość logiczną do klasy i sprawdzaj wszędzie, gdzie jest to konieczne, czy klasa została zainicjowana; jeśli nie, wyrzucę wyjątek z informacją: „Klasa nie została zainicjowana, czy na pewno dzwonisz .Initialize(string,string)?”.

Takie podejście jest w porządku, ale prowadzi do kompilacji kodu i ostatecznie nie jest konieczne dla użytkownika końcowego.

Czasami jest to nawet więcej kodu, gdy jest więcej metod niż tylko Initiliazewywołanie. Staram się, aby moje zajęcia były prowadzone przy użyciu niezbyt wielu publicznych metod / działań, ale to nie rozwiązuje problemu, a jedynie utrzymuje rozsądek.

Szukam tutaj:

  • Czy moje podejście jest prawidłowe?
  • Czy jest lepszy?
  • Co robicie / doradzacie?
  • Czy próbuję rozwiązać problem? Koledzy powiedzieli mi, że programista powinien sprawdzić klasę, zanim spróbuje z niej skorzystać. Z szacunkiem się nie zgadzam, ale wierzę, że to kolejna sprawa.

Mówiąc prościej, próbuję znaleźć sposób, aby nigdy nie zapomnieć o implementacji wywołań, gdy ta klasa zostanie ponownie użyta później lub przez kogoś innego.

WYJAŚNIENIA:

Aby wyjaśnić wiele pytań tutaj:

  • Zdecydowanie NIE mówię tylko o części zajęć z Inicjalizacji, ale raczej o całym jej życiu. Zapobiegaj kolegom wywoływania metody dwa razy, upewniając się, że wywołują X przed Y itp. Wszystko, co skończyłoby się obowiązkiem i dokumentacją, ale chciałbym, żeby było to w kodzie, tak proste i małe, jak to możliwe. Bardzo podobał mi się pomysł Asserts, choć jestem pewien, że będę musiał wymieszać kilka innych pomysłów, ponieważ Asserts nie zawsze będą możliwe.

  • Używam języka C #! Jak o tym nie wspomniałem ?! Jestem w środowisku Xamarin i buduję aplikacje mobilne, zwykle wykorzystując około 6 do 9 projektów w rozwiązaniu, w tym projekty PCL, iOS, Android i Windows. Jestem programistą przez około półtora roku (szkoła i praca łącznie), stąd moje czasem śmieszne wypowiedzi / pytania. To chyba nie ma znaczenia, ale zbyt wiele informacji nie zawsze jest złą rzeczą.

  • Nie zawsze mogę umieścić w konstruktorze wszystko, co jest obowiązkowe, ze względu na ograniczenia platformy i użycie wstrzykiwania zależności, ponieważ parametry inne niż interfejsy są poza tabelą. A może moja wiedza nie jest wystarczająca, co jest wysoce możliwe. Przez większość czasu nie jest to kwestia inicjalizacji, ale więcej

jak mogę się upewnić, że zarejestrował się na tym wydarzeniu?

jak mogę się upewnić, że nie zapomniał „zatrzymać procesu w pewnym momencie”

Tutaj pamiętam zajęcia z pobierania reklam. Tak długo, jak widoczny jest widok reklamy, klasa co minutę pobiera nową reklamę. Ta klasa potrzebuje widoku, gdy jest skonstruowana w miejscu, w którym może wyświetlać reklamę, która może oczywiście zawierać parametr. Ale gdy widok zniknie, należy wywołać StopFetching (). W przeciwnym razie klasa nadal pobierałaby reklamy dla widoku, którego nawet tam nie ma, a to źle.

Ponadto w tej klasie znajdują się zdarzenia, które należy wysłuchać, na przykład „AdClicked”. Wszystko działa dobrze, jeśli nie jest słuchane, ale tracimy śledzenie danych analitycznych, jeśli krany nie są zarejestrowane. Reklama nadal działa, więc użytkownik i programista nie zobaczą różnicy, a dane analityczne będą po prostu mieć nieprawidłowe dane. Trzeba tego unikać, ale nie jestem pewien, skąd programista może wiedzieć, że musi zarejestrować się na wydarzeniu tao. Jest to jednak uproszczony przykład, ale istnieje idea „upewnij się, że korzysta z publicznej Akcji, która jest dostępna” i oczywiście w odpowiednim czasie!

Gil Sand
źródło
13
Zapewnienie, że wywołania metod klasy występują w określonej kolejności, nie jest ogólnie możliwe . Jest to jeden z wielu problemów równoważnych problemowi zatrzymania. Chociaż w niektórych przypadkach możliwe byłoby uczynienie niezgodności błędem podczas kompilacji , nie ma ogólnego rozwiązania. Prawdopodobnie dlatego niewiele języków obsługuje konstrukcje, które pozwoliłyby zdiagnozować przynajmniej oczywiste przypadki, mimo że analiza przepływu danych stała się dość skomplikowana.
Kilian Foth,
5
Odpowiedź na drugie pytanie jest nieistotna, pytanie nie jest takie samo, dlatego są to różne pytania. Gdyby ktoś szukał takiej dyskusji, nie wpisałby tytułu drugiego pytania.
Gil Sand
6
Co uniemożliwia scalenie treści inicjalizacji w ctor? Czy wezwanie initializenależy wykonać późno po utworzeniu obiektu? Czy ctor byłby zbyt „ryzykowny” w tym sensie, że mógłby rzucać wyjątki i przerywać łańcuch tworzenia?
Zauważył
7
Ten problem nazywa się sprzężeniem czasowym . Jeśli możesz, staraj się unikać umieszczania obiektu w niepoprawnym stanie, rzucając wyjątki w konstruktorze, gdy napotkasz nieprawidłowe dane wejściowe, w ten sposób nigdy nie przeoczysz wywołania, newjeśli obiekt nie jest gotowy do użycia. Poleganie na metodzie inicjalizacji z pewnością będzie cię prześladować i powinno się jej unikać, chyba że jest to absolutnie konieczne, przynajmniej takie jest moje doświadczenie.
Serize

Odpowiedzi:

161

W takich przypadkach najlepiej jest użyć systemu pisma w swoim języku, aby pomóc w prawidłowej inicjalizacji. Jak możemy zapobiec FooManagerprzed wykorzystywane bez zainicjowana? , Przez zapobieganie FooManagerprzed stworzony bez informacji niezbędnych do prawidłowego go zainicjować. W szczególności za wszelką inicjalizację odpowiedzialny jest konstruktor. Nigdy nie należy pozwalać konstruktorowi tworzyć obiektu w stanie nielegalnym.

Ale dzwoniący muszą zbudować, FooManagerzanim będą mogli zainicjować, np. Ponieważ FooManagerjest przekazywany jako zależność.

Nie twórz, FooManagerjeśli go nie masz. Zamiast tego możesz przekazać obiekt, który pozwala pobrać w pełni skonstruowany FooManager, ale tylko z informacjami o inicjalizacji. (Mówiąc w programowaniu funkcjonalnym, sugeruję użycie częściowej aplikacji dla konstruktora.) Np .:

ctorArgs = ...;
getFooManager = (initInfo) -> new FooManager(ctorArgs, initInfo);
...
getFooManager(myInitInfo).fooMethod();

Problem polega na tym, że musisz podawać informacje o inicjowaniu za każdym razem, gdy uzyskujesz dostęp do FooManager.

Jeśli jest to konieczne w twoim języku, możesz zawinąć getFooManager()operację w klasę przypominającą fabrykę lub konstruktora.

Naprawdę chcę sprawdzać w czasie wykonywania, czy initialize()metoda została wywołana, zamiast używać rozwiązania na poziomie systemu.

Można znaleźć kompromis. Tworzymy klasę opakowującą, MaybeInitializedFooManagerktóra ma get()metodę, która zwraca FooManager, ale wyrzuca, jeśli FooManagernie została w pełni zainicjowana. Działa to tylko wtedy, gdy inicjalizacja jest wykonywana przez opakowanie lub jeśli istnieje FooManager#isInitialized()metoda.

class MaybeInitializedFooManager {
  private final FooManager fooManager;

  public MaybeInitializedFooManager(CtorArgs ctorArgs) {
    fooManager = new FooManager(ctorArgs);
  }

  public FooManager initialize(InitArgs initArgs) {
    fooManager.initialize(initArgs);
    return fooManager;
  }

  public FooManager get() {
    if (fooManager.isInitialized()) return fooManager;
    throw ...;
  }
}

Nie chcę zmieniać interfejsu API mojej klasy.

W takim przypadku będziesz chciał uniknąć if (!initialized) throw;warunków warunkowych w każdej metodzie. Na szczęście istnieje prosty wzorzec rozwiązania tego problemu.

Obiekt, który udostępniasz użytkownikom, jest pustą powłoką, która deleguje wszystkie wywołania do obiektu implementacji. Domyślnie obiekt implementacji zgłasza błąd dla każdej metody, że nie został zainicjowany. Jednak initialize()metoda zastępuje obiekt implementacji w pełni zbudowanym obiektem.

class FooManager {
  private CtorArgs ctorArgs;
  private Impl impl;

  public FooManager(CtorArgs ctorArgs) {
    this.ctorArgs = ctorArgs;
    this.impl = new UninitializedImpl();
  }

  public void initialize(InitArgs initArgs) {
    impl = new MainImpl(ctorArgs, initArgs);
  }

  public X foo() { return impl.foo(); }
  public Y bar() { return impl.bar(); }
}

interface Impl {
  X foo();
  Y bar();
}

class UninitializedImpl implements Impl {
  public X foo() { throw ...; }
  public Y bar() { throw ...; }
}

class MainImpl implements Impl {
  public MainImpl(CtorArgs c, InitArgs i);
  public X foo() { ... }
  public Y bar() { ... }
}

Wyodrębnia to główne zachowanie klasy do MainImpl.

amon
źródło
9
Lubię dwa pierwsze rozwiązania (+1), ale nie sądzę, ostatni fragment z jego powtórzenie metod FooManageri UninitialisedImpljest lepsza niż powtarzanie if (!initialized) throw;.
Bergi
3
@Bergi Niektórzy ludzie za bardzo kochają zajęcia. Chociaż FooManagerma wiele metod, może być łatwiejsze niż potencjalne pominięcie niektórych if (!initialized)kontroli. Ale w takim przypadku prawdopodobnie wolisz rozbić klasę.
immibis
1
Może i zainicjowany Foo nie wydaje się lepszy od zainicjowanego Foo też dla mnie, ale daje +1 za podanie niektórych opcji / pomysłów.
Matthew James Briggs,
7
+1 Fabryka jest prawidłową opcją. Nie można ulepszyć projektu bez jego zmiany, więc IMHO ostatnia odpowiedź na pytanie, jak zrobić połowę, nie pomoże OP.
Nathan Cooper
6
@Bergi Uważam, że technika przedstawiona w ostatnim rozdziale jest niezwykle przydatna (patrz także technika refaktoryzacji „Zamień warunki warunkowe poprzez polimorfizm” oraz wzorzec stanu). Łatwo zapomnieć o sprawdzeniu w jednej metodzie, jeśli używamy if / else, o wiele trudniej jest zapomnieć o implementacji metody wymaganej przez interfejs. Co najważniejsze, MainImpl odpowiada dokładnie obiektowi, w którym metoda inicjalizacji łączy się z konstruktorem. Oznacza to, że implementacja MainImpl może korzystać z silniejszych gwarancji, jakie daje, co upraszcza główny kod. …
amon
79

Najbardziej skutecznym i pomocnym sposobem zapobiegania „niewłaściwemu” użyciu obiektu przez klientów jest uniemożliwienie go.

Najprostszym rozwiązaniem jest połączenie Initializez konstruktorem. W ten sposób obiekt nigdy nie będzie dostępny dla klienta w stanie niezainicjowanym, więc błąd nie jest możliwy. Jeśli nie możesz zainicjować samego konstruktora, możesz utworzyć metodę fabryczną. Na przykład, jeśli twoja klasa wymaga rejestracji określonych zdarzeń, możesz wymagać detektora zdarzeń jako parametru w sygnaturze konstruktora lub metody fabrycznej.

Jeśli musisz mieć dostęp do niezainicjowanego obiektu przed inicjalizacją, możesz mieć zaimplementowane dwa stany jako osobne klasy, więc zaczynasz od UnitializedFooManagerinstancji, która ma Initialize(...)metodę, która zwraca InitializedFooManager. Metody, które można wywołać tylko w stanie zainicjowanym, istnieją tylko na InitializedFooManager. W razie potrzeby to podejście można rozszerzyć na wiele stanów.

W porównaniu z wyjątkami środowiska wykonawczego reprezentowanie stanów jako odrębnych klas wymaga więcej pracy, ale daje także gwarancję czasu kompilacji, że nie wywołujesz metod, które są nieprawidłowe dla stanu obiektu, i dokumentuje przejścia stanu w kodzie jaśniej.

Ale ogólnie rzecz biorąc, idealnym rozwiązaniem jest zaprojektowanie klas i metod bez ograniczeń, takich jak wymaganie od ciebie wywoływania metod w określonym czasie i określonej kolejności. Nie zawsze jest to możliwe, ale w wielu przypadkach można tego uniknąć, stosując odpowiedni wzór.

Jeśli masz złożone sprzężenie czasowe (kilka metod musi być wywoływanych w określonej kolejności), jednym rozwiązaniem może być odwrócenie kontroli , więc tworzysz klasę, która wywołuje metody w odpowiedniej kolejności, ale używa metod szablonów lub zdarzeń aby umożliwić klientowi wykonywanie niestandardowych operacji na odpowiednich etapach przepływu. W ten sposób odpowiedzialność za wykonywanie operacji w odpowiedniej kolejności jest przenoszona z klienta na samą klasę.

Prosty przykład: masz obiekt File-object, który pozwala czytać z pliku. Jednak klient musi zadzwonić, Openzanim ReadLinebędzie można wywołać metodę, i pamiętaj, aby zawsze wywoływać Close(nawet jeśli wystąpi wyjątek), po czym ReadLinenie można już wywoływać metody. To jest sprzężenie czasowe. Można tego uniknąć, stosując jedną metodę, która przyjmuje jako wywołanie wywołanie zwrotne lub delegację. Metoda zarządza otwarciem pliku, wywołaniem zwrotnym, a następnie zamknięciem pliku. Może przekazać odrębny interfejs z Readmetodą do wywołania zwrotnego. W ten sposób klient nie może zapomnieć o wywołaniu metod we właściwej kolejności.

Interfejs ze sprzężeniem czasowym:

class File {
     /// Must be called on a closed file.
     /// Remember to always call Close() when you are finished
     public void Open();

     /// must be called on on open file
     public string ReadLine();

     /// must be called on an open file
     public void Close();
}

Bez sprzężenia czasowego:

class File {
    /// Opens the file, executes the callback, and closes the file again.
    public void Consume(Action<IOpenFile> callback);
}

interface IOpenFile {
    string ReadLine();
}

Bardziej ciężkim rozwiązaniem byłoby zdefiniowanie Filejako klasy abstrakcyjnej, która wymaga implementacji (chronionej) metody, która zostanie wykonana na otwartym pliku. Jest to nazywane wzorcem metody szablonu .

abstract class File {
    /// Opens the file, executes ConsumeOpenFile(), and closes the file again.
    public void Consume();

    /// override this
    abstract protected ConsumeOpenFile();

    /// call this from your ConsumeOpenFile() implementation
    protected string ReadLine();
}

Zaleta jest taka sama: klient nie musi pamiętać, aby wywoływać metody w określonej kolejności. Po prostu niemożliwe jest wywoływanie metod w niewłaściwej kolejności.

JacquesB
źródło
2
Pamiętam też przypadki, w których po prostu nie można było przejść od konstruktora, ale nie mam teraz żadnego przykładu do pokazania
Gil Sand
2
Przykład opisuje teraz wzór fabryczny - ale pierwsze zdanie faktycznie opisuje paradygmat RAII . Którą z nich ma polecić ta odpowiedź?
Ext3h
11
@ Ext3h Nie sądzę, aby wzór fabryczny i RAII wzajemnie się wykluczały.
Pieter B
@PieterB Nie, nie są, fabryka może zapewnić RAII. Ale tylko z dodatkową implikacją, że argument / konstruktor argumentów (wywoływany UnitializedFooManager) nie może współużytkować stanu z instancjami ( InitializedFooManager), tak jak Initialize(...)ma to rzeczywiście Instantiate(...)znaczenie semantyczne. W przeciwnym razie ten przykład prowadzi do nowych problemów, jeśli programista użyje dwukrotnie tego samego szablonu. I to nie jest możliwe, aby zapobiec / zweryfikować statycznie, jeśli język nie obsługuje movesemantyki w celu niezawodnego wykorzystania szablonu.
Ext3h
2
@ Ext3h: Polecam pierwsze rozwiązanie, ponieważ jest najprostsze. Ale jeśli klient musi mieć dostęp do obiektu przed wywołaniem opcji Inicjuj, nie możesz go użyć, ale możesz użyć drugiego rozwiązania.
JacquesB
39

Zwykle sprawdzałbym po inicjalizacji i rzucałbym (powiedzmy), IllegalStateExceptionjeśli spróbujesz użyć go bez inicjalizacji.

Jeśli jednak chcesz być bezpieczny w czasie kompilacji (a jest to godne pochwały i preferowane), dlaczego nie traktować inicjalizacji jako metody fabrycznej zwracającej skonstruowany i zainicjowany obiekt np.

ComponentBuilder builder = new ComponentBuilder();
Component forClients = builder.initialise(); // the 'Component' is what you give your clients

i tak Ci kontrolować tworzenie obiektów i cykl życia, a klienci uzyskać Componentw wyniku swojej inicjalizacji. To skutecznie leniwa instancja.

Brian Agnew
źródło
1
Dobra odpowiedź - 2 dobre opcje w ładnej, zwięzłej odpowiedzi. Inne odpowiedzi powyżej obecnej gimnastyki systemowej, które są (teoretycznie) ważne; ale w większości przypadków te 2 prostsze opcje są idealnymi praktycznymi wyborami.
Thomas W
1
Uwaga: W .NET InvalidOperationException jest promowany przez Microsoft jako to, co nazywamy IllegalStateException.
miroxlav 16.04.16
1
Nie głosuję za odrzuceniem tej odpowiedzi, ale nie jest to naprawdę dobra odpowiedź. Po prostu rzucając IllegalStateExceptionlub InvalidOperationExceptionnieoczekiwanie, użytkownik nigdy nie zrozumie, co zrobił źle. Te wyjątki nie powinny stać się obejściem w naprawianiu błędów projektowych.
displayName
Zauważysz, że zgłaszanie wyjątków jest jedną z możliwych opcji, i kontynuuję opracowywanie mojej preferowanej opcji - dzięki czemu czas kompilacji jest bezpieczny. Zredagowałem moją odpowiedź, aby to podkreślić
Brian Agnew
14

Oderwę się trochę od innych odpowiedzi i nie zgodzę się z tym: nie da się na nie odpowiedzieć bez znajomości języka, w którym pracujesz. Czy jest to opłacalny plan, czy też właściwe „ostrzeżenie” Twoi użytkownicy zależą całkowicie od mechanizmów udostępnianych przez Twój język i konwencji, których inni programiści tego języka używają.

Jeśli masz FooManagerw Haskell, przestępstwem byłoby zezwolenie użytkownikom na zbudowanie takiego, który nie jest w stanie zarządzać Foos, ponieważ system typów sprawia, że ​​jest to takie proste i są to konwencje, których oczekują programiści Haskell.

Z drugiej strony, jeśli piszesz w C, Twoi współpracownicy mieliby pełne prawo do wycofania się z ciebie i zastrzelenia cię za zdefiniowanie osobnych struct FooManageri struct UninitializedFooManagertypów, które obsługują różne operacje, ponieważ prowadziłoby to do niepotrzebnie złożonego kodu z bardzo niewielką korzyścią .

Jeśli piszesz w Pythonie, nie ma mechanizmu pozwalającego Ci to zrobić.

Prawdopodobnie nie piszesz w języku Haskell, Python lub C, ale są to przykładowe przykłady pod względem tego, ile / jak mało pracy oczekuje system typów i jaki jest w stanie wykonać.

Podążaj za rozsądnymi oczekiwaniami programisty w swoim języku i oprzyj się nadmiernej inżynierii rozwiązania, które nie ma naturalnej, idiomatycznej implementacji (chyba że błąd jest tak łatwy do popełnienia i tak trudny do uchwycenia, że ​​warto przejść w skrajność długości, aby było to niemożliwe). Jeśli nie masz wystarczającego doświadczenia w swoim języku, aby ocenić, co jest rozsądne, postępuj zgodnie z radą kogoś, kto zna to lepiej niż ty.

Patrick Collins
źródło
Jestem trochę zdezorientowany: „Jeśli piszesz w Pythonie, nie ma mechanizmu pozwalającego Ci to zrobić”. Python ma konstruktory i tworzenie metod fabrycznych jest dość proste. Więc może nie rozumiem, co rozumiesz przez „to”?
AL Flanagan
3
Przez „to” miałem na myśli błąd podczas kompilacji, w przeciwieństwie do błędu w czasie wykonywania.
Patrick Collins,
Wystarczy przeskoczyć, żeby wspomnieć o Pythonie 3.5, który jest obecną wersją, ma typy przez system typów wtykowych. Zdecydowanie możliwe jest spowodowanie błędu Python przed uruchomieniem kodu tylko dlatego, że został zaimportowany. Do wersji 3.5 było to jednak całkowicie poprawne.
Benjamin Gruenbaum,
OK Spóźnione +1. Nawet zmieszany był to bardzo wnikliwy.
AL Flanagan 18.04.16
Podoba mi się twój styl Patrick.
Gil Sand,
4

Ponieważ wydaje się, że nie chcesz wysyłać testu kodu do klienta (ale wydaje się, że możesz to zrobić dla programisty), możesz użyć funkcji asercji , jeśli są one dostępne w twoim języku programowania.

W ten sposób masz kontrole w środowisku programistycznym (i wszelkie testy, które inny programista mógłby nazwać WILL, nie da się przewidzieć), ale nie wyślesz kodu do klienta, ponieważ twierdzenia (przynajmniej w Javie) są kompilowane selektywnie.

Tak więc klasa Java korzystająca z tego wygląda następująco:

/** For some reason, you have to call init and connect before the manager works. */
public class FooManager {
   private int state = 0;

   public FooManager () {
   }

   public synchronized void init() {
      assert state==0 : "you called init twice.";
      // do something
      state = 1;
   }

   public synchronized void connect() {
      assert state==1 : "you called connect before init, or connect twice.";
      // do something
      state = 2;
   }

   public void someWork() {
      assert state==2 : "You did not properly init FooManager. You need to call init and connect first.";
      // do the actual work.
   }
}

Asercje są doskonałym narzędziem do sprawdzania stanu środowiska uruchomieniowego programu, którego potrzebujesz, ale w rzeczywistości nie oczekuj, że ktoś zrobi coś złego w prawdziwym środowisku.

Są też dość szczupłe i nie zajmują dużych części if () throw ... składnia, nie trzeba ich łapać itp.

Angelo Fuchs
źródło
3

Patrząc na ten problem bardziej ogólnie niż obecne odpowiedzi, które skupiły się głównie na inicjalizacji. Rozważ obiekt, który będzie miał dwie metody, a()i b(). Wymagane jest, aby a()zawsze wywoływać wcześniej b(). Możesz utworzyć sprawdzanie czasu kompilacji, że dzieje się tak, zwracając nowy obiekt a()i przenosząc b()go do nowego obiektu zamiast do oryginalnego. Przykładowa implementacja:

class SomeClass
{
   private int valueRequiredByMethodB;

   public IReadyForB a (int v) { valueRequiredByMethodB = v; return new ReadyForB(this); }

   public interface IReadyForB { public void b (); }

   private class ReadyForB : IReadyForB
   {
      SomeClass owner;
      private ReadyForB (SomeClass owner) { this.owner = owner; }
      public void b () { Console.WriteLine (owner.valueRequiredByMethodB); }
   }
}

Teraz niemożliwe jest wywołanie b () bez uprzedniego wywołania a (), ponieważ jest ono implementowane w interfejsie, który jest ukryty, dopóki nie zostanie wywołane a (). Trzeba przyznać, że jest to dość duży wysiłek, więc zwykle nie korzystałbym z tego podejścia, ale są sytuacje, w których może on być korzystny (szczególnie, jeśli twoja klasa będzie ponownie wykorzystywana w wielu sytuacjach przez programistów, którzy mogą nie być zaznajomiony z jego implementacją lub w kodzie, w którym niezawodność ma kluczowe znaczenie). Zauważ również, że jest to uogólnienie wzorca konstruktora, jak sugeruje wiele istniejących odpowiedzi. Działa prawie w ten sam sposób, jedyną prawdziwą różnicą jest to, gdzie dane są przechowywane (w oryginalnym obiekcie, a nie w zwróconym) i kiedy jest przeznaczone do użycia (w dowolnym momencie, a nie tylko podczas inicjalizacji).

Jules
źródło
2

Kiedy implementuję klasę podstawową, która wymaga dodatkowych informacji inicjalizacyjnych lub łączy z innymi obiektami, zanim stanie się użyteczna, staram się uczynić tę klasę podstawową abstrakcyjną, a następnie zdefiniować kilka metod abstrakcyjnych w tej klasie, które są używane w przepływie klasy podstawowej (np. abstract Collection<Information> get_extra_information(args);i abstract Collection<OtherObjects> get_other_objects(args);które zgodnie z umową dziedziczenia muszą zostać zaimplementowane przez konkretną klasę, zmuszając użytkownika tej klasy podstawowej do dostarczenia wszystkich rzeczy wymaganych przez klasę podstawową.

Tak więc, kiedy wdrażam klasę podstawową, natychmiast i wyraźnie wiem, co muszę napisać, aby klasa podstawowa zachowała się poprawnie, ponieważ po prostu muszę zaimplementować metody abstrakcyjne i to wszystko.

EDYCJA: aby wyjaśnić, jest to prawie to samo, co dostarczanie argumentów do konstruktora klasy podstawowej, ale implementacje metod abstrakcyjnych pozwalają implementacji przetwarzać argumenty przekazywane do wywołań metod abstrakcyjnych. Lub nawet w przypadku braku argumentów, może być nadal użyteczne, jeśli zwracana wartość metody abstrakcyjnej zależy od stanu, który można zdefiniować w treści metody, co nie jest możliwe, gdy zmienna jest przekazywana jako argument do konstruktor. Oczywiście nadal możesz przekazać argument, który będzie zachowywał się dynamicznie w oparciu o tę samą zasadę, jeśli wolisz używać kompozycji zamiast dziedziczenia.

klaar
źródło
2

Odpowiedź brzmi: tak, nie i czasami. :-)

Niektóre z opisanych przez Ciebie problemów można łatwo rozwiązać, przynajmniej w wielu przypadkach.

Sprawdzanie w czasie kompilacji jest z pewnością lepsze niż sprawdzanie w czasie wykonywania, co jest lepsze niż brak sprawdzania w ogóle.

Czas działania RE:

Wymuszanie, aby funkcje były wywoływane w określonej kolejności itp., Jest przynajmniej koncepcyjnie proste przy użyciu kontroli w czasie wykonywania. Wystarczy wstawić flagi, które mówią, która funkcja została uruchomiona, i niech każda funkcja zaczyna się od czegoś takiego jak „jeśli nie warunek wstępny-1 lub warunek wstępny-2, to wyrzuć wyjątek”. Jeśli kontrole stają się skomplikowane, możesz wypchnąć je do funkcji prywatnej.

Możesz sprawić, by niektóre rzeczy działały automatycznie. Na przykład programiści często mówią o „leniwej populacji” obiektu. Podczas tworzenia instancji ustawiasz flagę wypełnioną na wartość false, lub odwołanie do jakiegoś kluczowego obiektu na wartość null. Następnie, gdy wywoływana jest funkcja, która potrzebuje odpowiednich danych, sprawdza flagę. Jeśli ma wartość false, wypełnia dane i ustawia flagę na true. Jeśli to prawda, to po prostu zakłada, że ​​dane tam są. Możesz zrobić to samo z innymi warunkami wstępnymi. Ustaw flagę w konstruktorze lub jako wartość domyślną. Następnie, gdy osiągniesz punkt, w którym powinna zostać wywołana jakaś funkcja warunku wstępnego, jeśli jeszcze nie została wywołana, wywołaj ją. Oczywiście działa to tylko wtedy, gdy masz dane do wywołania go w tym momencie, ale w wielu przypadkach możesz zawrzeć dowolne wymagane dane w parametrach funkcji „

Czas kompilacji RE:

Jak powiedzieli inni, jeśli trzeba zainicjować, zanim będzie można użyć obiektu, wstawi to inicjację do konstruktora. To dość typowa rada dla OOP. Jeśli to w ogóle możliwe, należy zmusić konstruktora do utworzenia prawidłowej, użytecznej instancji obiektu.

Widzę dyskusję na temat tego, co należy zrobić, jeśli trzeba przekazać odniesienia do obiektu przed jego zainicjowaniem. Nie mogę wymyślić prawdziwego przykładu tego z głowy, ale przypuszczam, że to może się zdarzyć. Rozwiązania zostały zasugerowane, ale rozwiązania, które widziałem tutaj i które mogę wymyślić, są nieuporządkowane i komplikują kod. W pewnym momencie musisz zapytać: czy warto tworzyć brzydki, trudny do zrozumienia kod, abyśmy mogli sprawdzać w czasie kompilacji, a nie w czasie wykonywania? Czy też tworzę dużo pracy dla siebie i innych dla celu, który jest miły, ale nie wymagany?

Jeśli dwie funkcje powinny być zawsze uruchamiane razem, prostym rozwiązaniem jest uczynienie z nich jednej funkcji. Jak gdyby za każdym razem, gdy dodajesz reklamę do strony, powinieneś również dodać do niej moduł obsługi metryk, a następnie wstaw kod, aby dodać moduł obsługi metryk wewnątrz funkcji „dodaj reklamę”.

Niektóre rzeczy byłyby prawie niemożliwe do sprawdzenia w czasie kompilacji. Jak wymóg, że pewna funkcja nie może być wywołana więcej niż raz. (Napisałem wiele takich funkcji. Niedawno napisałem funkcję, która wyszukuje pliki w magicznym katalogu, przetwarza znalezione pliki, a następnie usuwa je. Oczywiście po usunięciu pliku nie można go ponownie uruchomić. Itd.) Nie znam żadnego języka, który ma funkcję, która pozwala zapobiec dwukrotnemu wywołaniu funkcji w tej samej instancji podczas kompilacji. Nie wiem, w jaki sposób kompilator mógł ostatecznie dowiedzieć się, że tak się dzieje. Wszystko, co możesz zrobić, to poddać się kontroli czasu wykonywania. To szczególnie kontrola czasu pracy jest oczywista, prawda? msgstr "jeśli już-tu-tutaj = prawda, to wyrzuć wyjątek, inny ustawiony już-już-tutaj = prawda"

RE niemożliwe do wyegzekwowania

Często zdarza się, że klasa wymaga czyszczenia po zakończeniu: zamykania plików lub połączeń, zwalniania pamięci, zapisywania wyników końcowych w bazie danych itp. Nie ma łatwego sposobu na wymuszenie tego w czasie kompilacji lub czas pracy. Myślę, że większość języków OOP ma pewien rodzaj funkcji „finalizatora”, która jest wywoływana, gdy instancja jest zbierana w pamięci, ale myślę, że większość twierdzi również, że nie mogą zagwarantować, że to się kiedykolwiek uruchomi. Języki OOP często zawierają funkcję „dispose” z jakimś przepisem umożliwiającym ustawienie zakresu użycia instancji, klauzulę „using” lub „with”, a kiedy program wychodzi z tego zakresu, uruchamiane jest dispose. Wymaga to jednak od programisty użycia odpowiedniego specyfikatora zakresu. Nie zmusza programisty do robienia tego dobrze,

W przypadkach, w których nie można zmusić programisty do prawidłowego korzystania z klasy, staram się, aby program wybuchł, jeśli zrobi to źle, zamiast podawać nieprawidłowe wyniki. Prosty przykład: widziałem wiele programów, które inicjują wiązkę danych do wartości fikcyjnych, dzięki czemu użytkownik nigdy nie uzyska wyjątku zerowego, nawet jeśli nie wywoła funkcji w celu prawidłowego zapełnienia danych. I zawsze zastanawiam się dlaczego. Robisz wszystko, aby trudniej było znaleźć błędy. Jeśli programista nie zadzwoni do funkcji „ładuj dane”, a następnie beztrosko spróbuje użyć danych, otrzyma wyjątek wskaźnika zerowego, wyjątek ten szybko pokaże mu, gdzie jest problem. Ale jeśli ukryjesz go, czyniąc dane pustymi lub zerowymi w takich przypadkach, program może uruchomić się do końca, ale wygenerować niedokładne wyniki. W niektórych przypadkach programista może nawet nie zdawać sobie sprawy, że wystąpił problem. Jeśli to zauważy, być może będzie musiał przejść długą ścieżkę, aby znaleźć miejsce, w którym popełnił błąd. Lepiej wcześniej zawieść, niż odważnie kontynuować.

Ogólnie rzecz biorąc, zawsze dobrze jest, gdy niewłaściwe użycie obiektu jest po prostu niemożliwe. Dobrze jest, jeśli funkcje są zbudowane w taki sposób, że programiści nie mogą po prostu wykonywać nieprawidłowych wywołań lub jeśli nieprawidłowe wywołania generują błędy podczas kompilacji.

Ale jest też punkt, w którym musisz powiedzieć: programista powinien przeczytać dokumentację dotyczącą funkcji.

Sójka
źródło
1
Jeśli chodzi o wymuszanie czyszczenia, istnieje wzorzec, którego można użyć, gdy zasób można przydzielić tylko przez wywołanie określonej metody (np. W typowym języku OO może mieć prywatnego konstruktora). Ta metoda przydziela zasób, wywołuje funkcję (lub metodę interfejsu), która jest do niego przekazywana z odniesieniem do zasobu, a następnie niszczy zasób po powrocie tej funkcji. Oprócz nagłego zakończenia wątku, ten wzór zapewnia, że ​​zasób jest zawsze niszczony. Jest to powszechne podejście w językach funkcjonalnych, ale widziałem również, że jest stosowane w systemach OO z dobrym skutkiem.
Jules
@Jules znany również jako
Benjamin Gruenbaum,
2

Tak, twoje instynkty są precyzyjne. Wiele lat temu pisałem artykuły w czasopismach (trochę jak to miejsce, ale na martwym drzewie i wydawane raz w miesiącu), omawiając debugowanie , abugowanie i antybugowanie .

Jeśli uda Ci się nakłonić kompilator do wychwycenia niewłaściwego użycia, jest to najlepszy sposób. Dostępne funkcje językowe zależą od języka i nie został określony. W każdym razie szczegółowe techniki byłyby szczegółowe dla poszczególnych pytań na tej stronie.

Ale test wykrywający użycie w czasie kompilacji zamiast w czasie wykonywania jest naprawdę tym samym rodzajem testu: nadal musisz wiedzieć, jak najpierw wywołać odpowiednie funkcje i używać ich we właściwy sposób.

Zaprojektowanie komponentu tak, aby nawet to nie było problemem, jest o wiele bardziej subtelne i podoba mi się, że bufor jest jak przykład elementu . Część 4 (opublikowana dwadzieścia lat temu! Wow!) Zawiera przykład z dyskusją, który jest bardzo podobny do twojego przypadku: Tej klasy należy używać ostrożnie, ponieważ bufor () może nie zostać wywołany po rozpoczęciu korzystania z read (). Raczej należy go użyć tylko raz, natychmiast po zakończeniu budowy. Gdyby klasa zarządzała buforem automatycznie i niewidocznie dla osoby dzwoniącej, uniknęłaby problemu koncepcyjnie.

Pytasz, czy testy można wykonać w czasie wykonywania, aby zapewnić prawidłowe użytkowanie, czy powinieneś to zrobić?

Powiedziałbym tak . Pozwoli to zaoszczędzić Twojemu zespołowi wiele pracy związanej z debugowaniem pewnego dnia, gdy kod zostanie utrzymany, a określone użycie zostanie zakłócone. Testowanie można skonfigurować jako formalny zestaw ograniczeń i niezmienników, które można przetestować. Nie oznacza to, że narzut związany z dodatkowym miejscem do śledzenia stanu i sprawdzania jest zawsze pozostawiony dla każdego połączenia. Jest w klasie, więc można go nazwać, a kod dokumentuje rzeczywiste ograniczenia i założenia.

Być może sprawdzanie odbywa się tylko w kompilacji debugowania.

Być może kontrola jest złożona (na przykład kontrola stosu), ale jest obecna i może być wykonywana raz na jakiś czas lub dodawane tu i tam wywołania, gdy kod jest przetwarzany i pojawia się jakiś problem, lub w celu wykonania konkretnych testów upewnij się, że nie ma takiego problemu w programie testów jednostkowych lub testach integracyjnych , który prowadzi do tej samej klasy.

Możesz zdecydować, że koszty ogólne są trywialne. Jeśli klasa zapisuje operacje we / wy, dodatkowy bajt stanu i test na taki stan to nic. Typowe klasy iostream sprawdzają, czy strumień jest w złym stanie.

Twoje instynkty są dobre. Tak trzymaj!

JDługosz
źródło
0

Zasada ukrywania informacji (enkapsulacji) polega na tym, że podmioty spoza klasy nie powinny wiedzieć więcej niż jest to wymagane do prawidłowego korzystania z tej klasy.

Twoja sprawa wygląda na to, że próbujesz sprawić, by obiekt klasy działał poprawnie, bez informowania użytkowników zewnętrznych o klasie. Ukryłeś więcej informacji, niż powinieneś.

  • Albo wyraźnie poproś o te wymagane informacje w konstruktorze;
  • Lub utrzymuj konstruktory w stanie nienaruszonym i zmodyfikuj metody, aby wywoływać metody, musisz podać brakujące dane.

Słowa mądrości:

* Tak czy inaczej, musisz przeprojektować klasę i / lub konstruktor i / lub metody.

* Nie projektując poprawnie i głodząc klasę na podstawie prawidłowych informacji, ryzykujesz, że klasa pęknie w nie jednym, ale potencjalnie wielu miejscach.

* Jeśli źle napisałeś wyjątki i komunikaty o błędach, twoja klasa będzie rozdawać jeszcze więcej o sobie.

* Na koniec, jeśli osoba postronna powinna wiedzieć, że musi zainicjować a, b i c, a następnie wywołać d (), e (), f (int), to masz przeciek w swojej abstrakcji.

wyświetlana nazwa
źródło
0

Ponieważ określiłeś, że używasz C #, realnym rozwiązaniem twojej sytuacji jest wykorzystanie Roslyn Code Analyzers . Umożliwi to natychmiastowe wykrycie naruszeń, a nawet zaproponowanie poprawek kodu .

Jednym ze sposobów wdrożenia byłoby udekorowanie metod tymczasowo połączonych za pomocą atrybutu określającego kolejność, w której metody muszą być nazywane 1 . Gdy analizator znajdzie te atrybuty w klasie, sprawdza, czy metody są wywoływane w kolejności. Dzięki temu Twoja klasa będzie wyglądać mniej więcej tak:

public abstract class Foo
{
   [TemporallyCoupled(1)]
   public abstract void Init();

   [TemporallyCoupled(2)]
   public abstract void DoBar();

   [TemporallyCoupled(3)]
   public abstract void Close();
}

1: Nigdy nie napisałem Roslyn Code Analyzer, więc może to nie być najlepsza implementacja. Pomysł użycia Roslyn Code Analyzer do sprawdzenia, czy interfejs API jest używany prawidłowo, to jednak dźwięk w 100%.

Erik
źródło
-1

Sposób, w jaki rozwiązałem ten problem wcześniej, był z prywatnym konstruktorem i MakeFooManager()metodą statyczną w klasie. Przykład:

public class FooManager
{
     public string Foo { get; private set; }
     public string Bar { get; private set; }

     private FooManager(string foo, string bar)
     {
         Foo = foo;
         Bar = bar;
     }

     public static FooManager MakeFooManager(string foo, string bar)
     {
         // Can do other checks here too.
         if(foo == null || bar == null)
         {
             return null;
         }
         else
         {
             return new FooManager(foo, bar);
         }
     }
}

Ponieważ konstruktor jest zaimplementowany, nikt nie może utworzyć instancji FooManagerbez przejścia MakeFooManager().

WillB3
źródło
1
wydaje się to jedynie powtórzyć punkt poczyniony i wyjaśniony w poprzedniej odpowiedzi, która została opublikowana kilka godzin wcześniej
komnata
1
czy ma to jakąkolwiek przewagę nad zwykłym sprawdzaniem wartości zerowej w ctor? wygląda na to, że właśnie stworzyłeś metodę, która nie robi nic poza opakowaniem innej metody. dlaczego?
sara,
Ponadto, dlaczego zmienne foo i zmienne prętowe?
Patrick M
-1

Istnieje kilka podejść:

  1. Spraw, aby klasa była łatwa w użyciu, a trudna w użyciu. Niektóre inne odpowiedzi koncentrują się wyłącznie na tym punkcie, więc po prostu wspomnę o RAII i wzorze konstruktora .

  2. Pamiętaj, jak używać komentarzy. Jeśli klasa sama się wyjaśnia, nie pisz komentarzy. * W ten sposób ludzie będą częściej czytać twoje komentarze, kiedy klasa ich naprawdę potrzebuje. A jeśli masz jeden z tych rzadkich przypadków, w których klasa jest trudna w użyciu i potrzebuje komentarza, dołącz przykłady.

  3. Użyj twierdzeń w kompilacjach debugowania.

  4. Zgłaszaj wyjątki, jeśli użycie klasy jest nieprawidłowe.

* Oto rodzaje komentarzy, których nie powinieneś pisać:

// gets Foo
GetFoo();
Piotr
źródło