Strings of TStringBuilder?
Op het SDN event van december werd het nog gezegd: “geheugenproblemen bestaan eigenlijk niet meer”. Dat had je gedacht. Ja, wij ontwikkelaars zetten wel een bak vol met geheugen neer. Maar wat nu als je gebruikers op een computer van een paar jaar oud werken. Kortom, vlak voor dat SDN event kreeg ik dus meldingen dat mijn applicatie steeds vastliep. En toen ik zelf eens ging kijken bleek dat ik het bestandje van 3 Mb zodanig bewerkte dat de applicatie 300 Mb geheugen nodig had. En dat alles om nog een beetje snel met de inhoud van het bestand te kunnen werken. Na wat analyse bleek al snel dat ik het geheugengebruik kon halveren door gebruik te maken van de TStringBuilder component.
Delphi en strings.
Eerst even het geheugen opfrissen. Als we het hebben over Delphi en strings dan gaat het al snel over “reference counting”, “copy on write”, “dynamisch geheugen” en “geheugen vrijgeven”. Waar het op neer komt is dat Delphi probeert te voorkomen dat er kopieën van strings in het geheugen gemaakt worden, en het geheugen weer vrijgeeft als de string niet meer nodig is. Op zich allemaal prettige zaken. In normale situaties scheelt het je een hoop gedoe.
Maar er zijn wel een paar nadelen. Ten eerste wordt aan iedere string 13 bytes extra informatie geplakt om de referece-counting te regelen. Gevolg: als je allemaal strings van 6 karakters (12 bytes) of korter gebruikt ben je aan directe overhead meer geheugen kwijt dan aan de feitelijke data.
Daarnaast moet er logischerwijs op een hoger nivo ook nog geheugen gebruikt worden. Op een of andere manier moet immers ergens vastgelegd worden welke delen van het geheugen (weer) vrij komen en weer (her)gebruikt kunnen worden. En tegen de tijd dat geheugen sterk gaat lijken op een gatenkaas met veel gaten heb je natuurlijk ook een probleem.
Een ander nadeel ontstaat als je aan de slag gaat met functies die een PChar als parameter gebruiken, bijvoorbeeld als je direct gebruik wilt maken van de standaardfuncties in windows. Dan is er dus ineens geen “copy on write” meer maar wijzig je alle referenties, wat niet altijd de bedoeling is.
Zelf je strings managen.
Het standaard Delphi geheugenmanagement voor strings werkt prima in de meeste standaardsituaties. In situaties waarin je veel (korte) strings gebruikt die bovendien toch nooit opgeruimd worden kan het handig zijn het geheugenmanagement in eigen handen te nemen. Het is natuurlijk niet mogelijk om het geheugenmanagement van Delphi uit te schakelen, dus moeten we zelf wat verzinnen. Het basisidee is een groot array van Chars te declareren en daar dan zelf je strings in te stoppen. In principe hoef je dan alleen je startindex te onthouden. Wat overigens evenveel geheugen kost als de reference pointer die je gebruikt om naar je string te verwijzen. Om het ons makkelijk te maken kunnen we daarvoor gebruik maken van de TStringbuilder component.
Als je strings van 6 karakters of korter gebruikt ben je aan overhead meer geheugen kwijt dan aan de feitelijke data
De TStringbuilder component.
De TStringbuilder component wordt gedefinieerd in de SysUtils unit. Hij is standaard al uitgerust met procedures en functies om van alles en nog wat toe te voegen, er iets tussen te voegen of te vervangen. En natuurlijk ook om wat je erin gestopt hebt er weer uit te krijgen:
TStringBuilder = class
…..
function Append(const Value: Integer): TStringBuilder; overload;
function Append(const Value: TObject): TStringBuilder; overload;
function Append(const Value: string): TStringBuilder; overload;
…….
function Insert(Index: Integer; const Value: Integer): TStringBuilder; overload;
function Insert (const Value: TObject): TStringBuilder; overload;
function Insert(Index: Integer; const Value: string): TStringBuilder; overload;
…….
function Replace(const OldValue: string; const NewValue: string): function …….
function ToString(StartIndex: Integer; StrLength: Integer): string; reintroduce; overload;
…….
property Capacity: Integer read get_Capacity write set_Capacity;
property Chars[index: Integer]: Char read get_Chars write set_Chars; default;
property Length: Integer read get_Length write set_Length;
property MaxCapacity: Integer read get_MaxCapacity;
end;
Listing 1: interface van TStringbuilder (deel)
Je kan er dus standaard alles heen schrijven, tot en met een TObject aan toe. Als er maar een string van kan worden gemaakt. Dat maakt deze component dus ook uitermate geschikt om te gebruiken als je een tekstbestand wilt samenstellen. En dus is jammer dat er geen procedure wordt meegeleverd om de inhoud naar een bestand en/of stream te schrijven. Gelukkig is, bij wijze van uitzondering, de datapointer als protected gedefinieerd zodat we dat zelf eenvoudig kunnen regelen in een afgeleide component. Het beste is dat via een procedure “SaveToStream” te doen. Je kan die procedure dan gebruiken om via een TFileStream direct naar een bestand te sturen, maar je kan die ook gebruiken om de data in je eigen binaire bestand op te nemen. Een complicatie hierbij is natuurlijk wel dat je na moet denken over de Encoding die je wilt gebruiken en of je wel of niet een PreAmble wilt meeschrijven. Maar afgezien daarvan is het een makkie. De code is te vinden in de download.
Een andere ontbrekende standaardfunctie is de “assign” om een exacte kopie te krijgen. Niet dat je die vaak nodig zult hebben, maar hij kan handig zijn. Ook die is eenvoudig te maken:
procedure TMyStringBuilder.assign(aSource:TStringBuilder);
begin
self.Capacity :=aSource.Capacity;
self.Length :=aSource.Length;
aSource.CopyTo(0, self.FData,0,aSource.Length);
end;
Listing 2: Toegevoegde procedure “Assign”.
Een voorbeeldapplicatie.
Als voorbeeld maken we een applicatie die alle bestanden in een map (en onderliggende mappen) inleest, waarna we kunnen zoeken of bestandnaam bestaat.
De gevonden bestanden sla ik, voor later gebruik, op in een class die er als volgt uit ziet:
TFileStringClass=class
Selected :boolean;
Path :string;
Filename :string;
Extension:string;
end;
TSDNstringInfo=record Offset:integer; size:word end;
TFileStringbuilderClass=class
Selected :boolean;
PathInfo :TSDNstringInfo;
NameInfo :TSDNstringInfo;
ExtnInfo :TSDNstringInfo;end;
end;
Listing 3: Classtype om bestandsdata op te slaan.
In het voorbeeldprogramma zijn deze TFileStringClass en TFileStringbuilderClass overigens samengevoegd tot één class, om eenvoudig tussen beide varianten te kunnen switchen. In een TSDNstringInfo record worden de gegevens die we nodig hebben om de oorspronkelijke string weer uit de TStringbuilder te halen opgeslagen. Om deze informatie te achterhalen maak ik gebruik van de volgende, zelf toegevoegde procedure:
function TSDNstringBuilder.AppendText(const aTekst:string):TSDNstringInfo;
begin
result.Offset:=self.Length;
self.Append(aTekst);
result.Size :=self.Length-result.Offset;
end;
Listing 4: Een tekst toevoegen aan de TStringbuilder
De toe te voegen tekst wordt achter aan de bestaande data geplakt met een reguliere “append”. De offset en lengte voor deze tekst volgen uit de lengte van de data vóór en na het toevoegen. Overigens ga ik er van uit dat de lengte van de strings altijd in een word past. Het teruggeven van de size kan worden weggewerkt door bij het appenden een goed gekozen scheidingskarakter toe te voegen. Het bespaart niets, want de data die we opslaan wordt nu groter, en bovendien wordt het teruglezen ingewikkelder. Door de gekozen oplossing is het teruglezen nu erg eenvoudig:
function TSDNstringBuilder.InfoToString(aInfo:TSDNstringInfo):string;
begin
result:=ToString(aInfo.Offset, aInfo.Size);
end;
Listing 5: Een string teruglezen
Als alternatief zou je er ook voor kunnen kiezen om vóór de werkelijke data de lengte van de tekst, als 2-bits karakter, te schrijven. Je maakt dan als het ware “Unicode-pascalstring”. Ik ben er geen voorstander van, zoals later in dit artikel nog zal blijken. De gekozen oplossing heeft als voordeel dat hij eenvoudig op te schalen is naar een variant waarbij het wel toegestaan is langere strings op te slaan, door de Size eenvoudigweg als integer te definiëren.
Testen.
Bij een eerste test van het programma bleek de oplossing met strings in eerste instantie minder geheugen te kosten dan de oplossing met TStringBuilder. Maar daar was een zeer logische verklaring voor. In het gekozen voorbeeld wordt ook de mapnaam in de structuur opgeslagen. In de reguliere stringtoepassing krijg je dan het voordeel van de reference counting. Terwijl de TStringBuilder variant braaf iedere keer de mapnaam opnieuw toevoegt. Als we de reference counting met een trucje te omzeilen gaat de TStringBuilder variant het wel winnen. Het geeft nog maar eens aan dat het afhankelijk is van de applicatie en de data zelf of er in de specifieke situatie voordeel te behalen valt met een TStringbuilder oplossing.
Een andere opmerking is dat de TStringbuilder ook dynamisch groeit en dus bijdraagt aan het ontstaan van gatenkaasgeheugen. Maar je kan dat wel zelf beperken door vooraf een goed gekozen “capacity” in te stellen. Welke waarde dat moet zijn is natuurlijk afhankelijk van de applicatie en de te verwachten data.
Een en ander is allemaal eenvoudig te testen met het programma in de download. Om te laten zien dat het echt werkt heb ik een mogelijkheid ingebouwd om te zoeken op bestandsnaam: In de editbox voer je (een deel van) de gezochte naam in en drukt op de knop. Alle bestanden waarin de opgegeven tekst voorkomst worden dan gevonden. En daar loop je dan tegen een probleem op. Ik gebruik daar de standaard “pos” functie binnen Delphi maar daarin kan je, voor zover ik weet, niet aangeven dat je wilt zoeken onafhankelijk van de “case”. Als voorbeeld laat ik mijn hele C-schijf scannen. Als ik vervolgens ga zoeken op “app” krijg ik een andere selectie dan als ik zoek op “APP”. De standaardoplossing is om tijdens het zoeken alle strings met “Uppercase” in hoofdletters te zetten. Een omslachtige operatie, en zeker als je meerdere zoekopdrachten hebt ook een tijdrovende. Met de TStringbuilder kan dit eenvoudiger: je kan eenvoudig een kopie maken en deze in éé keer in “Uppercase” zetten. Vervolgens gebruik je die kopie tijdens het zoeken. Om die kopie te maken kan je de eerder genoemde assign gebruiken. Om de hele inhoud in uppercase te zetten heb ik een procedure toegevoegd die direct gebruik maakt van de Windows aanroep (zoals die ook gebruikt wordt in de standaardfunctie “AnsiUppercase”).
procedure TSDNstringBuilder.setUppercase;
begin
windows.CharUpperBuff(PChar(DataPtr), self.Length);
end;
Listing 6: De inhoud van de TStringbuilder uppercase maken.
Het is relatief eenvoudig om zelf procedures toe te voegen die allerlei andere manipulaties doen. Bijvoorbeeld het vervangen van accentkarakters in accentloze karakters. Dit soort manipulaties is precies waarom ik eerder stelde geen voorstander te zijn van scheidingstekens en/of het opslaan van de lengte. Als die er niet in staan hoef je er ook geen rekening mee te houden als je met de tekst gaat manipuleren.
Voorkomen van dubbele strings.
Een van de voordelen van de standaardafhandeling kan het voorkomen van duplicaten zijn. We zagen het in dit voorbeeld al eerder bij het opnemen van de directory van het bestand. Er zijn ook situaties waarin je veel “dubbelen” hebt maar die niet als “dubbel” gezien zullen worden, ook niet in de standaardoplossing. In het voorbeeld: veel extensies zullen hetzelfde zijn. Maar omdat ze niet als dezelfde verwijzing binnenkomen zal ook Delphi ze niet als duplicaat zien. Een oplossing zou kunnen zijn om een TStringlist te gebruiken en daar “AddExclusive” te gebruiken. Zoiets zit, uiteraard, niet in de standaard TStringbuilder, maar we kunnen die er wel eenvoudig aan toevoegen. Het probleem bij het toevoegen is dat je moet weten waar de afzonderlijke strings beginnen. Zoals eerder opgemerkt zou dit kunnen door er een endmarker aan toe te voegen. Een andere methode is om een array bij te houden met de lengte van de opgenomen strings. Dat heeft bij het toevoegen een voordeeltje: alleen strings van de juiste lengte hoeven nog te worden gecontroleerd. In code ziet dat er als volgt uit:
…
for lSize in FSizes do begin
if (lSize=result.Size)
and(aTekst=self.ToString(lOffset,lSize))
then begin
result.Offset:=lOffset;
EXIT;
end
else inc(lOffset,lSize);
end;
…
Listing 7: Een string “exclusief toevoegen (deel)
Ook in de download is deze optie toegevoegd en kan worden gebruikt bij het opslaan van extensies.
Conclusie en samenvatting.
De standaardafhandeling van strings binnen Delphi werkt in de meeste standaardsituaties prima. Er kunnen echter situaties ontstaan waarin het wenselijk kan zijn het managen van de strings zelf ter de hand te nemen. De TStringbuilder component is dan een kandidaat om te gebruiken als basis voor zo’n eigen implementatie.
Voorbeeld applicatie