String műveletek belülről

A string típus egy furcsa szeglete a .NET-nek. Elvileg egyszerű dologról van szó, mégis több ökölszabály/legenda kering a témával kapcsolatban, más dolgok pedig teljesen homályosak.

Kezdjük például egy egyszerű kérdéssel. Mi történik, az alábbi kódban?

static void f()
{
  var s = "alma";
  Console.WriteLine(s);
} // f()

Itt nem az a lényeg, hogy kiírja, hogy alma. A lényeg, hogy hogyan oldódik fel az s változó? Mi történik a háttérben? Az biztos, hogy a művelet végére lesz egy referencia egy string példányra, de ki állítja elő azt a példányt? Az f() függvény valami olyasmire fordul, ami létrehoz egy string példányt? És ha igen, mit csinál az alábbi program:

static void g()
{
  var s = "alma";
  Console.WriteLine(s);
} // f()

static void f()
{
  g();
  var s = "alma";
  Console.WriteLine(s);
} // f()

Lesz két string példány a memóriában, vagy nem? Vagy mi lesz az eredménye például ennek:

static void f()
{
    var e1 = String.Empty; // static readonly string Empty = ""
    var e2 = "";
    Console.WriteLine(Object.ReferenceEquals(e1, e2));
} // f()

Ha elsőre nem világos, ez azért trükkös kérdés, mert a String.Empty más assembly-ben definiált mint az f(), aminek meg van a saját metaadata a user string heap-pel, saját üres stringgel. (hogy ez mit jelent, lásd később) A kis példaprogramnak, ami az f() függvényt definiálja szintén saját metaadata van, saját user string heap-pel saját üres stringgel. Mégis, a programot lefuttatva látszik, hogy a referenciák összetalálkoznak valahogy.

User String Heap

Az assembly metaadatok ismerősen csenghetnek. A .NET assembly-k egy rakás táblázattal rendelkeznek, a definiált osztályokról, hivatkozott osztályokról, az osztályok mezőiről, és hát az assembly-ben használt string literálokról is. Ezek a literálok kerülnek a User String Heap-be, ami a program bináris állományának egy része, és nincsen semmi köze a futás közbeni heap-hez, amibe a referencia típusú objektum példányok kerülnek. A következő kis programmal kipróbálható, milyen stringeket tesz a fordító a User String Heap-be:

static void f()
{
    var a = "alma";
    var b = "fa";
    var c = "alma" + "fa";
    var d = "érik" + " a " +
            "cseresznye" + " az " +
            "almafán";
    var a2 = "alma";
    var b2 = "fa";
} // f()

Az IL Disassembler segítségével beletekinthetünk a metaadatok közé (fontos, hogy előtte be kell kapcsolni a View/MetaInfo/Raw:Heaps opciót):

Több dolgot észrevehetünk. Egyrészt látszik, hogy a fordító összevonja a több helyen szereplő, de azonos tartalmú stringeket. Nem szerepel emiatt kétszer az “alma” és a “fa”. Másik dolog, amit megállapíthatunk, hogy azokat a string műveleteket, amelyek kimenetele biztos, a fordító elvégzi, és az eredményt veszi fel a User Stringek közé. Ezeknek tehát két mikro-optimalizációs vonatkozása van: egyrészt nem pazarolunk memóriát a több helyre felvett string litárálokkal (de attól ez még csúnya!!!) másrészt nem érdemes 150 karakter széles string literálokkal dolgozni, hogy elkerüljünk felesleges műveleteket – nyugodtan lehet használni sortöréseket.

Hogy kel életre a string literál?

A bevezető kérdés az volt, hogy hogyan működik az alábbi kód:

var s = "alma";
Console.WriteLine(s);

Ennek legegyszerűbben úgy járhatunk utána, ha megnézzük, mit fordít belőle a compiler. Ahhoz, hogy a lényeget lássuk, az IL Disassembler-t fogjuk használni, beállítva a view/Show bytes opciót:

