Jak zaprojektować interfejs, aby było jasne, które właściwości mogą zmienić ich wartość, a które pozostaną stałe?

12

Mam problem projektowy dotyczący właściwości .NET.

interface IX
{
    Guid Id { get; }
    bool IsInvalidated { get; }
    void Invalidate();
}

Problem:

Ten interfejs ma dwie właściwości tylko do odczytu Idi IsInvalidated. Jednak fakt, że są one tylko do odczytu, sam w sobie nie gwarantuje, że ich wartości pozostaną stałe.

Powiedzmy, że moim zamiarem było wyjaśnienie, że…

  • Id reprezentuje stałą wartość (która dlatego może być bezpiecznie buforowana), natomiast
  • IsInvalidatedmoże zmienić swoją wartość w czasie życia IXobiektu (i dlatego nie powinien być buforowany).

Jak mogę zmodyfikować, interface IXaby umowa była wystarczająco wyraźna?

Moje trzy próby rozwiązania:

  1. Interfejs jest już dobrze zaprojektowany. Obecność wywoływanej metody Invalidate()pozwala programiście wnioskować, że IsInvalidatedmoże mieć na nią wpływ wartość właściwości o podobnej nazwie .

    Ten argument obowiązuje tylko w przypadkach, w których metoda i właściwość są podobnie nazwane.

  2. Rozszerz ten interfejs o wydarzenie IsInvalidatedChanged:

    bool IsInvalidated { get; }
    event EventHandler IsInvalidatedChanged;

    Obecność …Changedzdarzenia dla IsInvalidatedoznacza, że ​​ta właściwość może zmienić swoją wartość, a brak podobnego zdarzenia dla Idjest obietnicą, że ta właściwość nie zmieni swojej wartości.

    Podoba mi się to rozwiązanie, ale jest wiele dodatkowych rzeczy, które w ogóle mogą się nie przydać.

  3. Zastąp właściwość IsInvalidatedmetodą IsInvalidated():

    bool IsInvalidated();

    To może być zbyt subtelna zmiana. Powinna to być wskazówka, że ​​wartość jest obliczana świeżo za każdym razem - co nie byłoby konieczne, gdyby była stała. W temacie MSDN „Wybieranie właściwości i metod” można o tym powiedzieć:

    Użyj metody, a nie właściwości, w następujących sytuacjach. […] Operacja zwraca inny wynik przy każdym wywołaniu, nawet jeśli parametry się nie zmieniają.

Jakich odpowiedzi oczekuję?

  • Najbardziej interesują mnie zupełnie inne rozwiązania problemu, wraz z wyjaśnieniem, w jaki sposób pokonały moje powyższe próby.

  • Jeśli moje próby są logicznie błędne lub mają znaczące wady, o których jeszcze nie wspomniano, tak, że pozostaje tylko jedno rozwiązanie (lub żadne), chciałbym usłyszeć o tym, gdzie popełniłem błąd.

    Jeśli wady są niewielkie, a po ich uwzględnieniu pozostaje więcej niż jedno rozwiązanie, prosimy o komentarz.

  • Przynajmniej chciałbym uzyskać informacje zwrotne na temat tego, które jest Twoim preferowanym rozwiązaniem iz jakiego powodu (powodów).

stakx
źródło
Nie IsInvalidatedpotrzebuje private set?
James
2
@James: Niekoniecznie, ani w deklaracji interfejsu, ani w implementacji:class Foo : IFoo { private bool isInvalidated; public bool IsInvalidated { get { return isInvalidated; } } public void Invalidate() { isInvalidated = true; } }
stakx
@James Właściwości w interfejsach nie są takie same jak właściwości automatyczne, mimo że używają tej samej składni.
svick
Zastanawiałem się: czy interfejsy „mają moc” narzucania takiego zachowania? Czy to zachowanie nie powinno być delegowane do każdej implementacji? Myślę, że jeśli chcesz wymusić pewne rzeczy na tym poziomie, powinieneś rozważyć utworzenie klasy abstrakcyjnej, aby podtypy dziedziczyły po niej, zamiast implementacji danego interfejsu. (nawiasem mówiąc, czy moje uzasadnienie ma sens?)
heltonbiker
@heltonbiker Niestety, większość języków (wiem) nie pozwala na wyrażanie takich ograniczeń na typach, ale zdecydowanie powinna . Jest to kwestia specyfikacji, a nie implementacji. W mojej opcji brakuje tylko możliwości wyrażania niezmienników klasowych i post-warunku bezpośrednio w języku. Idealnie, nie powinniśmy nigdy na przykład martwić się o InvalidStateExceptions, ale nie jestem pewien, czy jest to nawet teoretycznie możliwe. Byłoby jednak miło.
proskor

