Ako písať čitateľný kód s immutable objektami

Predstavme si, že chceme vytvoriť Immutable triedu / Entitu. Prikladom može byť Value object, message alebo Data Transfer Object (DTO). (Message ktorú mám na mysli je v podstate špeciálnym typom DTO)

Jediná možnosť, ako dostať do triedy parametre, je posunúť parametre konštruktoru triedy.
V rámci konštruktora možeme elegatne vykonať validácie, takže by sa nám nemalo podariť vytvoriť objekt v nekonzistentnom stave. Dajme si (silno neoreginálny) príklad - Immutable Objednávka a Immutable Riadok objednávky s reláciou 1 : n.

public class Order
{
private readonly string _firstName, _lastName;
private readonly int _orderNumber;
private readonly DateTime _created;
private readonly ReadOnlyCollection<OrderLine> _orderLines;

public string LastName
{
get { return _lastName; }
}

public string FirstName
{
get { return _firstName; }
}

public int OrderNumber
{
get { return _orderNumber; }
}

public DateTime Created
{
get { return _created; }
}

public ReadOnlyCollection<OrderLine> OrderLines
{
get { return _orderLines; }
}

public Order(string firstName, string lastName, int orderNumber, DateTime created, IList<OrderLine> orderLines)
{
if (lastName == null)
throw new ArgumentNullException("lastName");

_firstName = firstName;
_lastName = lastName;
_orderNumber = orderNumber;
_created = created;

_orderLines = new ReadOnlyCollection<OrderLine>(orderLines ?? new OrderLine[0]);
}
}

public class OrderLine
{
private readonly string _product;
private readonly int _quantity;
private readonly decimal _price;

public string Product
{
get { return _product; }
}

public int Quantity
{
get { return _quantity; }
}

public decimal Price
{
get { return _price; }
}

public OrderLine(string product, int quantity, decimal price)
{
if (quantity <= 0)
throw new ArgumentOutOfRangeException(
"quantity",
"Value must be higher than 0.");
_product = product;
_quantity = quantity;
_price = price;
}
}

Čím viacej vlastností bude entita mať, tým viac parametrov bude mať konštruktor a tým menej čitatelný kód bude pred nami.

Order order = new Order(
"Mickey",
"Mouse",
12345,
DateTime.Now,
new OrderLine[]{
new OrderLine("Product 1", 10, 20),
new OrderLine("Product 2", 2, 4)
}
);

Mne sa zdá čitatelnejší rozhodne takýto kód.

Order order2 = new OrderBuilder()
.WithNumber(12345)
.CreatedOn(DateTime.Now)
.ForCustomerNamed("Mickey", "Mouse")
.WithOrderLine("Product 1", 10, 20)
.WithOrderLine("Product 2", 2, 4);

Kto nesúhlasí, nech nečíta ďalej :-)

Riešenie je jednoduché. Immutable object + builder = fluent interface. Čo to znamená? Nič ako to, že potrebujeme vytvoriť ku našej triede ešte builder, ktorý umožní postupné nastavovanie parametrov cez metódy, ktoré bude možné chainovať.

public class OrderBuilder
{
private string _firstName, _lastName;
private int _orderNumber;
private DateTime _created;
private List<OrderLine> _orderLines;

public OrderBuilder ForCustomerNamed(string firstName, string lastName)
{
_firstName = firstName;
_lastName = lastName;
return this;
}

public OrderBuilder CreatedOn(DateTime date)
{
_created = date;
return this;
}

public OrderBuilder WithNumber(int number)
{
_orderNumber = number;
return this;
}

public OrderBuilder WithOrderLine(string product, int quantity, decimal price)
{
if (_orderLines == null)
_orderLines = new List<OrderLine>();

_orderLines.Add(new OrderLine(product, quantity, price));
return this;
}

public Order BuildOrder()
{
return new Order(_firstName, _lastName, _orderNumber, _created, _orderLines);
}

public static implicit operator Order(OrderBuilder builder)
{
return builder.BuildOrder();
}
}

Veľa zbytočného kódu naviac? Povedzme, že nás ako slušných programátorov čakajú Test Cases, povedzme ze objekt vytvárame v kóde na viacerých miestach. Investícia sa nám rýchlo vráti.

Ak sa nechceme pri debugovaní príliš zahrabať v chainovanom builder kóde, možeme si pomôct označením jednotlivých metód buildera pracujúcich s fieldami(pretože nič zaujímavé sa tam nedeje), attribútom [DebuggerStepThrough] (System.Diagnostics) a F11(step into) skočíme rovno do konštuktora buildovanej triedy.