.locals init ([0] string s)
/* 72   | (70)000001       */ ldstr      "alma"
/* 0A   |                  */ stloc.0
/* 06   |                  */ ldloc.0
/* 28   | (0A)000010       */ call       void [mscorlib]System.Console::WriteLine(string)
/* 2A   |                  */ ret

A lényeg az első soron van, az s = “alma” egy ldstr IL utasítássá fordult, és bár az IL Disassembler a jobb olvashatóság érdekében volt olyan kedves úgy ábrázolni, hogy ennek az utasításnak a paramétere az “alma”, a hexa kódokból (na meg az ldstr dokumentációjából) látszik, hogy valójában egy metaadat tokent vár (0x70000001). Mire az ldstr lefut, az evaluation stackon ott van a string példány referenciája, hiszen azt el tudja tárolni az stloc.0-cal egy string típusú referencia változóba.

Mivel az IL szinten túl nagy lépésben történnek a dolgok, nézzük meg mi az x86-os gépikód (debugger attacholva, nem optimalizál a jitter). A kódban csak két sor lényeges, de a többit is felkommenteztem, ha valakit érdekelne:

// verem állapot mentése és előkészítése
// a függvény futásához
00000000  push        ebp  
00000001  mov         ebp,esp 
// egy lokális változó van, itt nem az
// eax-et menti el a jitter, csak helyet csinál az 
// s lokális változónak
00000003  push        eax  
// Console statikus konstruktor lefutott?
00000004  cmp         dword ptr ds:[00362E28h],0 
// ha igen ugorja át a call-t, ha nem, statikus
// konstruktor hívás
0000000b  je          00000012 
0000000d  call        64276899 
// az s mint lokális változó nullázása,
// ebp-4 az s változó helye (ebp regiszter volt a verem
// teteje, de rátettük az eax regisztert, és a verem
// visszafele növekszik, ezért epb-4 lett az s változó helye)
// A compilerek xor művelettel szoktak nullázni, ez gyorsabb
// és rövidebb mint a mov edx, 0
00000012  xor         edx,edx 
00000014  mov         dword ptr [ebp-4],edx 
// Ez itt a lényeges rész: a kód előránt egy referenciát (pointert)
// ds:[029D3378h]-ről, és elhelyezi s-ben (azaz ebp-4-en)
00000017  mov         eax,dword ptr ds:[029D3378h] 
0000001d  mov         dword ptr [ebp-4],eax 
// Felkészülés függvényhíváshoz, ecx-ben megy a paraméter,
// a jitter mindig ebben adja át az elsőt.
// s értékének betöltése első paraméterként
00000020  mov         ecx,dword ptr [ebp-4] 
// Console.WriteLine()
00000023  call        FFA0B1D8 
// Verem helyreállítása és visszatérés a hívóhoz
00000028  nop              
00000029  mov         esp,ebp 
0000002b  pop         ebp  
0000002c  ret             

Mit látunk? Azt, hogy mire ideér a vezérlés, addigra a string literál már objektumként életre kelt, és a referenciája megtalálható a memória egy rekeszében, ahonnan a fenti gépikódú részlet direkt előrántja. Ebből két dolog következik. Az egyik, hogy a string literálok használata nagyon gyors. Ebben a kódban nincs példányosítás, nincs keresgélés, a program direktben oda nyúl, ahol a megfelelő string objektum példány referenciája található. Másrészről, az, hogy a fenti kód nem példányosít és keresgél semmit, azt sejteti, hogy vagy a CLR intézett el mindent az adott modul betöltésekor, vagy a jitter tette meg, amikor generálta a fenti kódot. Ha a modul betöltésekor példányosulnának a string literálok, az feleslegesen nagy overhead-et jelentene. Ha egy nagyobb program beszédes hibaüzenetekkel van tele, amelyekre egyébként (szerencsés esetben) ritkán van szükség, akkor a CLR feleslegesen túráztatja a processzort/memóriát a stringek példányosításával (történelmi érdekesség, hogy régen ezt csinálta a CLR) Kezdjük emiatt a jitterrel.