Odpowiedzi:

2

Wolałbym rozwiązanie 3 niż 1 i 2.

Mój problem z rozwiązaniem 1 brzmi : co się stanie, jeśli nie ma Invalidatemetody? Załóżmy interfejs z IsValidwłaściwością, która zwraca DateTime.Now > MyExpirationDate; możesz nie potrzebować SetInvalidtutaj jawnej metody. Co jeśli metoda wpływa na wiele właściwości? Załóżmy, że typ połączenia ma właściwości IsOpeni IsConnected- na oba wpływ ma Closemetoda.

Rozwiązanie 2 : Jeśli jedynym punktem wydarzenia jest poinformowanie programistów, że właściwość o podobnej nazwie może zwracać różne wartości przy każdym wywołaniu, zdecydowanie odradzam to. Powinieneś utrzymywać krótkie i jasne interfejsy; co więcej, nie wszystkie wdrożenia mogą wywołać to zdarzenie. Ponownie wykorzystam mój IsValidprzykład z góry: będziesz musiał wdrożyć Timer i wystrzelić zdarzenie, gdy dotrzesz MyExpirationDate. W końcu, jeśli to zdarzenie jest częścią twojego publicznego interfejsu, użytkownicy interfejsu będą oczekiwać, że to wydarzenie zadziała.

Mówiąc o tych rozwiązaniach: nie są złe. Obecność metody lub zdarzenia BĘDZIE wskazywać, że podobnie nazwana właściwość może zwracać różne wartości przy każdym wywołaniu. Mówię tylko, że same nie wystarczą, aby zawsze przekazać to znaczenie.

Rozwiązanie 3 jest tym, co chciałbym. Jak wspomniałem aviv, może to jednak działać tylko dla programistów C #. Dla mnie jako C # -dev fakt, że IsInvalidatednie jest własnością, natychmiast przekazuje znaczenie „to nie jest zwykły akcesor, coś tu się dzieje”. Nie dotyczy to jednak wszystkich i, jak wskazał MainMa, sama platforma .NET nie jest tutaj spójna.

Jeśli chciałbyś skorzystać z rozwiązania 3, poleciłbym ogłosić, że jest to konwencja i niech cały zespół zastosuje się do niego. Uważam, że dokumentacja jest również niezbędna. Moim zdaniem podpowiedź przy zmienianiu wartości jest dość prosta: „ zwraca wartość true, jeśli wartość jest nadal ważna ”, „ wskazuje, czy połączenie zostało już otwarte ” vs. „ zwraca true, jeśli ten obiekt jest prawidłowy ”. W ogóle nie zaszkodziłoby to, by być bardziej wyraźnym w dokumentacji.

Tak więc moja rada brzmiałaby:

Zadeklaruj rozwiązanie 3 jako konwencję i konsekwentnie go przestrzegaj. Wyjaśnij w dokumentacji właściwości, czy właściwość ma zmieniające się wartości. Programiści pracujący z prostymi właściwościami poprawnie zakładają, że nie zmieniają się (tj. Zmieniają się tylko, gdy stan obiektu jest modyfikowany). Deweloperzy napotykają metodę, która brzmi jak to mogłoby być nieruchomość ( Count(), Length(), IsOpen()) wie, że cos się dzieje i (miejmy nadzieję) przeczytać dokumenty metoda zrozumieć, co robi dokładnie sposób i jak się zachowuje.

enzi
źródło
To pytanie otrzymało bardzo dobre odpowiedzi i szkoda, że ​​mogę zaakceptować tylko jedną z nich. Wybrałem twoją odpowiedź, ponieważ stanowi dobre podsumowanie niektórych kwestii poruszonych w innych odpowiedziach i ponieważ jest to mniej więcej to, co postanowiłem zrobić. Dziękujemy wszystkim, którzy opublikowali odpowiedź!
stakx
8

Istnieje czwarte rozwiązanie: polegać na dokumentacji .

Skąd wiesz, że ta stringklasa jest niezmienna? Po prostu to wiesz, ponieważ przeczytałeś dokumentację MSDN. StringBuilderz drugiej strony jest zmienna, ponieważ dokumentacja ci to mówi.

/// <summary>
/// Represents an index of an element stored in the database.
/// </summary>
private interface IX
{
    /// <summary>
    /// Gets the immutable globally unique identifier of the index, the identifier being
    /// assigned by the database when the index is created.
    /// </summary>
    Guid Id { get; }

