Ahmed Tarek
Ahmed TarekSoftware Engineer

Kowariancja i kontrawariancja w .NET C#

Poznaj różnice pomiędzy kowariancją i kotrwariancją. Sprawdź, jak są stosowane w praktyce.
16.12.202111 min
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:

  1. Klasa A ma zdefiniowaną F1()
  2. Klasa B ma zdefiniowane F1() i F2()
  3. Klasa C ma zdefiniowane F1(), F2() i F3().
  4. Interfejs IReaderWriter ma Read(), co zwraca obiekt typu TEntity oraz Write(TEntity entity), który oczekuje parametru typu TEntity.


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:

  1. param.Read()zwróciłaby instancję klasy A, a nie B
    => Tak więc var b będzie w rzeczywistości typu A, a nie B
    => To prowadziłoby do tego, że linijka b.F2() nie powiedzie się, ponieważ var b — która w rzeczywistości jest typu A — nie posiada zdefiniowanej F2()
  2. Linia param.Write() w powyższym kodzie oczekiwałaby otrzymania parametru typu A, a nie B
    => Tak więc wywołanie param.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:

  1. param.Read()zwróciłoby instancję klasy C, a nie B
    => Tak więc var b będzie w rzeczywistości typu C, a nie B
    => To doprowadziłoby do tego, że linijka b.F2() działałaby dobrze, ponieważ var b miałaby F2()
  2. Linijka param.Write() w powyższym kodzie oczekiwałaby otrzymania parametru typu C, a nie B
    => W rezultacie wywołanie param.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:

  1. Wywołanie TestReadWriter(IReaderWriter<B> param) przy przekazaniu instancji IReaderWriter<B> jest zawsze w porządku.
  2. Wywołanie TestReadWriter(IReaderWriter<B> param) podczas przekazywania instancji IReaderWriter<A> byłoby w porządku, gdyby nie było wywołania param.Read().
  3. Wywołanie TestReadWriter(IReaderWriter<B> param) podczas przekazywania instancji IReaderWriter<C> byłoby w porządku, gdyby nie było wywołania param.Write()
  4. Jednakże, ponieważ zawsze mamy mieszankę pomiędzy param.Read() i param.Write(), zawsze musielibyśmy trzymać się wywołania TestReadWriter(IReaderWriter<B> param) z przekazaniem instancji IReaderWriter<B>, nic więcej.
  5. 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

  1. W prawdziwym świecie kompilator — w czasie projektowania — nigdy nie pozwoliłby na wywołanie TestReadWriter(IReaderWriter<B> param) z przekazaniem instancji IReaderWriter<A>. Otrzymalibyśmy błąd kompilacji.
  2. Również kompilator — w czasie projektowania — nie pozwoliłby na wywołanie TestReadWriter(IReaderWriter<B> param) z przekazaniem instancji IReaderWriter<C>. Otrzymalibyśmy błąd kompilacji.
  3. Punkt #1 i #2, nazywamy to inwariancją.
  4. Nawet jeśli porzucilibyśmy TEntity Read() z interfejsu IReaderWriter<TEntity>, kompilator — w czasie projektowania — nie pozwoliłby na wywołanie TestReadWriter(IReaderWriter<B> param) z przekazaniem instancji IReaderWriter<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 od TEntity, albo będą traktować go jako wejście, a nie wyjście. Nazywa się to kontrawariancją.
  5. Podobnie, nawet jeśli porzucimy void Write(TEntity entity) z interfejsu IReaderWriter<TEntity>, kompilator — w czasie projektowania — nie pozwoli nam na wywołanie TestReadWriter(IReaderWriter<B> param) z przekazaniem instancji IReaderWriter<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 od TEntity, albo będą traktować go jako wyjście, a nie wejście. Nazywa się to kowariancją.
  6. 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:

  1. Mieszania między typem ogólnym wejścia a wyjścia => inwariancja => najbardziej restrykcyjny => nie można zastąpić rodzicami ani dziećmi.
  2. Dodanie <in > => tylko wejście => kontrwariancja => sama lub zastąpione rodzicami.
  3. 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.

<p>Loading...</p>