Kiedy należy stosować typ skojarzony, a kiedy typ ogólny?

109

W tym pytaniu pojawił się problem, który można rozwiązać, zmieniając próbę użycia parametru typu ogólnego na typ skojarzony. To wywołało pytanie „Dlaczego skojarzony typ jest tutaj bardziej odpowiedni?”, Co sprawiło, że chciałem wiedzieć więcej.

Dokument RFC, który wprowadził powiązane typy, mówi:

Ten dokument RFC wyjaśnia dopasowanie cech przez:

  • Traktowanie wszystkich parametrów typu cechy jako typów wejściowych i
  • Dostarczanie powiązanych typów, które są typami wyjściowymi .

RFC używa struktury grafów jako motywującego przykładu i jest to również używane w dokumentacji , ale przyznam, że nie doceniam w pełni zalet powiązanej wersji typu nad wersją sparametryzowaną na typ. Najważniejsze jest to, że distancemetoda nie musi zwracać uwagi na Edgetyp. To fajne, ale wydaje się trochę płytkie powody, dla których w ogóle mają powiązane typy.

Odkryłem, że powiązane typy są dość intuicyjne w użyciu w praktyce, ale mam problemy z podjęciem decyzji, gdzie i kiedy powinienem ich używać w moim własnym API.

Kiedy pisząc kod, powinienem wybrać typ skojarzony zamiast parametru typu ogólnego, a kiedy powinienem zrobić odwrotnie?

Shepmaster
źródło

Odpowiedzi:

76

Jest to teraz poruszane w drugiej edycji Języka programowania Rusta . Jednak zanurzmy się trochę dodatkowo.

Zacznijmy od prostszego przykładu.

Kiedy należy stosować metodę cech?

Istnieje wiele sposobów zapewnienia późnego wiązania :

trait MyTrait {
    fn hello_word(&self) -> String;
}

Lub:

struct MyTrait<T> {
    t: T,
    hello_world: fn(&T) -> String,
}

impl<T> MyTrait<T> {
    fn new(t: T, hello_world: fn(&T) -> String) -> MyTrait<T>;

    fn hello_world(&self) -> String {
        (self.hello_world)(self.t)
    }
}

Pomijając jakąkolwiek strategię wdrożenia / wydajności, oba powyższe fragmenty pozwalają użytkownikowi dynamicznie określić, jak hello_worldpowinien się zachować.

Jedyną różnicą (semantycznie) jest to, że traitimplementacja gwarantuje, że dla danego typu Timplementacja trait, hello_worldzawsze będzie miała takie samo zachowanie, podczas gdy structimplementacja pozwala na inne zachowanie na podstawie instancji.

To, czy użycie metody jest właściwe, czy nie, zależy od przypadku użycia!

Kiedy należy użyć powiązanego typu?

Podobnie jak w przypadku traitpowyższych metod, skojarzony typ jest formą późnego wiązania (chociaż występuje podczas kompilacji), umożliwiając użytkownikowi traitokreślenie dla danej instancji, który typ ma zastąpić. To nie jedyny sposób (stąd pytanie):

trait MyTrait {
    type Return;
    fn hello_world(&self) -> Self::Return;
}

Lub:

trait MyTrait<Return> {
    fn hello_world(&Self) -> Return;
}

Są równoważne z późnym wiązaniem powyższych metod:

  • pierwszy wymusza, że ​​dla danego Selfjest jeden Returnpowiązany
  • druga natomiast pozwala na zaimplementowanie MyTraitdo Selfdo wielokrotnościReturn

To, która forma jest bardziej odpowiednia, zależy od tego, czy wymuszanie jedności ma sens, czy nie. Na przykład:

  • Deref używa skojarzonego typu, ponieważ bez unikalności kompilator zwariowałby podczas wnioskowania
  • Add używa skojarzonego typu, ponieważ jego autor uważał, że biorąc pod uwagę te dwa argumenty, będzie to logiczny typ zwracany

Jak widać, gdy Derefjest oczywiste, USECASE (ograniczenie techniczny), sprawa Addjest mniej jednoznaczne: może to mieć sens dla i32 + i32uzyskując albo i32czy Complex<i32>w zależności od kontekstu? Niemniej jednak autor wywiązał się ze swojego osądu i uznał, że przeładowanie zwracanego typu dla dodatków nie jest konieczne.

Osobiście uważam, że nie ma właściwej odpowiedzi. Mimo wszystko, poza argumentem unicity, wspomniałbym, że powiązane typy ułatwiają korzystanie z cechy, ponieważ zmniejszają liczbę parametrów, które należy określić, więc w przypadku, gdy korzyści płynące z elastyczności stosowania zwykłego parametru cechy nie są oczywiste, ja sugeruj rozpoczęcie od powiązanego typu.

Matthieu M.
źródło
4
Spróbuję trochę uprościć: trait/struct MyTrait/MyStructpozwala dokładnie na jeden impl MyTrait forlub impl MyStruct. trait MyTrait<Return>zezwala na wiele impls, ponieważ jest ogólny. Returnmoże być dowolnego typu. Struktury ogólne są takie same.
Paul-Sebastian Manole
2
Uważam, że twoja odpowiedź jest znacznie łatwiejsza do zrozumienia niż ta w "Języku programowania Rusta"
drojf
„pierwszy wymusza, że ​​dla danej Self jest powiązany pojedynczy Zwrot”. Jest to prawdą w sensie bezpośrednim, ale można oczywiście obejść to ograniczenie, tworząc podklasy z cechą ogólną. Być może jedyność może być tylko sugestią, a nie wymuszoną
joel
37

Powiązane typy są mechanizmem grupującym , dlatego należy ich używać, gdy ma sens grupowanie typów razem.

GraphCecha wprowadzone w dokumentacji jest tego przykładem. Chcesz, Graphaby był ogólny, ale gdy masz już określony rodzaj Graph, nie chcesz już, aby typy Nodelub Edgesię zmieniały. Konkretny Graphnie będzie chciał zmieniać tych typów w ramach jednej implementacji, a właściwie chce, aby zawsze były takie same. Są zgrupowane razem, a nawet można powiedzieć, że są powiązane .

Steve Klabnik
źródło
5
Trochę czasu zajęło mi zrozumienie. Dla mnie wygląda to bardziej na zdefiniowanie kilku typów na raz: Edge i Node nie mają sensu na wykresie.
tafia