public class OrderBuilder
{
private string _firstName, _lastName;
private int _orderNumber;
private DateTime _created;
private List<OrderLine> _orderLines;

[DebuggerStepThrough]
public OrderBuilder ForCustomerNamed(string firstName, string lastName)
{
_firstName = firstName;
_lastName = lastName;
return this;
}

[DebuggerStepThrough]
public OrderBuilder CreatedOn(DateTime date)
{
_created = date;
return this;
}

[DebuggerStepThrough]
public OrderBuilder WithNumber(int number)
{
_orderNumber = number;
return this;
}

[DebuggerStepThrough]
public OrderBuilder WithOrderLine(string product, int quantity, decimal price)
{
if (_orderLines == null)
_orderLines = new List<OrderLine>();

_orderLines.Add(new OrderLine(product, quantity, price));
return this;
}

[DebuggerStepThrough]
public Order BuildOrder()
{
return new Order(_firstName, _lastName, _orderNumber, _created, _orderLines);
}

public static implicit operator Order(OrderBuilder builder)
{
return builder.BuildOrder();
}
}

Alternatívne riešenia samozrejme existujú. Builder s gettermi a settermi, ktorý ale neumožnuje logické zoskupovanie relevatných properties a zabstraktraktnenie interface buildera. Dalšou možnosťou je muttable param object vystavujúci všetky properties entity posunutý do konštruktora miesto jednotlivých hodnôt a ich následné prekopírovanie v konštruktore do memberov. Vnášame do našeho immutable objektu zbytočný kód, vytvárame takto zbytočnú obojsmernú závislosť. Rovnaký problém má aj posunutie naplneného builder objektu hoci fluent prístupom do konštruktora. Tieto veci proste smrdia :-) Ďalšie nie moc štastné riešenie fluent buildera, ktoré som videl, bolo cez extension metódy.

Z kategoricky (ne)alternatívnych riešení možem vybať ešte dopisovanie komentárov ku každému parametru všade, kde budeme vytvárať objekt - pracné, neprehľadné a zbytočné. Ani Mouseover vo Visual Studiu pre mňa tiež nie je uspokojivá odpoveď.

Na niektorých detailoch zaleží. Konštruktor parametre ponechané len tak nám budú hovoriť niečo najviac tak mesiac, potom začneme bezpečne zabúdať na ich kontext a skomplikuje to čitatelnosť kódu aj nám, keď už ostatní jej čelia automaticky.

Zaradené do: , , ,

Komentáre

# vlko said:

este ma napada internal konstruktor a vytvaranie objektu cez repository

alebo inicializacia cez anonymny objekt, tam uz bude trosku zlozitejsi konstruktor, ale na druhu stranu sa napise iba raz a pri spojeni cez nejake vlastne atributy (check pre not null a pod0

Wednesday, March 04, 2009 12:45 PM
# T said:

vdaka za comment Vlko....

internal constructor - evidentna dependancy na objekte, ktory je zodpovedny za jeho vytvorenie. (ak je to ciel a opodstatnene tak OK, mohol som ho urobit internal aj ja)

inicializacia cez anonymny objekt - ten isty pripad ako param objekt do kontruktora(triedu vytvori compiler) a tie iste argumenty. ak ma v construktore aspon interface. To je este ten lepsi pripad

Ak chcem obist interface, musim urobit takuto zverinu, myslim, ze tento (very bad) design nepotrebuje comment ;-) Alebo sa da este inak?

public class FooClass

{

private int _foo;

private string _bar;

public FooClass(object p)

{

var p2 = Cast(p, new { Foo=1, Bar="" });

_foo= p2.Foo;

_bar= p2.Bar;

}

public T Cast<T>(object obj, T type)

{

return (T)obj;

}

public override string ToString()

{

return String.Format("{0} {1}", _foo, _bar);

}

}

