Użycie struct do wymuszenia sprawdzania poprawności wbudowanego typu

9

Zwykle obiekty domeny mają właściwości, które mogą być reprezentowane przez wbudowany typ, ale których prawidłowe wartości stanowią podzbiór wartości, które mogą być reprezentowane przez ten typ.

W takich przypadkach wartość można zapisać za pomocą wbudowanego typu, ale należy upewnić się, że wartości są zawsze sprawdzane w punkcie wejścia, w przeciwnym razie możemy skończyć z nieprawidłową wartością.

Jednym ze sposobów rozwiązania tego problemu jest przechowywanie wartości jako niestandardowej, structktóra ma jedno private readonlypole bazowe typu wbudowanego i której konstruktor sprawdza podaną wartość. Dzięki temu zawsze możemy być pewni, że użyjemy tylko zweryfikowanych wartości struct.

Możemy również zapewnić operatory rzutowania zi do wbudowanego typu bazowego, aby wartości mogły bezproblemowo wchodzić i wychodzić jako typ bazowy.

Weźmy jako przykład sytuację, w której musimy reprezentować nazwę obiektu domeny, a poprawnymi wartościami są dowolne ciągi o długości od 1 do 255 znaków włącznie. Możemy to przedstawić za pomocą następującej struktury:

public struct ValidatedName : IEquatable<ValidatedName>
{
    private readonly string _value;

    private ValidatedName(string name)
    {
        _value = name;
    }

    public static bool IsValid(string name)
    {
        return !String.IsNullOrEmpty(name) && name.Length <= 255;
    }

    public bool Equals(ValidatedName other)
    {
        return _value == other._value;
    }

    public override bool Equals(object obj)
    {
        if (obj is ValidatedName)
        {
            return Equals((ValidatedName)obj);
        }
        return false;
    }

    public static implicit operator string(ValidatedName x)
    {
        return x.ToString();
    }

    public static explicit operator ValidatedName(string x)
    {
        if (IsValid(x))
        {
            return new ValidatedName(x);
        }
        throw new InvalidCastException();
    }

    public static bool operator ==(ValidatedName x, ValidatedName y)
    {
        return x.Equals(y);
    }

    public static bool operator !=(ValidatedName x, ValidatedName y)
    {
        return !x.Equals(y);
    }

    public override int GetHashCode()
    {
        return _value.GetHashCode();
    }

    public override string ToString()
    {
        return _value;
    }
}

Przykład pokazuje To- stringobsada jak implicitjak to nigdy nie może zabraknąć, ale od- stringobsada jak explicitjak to będzie rzucać za nieprawidłowe wartości, ale oczywiście te mogą zarówno być implicitalbo explicit.

Należy również zauważyć, że można zainicjować tę strukturę tylko za pomocą rzutowania z string, ale można przetestować, czy rzutowanie zakończy się niepowodzeniem za pomocą tej IsValid staticmetody.

Wydaje się, że jest to dobry wzorzec wymuszania sprawdzania poprawności wartości domen, które mogą być reprezentowane przez proste typy, ale nie widzę, aby były często używane lub sugerowane i jestem zainteresowany, dlaczego.

Moje pytanie brzmi zatem: jakie są zalety i wady korzystania z tego wzoru i dlaczego?

Jeśli uważasz, że jest to zły wzór, chciałbym zrozumieć, dlaczego i co uważasz za najlepszą alternatywę.

Uwaga : Pierwotnie zadałem to pytanie na temat przepełnienia stosu, ale zostało ono wstrzymane jako oparte głównie na opiniach (ironicznie subiektywne samo w sobie) - mam nadzieję, że może się tutaj cieszyć większym sukcesem.

Powyżej znajduje się oryginalny tekst, poniżej kilka innych przemyśleń, częściowo w odpowiedzi na odpowiedzi otrzymane tam, zanim zostały zawieszone:

  • Jednym z głównych argumentów podanych w odpowiedziach była ilość kodu płyty kotła niezbędna do powyższego wzoru, zwłaszcza gdy wymaganych jest wiele takich typów. Jednak w obronie tego wzoru można to w dużej mierze zautomatyzować za pomocą szablonów i właściwie nie wydaje mi się to takie złe, ale to tylko moja opinia.
  • Z koncepcyjnego punktu widzenia nie wydaje się dziwne, że podczas pracy z silnie typowanym językiem, takim jak C #, stosowanie silnie typowanej zasady dotyczy tylko wartości złożonych, a nie rozszerzanie jej na wartości, które mogą być reprezentowane przez wystąpienie wbudowany typ?
