Primitive obsession és data objects
Léteznek a design patternek, amik segÃtenek abban, hogy a kódunkban ne találjuk fel újra a kereket és használjunk egy adott problémára jól bevált mintát. Ezen minták egy alcsoportja az anti-patternek, amelyek olyan minták, amik közép-hosszú távon inkább csak problémákat okoznak, mint megoldanak. Ma egy ilyen anti-pattern-rÅ‘l, a primitive obsession-rÅ‘l és a megoldásáról a data objects-rÅ‘l lesz szó.
A primitive obsession alapvetÅ‘en ez azért rossz, mert a primitÃv tÃpus, ami reprezentálja az adatot és az adat kezeléséhez szánt kód két helyen él és felesleges, ismétlÅ‘dÅ‘ redundancia lesz a kódban. Nézzünk egy példát. Például a kódunkban MAC cÃmeket kell kezelnünk és ezt egy string primitÃv tÃpussal reprezentáljuk. Milyen problémáink lehetnek?
- Validáció. Először is validálni kellene majd azt, hogy ez 48 bitnyi valid hexadecimális szám, ami vagy kettősponttal vagy kötőjellel elválasztott formátumban él.
- Konverziók. A DTO objektumokban, amit az átvitelnél használunk string tárolás teljesen jó, de elképzelhető, hogy másként byte tömbként lenne értelme tárolni. Erre kézenfekvő, hogy csinálunk egy konverziós metódust, amit valahova eldugunk az architektúránkban, de ahogy nőni fog a kód mérete tuti biztos, hogy ugyanarra a konverzióra lesz vagy két külön metódus két külön működéssel és más hibákkal. Szóval ha valami félremegy, akkor nem lesz egyszerű megtalálni.
- ÖsszehasonlÃtás: A
18-C0-5D-24-B2-8Eés a18:C0:5D:24:B2:8Emac cÃm azonos, de a szöveges reprezentációjuk más, ezért külön logikát kell Ãrnod összehasonlÃtásra, amit ha egy kritikus részen elfelejtesz meghÃvni és csak szimplán==operátorral hasonlÃtasz össze, akkor azonnal keletkezik egy hibád.
Ezen felül számos hibalehetÅ‘ség adódhat még, de talán ennyibÅ‘l sejthetÅ‘, hogy miért problémás az, ha egy primitÃv tÃpust használunk egy összetett reprezentálására.
De mi a megoldás? KészÃtsünk osztályokat, struktúrákat a primitÃvek helyett! Például legyen egy MacAddress struktúránk, ami belsÅ‘leg 6 byte-on tárolja az adatot és legyen ez a tÃpus ellátva metódusokkal, amelyek a konverziókhoz és egyebekhez tartoznak. Egy példa implementáció a MacAddress tÃpusra:
// IEquitable<T>, mert boxing, unboxingot elkerüljük
// IParsable<T>, mert a Parse és TryParse metódusokat implementáljuk
public readonly struct MacAddress : IEquatable<MacAddress>, IParsable<MacAddress>
{
//Azért nem tömb, mert a tömbök referenciák,
//és nem szerencsés value type-ban referenciát tárolni,
//mert akkor van egy stack-heap átmenet, ami lassÃtja a kódot.
private readonly byte _byte0;
private readonly byte _byte1;
private readonly byte _byte2;
private readonly byte _byte3;
private readonly byte _byte4;
private readonly byte _byte5;
public MacAddress(byte[] bytes)
{
if (bytes.Length != 6)
{
throw new ArgumentException("MAC address must be 6 bytes long.", nameof(bytes));
}
_byte0 = bytes[0];
_byte1 = bytes[1];
_byte2 = bytes[2];
_byte3 = bytes[3];
_byte4 = bytes[4];
_byte5 = bytes[5];
}
// SzövegbÅ‘l mac cÃm létrehozása, a Parse metódus implementálása
public static MacAddress Parse(string s, IFormatProvider? provider)
{
// Ha - jel van benne, akkor az az elválasztó.
char seperator = s.Contains('-') ? '-' : ':';
string[] parts = s.Split(seperator, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 6)
{
throw new FormatException("Invalid MAC address format.");
}
byte[] bytes = new byte[6];
for (int i = 0; i < 6; i++)
{
if (!byte.TryParse(parts[i], System.Globalization.NumberStyles.HexNumber, provider, out bytes[i]))
{
throw new FormatException("Invalid MAC address format.");
}
}
return new MacAddress(bytes);
}
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out MacAddress result)
{
if (string.IsNullOrWhiteSpace(s))
{
result = default;
return false;
}
try
{
result = Parse(s, provider);
return true;
}
catch (FormatException)
{
result = default;
return false;
}
}
// ToString metódus implementálása, a MAC cÃm szöveges reprezentációjának visszaadása
public override string ToString()
=> $"{_byte0:X2}:{_byte1:X2}:{_byte2:X2}:{_byte3:X2}:{_byte4:X2}:{_byte5:X2}";
// Equals felülÃrása, mert van tÃpusos Equals metódusunk
public override bool Equals(object? obj)
=> obj is MacAddress address && Equals(address);
// TÃpusos Equals metódus implementálása a boxing elkerülése végett
public bool Equals(MacAddress other)
{
return _byte0 == other._byte0 &&
_byte1 == other._byte1 &&
_byte2 == other._byte2 &&
_byte3 == other._byte3 &&
_byte4 == other._byte4 &&
_byte5 == other._byte5;
}
// Ha van equals felülÃrás, akkor a GetHashCode-ot is felül kell Ãrni, hogy a hash kódok is egyezzenek
public override int GetHashCode()
=> HashCode.Combine(_byte0, _byte1, _byte2, _byte3, _byte4, _byte5);
// ÖsszehasonlÃtó operátorok felülÃrása, hogy a == és != operátorok is működjenek.
// Ez opcionális, de hasznos lehet, ha a MacAddress tÃpusú változókat össze akarjuk hasonlÃtani.
public static bool operator ==(MacAddress left, MacAddress right)
=> left.Equals(right);
public static bool operator !=(MacAddress left, MacAddress right)
=> !(left == right);
//Explicit operátor, mert itt keletkezhet kivétel,
//ha a string nem megfelelő formátumú, ezért explicit konverziót használunk.
public static explicit operator MacAddress(string s)
=> Parse(s, null);
//Szintén explicit operátor, mert a byte tömb hossza nem biztos, hogy 6, ezért itt is keletkezhet kivétel.
public static explicit operator MacAddress(byte[] bytes)
=> new MacAddress(bytes);
//Ez implicit operátor, mert a MacAddress tÃpusú változókat szöveggé konvertálhatjuk anélkül, hogy kivétel keletkezne.
public static implicit operator string(MacAddress mac)
=> mac.ToString();
}
Néhány megfontolandó dolog:
- Ha a tÃpusodban tárolt adat < 32 byte, akkor legyen
struct. Ha ennél több adatot kell tárolni, vagy referencia tÃpust tárol a tÃpus, akkorclass. - A value objectek esetén nincs értelme öröklÅ‘désnek ezért az ilyen osztályok legyenek
sealdmódosÃtóval ellátva. Ha mégis kellene öröklés, az akkor nem value object és érdemes átgondolni a modellezést. - A value object legyen immutable, ha lehet, elkerülve a több szálon futás problémáit.
- Mivel value object az
IEquitable<T>legyen implementálva, hogy a megfelelÅ‘ érték szerinti és nem referencia alapú összehasonlÃtás tudjon működni. - Opetátorok: Implicit és explicit operátorokat nem kötelezÅ‘ definiálni, de megkönnyÃtik az életed és a kódÃrást. Implicit csak akkor legyen egy konverziós operátor, ha sosem történhet kivétel és adatvesztés. Ha adatvesztés vagy kivétel veszélye áll fenn, akkor explicit legyen az az operátor.
- Interfész és operátor harmónia: Ha a tÃpusod felülÃrja az
==operátort és a!=opertátort, akkor azIEquitable<T>interfész is legyen implementálva, ha az összehasonlÃtást implementálod (<, >, >=, <=), akkor azIComparable<T>is legyen implementálva, illetve lehetÅ‘leg a megfelelÅ‘ operátorokhoz tartozó interfészek is. Pl ha a tÃpusIEquitable<T>ésIComparable<T>is, akkor azIComparisonOperators<T1, T2, bool>is legyen implementálva.