Kontext clanku je mimo problematiku designu vrstiev takze pojem repository je irelevantne ;-)....ale OK, ak budes vytvarat napr. instanciu (immutable) value objektu priamo v repostory, budes celit problemu s citatelnostou tiez, problem sa len presuva na uroven metod repository.(alebo potom neviem, na mas na mysli :-(

Wednesday, March 04, 2009 1:26 PM
# vlko said:

No na ten cast bude urcite potrebne troska reflexie (blog.renestein.net/LINQ+II+P%C5%99etypov%C3%A1v%C3%A1n%C3%AD+I+Vno%C5%99en%C3%BDch+Anonymn%C3%ADch+Datov%C3%BDch+Typ%C5%AF+Z+Jin%C3%A9+Assembly.aspx), ale pretoze check ci boli zadane vsetky potrebne udaje nemas ani v tom tvojom builderi, tak to je v podstate jedno, ci pouzijes builder, alebo anonymny typ, a nemyslim si ze sa aj citatelnost zhorsi oproti fluent interface.

Problemom akurat bude, ze prides o typecheck a tym o typovu bezpecnost. Na druhu stranu vo verzii 4.0 bude dynamicka podpora a tym padom pretypovanie dynamickeho typu na interface a to by uz malo dost veci riesit.

Wednesday, March 04, 2009 1:58 PM
# T said:

Validacie - su v kontruktore objektu. Nema zmysel ich replikovat. Build metoda automaticky forcene validaciu, kedze je zodpovedna za vytvorenie instancie buildovaneho objeku.

Ano, typecheck a hidenutie contractu je najjasnejsi problem...prinos ziadny.  

Ten Reneho clanok (vid. prva cast) len rozvija henten moj sample a Rene sam rozumne varuje hned na zaciatku pred praktizovanim podobnych veci. Inak stalo ma to dost sil, kym som sa cez to preluskal, cvicenie to bolo dobre :-)

Pretypovanie anonymouse na interface...neuvedomil som si, ze to este nechodi, spomenul som to vyssie v diskusii...(mozeme to jedine znasilnit nejakym API tak, ze vyrobime nad tym proxac a supneme na neho IFace...ale to smrdi este viac :-)

public interface IParams

{

int Foo { get; }

string Bar { get;}

}

public class FooClass2

{

private int _foo;

private string _bar;

public FooClass2(IParams p)

{

_foo= p.Foo;

_bar= p.Bar;

}

public override string ToString()

{

return String.Format("{0} {1}", _foo, _bar);

}

}

Co ziskam oproti naimplementovaniu rovno simple Param class, zavislost zostavaju...ci bude IFace alebo rovno Class.

Wednesday, March 04, 2009 2:35 PM
# vlko said:

teraz neviem, je to teda dobre riesenie, alebo nie?:)

Wednesday, March 04, 2009 2:55 PM
# T said:

Zalezi ktore, v diskusii spominame horsie, zle, a uplne zle...

tie anonymouse class riesenia su vsetky z kategorie zle...

to, ktore pouziva "Cast" funkciu je "uplne zle" :-)...

v kategorii "horsie" su tie alternativy co spominam v clanku + analogicke...nejake si doplnil Ty ;-)

Wednesday, March 04, 2009 3:30 PM
# vlko said:

Tak ma este napadlo jedno za pouzitia AOP dovolit  volanie settera iba pred akymkolvek volanim gettera (pripadne ho nejak inak obmedzit, je to sice troska divne, ale potom mozes pouzit inicializaciu, co je pekny posun v citatelnosti kodu.

Potom tu mame IoC kontainer a dynamic proxy a pri implicitnom caste vytvorit readonly objekt, ale to je podobne s riesenim s AOP.

A tretim je umoznit volat setter dokial nie je zavolana napr metoda Finalize, tu uz iba postaci nejaka privatna property a kusok kodu do kazdeho settera.

Wednesday, March 04, 2009 4:09 PM
# T said:

Ten AOP(aj bez AOP ale privela prace) pristup je runtime(checkom) riesenie immutability,  nem paci sa mi :-) Je de facto to iste ako ten posledny napad. Navyse mozes viackrat volat ten isty setter za sebou(na objekte). Da sa tiez obkodovat...ale...zvrhava sa to...

Ved ak chces velmi settre, urob builder so settrami...= zrejme to iste co chces vyriesit s dynamic proxy.

Minimalne preto, ze z interface triedy nie je jasne, ze je trieda je immutable. OK, toto by som dal do kategorie "horsie" ;-) A aj tak rozmyslam, co napr. s collections.

Este jedna vyhoda Buildera, viem ho reusenut.

OrderBuilder orderBuilder = new OrderBuilder()

.WithOrderLine("Product 1", 10, 20)

.WithOrderLine("Product 2", 2, 4);

Order orderA = orderBuilder

.WithNumber(1)

.CreatedOn(DateTime.Now)

.ForCustomerNamed("Bill", "Gates");

Order orderB = orderBuilder

.WithNumber(2)

.CreatedOn(DateTime.Now)

.ForCustomerNamed("Steve", "Ballmer");

V tomto pripade to vsak pri praci s collection tak ako je to navrhnute zacina smrdiet. Musim dorobit metodu ClearLineItems() a to sa mi nem paci. A vseobecne, pri praci parent/child entitami, ktore maju builder zacina byt problem, prestava to byt fluent a citatelne

Mam dve moznosti, prva - exposenut na OrderBuildery len metodu WithOrderLines(List<OrderLine> orderLines)