    /// <summary>
    /// Gets a value indicating whether the index is invalidated, which means that the
    /// indexed element is no longer valid and should be reloaded from the database. The
    /// index can be invalidated by calling the <see cref="Invalidate()" /> method.
    /// </summary>
    bool IsInvalidated { get; }

    /// <summary>
    /// Invalidates the index, without forcing the indexed element to be reloaded from the
    /// database.
    /// </summary>
    void Invalidate();
}

Twoje trzecie rozwiązanie nie jest złe, ale .NET Framework go nie stosuje . Na przykład:

StringBuilder.Length
DateTime.Now

są właściwościami, ale nie oczekuj, że pozostaną one stałe za każdym razem.

To, co jest konsekwentnie stosowane w samej platformie .NET Framework, to:

IEnumerable<T>.Count()

jest metodą, podczas gdy:

IList<T>.Length

jest własnością. W pierwszym przypadku robienie rzeczy może wymagać dodatkowej pracy, w tym na przykład zapytania do bazy danych. Ta dodatkowa praca może trochę potrwać . W przypadku nieruchomości oczekuje się, że potrwa to krótko : w rzeczywistości długość może się zmienić w trakcie życia listy, ale jej zwrot nie wymaga praktycznie nic.


W przypadku klas samo patrzenie na kod wyraźnie pokazuje, że wartość właściwości nie zmieni się w czasie życia obiektu. Na przykład,

public class Product
{
    private readonly int price;

    public Product(int price)
    {
        this.price = price;
    }

    public int Price
    {
        get
        {
            return this.price;
        }
    }
}

jest jasne: cena pozostanie taka sama.

Niestety, nie można zastosować tego samego wzorca do interfejsu, a biorąc pod uwagę, że C # nie jest wystarczająco ekspresyjny w takim przypadku, dokumentacja (w tym Dokumentacja XML i diagramy UML) powinna zostać użyta do wypełnienia luki.

Arseni Mourzenko
źródło
Nawet wytyczne „brak dodatkowej pracy” nie są stosowane całkowicie konsekwentnie. Na przykład Lazy<T>.Valueobliczenie może zająć bardzo dużo czasu (gdy jest uzyskiwany po raz pierwszy).
svick
1
Moim zdaniem nie ma znaczenia, czy platforma .NET jest zgodna z konwencją rozwiązania 3. Oczekuję, że programiści będą wystarczająco inteligentni, aby wiedzieć, czy pracują z wewnętrznymi typami lub typami ram. Deweloperzy, którzy nie wiedzą, że nie czytają dokumentów, a zatem rozwiązanie 4 również nie pomogłoby. Jeśli rozwiązanie 3 zostanie zaimplementowane jako konwencja firmowa / zespołowa, uważam, że nie ma znaczenia, czy inne interfejsy API również go przestrzegają (choć oczywiście byłoby to bardzo mile widziane).
enzi
4

Moje 2 centy:

Opcja 3: jako nie-C # -er, nie byłby to żadna wskazówka; Jednak sądząc po cytacie, który przyniosłeś, biegli programiści C # powinni wiedzieć, że to coś znaczy. Nadal powinieneś jednak dodawać dokumenty na ten temat.

Opcja 2: Jeśli nie planujesz dodawać wydarzeń do wszystkich rzeczy, które mogą się zmienić, nie idź tam.

Inne opcje:

  • Zmiana nazwy interfejsu IXMutable, aby było jasne, że może on zmienić swój stan. Jedyną niezmienną wartością w twoim przykładzie jest id, która zwykle przyjmuje się, że jest niezmienna nawet w obiektach zmiennych.
  • Nie wspominając o tym. Interfejsy nie są tak dobre w opisywaniu zachowań, jak byśmy tego chcieli; Częściowo dzieje się tak, ponieważ język tak naprawdę nie pozwala opisać takich rzeczy, jak: „Ta metoda zawsze zwraca tę samą wartość dla określonego obiektu” lub „Wywołanie tej metody z argumentem x <0 spowoduje zgłoszenie wyjątku”.
  • Tyle że istnieje mechanizm rozszerzania języka i dostarczania dokładniejszych informacji o konstrukcjach - Adnotacje / Atrybuty . Czasami wymagają trochę pracy, ale pozwalają ci określić dowolnie złożone rzeczy dotyczące twoich metod. Znajdź lub wymyśl adnotację z napisem „Wartość tej metody / właściwości może się zmieniać przez cały okres istnienia obiektu” i dodaj ją do metody. Konieczne może być wykonanie kilku sztuczek, aby metody implementujące mogły je „odziedziczyć”, a użytkownicy mogą potrzebować przeczytać dokument dotyczący tej adnotacji, ale będzie to narzędzie, które pozwoli ci powiedzieć dokładnie to, co chcesz powiedzieć, zamiast podpowiedzi na to.
