„Кръговрат“ на данните при обектноориентираните езици

Нека видим как се работи с паметта при някои обектноориентирани езици.

Смолтоук

Това е най-типичният представител на динамично типизираните обектноориентирани езици. Тук всички неща в програмата са обекти. Аргументите на методите са обекти, резултатът, който се връща, също е обект. В действителност при транслация аргументите на методите са указатели към обекти, а връщаната стойност отново е указател, но това остава скрито за програмиста. Тук по-същество цялата памет е динамична — дори локалните променливи в действителност са указатели към обекти в динамичната памет. Програмистът не се занимава с управлението на паметта. Когато по време на изпълнението на програмата се създаде нов обект, необходимата за него памет се заделя автоматично. Когато даден обект повече не е нужен, заеманата от него памет се освобождава автоматично от събирача на боклук.

Джава

Ситуацията при този език за програмиране прилича на тази при Смолтоук. Джава също спада към категорията на динамично типизираните обектноориентирани езици[12]. Тук обаче не всичко са обекти. С цел по-добра ефективност числовите данни се предават директно като аргументи вместо да се предават указатели към обекти.

Айфел

Това е най-яркият представител на групата на статичнотипизираните обектноориентирани езици. При Айфел всичко са обекти. Тук обаче обектите са два вида. Едните се предават с указатели, както при Смолтоук и Джава, другите обаче се предават по стойност. Числата например са обекти, но се предават по стойност. Програмистът може да дефинира собствени обекти, които да се предават по стойност.

Си++

Макар и да не е напълно обектноориентиран, този език позволява такъв стил на програмиране. Спада към групата на статичнотипизираните обектноориентирани езици. Тук програмистът може явно да задава дали обектите ще се предават като аргументи по стойност или чрез указатели. За разлика от Айфел обаче, дали обектът ще се предаде като указател или по стойност зависи не от самия обект, а от прототипа на метода. Методът казва какви са аргументите му — указатели към обектите или самите обекти. Не се използва събирач на боклука, така че програмистът трябва „ръчно“ да заделя и освобождава паметта.

Най-общо казано при програмиране се използват два вида обекти — обекти-стойности (value object) и същински обекти (entity). Обектите-стойности се предават директно като аргументи, докато същинските обекти се предават чрез указатели.

Обектите-стойност са малки по размер, докато същинските обекти могат да бъдат много големи. Тук под размер разбираме логическия размер на обектите. Въпреки, че обектите от клас BINARY_TREE може да са представени от една малка структура, съдържаща само два указателя (за ляв и десен клон), те са същински, защото логически погледнато обектите, към които сочат двата указателя, са част от дървото.

Друга разлика между тези два вида обекти е свойството им да бъдат изменяеми. Обектите-стойност почти винаги са неизменяеми, докато същинските обекти по-често са изменяеми. Така например обектите от клас COMPLEX_NUMBER са неизменяеми, тъй като едно число не може да променя стойността си. По размер тези обекти не са големи, така че е уместно да приемем, че става въпрос за обекти-стойност.

Тъй като обектите-стойност се предават директно като аргументи, то те задължително трябва да имат дефиниран копиращ конструктор. При същинските обекти наличието на копиращ конструктор не е задължително. Но тъй като Си++ по подразбиране винаги създава копиращ конструктор и го използва неявно, това може да доведе до трудни за откриване грешки. Затова е препоръчително винаги или да дефинираме сами копиращ конструктор, когато такъв е нужен, или да дефинираме празен копиращ конструктор в раздела private: като по този начин ефективно забраним използването му.

При чисто обектноориентирано програмиране трябва да се използват само методи, обявени като virtual. Едва след като програмата е завършена и то само ако се налага може да премахнем част от ключовите думи virtual при декларациите на методи. При обектите-стойност това изискване може донякъде да се отслаби — те рядко се унаследяват, така че използването на virtual донякъде се обезсмисля. Изпускането на virtual при обектите-стойност обикновено подобрява ефективността в по-голяма степен, отколкото изпускането му при същинските обекти.

Да предположим, че имаме клас LINKED_LIST, който представлява свързан списък от някакви обекти от клас T. Един от методите в протокола на този клас е add_last. Този метод има един аргумент — обект, който трябва да се добави в края на списъка.