A jitter működését legegyszerűbben az sscli referencia implementáción keresztül deríthetjük fel. Mivel elég összetett C/C++ nyelvű kódról van szó, csak az eredményeket írom le, illetve az érdeklődőknek néhány érdekes pontot nevezek meg.

A ldstr és a jitter

Az IL kódokat több csoportra lehet osztani abból a szempontból, hogy a jitter miként generál hozzájuk kódot. Egy korábbi cikkben az “is” operátor kapcsán már láttunk olyan IL utasítást, amit a jitter “konzerv kódra” fordít, azaz mindig ugyanazok az utasítások szerepelnek a megvalósításban (leszámítva persze a regisztereket, egyéb paramétereket). A ldstr-hez szintén konzerv kód található, azonban a kód generálása előtt a Jitter egy kis előkészítő munkát végez (érdeklődőknek: \sscli20\clr\src\fjit\fjit.cpp, FJit::compileCEE_LDSTR() függvény)

Az ldstr utasítást fordító kód felépítése nagyon egyszerű. Első lépésben szerez az ldstr paraméterének megadott token-hez egy “string literal handle”-t. Ezután generálja a látott gépikódú részt, a string literal handle-t egy indirekt címzéshez felhasználva. Ebből következik, hogy a string literal handle nem más, mint a string példány címét tartalmazó cím.

A fentieket ábrázolja a következő ábra:

Honnan jön a string literal handle?

A folyamat viszonylag egyszerű, a lényeg a következő ábrán látszik:

A jitter egy szimpla GetLiteralHandle(token) (nem ez az igazi név, de az lelőné előre a poént) jellegű hívással elintézte a problémáját, mi azonban még mindig nem tudjuk, hogy mikor és hogyan jön létre a literálból a System.String objektum példány. Azt azonban látjuk, hogy valahol ebben a hívásban történik az érdekes rész. Valóban, a függvényhívási láncban először a token alapján létrejön egy pointer, ami a User String Heap megfelelő helyére mutat (konkrétan a string byte-jaira). Ez nem egy drága művelet, mert ha megfigyeljük a korábbi ábrán, a token értéke tükrözi az offsetet is, ahol a string található a heapen belül (pl a 70000128 token esetében a string a 0x0128 offseten van a User String Heap-en).

Ezután a vezérlés átadódik az application domain kódjába (sscli20\clr\src\vm\appdomain.cpp,BaseDomain::GetStringObjRefPtrFromUnicodeString()). Az application domain karbantart egy objektumot, amit StringLiteralMap-nek hív. Ez az objektum egy dictionary jellegű adatszerkezet. A dictionary-ben a kulcs egy int, az érték pedig (leegyszerűsítve) egy object handle.

Az SSCLI kódból látszik, hogy a kulcsot a korábban megszerzett string literálból egy hash számítással képzi (aminek ugye már meg van a pointere, ami közvetlenül a metaadatba mutat). A kódból az is kiderül, hogy a felső szinten string literal handle-nek nevezett dolog nem más, mint az object handle cast-olva.

Amikor egy string literál nincs a StringLiteralMap-ben, akkor az application domain kódja megpróbálja megkeresni a kívánt értéket a system domain StringLiteralMap-jében is. A system domain a .NET CLR egy optimalizációs eszköze. Elvileg minden application domain-nek meg van a saját kódbázisa, azaz ha van egy Alma osztályom, és azt (egy processzen belül) két application domainben használom, akkor az Alma kódja mind a két domainben előáll. Ez memóriapazarlásnak tűnik, de az application domain a szeparáció eszköze, és ezzel az áldozattal lehet megvalósítani (például a szükségtelen kódokat kitakarítani a processzből). Ugyanakkor van rengeteg típus (pl System.String) amit szinte minden kód használ, ezeket talán tényleg felesleges minden egyes application domain-be betölteni. Emiatt a CLR készít egy system domain-t, ahova például az mscorlib.dll típusai töltődnek be, és ezen oszályok kódján közösködnek az application domain-ek. A system domain-ben van egy String Literal Map is, és ott tartottunk, hogy ha az application domain nem talál egy stringet a saját map-jében, megpróbálkozik a kereséssel a system domain-ben is.