Vysledok nic moc

Order order = new OrderBuilder()

.WithNumber(12345)

.CreatedOn(DateTime.Now)

.ForCustomerNamed("Mickey", "Mouse")

.WithOrderLines

(

new OrderLine[]

{

new OrderLineBuilder()

.ForProduct("Product 1")

.OfQuantity(10)

.WithPrice(20),

new OrderLineBuilder()

.ForProduct("Product 2")

.OfQuantity(2)

.WithPrice(4)

}

);

Nic moc. Lepsie pouzit params ... toto vyzera znesitelnejsie ale uz to nie je fluent v tom zmysle ako povodne riesenie z clanku :-(

Order order = new OrderBuilder2()

.WithNumber(12345)

.CreatedOn(DateTime.Now)

.ForCustomerNamed("Mickey", "Mouse")

.WithOrderLines

(

new OrderLineBuilder()

.ForProduct("Product 1")

.OfQuantity(10)

.WithPrice(20),

new OrderLineBuilder()

.ForProduct("Product 2")

.OfQuantity(2)

.WithPrice(4)

)

Ale uz sme asi okolo toho rozbehli jadrovo elektrarenske uvahy :-)

Wednesday, March 04, 2009 6:02 PM
# T said:

snad v tych myslienkovych skokoch vysomaris, lepil som to pomedzi pracu...a ten kod je vo VS samozrejme kategoricky inak citalny ako ked to hentak hlupo zalomi...

Wednesday, March 04, 2009 6:14 PM
# T said:

aj tak stale zle :-(

Wednesday, March 04, 2009 7:56 PM
# joey said:

Ahoj,

diky za ukazku custom builderu k objektu - je to velmi nazorne.

Mam nejake poznamky - vzhledem k datu clanku (rok 2009) bych slapal cesticku uz alespon v C# 3.5 a napriklad jeste zjednodusil Properties a nahradil ty fieldy konstruktem v properite: { get; private set; } coz by melo byt stejne silne (jestli nejsem mimo).

Taky bych podotknul ze prave v danem priklade je dost casto potreba mit moznost data, kterymi se objekt inicializuje take menit i po konstrukci (i to jmeno je kolikrat potreba mit moznost zmenit), takze bych to videl i na public set -- v takovem pripade pak uz neni problem pouzit post-inicializaci po konstrukci zkracenym zapisem:

new Order()

{

 ForProduct = "Product 1",

 OfQuantity = 10,

 ...

 WithPrice(20),

}

Tam pak uz builder objekt dle meho nema tu silu a ten argument o prehlednosti pada. Je to tak?

Wednesday, June 24, 2009 4:18 PM
# T said:

Diky za komment joey.

Dal som to do kontextu DTO - cize dataTransfer objekty, kde je dalsie modifikovanie nezelanie. Objekt len nosicom dat.

Som si vedomy moznosti riesit to cez syntax new Object { ... }.

Problem je vsak v tom, ze ja chcem zabranit, aby sa objekt vytvoril v nevalidnom stave a chcem presne vymedzit a ovalidovat vstupy pri vytvarani objektu.

Modifikovat (post-inicializovat) mozes builder ale v momente, ked ho nechas vytvorit objekt, v ktoreho kontruktore su validacie, uz je nexiaduce aby si ho modifikovat cez settre.

Co sa tyka modifikacii...povedzme ze nepojde o DTO ale domain object... a tu je doraz na to, ze existuje nejaky inicialny stav objektu, vytvorim instanciu spolu s jeho inicialnym stavom.Ak sa vytvori instancia, viem, ze je vo validnom stave a udrzat vo validnom stave sa ju budem snazit aj dalej.

Kazda zmena dalsia stavu objektu je spojena s nejakym behavior, ktory sa da pomenovat a zachytit v nazve metody. Parametre metody obsahuju presne vymedzenu mnozinu dat, ktore vystupuju v ramci tohoto behavior. Viem tu vyriesit validacie, takze sa objet nikdy nedostane do nevalidneho stavu.

Aj pri immutable scenari mam vyhodu, nevytvorim kopiu objektu po kazdej zmene property.

Settre vnasaju prilis vela komplexity, rozne kombinacie parametrov, mas ovela zlozitejsie testovanie etc.

Hore uvedeny sposob mi umozni lahko trackovat zmenu stavu, sledovat tranzakcne spravanie pri modifikacii objektu.

Ale to sme uz o kus dalej.

Tie buildre som posunul este dalej, ale nemal som cas publikovat...

Monday, June 29, 2009 9:17 AM
Prihlásiť | Registrovať | Pomoc