Zwykle programiści korzystają z podstawowych typów dostarczonych przez C#, takich jak String, Int32 czy Double. W świecie programowania obiektowego można jednak pójść o krok dalej i budować własne typy danych. Przeważnie programiści korzystają z nich wyłącznie, gdy do zaimplementowania jest jakaś logika. Dlaczego nie tworzyć ich nawet w sytuacjach, gdy mają one przechowywać wyłącznie dane?
Problem z podstawowymi typami takimi jak String Czy Int32 to fakt, że stanowią one wyłącznie fizyczną, a nie logiczną reprezentację danych. Int32 opisuje każdą liczbę całkowitą, co jest dość szerokim określeniem. Załóżmy, że w kodzie mamy dwie jednostki do opisania:
– prędkość (km/h)
– temperatura (C)
Standardowe rozwiązanie to użycie typów podstawowych, np. Int32:
int velocity = 85;
int temperature = 20;
Niestety powyższy kod nie będzie mógł korzystać z zalet statycznego typowania. Poniższy kod skompiluje się i o błędzie dowiemy się dopiero przy wykonywaniu jakiś operacji:
int velocity = 85;
int temperature = 20;
int result = velocity * temperature;
Prawdopodobnie nie ma sensu mnożyć prędkości przez temperaturę. Analogicznie typy podstawowe nie dostarczają żadnej walidacji logicznej reprezentacji. Co prawda w przypadku liczb, możemy zadeklarować velocity jako uint co ma większy sens ponieważ unikniemy wtedy błędów z związanych z ujemną prędkością. Problem jednak pojawi się w przypadku string:
string email="test@test.com";
W tym przypadku zdecydowanie potrzebna jest walidacja.
Zdefiniujmy zatem typy opisujące logiczną reprezentację danych:
struct Velocity
{
private readonly uint _value;
public Velocity(uint value)
{
_value = value;
}
}
struct Temperature
{
private readonly int _value;
public Temperature(int value)
{
_value = value;
}
}
Od tej pory, niemożliwe jest popełnienie następującego błędu:
var velocity = new Velocity(43);
var temperature = new Temperature(5);
var result = velocity + temperature;
Kod zakończy się błędem kompilacji: “Operator ‘+’ cannot be applied to operands of type ‘Velocity’ and ‘Temperature'”. Błąd wykryty na etapie kompilacji jest oczywiście dużo bardziej pożądany niż te spotykane już w trakcie działania aplikacji.
Oprócz korzyści z związanych z bezpiecznym typowaniem, kod jest łatwiejszy w zrozumieniu. Typ nadaje kontekst danej wartości.
Dodanie walidacji sprowadza się teraz wyłącznie do:
struct Temperature
{
private readonly int _value;
public Temperature(int value)
{
_value = value;
if (!IsValid(value))
throw new ArgumentException("Temperature must be between -2000 and 2000.");
}
private bool IsValid(int value)
{
return value < 2000 && value > -2000;
}
}
Oczywiście to dopiero początek. W praktyce chcemy mieć do dyspozycji pewne operatory, np.:
var temperature1 = new Temperature(42);
var temperature2 = new Temperature(42);
var temperature3 = new Temperature(41);
Console.WriteLine(temperature1 == temperature2); // true
Console.WriteLine(temperature1 == temperature3); // false
Jeśli chcemy wspierać ==, wtedy wystarczy napisać:
struct Temperature
{
private readonly int _value;
public Temperature(int value)
{
_value = value;
if (!IsValid(value))
throw new ArgumentException("Temperature must be between -2000 and 2000.");
}
private bool IsValid(int value)
{
return value < 2000 && value > -2000;
}
public static bool operator ==(Temperature t1, Temperature t2)
{
return t1.Equals(t2);
}
public static bool operator !=(Temperature t1, Temperature t2)
{
return !t1.Equals(t2);
}
}
W praktyce warto zadeklarować bazowy typ, który implementuje najczęściej używane operacje (==,!=,>,<,+,-). Nie można również przesadać w drugą stronę i każdą zmienną owijać w typ. Moim zdaniem bardzo szybko taki kod stanie się przykładem overengineering. Jeśli jakiś typ danych występuje bardzo często w kodzie (np. money), wtedy warto o tym pomyśleć.