Ha itt sincs meg az érték, ekkor jön el a pillanat, hogy bele kell helyezni. (sscli20\clr\src\vm\stringliteralmap.cpp, AppDomainStringLiteralMap::GetStringLiteral()). Ebben az esetben a Large Object Heap-en létrehoz a kód egy System.String példányt, amely ugyanazt a karaktersorozatot fogja tartalmazni, amit a string literál (tehát a literál másolódik). Ha valaki nem ismerné, a Large Object Heap az a terület, ahova egyebek mellett a nagy méretű objektumok kerülnek, mint például egy bitmap számára foglalt tömb. A Large Object Heap működése kicsit másmilyen, mint amit a heap működéséről általánosságban tudunk. A .NET memóriakezelésének egyik előnye, hogy képes mozgatni az objektumokat a memóriában, ezzel egyrészt kiküszöbölve a memória fragmentálódását, másrészt nagyon felgyorsítva a memóriafoglalást (nem kell alkalmas lyukakat keresgélni). A Large Object Heap azonban nem így működik, az ezen foglalt memóriaterületek nem lesznek átmozgatva (túl sok erőforrást igényelne), és az ebben foglalt objektumok címei egy listába vannak gyűjtve, mely listát Large Heap Handle Table-nek hívnak. A tábla egy eleme az Object Handle, ami így nem más, mint egy mutató egy objektumra a Large Object Heapen.

Miután az application domain létrehozta a System.String példányt a Large Object Heap-en, a hozzá tartozó Object Handle-t belehelyezi mind a System Domain StringLiteralMap-jébe, mind a sajátjába. Ez azért fontos mozzanat, mert így egy processzen belül (a system domain ugye egy processzen belül megosztott) minden más application domain látni fogja a most létrehozott System.String példányt.

A StringLiteralMap feltöltése után az Object Handle adódik visszafele, ahogy a függvények a hívási láncon térnek vissza. Egy pont után ez átminősül String Literal Handle-nek, amihez a jitter pedig megfelelő kódot generál, ami feloldja az indirekciót és megszerzi a System.String példányt.

String Interning

A fenti mechanizmus nem csak a jitter számára elérhető, és nem csak string literálokat használva. A String.Intern() függvény belülről pont a fenti végrehajtási láncra tereli a vezérlést, amely amúgy elég copy-paste gyanús (lásd sscli20\clr\src\vm\stringliteralmap.cpp, AppDomainStringLiteralMap::GetInternedString() vs GetStringLiteral()).

A String Interning egy design pattern (barátja, a Flyweight design pattern talán ismerősebb), és a stringek által felhasznált memória optimalizálására lehet használni. Emellett arra is jó, hogy biztosítsuk, két azonos tartalmú string referenciája ugyanaz legyen. Ha így járunk el, a string összehasonlításokat referencia összehasonlítássá lehet alakítani, ami jóval gyorsabb. Ez az előny viszont csak nagy számú művelet esetén jön elő, így átlagos feladatoknál nem érdemes ezzel optimalizálgatni.

String.Empty vs “”

Ha valaki rákeres a fenti szavakra a google-ön, rengeteg forumot talál majd, ahol ezt a kérdést feszegetik. Valahol filozófiai fejtegetés megy, hogy mi a különbség (érdemes beleolvasgatni, viccesek), valaki egy tízmilliárdos cikluson tapasztal 25ms-os különbséget mutogatja, valakinek pedig sikerült olyan ügyes szemantikai környezetet találni, ahol a fordító az egyik esetben nem tudja kioptimalizálni a műveletet, emiatt tényleg nagy időkülönbséggel fut le a kód. Hogy nehogy valaki belefusson ebbe a “hibába”, ezt a példát említi az oldal:

