Kowariancja i kontrawariancja w .NET C#
Jeśli trudno jest Ci zrozumieć, co oznacza kowariancja i kontrawariancja w .NET C#, nie martw się, nie jesteś sam. Zdarzyło się to mnie i wielu innym programistom. Znam nawet tych doświadczonych, którzy albo o tych terminach nic nie wiedzą, albo używają ich, ale wciąż nie potrafią ich wystarczająco dobrze zrozumieć. Z tego co widzę, dzieje się tak, ponieważ za każdym razem, gdy natrafiam na artykuł mówiący o kowariancji i kontrawariancji, zauważam, że koncentruje się on na jakiejś technicznej terminologii, zamiast zajmować się powodem, dla którego w ogóle je mamy i co by nas ominęło, gdyby wcale nie istniały.
4 listopada 2021 roku
Zauważyłem, że zrozumienie inwariancji, kowariancji i kontrawariancji pomoże ci zrozumieć inne tematy i podejmować właściwe decyzje projektowe.
Definicja Microsoftu
Jeśli sprawdzisz dokumentację Microsoftu dla kowariancji i kontrawariancji w .NET C#, znajdziesz taką definicję:
W języku C# kowariancja i kontrawariancja umożliwiają niejawną konwersję odwołania dla typów tablic, typów delegatów i argumentów typu ogólnego. Kowariancja zachowuje zgodność przypisania i kontrawariancja go odwraca.
Zrozumiałeś wszystko? Podoba Ci się taka definicja?
Przeszukując Internet, znajdziesz całe mnóstwo informacji na ten temat. Znajdziesz definicje, historię, kiedy zostało to wprowadzone, próbki kodu i wiele, wiele innych. Jednak to nie jest to, co znajdziesz w tym artykule. Obiecuję Ci, że to, co tu zobaczysz, jest całkowicie inne....
Tak więc czym właściwie są te pojęcia?
Zasadniczo, to co zrobił Microsoft, to dodanie małego dodatku do sposobu, w jaki definiuje się ogólny symbol zastępczy typu szablonu, słynnego <T>.
To, co zwykle robiłeś podczas definiowania ogólnego interfejsu, to podążanie za wzorcem public interface IMyInterface<T> {...}
. Po wprowadzeniu kowariancji i kontrawariancji możesz teraz skorzystać ze wzorca public interface IMyInterface<out T> {...}
lub public interface IMyInterface<in T> {...}
.
Czy rozpoznajesz dodatkowe out
oraz in
?
Czy widziałeś je gdzieś indziej?
Może w słynnym public interface IEnumerable<out T>
?
Lub słynnym public interface IComparable<in T>
?
Microsoft wprowadził nową koncepcję, aby kompilator upewnił się, że typy obiektów, których używasz i przekazujesz do typów ogólnych, nie będą rzucać wyjątków runtime spowodowanych przez błędną wartość oczekiwanego typu.
Dalej nic Ci to nie mówi? Zostań ze mną... Załóżmy, że kompilator nie stosuje żadnych ograniczeń czasu projektowania i zobaczmy, co by się stało w takiej sytuacji.
Co jeśli kompilator nie stosuje żadnych ograniczeń czasu projektowania?
Aby móc pracować na naszym przykładzie, zdefiniujmy poniższe:
public class A
{
public void F1(){}
}
public class B : A
{
public void F2(){}
}
public class C : B
{
public void F3(){}
}
public interface IReaderWriter<TEntity>
{
TEntity Read();
void Write(TEntity entity);
}
public class ReaderWriter<TEntity> : IReaderWriter<TEntity> where TEntity : new()
{
public TEntity Read()
{
return new TEntity();
}
public void Write(TEntity entity)
{
}
}
Patrząc na powyższy kod, można zauważyć, że:
- Klasa A ma zdefiniowaną
F1()
. - Klasa B ma zdefiniowane
F1()
iF2()
. - Klasa C ma zdefiniowane
F1()
,F2(
) iF3()
. - Interfejs
IReaderWriter
maRead()
, co zwraca obiekt typuTEntity
orazWrite(TEntity entity)
, który oczekuje parametru typuTEntity
.
Następnie zdefiniujmy metodę TestReadWriter()
jak poniżej:
public static void TestReaderWriter(IReaderWriter<B> param)
{
var b = param.Read();
b.F1();
b.F2();
param.Write(b);
}
Wywołanie TestReadWriter()
, gdy przekazujemy instancję IReaderWriter<B>
Powinno działać dobrze, ponieważ nie naruszamy żadnych zasad. TestReadWriter()
jest oczekiwanym parametrem typu IReaderWriter<B>
.
Wywołanie TestReadWriter()
, gdy przekazujemy instancję IReaderWriter<A>
Pamiętając o założeniu, że kompilator nie stosuje żadnych ograniczeń, oznacza to, że:
param.Read()
zwróciłaby instancję klasy A, a nie B
=> Tak więcvar b
będzie w rzeczywistości typu A, a nie B
=> To prowadziłoby do tego, że linijkab.F2()
nie powiedzie się, ponieważvar b
— która w rzeczywistości jest typu A — nie posiada zdefiniowanejF2()
- Linia
param.Write()
w powyższym kodzie oczekiwałaby otrzymania parametru typu A, a nie B
=> Tak więc wywołanieparam.Write()
podczas przekazywania parametru typu B będzie działało również poprawnie
Skoro więc w punkcie #1 spodziewamy się niepowodzenia, to nie możemy wywołać TestReadWriter()
przekazując instancję IReaderWriter<A>
.
Wywołanie TestReadWriter()
gdy przekazujemy instancję IReaderWriter<C>
Pamiętając o założeniu, że kompilator nie stosuje żadnych ograniczeń w czasie projektowania, oznacza to, że:
param.Read()
zwróciłoby instancję klasy C, a nie B
=> Tak więcvar b
będzie w rzeczywistości typu C, a nie B
=> To doprowadziłoby do tego, że linijkab.F2()
działałaby dobrze, ponieważvar b
miałabyF2()
- Linijka
param.Write()
w powyższym kodzie oczekiwałaby otrzymania parametru typu C, a nie B
=> W rezultacie wywołanieparam.Write()
podczas przekazywania parametru typu B nie powiedzie się, ponieważ po prostu nie można zastąpić C jego rodzicem B
Skoro więc w punkcie #2 spodziewamy się niepowodzenia, to nie możemy wywołać TestReadWriter()
, przekazując instancję IReaderWriter<C>
.
Przeanalizujmy teraz to, czego się do tej pory dowiedzieliśmy:
- Wywołanie
TestReadWriter(IReaderWriter<B> param)
przy przekazaniu instancjiIReaderWriter<B>
jest zawsze w porządku. - Wywołanie
TestReadWriter(IReaderWriter<B> param)
podczas przekazywania instancjiIReaderWriter<A>
byłoby w porządku, gdyby nie było wywołaniaparam.Read()
. - Wywołanie
TestReadWriter(IReaderWriter<B> param)
podczas przekazywania instancjiIReaderWriter<C>
byłoby w porządku, gdyby nie było wywołaniaparam.Write()
. - Jednakże, ponieważ zawsze mamy mieszankę pomiędzy
param.Read()
iparam.Write()
, zawsze musielibyśmy trzymać się wywołaniaTestReadWriter(IReaderWriter<B> param)
z przekazaniem instancjiIReaderWriter<B>
, nic więcej. - Chyba że...
Alternatywa
Co jeśli upewnimy się, że interfejs IReaderWriter<TEntity>
definiuje albo TEntity Read()
albo void Write(TEntity entity)
, a nie obie jednocześnie.
Jeśli porzucimy TEntity Read()
, będziemy mogli wywołać TestReadWriter(IReaderWriter<B> param)
z przekazaniem instancji IReaderWriter<A>
lub IReaderWriter<B>
.
Podobnie, gdybyśmy porzucili void Write(TEntity entity)
, moglibyśmy wywołać TestReadWriter(IReaderWriter<B> param)
z przekazaniem instancji IReaderWriter<B>
lub IReaderWriter<C>
.
Byłoby to dla nas lepsze rozwiązanie, ponieważ byłoby mniej restrykcyjne, prawda?
Czas na trochę faktów
- W prawdziwym świecie kompilator — w czasie projektowania — nigdy nie pozwoliłby na wywołanie
TestReadWriter(IReaderWriter<B> param)
z przekazaniem instancjiIReaderWriter<A>
. Otrzymalibyśmy błąd kompilacji. - Również kompilator — w czasie projektowania — nie pozwoliłby na wywołanie
TestReadWriter(IReaderWriter<B> param)
z przekazaniem instancjiIReaderWriter<C>
. Otrzymalibyśmy błąd kompilacji. - Punkt #1 i #2, nazywamy to inwariancją.
- Nawet jeśli porzucilibyśmy
TEntity Read()
z interfejsuIReaderWriter<TEntity>
, kompilator — w czasie projektowania — nie pozwoliłby na wywołanieTestReadWriter(IReaderWriter<B> param)
z przekazaniem instancjiIReaderWriter<A>
. Otrzymalibyśmy błąd kompilacji. Dzieje się tak dlatego, że kompilator nie będzie domyślnie sprawdzał elementów zdefiniowanych w interfejsie, i czy będzie on zawsze działał w czasie wykonywania, czy nie. Będziesz musiał zrobić to samodzielnie poprzez<in TEntity>
. To działa jak obietnica złożona kompilatorowi przez użytkownika, że wszystkie elementy interfejsu albo nie będą zależne odTEntity
, albo będą traktować go jako wejście, a nie wyjście. Nazywa się to kontrawariancją. - Podobnie, nawet jeśli porzucimy
void Write(TEntity entity)
z interfejsuIReaderWriter<TEntity>
, kompilator — w czasie projektowania — nie pozwoli nam na wywołanieTestReadWriter(IReaderWriter<B> param)
z przekazaniem instancjiIReaderWriter<C>
. Otrzymalibyśmy błąd kompilacji. Dzieje się tak dlatego, że kompilator nie będzie domyślnie sprawdzał elementów zdefiniowanych w interfejsie i sprawdzał, czy będzie on zawsze działał w czasie wykonywania, czy nie. Będziesz musiał zrobić to samodzielnie poprzez<out TEntity>
. To działa jak obietnica złożona kompilatorowi przez użytkownika, że wszystkie elementy interfejsu albo nie będą zależne odTEntity
, albo będą traktować go jako wyjście, a nie wejście. Nazywa się to kowariancją. - Dlatego też dodanie
<out >
lub<in >
sprawia, że kompilator jest mniej restrykcyjny dla naszych potrzeb, a nie bardziej restrykcyjny, jak mogłoby się wydawać niektórym programistom.
Podsumujmy
Na ten moment powinieneś już rozumieć, o co chodzi w inwariancji, kowariancji i kontrawariancji. Podsumujmy to jednak jeszcze raz. Potraktuj poniższe informacje jako arkusz informacyjny:
- Mieszania między typem ogólnym wejścia a wyjścia => inwariancja => najbardziej restrykcyjny => nie można zastąpić rodzicami ani dziećmi.
- Dodanie
<in >
=> tylko wejście => kontrwariancja => sama lub zastąpione rodzicami. - Dodanie
<out >
=> tylko wyjście => kowariancja => sama lub zastąpione dziećmi.
Ponadto, w poniższym artykule zauważam, że zrozumienie inwariancji, kowariancji i kontrawariancji pomoże Ci zrozumieć inne tematy i podejmować właściwe decyzje projektowe.
Na koniec wrzucę tutaj trochę kodu, na który możesz rzucić okiem i z którym możesz trochę poćwiczyć.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace DotNetVariance
{
class Program
{
static void Main(string[] args)
{
IReader<A> readerA = new Reader<A>();
IReader<B> readerB = new Reader<B>();
IReader<C> readerC = new Reader<C>();
IWriter<A> writerA = new Writer<A>();
IWriter<B> writerB = new Writer<B>();
IWriter<C> writerC = new Writer<C>();
IReaderWriter<A> readerWriterA = new ReaderWriter<A>();
IReaderWriter<B> readerWriterB = new ReaderWriter<B>();
IReaderWriter<C> readerWriterC = new ReaderWriter<C>();
#region Covariance
// IReader<TEntity> is Covariant, this means that:
// 1. All members either don't deal with TEntity or have it in the return type, not the input parameters
// 2. In a call, IReader<TEntity> could be replaced by any IReader<TAnotherEntity> given that TAnotherEntity
// is a child -directly or indirectly- of TEntity
// TestReader(readerB) is ok because TestReader is already expecting IReader<B>
TestReader(readerB);
// TestReader(readerC) is ok because C is a child of B
TestReader(readerC);
// TestReader(readerA) is NOT ok because A is a not a child of B
TestReader(readerA);
#endregion
#region Contravariance
// IWriter<TEntity> is Contravariant, this means that:
// 1. All members either don't deal with TEntity or have it in the input parameters, not in the return type
// 2. In a call, IWriter<TEntity> could be replaced by any IWriter<TAnotherEntity> given that TAnotherEntity
// is a parent -directly or indirectly- of TEntity
// TestWriter(writerB) is ok because TestWriter is already expecting IWriter<B>
TestWriter(writerB);
// TestWriter(writerA) is ok because A is a parent of B
TestWriter(writerA);
// TestWriter(writerC) is NOT ok because C is a not a parent of B
TestWriter(writerC);
#endregion
#region Invariance
// IReaderWriter<TEntity> is Invariant, this means that:
// 1. Some members have TEntity in the input parameters and others have TEntity in the return type
// 2. In a call, IReaderWriter<TEntity> could not be replaced by any IReaderWriter<TAnotherEntity>
// IReaderWriter(readerWriterB) is ok because TestReaderWriter is already expecting IReaderWriter<B>
TestReaderWriter(readerWriterB);
// IReaderWriter(readerWriterA) is NOT ok because IReaderWriter<B> can not be replaced by IReaderWriter<A>
TestReaderWriter(readerWriterA);
// IReaderWriter(readerWriterC) is NOT ok because IReaderWriter<B> can not be replaced by IReaderWriter<C>
TestReaderWriter(readerWriterC);
#endregion
}
public static void TestReader(IReader<B> param)
{
var b = param.Read();
b.F1();
b.F2();
// What if the compiler allows calling TestReader with a param of type IReader<A>, This means that:
// param.Read() would return an instance of class A, not B
// => So, the var b would actually be of type A, not B
// => This would lead to the b.F2() line in the code above to fail as the var b doesn't have F2()
// What if the compiler allows calling TestReaderWriter with a param of type IReaderWriter<C>, This means that:
// param.Read() would return an instance of class C, not B
// => So, the var b would actually be of type C, not B
// => This would lead to the b.F2() line in the code above to work fine as the var b would have F2()
}
public static void TestWriter(IWriter<B> param)
{
var b = new B();
param.Write(b);
// What if the compiler allows calling TestWriter with a param of type IWriter<A>, This means that:
// param.Write() line in the code above would be expecting to receive a parameter of type A, not B
// => So, calling param.Write() while passing in a parameter of type A or B would both work
// What if the compiler allows calling TestWriter with a param of type IWriter<C>, This means that:
// param.Write() line in the code above would be expecting to receive a parameter of type C, not B
// => So, calling param.Write() while passing in a parameter of type B would not work
}
public static void TestReaderWriter(IReaderWriter<B> param)
{
var b = param.Read();
b.F1();
b.F2();
param.Write(b);
// What if the compiler allows calling TestReaderWriter with a param of type IReaderWriter<A>, This means that:
// 1. param.Read() would return an instance of class A, not B
// => So, the var b would actually be of type A, not B
// => This would lead to the b.F2() line in the code above to fail as the var b doesn't have F2()
// 2. param.Write() line in the code above would be expecting to receive a parameter of type A, not B
// => So, calling param.Write() while passing in a parameter of type A or B would both work
// What if the compiler allows calling TestReaderWriter with a param of type IReaderWriter<C>, This means that:
// 1. param.Read() would return an instance of class C, not B
// => So, the var b would actually be of type C, not B
// => This would lead to the b.F2() line in the code above to work fine as the var b would have F2()
// 2. param.Write() line in the code above would be expecting to receive a parameter of type C, not B
// => So, calling param.Write() while passing in a parameter of type B would not work
}
}
#region Hierarchy Classes
public class A
{
public void F1()
{
}
}
public class B : A
{
public void F2()
{
}
}
public class C : B
{
public void F3()
{
}
}
#endregion
#region Covariant IReader
// IReader<TEntity> is Covariant as all members either don't deal with TEntity or have it in the return type
// not the input parameters
public interface IReader<out TEntity>
{
TEntity Read();
}
public class Reader<TEntity> : IReader<TEntity> where TEntity : new()
{
public TEntity Read()
{
return new TEntity();
}
}
#endregion
#region Contravariant IWriter
// IWriter<TEntity> is Contravariant as all members either don't deal with TEntity or have it in the input parameters
// not the return type
public interface IWriter<in TEntity>
{
void Write(TEntity entity);
}
public class Writer<TEntity> : IWriter<TEntity> where TEntity : new()
{
public void Write(TEntity entity)
{
}
}
#endregion
#region Invariant IReaderWriter
// IReaderWriter<TEntity> is Invariant as some members have TEntity in the input parameters
// and others have TEntity in the return type
public interface IReaderWriter<TEntity>
{
TEntity Read();
void Write(TEntity entity);
}
public class ReaderWriter<TEntity> : IReaderWriter<TEntity> where TEntity : new()
{
public TEntity Read()
{
return new TEntity();
}
public void Write(TEntity entity)
{
}
}
#endregion
}
To tyle, mam nadzieję, że ten artykuł był dla Ciebie równie interesujący jak dla mnie interesujące było jego pisanie.
Oryginał tekstu w języku angielskim możesz przeczytać tutaj.