Aviv
źródło
3

Inną opcją byłoby podzielenie interfejsu: zakładając, że po wywołaniu Invalidate()każdego wywołania IsInvalidatedpowinna zwrócić tę samą wartość true, wydaje się, że nie ma powodu, aby wywoływać IsInvalidatedtę samą część, która została wywołana Invalidate().

Sugeruję więc, abyś sprawdził unieważnienie lub spowodował je, ale wydaje się to nierozsądne. Dlatego sensowne jest zaoferowanie jednego interfejsu zawierającego Invalidate()operację do pierwszej części, a drugiego interfejsu do sprawdzania ( IsInvalidated) drugiej. Ponieważ z podpisu pierwszego interfejsu jest całkiem oczywiste, że spowoduje unieważnienie instancji, pozostałe pytanie (dotyczące drugiego interfejsu) jest rzeczywiście dość ogólne: jak określić, czy dany typ jest niezmienny .

interface Invalidatable
{
    bool IsInvalidated { get; }
}

Wiem, że to nie odpowiada bezpośrednio na pytanie, ale przynajmniej sprowadza się do pytania, jak oznaczyć jakiś typ niezmienny , co jest cichym powszechnym i zrozumiałym problemem. Na przykład, zakładając, że wszystkie typy są zmienne, chyba że zdefiniowano inaczej, można wywnioskować, że drugi interfejs ( IsInvalidated) jest zmienny i dlatego może IsInvalidatedod czasu do czasu zmieniać wartość . Z drugiej strony załóżmy, że pierwszy interfejs wyglądał tak (pseudo-składnia):

[Immutable]
interface IX
{
    Guid Id { get; }
    void Invalidate();
}

Ponieważ jest oznaczony jako niezmienny, wiesz, że identyfikator się nie zmieni. Oczywiście wywołanie nieważności spowoduje zmianę stanu instancji, ale zmiana ta nie będzie możliwa do zaobserwowania za pośrednictwem tego interfejsu.

proskor
źródło
Niezły pomysł! Twierdziłbym jednak, że błędem jest oznaczenie interfejsu, [Immutable]o ile obejmuje on metody, których jedynym celem jest spowodowanie mutacji. Chciałbym przenieść Invalidate()metodę do Invalidatableinterfejsu.
stakx
1
@stakx Nie zgadzam się. :) Myślę, że mylisz pojęcia niezmienności i czystości (brak skutków ubocznych). W rzeczywistości, z punktu widzenia proponowanego interfejsu IX, w ogóle nie obserwuje się mutacji. Za każdym razem, gdy zadzwonisz Id, otrzymasz tę samą wartość za każdym razem. To samo dotyczy Invalidate()(nic nie dostajesz, ponieważ typem zwrotu jest void). Dlatego ten typ jest niezmienny. Oczywiście będziesz mieć efekty uboczne , ale nie będziesz mógł ich zaobserwować przez interfejs IX, więc nie będą miały żadnego wpływu na klientów IX.
proskor
OK, możemy się zgodzić, że IXjest niezmienne. I że są to skutki uboczne; są po prostu rozmieszczone między dwoma podzielonymi interfejsami, tak że klienci nie muszą się tym przejmować (w przypadku IX) lub że jest oczywiste, jaki może być efekt uboczny (w przypadku Invalidatable). Ale dlaczego nie przenieść Invalidatesię do Invalidatable? To uczyniłoby IXniezmiennym i czystym, i Invalidatablenie stałoby się bardziej zmienne, ani bardziej nieczyste (ponieważ jest to już oba z nich).
stakx
(Rozumiem, że w prawdziwym scenariuszu charakter podziału interfejsu prawdopodobnie zależy bardziej od praktycznych przypadków użycia niż od kwestii technicznych, ale staram się w pełni zrozumieć twoje rozumowanie tutaj.)
stakx
1
@stakx Po pierwsze, zapytałeś o dalsze możliwości, aby w jakiś sposób uczynić semantykę interfejsu bardziej wyraźną. Moim pomysłem było zredukowanie problemu do niezmienności, co wymagało podzielenia interfejsu. Ale nie tylko był rozłam wymagane, aby jeden niezmienny interfejsu, to również wielki sens, ponieważ nie ma żadnego powodu, aby wywołać zarówno Invalidate()i IsInvalidatedprzez tego samego konsumenta interfejsu . Jeśli zadzwonisz Invalidate(), powinieneś wiedzieć, że zostanie unieważniony, więc po co to sprawdzać? W ten sposób możesz zapewnić dwa różne interfejsy do różnych celów.
proskor