if (string.Empty == null)
 ...

vs

if ("" == null)
 ...

Mivel kicsit bizonytalan vagyok afelöl, hogy mennyire gyakran végzik el a fejlesztők a fenti ellenőrzést, most mi egy átlagos használatot fogunk megnézni, és nem foglalkozunk az időkkel, hanem a gerált kód alapján vizsgáljuk a különbséget.

var e1 = String.Empty;
var e2 = "";

A generált kód (debugger nincs attacholva, a jitter optimalizál, így lett a korábi két gépikódú utasításból egy):

// var e1 = String.Empty;
mov         edi,dword ptr ds:[0351102Ch] 

// var e2 = "";
mov         esi,dword ptr ds:[0351202Ch] 

Bár a generált kód teljesen azonos, és így nyilván a futásidő is, ez nem azért van, mert a compiler észrevette, hogy a kód értelme is ugyanaz.

Az első esetben egy statikus mező eléréséről van szó. Ezt a mezőt a String osztály statikus konstrukora inicializálta egy Empty = “” utasítással. Eközben az a folyamat játszódott le, mint amit korábban láttunk, tehát ha még nem volt a string literal map-ben az “”, akkor oda bekerült (közben létrejött a string példány, ami az üres stringet hordozza). Ezután a statikus konstruktor kódja végrehajtotta az indirekt címzést a literal handle-re, ebből megkapta a String példány referenciáját, amit bemásolt az Empty referencia változóba.

A statikus változóknak fix helye van a memóriában, konkrétan az adott típus metódus táblájában az egyik rekesz hordozza az értéket. Ebben az esetben ennek a rekesznek a címe 0351102C, és az e2 = String.Empty-hez generált kód innen olvassa ki a String példány referenciájának (címének) az értékét.

A második esetet (e2 = “”) már megbeszéltük, itt a jitter megkapta a literal handle-t (0351202C), ami pedig a String példány referenciáját hordozza. Mind a két esetben tehát ugyanúgy indirekt címzést kell használni, így a kód ugyanaz, de más okból. Akárhogy is, látszik, hogy teljesen mindegy mit használunk, a végső kód, és így a sebesség ugyanaz.

String konkatenációk

A string-gel kapcsolatos másik ismerős/ismeretlen téma, hogy hogyan érdemes stringeket összefűzni. Egy esetet már láttunk, a literálok esetében. Azonban nem csak sima literálokat lehet összeadogatni, és ennek optimális mikéntjéről sok legenda kering.

Mi az alapprobléma?

A gond az összeadás (+) operátor tulajdonságaiból adódik. Elvileg egy a + b + c művelet balról jobbra haladva értékelődik ki. Először kiszámításra kerül a + b, egy ideiglenes t értékként, majd ezután t + c. A t érték ezután eldobásra kerül. A string konkatenálása drága művelet, emiatt az ideiglenes t előállítása pazarló.

A részeredmények elkerülése miatt rendelkezésre áll a String.Concat() statikus függvény. Ennek több verziója van, 2-5 string paraméterig, illetve van string tömböt váró overload is.

Drága az operator+?

A stringre értelmezett + operátor illetve a String.Concat() kapcsolatában többféle információ kering. Lehet hallani olyat, hogy a String.Concat()-ot kell használni operator+ helyett. Lehet hallani olyat, hogy a C# fordító n elemig az operator+ helyet olyan kódot fordít, ami a String.Concat()-ot használja. Arra is vannak ökölszabályok, hogy hány string összeadásánál érdemes áttérni a StringBuilder osztály használatára.