gmoody1979
źródło
możesz stworzyć szablonową wersję, która przyjmuje bool (T) lambda
maniak ratchet

Odpowiedzi:

4

Jest to dość powszechne w językach w stylu ML, takich jak Standard ML / OCaml / F # / Haskell, gdzie znacznie łatwiej jest tworzyć typy opakowań. Zapewnia dwie korzyści:

  • Pozwala to na wymuszenie na kodzie, że łańcuch przeszedł sprawdzanie poprawności, bez konieczności dbania o samą weryfikację.
  • Pozwala zlokalizować kod weryfikacyjny w jednym miejscu. Jeśli ValidatedNamekiedykolwiek zawiera niepoprawną wartość, wiesz, że błąd dotyczy IsValidmetody.

Jeśli dobrze zastosujesz IsValidmetodę, masz gwarancję, że każda funkcja, która ją otrzyma, ValidatedNamefaktycznie otrzyma poprawną nazwę.

Jeśli potrzebujesz wykonywać operacje na łańcuchach, możesz dodać metodę publiczną, która akceptuje funkcję, która pobiera Ciąg (wartość ValidatedName) i zwraca Ciąg (nowa wartość) i sprawdza poprawność wyniku zastosowania funkcji. To eliminuje podstawową wartość uzyskiwania wartości String i ponownego jej zawijania.

Powiązanym zastosowaniem do zawijania wartości jest śledzenie ich pochodzenia. Np. Interfejsy API systemu operacyjnego oparte na C czasami dają uchwyty zasobów jako liczby całkowite. Możesz owinąć interfejsy API systemu operacyjnego, aby zamiast tego użyć Handlestruktury i zapewnić dostęp do konstruktora tylko tej części kodu. Jeśli kod, który tworzy Handles jest poprawny, zostaną użyte tylko poprawne uchwyty.

Doval
źródło
1

jakie są zalety i wady korzystania z tego wzoru i dlaczego?

Dobrze :

  • Jest samowystarczalny. Zbyt wiele bitów walidacyjnych ma wąsy sięgające w różne miejsca.
  • Pomaga w samodzielnej dokumentacji. Widok metody wymaga ValidatedStringwyraźniejszego określenia semantyki wywołania.
  • Pomaga ograniczyć sprawdzanie poprawności do jednego miejsca, zamiast konieczności duplikowania metod publicznych.

Źle :

  • Oszustwo rzucania jest ukryte. To nie jest idiomatyczny C #, więc może powodować zamieszanie podczas czytania kodu.
  • Rzuca. Posiadanie ciągów niespełniających sprawdzania poprawności nie jest wyjątkowym scenariuszem. Robienie IsValidprzed obsadą jest trochę niewygodne.
  • Nie może ci powiedzieć, dlaczego coś jest nie tak.
  • Wartość domyślna ValidatedStringjest niepoprawna / zweryfikowana.

Widziałem takie rzeczy częściej Useri AuthenticatedUserrzeczy, w których obiekt się zmienia. To może być dobre podejście, choć wydaje się nie na miejscu w C #.

