Ogólny czy wspólny interfejs?

20

Nie pamiętam, kiedy ostatni raz pisałem lekcje ogólne. Za każdym razem, gdy wydaje mi się, że potrzebuję go po pewnym namyśle, wyciągam wniosek, że nie.

Druga odpowiedź na to pytanie sprawiła, że ​​poprosiłem o wyjaśnienia (ponieważ nie mogę jeszcze komentować, zadałem nowe pytanie).

Weźmy więc podany kod jako przykład przypadku, w którym potrzebujemy ogólnych:

public class Repository<T> where T : class, IBusinessOBject
{
  T Get(int id)
  void Save(T obj);
  void Delete(T obj);
}

Ma ograniczenia typu: IBusinessObject

Mój zwykły sposób myślenia: klasa jest ograniczona do używania IBusinessObject, podobnie jak klasy, które z niej korzystają Repository. Repozytorium przechowuje te IBusinessObject, najprawdopodobniej ich klienci Repositorybędą chcieli uzyskiwać i wykorzystywać obiekty poprzez IBusinessObjectinterfejs. Dlaczego więc nie tylko

public class Repository
{
  IBusinessOBject Get(int id)
  void Save(IBusinessOBject obj);
  void Delete(IBusinessOBject obj);
}

Ten przykład nie jest dobry, ponieważ jest to po prostu inny typ kolekcji, a ogólna kolekcja to klasyka. W tym przypadku ograniczenie typu również wygląda dziwnie.

W rzeczywistości przykład class Repository<T> where T : class, IBusinessbBjectwygląda bardzo podobnie class BusinessObjectRepositorydo mnie. To jest to, co naprawia generycy.

Chodzi o to: czy ogólne są przydatne do czegokolwiek poza kolekcjami i czy ograniczenia typu nie czynią tego rodzaju specjalizacją tak, jak robi to użycie ograniczenia typu zamiast parametru typu ogólnego w klasie?

jungle_mole
źródło

Odpowiedzi:

24

Porozmawiajmy najpierw o czystym parametrycznym polimorfizmie, a później zajmiemy się ograniczonym polimorfizmem.

Polimorfizm parametryczny

Co oznacza polimorfizm parametryczny? Oznacza to, że konstruktor typu, a raczej typ jest parametryzowany przez typ. Ponieważ typ jest przekazywany jako parametr, nie można z góry wiedzieć, co to może być. Na tej podstawie nie można przyjmować żadnych założeń. Jeśli nie wiesz, co to może być, to jaki jest pożytek? Co możesz z tym zrobić?

Możesz na przykład przechowywać i odzyskiwać. Tak już wspomniałeś: kolekcje. Aby przechowywać element na liście lub tablicy, nie muszę nic wiedzieć o elemencie. Lista lub tablica mogą być całkowicie nieświadome typu.

Ale co z tym Maybetypem? Jeśli go nie znasz, Maybejest to typ, który może mieć wartość, a może nie. Gdzie byś go użył? Na przykład przy pobieraniu elementu ze słownika: fakt, że elementu nie ma w słowniku, nie jest wyjątkową sytuacją, więc naprawdę nie powinieneś rzucać wyjątku, jeśli elementu nie ma. Zamiast tego zwracasz instancję podtypu Maybe<T>, który ma dokładnie dwa podtypy: Nonei Some<T>. int.Parsejest kolejnym kandydatem na coś, co naprawdę powinno zwrócić Maybe<int>zamiast rzucać wyjątek lub cały int.TryParse(out bla)taniec.

Teraz możesz argumentować, że Maybeto trochę jak lista, która może mieć tylko zero lub jeden element. I tak jakby kolekcja.

Co z tego Task<T>? Jest to typ, który obiecuje zwrócić wartość w pewnym momencie w przyszłości, ale niekoniecznie ma teraz wartość.

A co powiesz na Func<T, …>? Jak przedstawiłbyś koncepcję funkcji z jednego typu na inny, jeśli nie możesz abstrakcji nad typami?

Lub bardziej ogólnie: biorąc pod uwagę, że abstrakcja i ponowne użycie są dwoma podstawowymi operacjami inżynierii oprogramowania, dlaczego nie chcesz mieć możliwości abstrakcji na typach?

Ograniczony polimorfizm

Porozmawiajmy teraz o ograniczonym polimorfizmie. Ograniczony polimorfizm występuje w zasadzie tam, gdzie spotykają się polimorfizm parametryczny i polimorfizm podtypu: zamiast konstruktora typu całkowicie nieświadomego parametru parametru, możesz powiązać (lub ograniczyć) typ, aby był podtypem określonego typu.

Wróćmy do kolekcji. Weź hashtable. Powiedzieliśmy powyżej, że lista nie musi nic wiedzieć o swoich elementach. No cóż, tablica skrótów robi: musi wiedzieć, że potrafi je haszować. (Uwaga: w języku C # wszystkie obiekty są haszowalne, podobnie jak wszystkie obiekty można porównać pod względem równości. Nie jest to jednak prawdą we wszystkich językach i czasami jest uważane za błąd projektowy nawet w języku C #).

Zatem chcesz ograniczyć parametr typu dla typu klucza w tablicy mieszającej, aby był instancją IHashable:

class HashTable<K, V> where K : IHashable
{
  Maybe<V> Get(K key);
  bool Add(K key, V value);
}

Wyobraź sobie, że zamiast tego masz to:

class HashTable
{
    object Get(IHashable key);
    bool Add(IHashable key, object value);
}

Co byś zrobił z tym, valueże się stamtąd wydostałeś? Nie możesz nic z tym zrobić, wiesz tylko, że to obiekt. A jeśli wykonasz iterację, wszystko, co zyskujesz, to para czegoś, o czym wiesz, że jest IHashable(co niewiele ci pomaga, ponieważ ma tylko jedną właściwość Hash) i coś, o czym wiesz, że jest object(co pomaga ci jeszcze mniej).

Lub coś na podstawie twojego przykładu:

class Repository<T> where T : ISerializable
{
    T Get(int id);
    void Save(T obj);
    void Delete(T obj);
}

Element musi być możliwy do serializacji, ponieważ będzie przechowywany na dysku. Ale co, jeśli masz to zamiast tego:

class Repository
{
    ISerializable Get(int id);
    void Save(ISerializable obj);
    void Delete(ISerializable obj);
}

Z ogólnym przypadku, jeśli umieścić BankAccountw, masz BankAccountplecy, z metody i właściwości, takie jak Owner, AccountNumber, Balance, Deposit, Withdraw, itd. Coś można pracować. A teraz druga sprawa? Włożysz BankAccountale można dostać z powrotem Serializable, który ma tylko jedną właściwość: AsString. Co zamierzasz z tym zrobić?

Istnieje również kilka ciekawych sztuczek, które można zrobić z ograniczonym polimorfizmem:

Polimorfizm związany z F.

Kwantyfikacja ograniczona przez F polega zasadniczo na tym, że zmienna typu pojawia się ponownie w ograniczeniu. Może to być przydatne w niektórych okolicznościach. Np. Jak piszesz ICloneableinterfejs? Jak napisać metodę, w której typ zwracany jest typem klasy implementującej? W języku z funkcją MyType to proste:

interface ICloneable
{
    public this Clone(); // syntax I invented for a MyType feature
}

W języku z ograniczonym polimorfizmem możesz zamiast tego zrobić coś takiego:

interface ICloneable<T> where T : ICloneable<T>
{
    public T Clone();
}

class Foo : ICloneable<Foo>
{
    public Foo Clone()
    {
        // …
    }
}

Zauważ, że nie jest to tak bezpieczne jak wersja MyType, ponieważ nic nie stoi na przeszkodzie, aby ktoś po prostu przekazał „niewłaściwą” klasę konstruktorowi typów:

class EvilBar : ICloneable<SomethingTotallyUnrelatedToBar>
{
    public SomethingTotallyUnrelatedToBar Clone()
    {
        // …
    }
}

Członkowie typu abstrakcyjnego

Jak się okazuje, jeśli masz elementy typu abstrakcyjnego i subtypy, możesz faktycznie przejść całkowicie bez parametrycznego polimorfizmu i nadal robić te same rzeczy. Scala zmierza w tym kierunku, będąc pierwszym głównym językiem, który zaczął od generycznych, a następnie próbował je usunąć, co jest dokładnie na odwrót od np. Java i C #.

Zasadniczo w Scali, podobnie jak możesz mieć pola i właściwości oraz metody jako członków, możesz także mieć typy. I podobnie jak pola, właściwości i metody mogą pozostać abstrakcyjne, aby można je było później zaimplementować w podklasie, elementy typu mogą być również abstrakcyjne. Wróćmy do kolekcji, prostej List, która wyglądałaby mniej więcej tak, gdyby była obsługiwana w C #:

class List
{
    T; // syntax I invented for an abstract type member
    T Get(int index) { /* … */ }
    void Add(T obj) { /* … */ }
}

class IntList : List
{
    T = int;
}
// this is equivalent to saying `List<int>` with generics
Jörg W Mittag
źródło
Rozumiem, że abstrakcja typów jest przydatna. po prostu nie widzę jego zastosowania w „prawdziwym życiu”. Func <> i Task <> oraz Action <> to typy bibliotek. i dzięki też pamiętałem interface IFoo<T> where T : IFoo<T>. to oczywiście aplikacja z prawdziwego życia. przykład jest świetny. ale z jakiegoś powodu nie czuję się usatysfakcjonowany. wolę rozmyślać, kiedy jest to właściwe, a kiedy nie. odpowiedzi tutaj wnoszą pewien wkład w ten proces, ale nadal czuję, że jestem do tego nieprzygotowany. to dziwne, ponieważ problemy na poziomie językowym nie przeszkadzają mi już tak długo.
jungle_mole
Świetny przykład. Czułem się, jakbym wrócił do klasy. +1
Chef_Code
1
@Chef_Code: Mam nadzieję, że to komplement :-P
Jörg W Mittag
Tak to jest. Później pomyślałem o tym, jak można to postrzegać po tym, jak już skomentowałem. Tak więc, aby potwierdzić szczerość ... Tak, to komplement nic innego.
Chef_Code
14

Chodzi o to: czy ogólne są przydatne do czegokolwiek poza kolekcjami i czy ograniczenia typu nie czynią tego rodzaju specjalistycznym, jak użycie ograniczenia typu zamiast ogólnego parametru parametru wewnątrz klasy?

Nie. Za dużo myślisz o tym Repository, gdzie jest prawie tak samo. Ale nie do tego służą generycy. Są tam dla użytkowników .

Kluczową kwestią tutaj nie jest to, że samo Repozytorium jest bardziej ogólne. Chodzi o to, że użytkownicy są bardziej wyspecjalizowani, tj. To Repository<BusinessObject1>i Repository<BusinessObject2>są różnego rodzaju, a ponadto, że jeśli wezmę Repository<BusinessObject1>, wiem , że z niego BusinessObject1wrócę Get.

Nie możesz zaoferować tego silnego pisania na podstawie zwykłego dziedziczenia. Proponowana klasa repozytorium nie robi nic, aby zapobiec pomyleniu repozytoriów dla różnych rodzajów obiektów biznesowych lub zagwarantowaniu poprawnego rodzaju zwracanych obiektów biznesowych.

DeadMG
źródło
Dzięki, to ma sens. Ale czy korzystanie z tej wysoko cenionej funkcji językowej jest tak proste, jak pomoc użytkownikom, którzy wyłączyli IntelliSense? (Trochę przesadzam, ale jestem pewien, że rozumiesz)
jungle_mole
@zloidooraque: IntelliSense również nie wie, jakie obiekty są przechowywane w repozytorium. Ale tak, możesz zrobić wszystko bez generyków, jeśli zamiast tego chcesz użyć rzutów.
zabójstwo
@gexicide o to chodzi: nie widzę, gdzie muszę używać rzutowań, jeśli używam wspólnego interfejsu. Nigdy nie powiedziałem „użyj Object”. Rozumiem także, dlaczego przy tworzeniu kolekcji używamy rodzajów ogólnych (zasada DRY). Prawdopodobnie moim początkowym pytaniem powinno być coś na temat używania leków generycznych poza kontekstem kolekcji.
jungle_mole
@zloidooraque: Nie ma to nic wspólnego ze środowiskiem. Intellisense nie może powiedzieć, czy an IBusinessObjectjest BusinessObject1a BusinessObject2. Nie może rozwiązać problemu przeciążenia na podstawie typu pochodnego, którego nie zna. Nie może odrzucić kodu, który przechodzi w niewłaściwym typie. Istnieje milion bitów silniejszego pisania, które Intellisense nie może zrobić absolutnie nic. Lepsza obsługa narzędzi to dobra zaleta, ale tak naprawdę nie ma nic wspólnego z podstawowymi powodami.
DeadMG,
@DeadMG i o to mi chodzi: intellisense nie może tego zrobić: użyć ogólnego, więc może? czy to ma znaczenie? kiedy dostajesz obiekt przez jego interfejs, po co go spuszczać? jeśli to zrobisz, to zły projekt, nie? i dlaczego i co to jest „rozwiązać przeciążenia”? użytkownik nie może decydować, czy metoda wywołania, czy też nie na podstawie typu pochodnego, czy deleguje wywołanie odpowiedniej metody do systemu (którym jest polimorfizm). i to ponownie prowadzi mnie do pytania: czy leki generyczne są przydatne poza pojemnikami? nie kłócę się z tobą, naprawdę muszę to zrozumieć.
jungle_mole
13

„najprawdopodobniej klienci tego repozytorium będą chcieli pobierać obiekty i korzystać z nich za pośrednictwem interfejsu IBusinessObject”.

Nie zrobią tego.

Rozważmy, że IBusinessObject ma następującą definicję:

public interface IBusinessObject
{
  public int Id { get; }
}

Po prostu definiuje identyfikator, ponieważ jest to jedyna wspólna funkcjonalność między wszystkimi obiektami biznesowymi. I masz dwa rzeczywiste obiekty biznesowe: Osoba i Adres (ponieważ Osoby nie mają ulic i Adresów nie mają nazw, nie możesz powiązać ich obu z interfejsem komunikacyjnym o funkcjonalności obu. Byłby to straszny projekt, naruszający Zasada degradacji interfejsu , „I” w SOLID )

public class Person : IBusinessObject
{
  public int Id { get; private set; }
  public string Name { get; private set; }
}

public class Address : IBusinessObject
{
  public int Id { get; private set; }
  public string City { get; private set; }
  public string StreetName { get; private set; }
  public int Number { get; private set; }
}

Co się stanie, gdy użyjesz ogólnej wersji repozytorium:

public class Repository<T> where T : class, IBusinessObject
{
  T Get(int id)
  void Save(T obj);
  void Delete(T obj);
}

Po wywołaniu metody Get w ogólnym repozytorium zwrócony obiekt zostanie silnie wpisany, umożliwiając dostęp do wszystkich członków klasy.

Person p = new Repository<Person>().Get(1);
int id = p.Id;
string name = p.Name;

Address a = new Repository<Address>().Get(1);
int id = a.Id;
string cityName = a.City;
int houseNumber = a.Number;

Z drugiej strony, gdy korzystasz z nietypowego repozytorium:

public class Repository
{
  IBusinessOBject Get(int id)
  void Save(IBusinessOBject obj);
  void Delete(IBusinessOBject obj);
}

Dostęp do członków będzie można uzyskać tylko z interfejsu IBusinessObject:

IBussinesObject p = new Repository().Get(1);
int id = p.Id; //OK
string name = p.Name; //Oooops, you dont have "Name" defined on the IBussinesObject interface.

Tak więc poprzedni kod nie zostanie skompilowany z powodu następujących wierszy:

string name = p.Name;
string cityName = a.City;
int houseNumber = a.Number;

Jasne, możesz rzucić IBussinesObject na rzeczywistą klasę, ale stracisz całą magię czasu kompilacji, na którą pozwalają leki generyczne (co prowadzi do wyjątków InvalidCastExceptions w dół drogi), niepotrzebnie ucierpi z powodu rzutowania ... A nawet jeśli tego nie zrobisz dbaj o czas kompilacji, nie sprawdzając żadnej wydajności (powinieneś), casting po zdecydowanie nie da ci żadnej przewagi nad używaniem generics na pierwszym miejscu.

Lucas Corsaletti
źródło