A valóság az, hogy a fordító akárhány részstring összeadása esetén a String.Concat() függvényt használja. Ha a részek száma nagyobb, mint 5, akkor foglal egy tömböt, belehelyezi a stringek referenciáit, és úgy hívja a String.Concat()-ot. Ennek eredményeképpen nem jönnek létre ideiglenes részeredmények. Ráadásul a String.Concat() kódja erősen optimalizált. Az egyetlen hátrány a paraméterátadáshoz használt tömb foglalása, ha több, mint 5 rész string-ünk van.

Bár nem nehéz elképzelni, mégis, nézzünk megy egy példát (csak hogy biztos String.Concat() lesz az operator+ ból)

 
static string f(
            string a,
            string b,
            string c,
            string d,
            string e,
            string f,
            string g)
        {
            return a + b + c + d + e + f + g;
        }
.method private hidebysig static string f(string a,
string b,
string c,
string d,
string e,
string f,
string g) cil managed
{
// Code size 45 (0x2d)
.maxstack 3
.locals init ([0] string[] CS$0$0000)
 
// 7 elemű tömb foglalása
IL_0000: ldc.i4.7
IL_0001: newarr [mscorlib]System.String
 
// tömb referencia tárolása a compiler generált ideiglenes
// változóban
IL_0006: stloc.0
 
// "a" paraméter a tömb 0. elemébe
IL_0007: ldloc.0    // tömb referencia a veremre
IL_0008: ldc.i4.0   // index a veremre (0)
IL_0009: ldarg.0    // 0. argumentum a veremre
IL_000a: stelem.ref // a veremre rakott paraméterek alapján a tömb
                    // egy rekeszének beállítása
 
// "b" paraméter a tömb 1. elemébe
IL_000b: ldloc.0
IL_000c: ldc.i4.1
IL_000d: ldarg.1
IL_000e: stelem.ref
... // többi argument a tömbbe  
 
// Concat hívása
IL_0026: ldloc.0    // concat paraméterének a veremre helyezése
IL_0027: call string [mscorlib]System.String::Concat(string[])
IL_002c: ret
} // end of method Program::f

A StringBuilder

A több string konkatenálására ajánlott StringBuilder osztály belső implementációjában az Append() függvény ugyanazt az optimalizált másolást használja, mint a String.Concat(). Az apró különbségeket inkább az ellenőrzések adják, mint például a StringBuilder kénytelen minden Append hívásnál megnézni, hogy elég nagy-e a belső puffere. Maga a két függvény teljesítménye így azonosnak sejthető, a “probléma” inkább a használattal van. A Concat egyetlen hívással megoldja több string összekötését, míg az Append függvényt sorozatban kell hívni, emiatt minden részre külön függvényhívás és ellenőrzés jut. Ez akkor érezteti jobban a hatását, amikor rövid stringek kerülnek a builderbe, mert ekkor arányaiban a műveletek nagyobb hányadát teszi ki a függvényhívás és az ellenőrzés.

Ennek ellenére a két megoldás teljesítménye nagyjából azonos, ha a StringBuilder nem kényszerül a belső puffer átméretezésére. Íme a tesztprogram:

 
string a = "alma";
string b = "korte";
string c = "barack";
string d = "szolo";
string e = "dinnye";
string f = "narancs";
string g = "citrom";

var sw = Stopwatch.StartNew();

int counter = 0;
for (int i = 0; i < 5000000; i++)
{
  var sb = new StringBuilder();
  // var sb = new StringBuilder(50); // nincs bufferbővítés

  sb.Append(a);
  sb.Append(b);
  sb.Append(c);
  sb.Append(b);
  sb.Append(e);
  sb.Append(f);
  sb.Append(g);

  counter += sb.ToString().Length;
} // for i

Console.WriteLine(sw.ElapsedMilliseconds);
Console.WriteLine(counter);

Ugyanez operator+ használatával, érdemes összevetni a külalakot is:

 
var sw = Stopwatch.StartNew();

int counter = 0;
for (int i = 0; i < 5000000; i++)
{
  var r = a + b + c + d + e + f + g;
  counter += r.Length;
} // for i

Console.WriteLine(sw.ElapsedMilliseconds);
Console.WriteLine(counter);