Какъв ще бъде прототипът на този метод? Имаме три възможни варианта:

  1. void set_last (T item);
              
  2. void set_last (T& item);
              
  3. void set_last (T* item);
              

Ако list е указател към свързан списък, а tree е обект от клас T, то за да се добави tree в края този свързан списък, се използва една от командите:

  1. list->set_last (tree);
              
  2. list->set_last (&tree);
              

Командата 4 използваме при варианти 1 и 2, а командата 5 при вариант 3. Така получаваме следните възможни комбинации: 1-4, 2-4 и 3-5. Коя от тях да използваме?

Да разгледаме следния примерен сценарий при използване на комбинацията 1-4:

  1. Създаваме обект tree (за целта се заделя и необходимата памет).

  2. Предаваме този обект като аргумент на set_last. Компилаторът неявно извиква копиращия конструктор на tree и в списъка се добавя копие на нашия обект.

  3. Работим с обекта tree

  4. След като обектът tree повече не ни трябва, го унищожаваме. Заеманата от него памет се освобождава, а в списъка, както и досега, се съхранява негово копие.

  5. Работим със списъка.

  6. Унищожаваме списъка. Неговият деструктор ще освободи и паметта, заемана от копието на tree.

Описаният сценарий е точно това, което трябва да се случва при използване на обекти-стойност. Но при използване на същински обекти трябва да потърсим друго решение. Горното решение има едно единствено предимство — просто управление на паметта, защото важи принципът “който заделя памет, той я освобождава[13]. Предаването на същински обекти директно като аргументи, а не чрез указатели има следните недостатъци:

Комбинациите 2-4 и 3-5 са семантично еквивалентни. И в двата случая компилаторът ще генерира код, при който set_last получава като аргумент указател. Разликата е единствено стилистична: при комбинацията 3-5 този указател присъства явно в програмата, докато при комбинацията 2-4 той присъства неявно. Редица авторитетни автори препоръчват комбинацията 2-4 като съответстваща в по-голяма степен на обектноориентирания стил на програмиране (вж. напр. [Cli96] и [Eck2000]). Други считат, че неявните указатели правят програмата по-неясна. Тук няма да даваме препоръка кой от двата варианта да се използва при програмиране; важното е в цялата програма да се използва систематично само един от тях, а не и двата безразборно. А тук, единствено с дидактическа цел ще използваме винаги само явни указатели, т.е. комбинация 3-5.

Нека видим какво се случва при използване на някоя от комбинациите 2-4 и 3-5.

  1. Създаваме обект tree (за целта се заделя и необходимата памет).

  2. Предаваме указател към този обект като аргумент на set_last. В списъка като последен елемент попада указател към нашия обект tree.

  3. Работим с обекта tree

  4. Дори и след като обектът tree повече не ни трябва, не можем просто да го унищожим, защото в списъка се съдържа копие към него.

  5. Работим със списъка.

  6. Унищожаваме списъка. Трябва ли обаче неговият деструктор да унищожи tree? Деструкторът „не знае“ дали сме си свършили работата с tree или все още използваме този обект.

При по-модерните езици за програмиране е възможно обектите въобще да нямат деструктори, а дори и да имат, деструкторите не се грижат да унищожават други обекти. Обектите се унищожават единствено от събирача на боклук. При Си и Си++ обаче програмистът трябва сам да планира и да се грижи кога да заделя и кога да освобождава паметта. Ако се осбободи памет, която всъщност се използва на някое скрито място в програмата, операционната система ще прекрати изпълнението на програмата със съобщение от вида Segmentation fault. А ако забравим да освободим ненужна памет, програмата привидно ще работи безпроблемно, което ще направи този проблем много трудно отстраним. Понякога за да го забележим, програмата ще трябва да работи непрекъснато в продължение на седмици.



[12] Въпреки че компилаторът осъществява статичен контрол на типовете, програмисът може да динамично да сменя типа на обектите, стига интерфейсът е съвместим.

[13] Не бива да се заблуждаваме от това, че в много книжки по Си++ е описан единствено този сценарий.