Zanim przejdę do wyjaśniania po co został wprowadzony atrybut StructLayout, najpierw wyjaśnię jak pola w strukturach danych są rozmieszczane w pamięci. Weźmy na przykład taką strukturę:
struct SampleStruct { public byte OneByte; public int FourBytes; public byte OneByte1; }
Ile pamięci powinno zostać zaalokowanej dla powyższej struktury? Może wydawać się, że 6 ponieważ Integer zajmuje 4 a Byte 1. Ze względu na optymalizacje nie jest to takie proste i oczywiste. Na moim komputerze jest to 12. Procesor operuje (w zależności od architektury) na danych o wielkości 4 bajty (32 bity). Z tego względu najszybciej dla niego jest odczytać fragmenty znajdujące się na pozycji 4n. Gdyby powyższa struktura zajmowała 6 bajtów wtedy Integer zaczynałby się od drugiego bajta. Aby procesor mógł odczytać taką zmienną musiałby przeczytać dwa kawałki pamięci (pamiętamy, że CPU operuje na 4 bajtowych fragmentach). Prosta optymalizacja polega zatem na wypełnieniu struktury pustymi bajtami. Tak więc OneByte zajmuje nie jeden bajt a cztery. Podobnie sprawa wygląda z ostatnim polem. Dzięki temu, CPU może za jednym razem odczytać każdą ze zmiennych – nie ma konieczności czytania dwóch fragmentów pamięci.
Nie zawsze jednak chcemy takiej optymalizacji. StructLayout daje nam większą kontrolę jak pola struktury będą wypełniane lub porządkowane. Parametr LayoutKind posiada 3 wartości:
-
Sequential – domyślna wartość. Pola są szeregowane jeden po drugim. Jeśli OneByte jest pierwszy w kolejności to i w pamięci będzie jako pierwszy.
-
Explicit – pozwala uporządkować dowolnie pola. Programista może samodzielnie określić gdzie dane pola powinny zaczynać się.
-
Auto – nie mamy żadnego wpływu i wszystko zostanie automatycznie ustalone przez środowisko uruchomieniowe.
Dla C# Sequential jest domyślną wartością ale oprócz LayoutKind można ustalić tzw. Pack. Zobaczmy przykład:
[StructLayout(LayoutKind.Sequential,Pack = 1)] struct SampleStruct { public byte OneByte; public int FourBytes; public byte OneByte1; }
Pack określa wspomniane wypełnianie. Pack równy jeden znaczy, że pola mogą mieć minimalnie jeden bajt. W praktyce oznacza to, że SampleStruct będzie miał rozmiar 6 – łatwo sprawdzić za pomocą funkcji Sizeof:
Console.WriteLine(Marshal.SizeOf(new SampleStruct()));
Analogicznie, Pack=2 spowoduje, że struktura zostanie upakowana do 8 bajtów:
class Program { [StructLayout(LayoutKind.Sequential,Pack = 2)] struct SampleStruct { public byte OneByte; public int FourBytes; public byte OneByte1; } static void Main(string[] args) { // 8 bajtów Console.WriteLine(Marshal.SizeOf(new SampleStruct())); } }
A co się stanie z poniższą deklaracją (Pack=3)?
[StructLayout(LayoutKind.Sequential,Pack = 3)] struct SampleStruct { public byte OneByte; public int FourBytes; public byte OneByte1; }
Powyższy kod nawet się nie skompiluje i dostaniemy następujący błąd:
System.Runtime.InteropServices.StructLayoutAttribute' attribute -- 'Incorrect argument value.'
.NET akceptuje wyłącznie następujące wartości Pack: 0, 1, 2, 4, 8, 16, 32, 64,128. Dla innych liczb, kompilacja po prostu będzie niemożliwa. Zero ( 0 ) jest wartością domyślną i oznacza, że pakowanie będzie dokonane przez runtime i zależy od aktualnej platformy. Jeden ( 1 ) w praktyce oznacza, że kolejne pola nie będą miały przerw między sobą – brak wspomnianego wypełniania pustymi bajtami. Pozostałe wartości (2 i większe) oznaczają, że pola będą rozpoczynały się od wielokrotności pola PACK. Zatem jeśli Pack=2 to poszczególne pola mogą zaczynać się od 0,2,4,6,8 itp. W praktyce wartości wyższe niż 4 lub 8 są po prostu ignorowane ponieważ procesor nie operuje na tak dużych porcjach danych. Poniższy kod wciąż zwraca 12:
[StructLayout(LayoutKind.Sequential,Pack = 128)] struct SampleStruct { public byte OneByte; public int FourBytes; public byte OneByte1; } static void Main(string[] args) { // PACK został zignorowany - niezgodny z architekturą aktualnego CPU Console.WriteLine(Marshal.SizeOf(new SampleStruct())); }
Pack można używać zarówno dla Sequential jak i Explicit. Dla Auto poskutkuje to błędem runtime:
class Program { [StructLayout(LayoutKind.Auto,Pack = 4)] struct SampleStruct { public byte OneByte; public int FourBytes; public byte OneByte1; } static void Main(string[] args) { // PACK został zignorowany - niezgodny z architekturą aktualnego CPU Console.WriteLine(Marshal.SizeOf(new SampleStruct())); } }
Aby przekonać się, że Sequential naprawdę jest wartością domyślną spróbujmy zmienić kolejność pól struktury:
class Program { struct SampleStruct { public int FourBytes; public byte OneByte; public byte OneByte1; } static void Main(string[] args) { Console.WriteLine(Marshal.SizeOf(new SampleStruct())); } }
Bez używania Pack, na ekranie wyświetli się wartość 8 – po drobnej zamianie kolejności nasza struktura ma mniejszy rozmiar. Ucieszy to na pewno osoby lubiące optymalizować wszystko co się da Dodam, że jest to znacznie lepsze niż używanie parametru Pack – Pack=1 nie jest dobrym rozwiązaniem ze względu na specyfikę pracy CPU i jego operowanie na porcjach danych (słowach).
Ostatnią wartością jest Explicit. Daje ona największe możliwości jeśli chodzi o alokację pamięci. Jeśli zdecydujemy się na Explicit, sami musimy za pomocą atrybutu FieldOffset określić gdzie dane pole zaczyna się w pamięci. Aby otrzymać rozmiar 8 bez wspomnianej wyżej zmiany kolejności pól wystarczy:
class Program { [StructLayout(LayoutKind.Explicit)] struct SampleStruct { [FieldOffset(0)] public byte OneByte1; [FieldOffset(2)] public int FourBytes; [FieldOffset(1)] public byte OneByte; } static void Main(string[] args) { // 8 Console.WriteLine(Marshal.SizeOf(new SampleStruct())); } }
Brak atrybutu FieldOffset spowoduje błąd na etapie kompilacji – jest to po prostu niezbędne dla Explicit. Powyższy przykład chyba jest jasny: pola Byte zaczynają się od 0 i 1 a Integer od 2. Oczywiście lepiej użyć Sequential i zmianę kolejności pól lub atrybut Pack. Nie zawsze się jednak tak się da ale o tym w następnym poście.
W dzisiejszym poście było dość teoretycznie, w zasadzie bez praktycznego przykładu. W następnym wpisie wyjaśnię dlaczego StructLayot został dostarczony i kiedy faktycznie z niego należy korzystać. Po dzisiejszym poście kwestia API powinna być już jasna.
Przy okazji warto wspomnieć o ciekawostce związanej z “Explicit”, mianowicie nakładanie na siebie pól (a także wskaźników) pozwala na modyfikowanie wewnętrznych danych nałożonych obiektów, więcej:
http://netpl.blogspot.com/2010/10/is-net-type-safe.html
Ta ciekawostka to sposób na stworzenie unii znanej z C/C++ w C#.