A StringBuiler, ha nincs megadva puffer méret, akkor 2 másodpercig fut. A String.Concat() (vagyis operator+) és a megfelelően inicializált StringBuilder pedig 1.5 másodpercig. Ha öt vagy kevesebb stringet fűzünk össze, a Concat() kb 10%-kal gyorsabb lesz, ami a paramétertömb létrehozásának megspórolásából adódik. Ebből látszik, hogy pusztán a sebesség szempontjából majdnem mindegy, hogy mit használunk. Hogy melyiket érdemes választani, inkább a körülményektől függ. Ha megvan az összes rész string, és egyszerűen össze kell őket fűzni, a String.Concat() és a + operátor talán átláthatóbb, emiatt jobb megoldás. Ha az eredmény string összerakása bonyolultabb, például feltételeket tartalmaz, vagy több kódrész dolgozik együtt, esetleg ciklust kell használni, akkor a StringBuilder jobb:

  
var sb =new StringBuilder();
 
if (IsPrologNeeded)
  sb.Append(Prolog);
 
GetPart1(sb);
GetPart2(sb);
 
foreach (...)
  sb.Append(x);

A fenti helyzetekben + operátort használva eldobandó részeredmények születnének, ugyanakkor érdemes figyelembe venni, hogy a tesztprogram ötmilliós ciklussal is nagyon gyorsan lefutott, tehát egy-két eldobott részeredmény nem jelent teljesítménygyilkolást.

String.Format()

Stringek manipulására a String.Format() függvény egy kedvelt eszköz. Ez belső megvalósításában a StringBuilder.AppendFormat() függvényét használja, ami egy kicsit bonyolultabb kód a format string parsolása miatt. Ugyanakkor a használata bizonyos esetekben átláthatóbb lehet, mint az + operátor vagy a String.Concat(). Az eddigiek ismeretében mindenki el tudja dönteni, mikor érdemes használnia.

Konklúzió