Telastyn
źródło
1
Dzięki, myślę, że twój czwarty „con” jest jak dotąd najbardziej przekonującym argumentem przeciwko nim - użycie domyślnej lub tablicy tego typu może dać niepoprawne wartości (w zależności od tego, czy ciąg zerowy / zerowy jest oczywiście prawidłową wartością). Są to (jak sądzę) jedyne dwa sposoby na uzyskanie niepoprawnej wartości. Ale gdybyśmy NIE użyli tego wzorca, te dwie rzeczy nadal dawałyby nam nieprawidłowe wartości, ale przypuszczam, że przynajmniej wiedzielibyśmy, że należy je zweryfikować. Może to potencjalnie unieważnić podejście, w którym domyślna wartość typu bazowego jest nieprawidłowa dla naszego typu.
gmoody1979,
Wszystkie minusy to raczej problemy z implementacją niż problemy z koncepcją. Dodatkowo uważam, że „wyjątki powinny być wyjątkowe” jest niewyraźną i źle zdefiniowaną koncepcją. Najbardziej pragmatycznym podejściem jest zapewnienie zarówno metody opartej na wyjątkach, jak i nie opartej na wyjątkach i umożliwienie dzwoniącemu wyboru.
Doval
@Doval Zgadzam się, z wyjątkiem przypadków wskazanych w moim innym komentarzu. Chodzi o to, aby mieć pewność, że jeśli mamy ValidatedName, musi ona być poprawna. Dzieje się tak, jeśli domyślna wartość typu bazowego nie jest również prawidłową wartością typu domeny. Jest to oczywiście zależne od domeny, ale bardziej prawdopodobne jest (tak bym pomyślał) dla typów opartych na łańcuchach niż dla typów numerycznych. Wzorzec działa najlepiej, gdy domyślny typ bazowy jest również odpowiedni jako domyślny typ domeny.
gmoody1979,
@Doval - ogólnie się zgadzam. Sama koncepcja jest w porządku, ale skutecznie próbuje wyrafinować typy dopracowania w języku, który ich nie obsługuje. Zawsze pojawią się problemy z implementacją.
Telastyn
Powiedziawszy to, przypuszczam, że możesz sprawdzić wartość domyślną w rzutowaniu „wychodzącym” oraz w dowolnym innym niezbędnym miejscu w ramach metod struktury i wyrzucać, jeśli nie zostanie zainicjowany, ale zaczyna się robić bałagan.
gmoody1979,
0

Twoja droga jest dość ciężka i intensywna. Zazwyczaj definiuję jednostki domeny takie jak:

public class Institution
{
    private Institution() { }

    public Institution(int organizationId, string name)
    {
        OrganizationId = organizationId;            
        Name = name;
        ReplicationKey = Guid.NewGuid();

        new InstitutionValidator().ValidateAndThrow(this);
    }

    public int Id { get; private set; }
    public string Name { get; private set; }        
    public virtual ICollection<Department> Departments { get; private set; }

    ... other properties    

    public Department AddDepartment(string name)
    {
        var department = new Department(Id, name);
        if (Departments == null) Departments = new List<Department>();
        Departments.Add(department);            
        return department;
    }

    ... other domain operations
}

W konstruktorze encji sprawdzanie poprawności jest uruchamiane przy użyciu FluentValidation.NET, aby upewnić się, że nie można utworzyć encji z niepoprawnym stanem. Pamiętaj, że wszystkie właściwości są tylko do odczytu - możesz je ustawić tylko za pomocą konstruktora lub dedykowanych operacji na domenie.

Walidacja tego bytu to osobna klasa:

public class InstitutionValidator : AbstractValidator<Institution>
{
    public InstitutionValidator()
    {
        RuleFor(institution => institution.Name).NotNull().Length(1, 100).WithLocalizedName(() =>   Prim.Mgp.Infrastructure.Resources.GlobalResources.InstitutionName);       
        RuleFor(institution => institution.OrganizationId).GreaterThan(0);
        RuleFor(institution => institution.ReplicationKey).NotNull().NotEqual(Guid.Empty);
    }  
}

Tych walidatorów można również łatwo użyć ponownie, a ty piszesz mniej kodu z szablonem. Kolejną zaletą jest to, że jest czytelny.

L-Four
źródło
Czy downvoter chciałby wyjaśnić, dlaczego moja odpowiedź została odrzucona?
L-Four
Pytanie dotyczyło struktury ograniczającej typy wartości, a ty przeszedłeś do klasy bez wyjaśnienia DLACZEGO. (Nie downvoter, tylko sugestie.)
DougM
Wyjaśniłem, dlaczego uważam to za lepszą alternatywę, i to było jedno z jego pytań. Dziękuję za odpowiedź.
L-Four
0

