riezebosch schreef op 25 July 2003 @ 12:46:
Soultaker:
dat idee van een gesloten ring spreekt me wel aan, heb er ook wel eerder aan gedacht, maar dan kan je toch niet bijhouden of je het kringetje nou al rond bent geweest als je alle elementen keertje langs moet lopen?
Nee, dat klopt inderdaad. Als je de iterator semantiek wil behouden moet je inderdaad twee pointers gebruiken. (Ik nam alvast een voorschot op de argumenten in het artikel waar ik naar verwees: "There are a few ways to access elements in a list. Bad: A list class could have a notion of a current node.")
In het algemeen zou ik dus aanraden om een losse iterator te definiëren, maar het kan inderdaad ook wel in deze klasse, met een aparte pointer naar het eerste en naar het "huidige" element.
En wat bedoel je met copy-on-write? Ik heb juist dat delen van die Count (en Size) gedaan omdat op die manier de destructor weet of ie de elementen ook moet verwijderen.
Het grote probleem met je huidige implementatie is dat je objecten allemaal dezelfde gegevens gebruiken:
C++:
1
2
3
4
5
6
7
| LinkedList<int> ll1;
ll1.add(1);
ll1.add(2);
ll1.add(3);
LinkedList<int> ll2 = ll1;
ll2.add(4);
ll1.size(); // 4!! |
Kortom, als je ll2 construeert uit ll1 en je wijzigt ll2, dan wijzig je ll1 ook. Het idee van object georïenteerd programmeren is dat elk object een eigen identiteit heeft, ongeacht van andere objecten. Als ik een objectwaarde ontvang (bijvoorbeeld als functie argument) dan verwacht ik als programmeur dat alle wijzigingen die ik binnen die functie op het object doe, verdwijnen zodra de functie verlaten wordt. Het object wordt dan immers opgeruimd.
Ik zie dan ook het nut niet van de copy constructor en assignment operator, zoals jij ze nu geïmplementeerd hebt, want ze werken nu exact alsof je refences kopieert. Vergelijk de volgende code met die hierboven:
C++:
1
2
3
4
5
6
7
| LinkedList<int> ll1;
ll1.add(1);
ll1.add(2);
ll1.add(3);
LinkedList<int2> &ll2 = l11; // let op &!
ll2.add(4);
ll1.size(); // 4! |
Het enige verschil is dat jij intern een heleboel overhead hebt met reference counting en het indirect benaderen van velden zoals het aantal elementen.
Wat ik dus wilde zeggen is dat het implementeren van copy semantics (constructor en assignment operator) alleen nuttig is als je ook daadwerkelijk een kopie maakt van de inhoud van het object in kwestie (een zogeheten "deep copy"). De makkelijkste manier is om direct een in de copy constructor (of dus assignment operator, maar die zal ik voor het type-gemak even buiten beschouwing laten

) de hele datastructuur te kopiëren. Dat betekent dat je dan geen reference count hoeft bij te houden.
Copy on write werkt eigenlijk letterlijk zoals de naam suggereert. Als je de copy constructor gebruikt, wordt de onderliggende datastructuur nog niet gekopieerd, maar wordt wel de onderlinge reference count verhoogd. Zodra de onderliggende gegevens echter worden aangepast (met mod() of add() of welke andere methode die de inhoud wijzigt dan ook) dan wordt een onafhankelijke kopie van de gegevens gemaakt. Het gaat er dus om, dat copy on write naar buiten hetzelfde gedrag vertoont als een "domme' implementatie die altijd de inhoud kopieert, behalve dat een implementatie die van copy on write gebruik maakt gemiddeld efficiënter zou kunnen zijn (omdat het kopiëren wordt vermeden als alleen lees-acties uitgevoerd worden op het object), ten koste van wat extra beheer. Of dit in de praktijk ook winst oplevert zou ik zeker in het algemeen niet durven zeggen.
Het eerste deel van een copy on write implementatie heb je al gemaakt (namelijk het verhogen van de reference count). Het tweede deel moet je nog doen, bijvoorbeeld door een methode toe te voegen die het object kan kopiëren. Een stukje voorbeeldcode zegt genoeg:
C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| private:
void prepareWrite()
{
if(*Count > 1)
{
// Underlying data is shared! Create a copy before modifying it.
Count = new int(1);
Size = new int (*Size);
// TODO: copy internal data structure...
FirstItem = ...;
LastItem = ...;
CurItem = ...;
}
} |
Onder TODO moet je nog wel meer dan die drie regels toevoegen, omdat je niet alleen het eerste, laatste en huidige element, maar ook alle tussenliggende elementen moet kopiëren en bovendien hun referenties moet updaten. Deze methode wordt dan vanuit alle schrijvende methoden (zoals add(), mod(), etc.) aangeroepen, zodat je zeker weet dat je met een onafhankelijke datastructuur werkt.
Copy on write heeft trouwens ook wel wat nadelen die niet direct duidelijk zijn. Omdat objecten hun datastructuren kunnen delen, is het niet direct gegarandeerd dat de datastructuren binnen een enkele thread gebruikt worden, wanneer een object binnen een enkele thread gebruikt wordt. Veel STL containers zijn bijvoorbeeld alleen veilig als ze vanuit verschillende threads gelezen of vanuit één thread geschreven worden; dat is praktisch eigenlijk de enige garantie die je kunt geven zonder (ingewikkelde en kostbare!) locking constructies toe te voegen. Het is duidelijk dat je met copy on write ook tussen objecten gegevens deelt, waardoor het gebruik van twee verschillende objecten in twee verschillende threads niet veilig kan zijn!
Als ik je nu dermate heb ontmoedigd dat je zeker geen copy on write wilt implementeren, dan zou ik willen suggereren om die hele reference counting eruit te gooien, want die vergroot nodeloos de grootte van je objecten in het geheugen, voegt nodeloos extra allocaties en indirectiestappen toe (voor Count en Size), en kost je nodeloos executieprestaties omdat je de reference count up to date moet houden, zonder dat je er verder iets mee doet. Maak dan liever een explicit (!) deep copying constructor (en assignment operator) of maak de copy constructor en assignment operator private en implementeer ze gewoon niet.