A stringek kezeléséről sok dolgot össze lehet olvasni. Ezek egy része alaptalan, például a StringBuilder feltétlen előnye, vagy a + operátor hatékonytalansága. Bizonyos esetekben 10-20% időket lehet spórolni különböző módszerekkel, de ez inkább teljesítménykritikus kódrészekben számít. Ha nem ilyen kódon dolgozunk, akkor nyugodtan lehet választani azt az eszközt, ami az átláthatóbb vagy praktikusabb kódot eredményezi.

  1. #1 by hurka on 2011. August 27. - 23:04

    “Bár nem nehéz elképzelni, mégis, nézzünk megy egy példát (csak hogy biztos String.Concat() lesz az operator+ ból)

    “static string f(
    string a,
    string b,
    string c,
    string d,
    string e,
    string f,
    string g)
    {
    return a + b + c + d + e + f + g;
    }

    A StringBuiler, ha nincs megadva puffer méret, akkor 2 másodpercig fut. A String.Concat() (vagyis operator+) és a megfelelően inicializált StringBuilder pedig 1.5 másodpercig.”

    Lehet , hogy már optimalizálták a + operatár használatát, de anno 1.1-es frameworkben sokkal lassabb volt, mint a StringBuilder-es megoldás.
    Kezdő koromban volt egy projekt, ahol bazi nagy stringeket kellett összepakolni kódból, és hát ugye először + operátorral csináltam.
    Így bazi lassú lett, percekig várt néha a kliens, átírtam StringBuilder-es megoldásra, és rögtön a mp töredéke lett a válaszidő. Azóta, ha 2/3 stringnél többet kell összefűzni, gondolkozás nélkül StringBuildert rántok elő.

  2. #2 by Laszlo Jasko on 2011. August 28. - 10:17

    Van egy olyan érzésem, hogy manapság bármit veszel elő és nem újkeletű, az agyon lesz optimalizálva, így lényegében a kódok szépségére kell igazából figyelnünk és nem arra, hogy mit lehet tovább gyorsítani. Egyik kedvenc vicces idézetem: “Nincs lassú program, csak gyenge gép!”

  3. #3 by Tóth Viktor on 2011. August 28. - 12:30

    Hurka: biztos régen úgy volt, ahogy mondod, és ez az egyik alapja a legendáknak. Az operator + / Concat kapcsolatát én is rosszul tudtam kb 2 hónappal ezelőttig, amíg szóba nem jött a munkahelyemen, ezért utánanéztem.

    László: egyetértek azzal, amit az átláthatóság/optimalizáció kapcsolatáról írsz. És az is igaz, hogy verzióról verzióra a compiler/framework próbálja a jellemző teljesítményproblámákat kiküszöbölni (lásd pl az örökké átbabrált thread pool).
    A string kezelésére is külön utak vannak a CLR-ben, jitter-ben. Már a string példányosítása is másmilyen. Ha belenézel reflektorral a String kódjába, látszik, hogy pár helyen, amikor string példányra van szükség, akkor egy FastAllocateString() függvényt hív, ami pedig egy C++ függvénye a CLR-nek. Ha mi írunk le egy new String(…)-et, akkor IL szinten egy közönséges newobj-nak látszik, a jitter viszont fordítás közben ezt másképpen kezeli, mivel a String területfoglalásának és konstruktoroknak saját gépikódú (!!!) implementációja van, legalábbis az SSCLI kódjában. Ha megnézed a reflektorban, nem is találsz csharp implementációt a string konstruktorokhoz, csak egy internal call attributummal megjelölt extern függvény nevet. Ugyanez igaz a tömbökre is.

  4. #4 by László Jaskó on 2011. August 31. - 17:41

    Egy olyan kérdésem lenne: volt már boncolgatva, hogy miért mondja azt nekem a StyleCop, hogy a using-okat illik a namespace-n belül felsorolni? Ennek mi az oka?

  5. #5 by Tóth Viktor on 2011. August 31. - 19:59

    László: egy-két oldalon mutatnak példát különbségre, például ezeken:

    http://blogs.msdn.com/b/ericlippert/archive/2007/06/25/inside-or-outside.aspx
    http://stackoverflow.com/questions/795098/net-namespaces-and-using-statements

    Először nekem is furcsa volt, de mindent meg lehet szokni…

  6. #6 by László Jaskó on 2011. September 1. - 10:18

    Aha, köszi! Szóval nem performancia okai voltak/vannak.

  7. #7 by eMeL on 2011. November 22. - 12:40

    Nem akarok belerondítani a hozzászólásokba (így javaslom egy olyan külön bejegzés csinálását, aminek a kommentjei kimondottan erre a célra szolgálnak).

    Mivel olyan mélyen bele ásod magad a témákba, egy vizsgálatra javasolt probléma (illetve kérdés):

    A List-nek ugye nincs SyncRoot eleme.
    Az “álmoskönyvek” pl. egy önálló Object-et javasolnak a lock()-hoz.

    Na de miért is nem javasolt magának a listának a használata a lock()-hoz?

    Nem javasolt kód:

    public void AddToList(List list)
    {
    lock (list)
    {
    list.Add(111);
    }
    }

    A hogyan nem kérdéses, csak a miért?

    A lock(typeof(…))-ot és a lock(this)-t még csak-csak “megmagyaráztam” magamnak, de ennek az okát nem értem.

    [Az én kis C++-ból jött lelkem meg szereti tudni az okokat, mert annak megértése sok lexikális tudást helyettesíteni bír… a fejem meg nem káptalan, hibátlan kódot meg ‘szeresünk írni ]

    Sok mindent olvastam, de azért az általános dumán kívül a mai napig nem láttam egy konkrét példát sem, ahol pl. azért fut deadlock-ra mert magát a list-et használják, míg ha külön erre létrehozott objectet, akkor nem.

  1. lock (mit?) - pro C# tology - devPortal

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: