(Toto je druhá část článku o hodnotových typech v jazyce C#, první část je zde, třetí část je zde.)
Minule jsme si rozebrali jak jsou hodnotové typy ukládány přímo ve storage odpovídající nějaké lokální proměnné nebo fieldu a instance nejsou samostatně alokovány “někde bokem” tak jako u typů referenčních.
Důsledkem je i to, že u každé struktury existuje “prázdná” hodnota tj. hodnota odpovídající vyprázdněnému storage (runtime pouze zajistí, že pokud je obsah storage z našeho programu dostupný je alespoň vždy již “vynulovaný” – celý vyplněný nulami (*)). Tato “prázdná” hodnota vznikne například explicitním voláním výchozího konstruktoru struktury nebo přiřazením default(T) v případě hodnotového typu T. Přitom se ale ve skutečnosti žádný konstruktor struktury ani nemusí volat, což je také důvod proč nelze u struktury výchozí konstruktor přepsat nebo změnit jeho viditelnost.
struct S
{
readonly int x;
private S() //<--Compile error: Structs cannot contain explicit parameterless constructors
{
this.x = 1;
}
public S(int x)
{
this.x = x;
}
}
Z opačného pohledu lze naopak říci, že každá struktura má vždy výchozí konstruktor, který inicializuje všechny datové položky struktury na jejich výchozí hodnoty.
Při psaní vlastních struktur je tedy třeba s existencí výchozí “prázdné” hodnoty počítat. Dobrým zvykem je například zavést statickou vlastnost nebo field Empty a umožnit porovnání mezi hodnotami struktur:
public struct Point
{
public static readonly Point Empty;
private readonly int x;
private readonly int y;
public int X { get { return x; } }
public int Y { get { return y; } }
public Point(int x, int y)
{
this.x = x;
this.y = y;
}
public static bool operator ==(Point left, Point right)
{
return (left.X == right.X) && (left.Y == right.Y);
}
public static bool operator !=(Point left, Point right)
{
return !(left == right);
}
public override bool Equals(object obj)
{
if (!(obj is Point))
{
return false;
}
Point point = (Point)obj;
return (point.X == this.X) && (point.Y == this.Y);
}
public override int GetHashCode()
{
return this.X ^ this.Y;
}
}
static class Test
{
static void Main()
{
Point[] a = new Point[1000];
Console.WriteLine(a[999] == Point.Empty); //True
}
}
Z testu je vidět, že například alokací pole vniknou ve všech prvcích pole platné instance naší struktury, jejíchž hodnoty jsou stejné jako je hodnota instance Empty (tedy 0, 0).
(Pozn.: Pokud by struktura nepodporovala porovnávání hodnot, kompilátor by nám tuto operaci ani nepovolil.)
Druhým problémem a rozdílem oproti psaní třídy je tzv. Definite Assignment tj. kontrola, která obecně zabraňuje používat neinicializované hodnoty. V kombinaci s auto-implemented properties, ale můžeme narazit. Pokud bychom naší strukturu z výše uvedeného příkladu napsali například takto:
public struct Point
{
public int X { get; private set; }
public int Y { get; private set; }
public Point(int x, int y) //<--Compile Error (1, 2)
{
this.X = x; //<--Compile Error (3)
this.Y = y;
}
}
Dostaneme následující na první pohled možná podivné chyby:
(1) Backing field for automatically implemented property 'X' must be fully assigned before control is returned to the caller.
(2) Backing field for automatically implemented property 'Y' must be fully assigned before control is returned to the caller.
(3) The 'this' object cannot be used before all of its fields are assigned to.
Důvod je ale poměrně jednoduchý, při použití auto-implemented property kompilátor neví, že do fieldů opravdu přiřazujeme, protože se tak děje nepřímo přes generovaný setter. Řešení je buď jednoduše
auto-implemented property ve struktuře nepoužívat jako jsme to udělali u našeho prvního příkladu nebo explicitně zavolat výchozí konstruktor struktury:
public struct Point
{
public int X { get; private set; }
public int Y { get; private set; }
public Point(int x, int y): this()
{
this.X = x;
this.Y = y;
}
}
Pozor ale, že tím přijdeme o kontrolu, že jsme opravdu nezapomněli v konstruktoru do některého z fieldů přiřadit, proto toto řešení není moc doporučované.
A ještě ukážu jedno okrajové omezení, které také vychází z nám již známé interní reprezentace instance struktury. Mějme následující třídu, která by mohla například představovat prvek jednoduchého “spojáku”:
class Node<T>
{
public T Current { get; private set; }
public Node<T> Next { get; private set; }
//...
}
Co by se stalo, kdyby jsme chtěli vytvořit strukturu, která by měla stejné datové položky jako uvedená třída?
struct Node<T>
{
public T Current { get; private set; }
public Node<T> Next { get; private set; } //<--Compile error
//...
}
To by odpovídalo tomu, že by musel být celý libovolně dlouhý spoják alokován staticky najednou. (Kompilátor nás na tuto situaci upozorní konkrétně chybou “Struct member ‘Next’ causes a cycle in the struct layout”.)
Tím jsme probrali nejzákladnější rozdíly při psaní struktur, příště budeme pokračovat tím proč je dobré struktury navrhovat jako immutable (pouze pro čtení) a zda vůbec lze ve struktuře udělat readonly field, který je skutečně “readonly”.
(Toto je druhá část článku o hodnotových typech v jazyce C#, první část je zde, třetí část je zde.)
(*) Ve skutečnosti je i alokace instancí hodnotových typů o něco složitější.
Update: Ve stručnosti jde o to, že instance struktury se ve skutečnosti inicializuje nejprve do pomocné temporary proměnné a teprve po její konstrukci je provedeno přiřazení (kopírování hodnotou) z této temporary proměnné do cílového umístění. Toto je teprve v případech, kdy pro C# program není možné pozorovat (observe) rozdíl, optimalizováno na pouhou “in-place” inicializaci.
Důvod můžeme demonstrovat následujícím programem:
struct S
{
private int x;
private int y;
public int X { get { return x; } }
public int Y { get { return y; } }
public S(int x, int y)
{
this.x = x;
throw new Exception();
this.y = y;
}
}
static void Main()
{
S s = default(S);
try { s = new S(123, 456); } catch { }
Console.WriteLine("X={0}, Y={1}", s.X, s.Y); //X=0, Y=0
}
Pokud by byla prováděna rovnou “in-place” inicializace, byla by hodnota s.X ve výpisu 123.