Podoba mi się to podejście do typów wartości. Pomysł jest świetny, ale mam kilka sugestii / skarg na wdrożenie.

Przesyłanie : W tym przypadku nie lubię używania przesyłania. Wyraźne rzutowanie z łańcucha nie jest problemem, ale nie ma dużej różnicy między (ValidatedName)nameValuenowym a nowym ValidatedName(nameValue). Więc wydaje się to trochę niepotrzebne. Implikacja rzutowania na ciąg jest najgorszym problemem. Myślę, że uzyskanie rzeczywistej wartości ciągu powinno być bardziej wyraźne, ponieważ może przypadkowo zostać przypisane do ciągu, a kompilator nie ostrzeże cię o możliwej „utracie precyzji”. Ten rodzaj utraty precyzji powinien być wyraźny.

ToString : Wolę używać ToStringprzeciążeń tylko do celów debugowania. I nie sądzę, aby przywrócenie wartości pierwotnej było dobrym pomysłem. Jest to ten sam problem, co w przypadku niejawnej konwersji na ciąg znaków. Uzyskanie wartości wewnętrznej powinno być jawną operacją. Wydaje mi się, że próbujesz sprawić, aby struktura zachowywała się jak normalny ciąg znaków do kodu zewnętrznego, ale myślę, że robiąc to, tracisz część wartości, którą otrzymujesz z implementacji tego typu.

Równa się i GetHashCode : Struktury domyślnie używają równości strukturalnej. Więc Equalsi GetHashCodedublują to zachowanie domyślne. Możesz je usunąć, a będzie to prawie to samo.

Euforyk
źródło
Rzutowanie: Semantycznie wydaje mi się to bardziej transformacją ciągu do ValidatedName niż tworzeniem nowego ValidatedName: identyfikujemy istniejący ciąg jako ValidatedName. Dlatego dla mnie obsada wydaje się bardziej poprawna semantycznie. Zgadza się, że pisanie na klawiaturze jest niewielkie. Nie zgadzam się na rzutowanie na ciąg: ValidatedName jest podzbiorem ciągu, więc nigdy nie może być utraty precyzji ...
gmoody1979
ToString: Nie zgadzam się. Dla mnie ToString jest całkowicie poprawną metodą do zastosowania poza scenariuszami debugowania, zakładając, że spełnia wymagania. Również w tej sytuacji, w której typ jest podzbiorem innego typu, myślę, że sensowne jest, aby przekształcenie zdolności z podzbioru w superset było tak proste, jak to możliwe, aby w razie potrzeby użytkownik mógł traktować go jak typu super-set, tj. string ...
gmoody1979
Equals i GetHashCode: Tak struktury używają strukturalnej równości, ale w tym przypadku porównuje odwołanie do ciągu, a nie wartość ciągu. Dlatego musimy zastąpić Equals. Zgadzam się, że nie byłoby to konieczne, gdyby typem podstawowym był typ wartości. Z mojego zrozumienia domyślnej implementacji GetHashCode dla typów wartości (która jest dość ograniczona), da to tę samą wartość, ale będzie bardziej wydajne. Naprawdę powinienem sprawdzić, czy tak jest w rzeczywistości, ale jest to kwestia poboczna w głównym punkcie pytania. Nawiasem mówiąc, dziękuję za odpowiedź :-).
gmoody1979
@ gmoody1979 Struktury są domyślnie porównywane przy użyciu równości w każdym polu. Nie powinno być problemu z łańcuchami. To samo z GetHashCode. Jeśli chodzi o strukturę będącą podzbiorem łańcucha. Lubię myśleć o tym typie jako o siatce bezpieczeństwa. Nie chcę pracować z ValidatedName, a następnie przypadkowo poślizgnąć się, aby użyć łańcucha. Wolałbym, żeby kompilator wyraźnie wskazał, że chcę teraz pracować z niesprawdzonymi danymi.
Euforia
Niestety tak, dobra uwaga na temat równości. Chociaż przesłonięcie powinno działać lepiej, biorąc pod uwagę, że domyślne zachowanie musi użyć refleksji do porównania. Przesyłanie: tak, być może dobry argument za uczynieniem go jawnym.
gmoody1979