Глава 5: Свободна памет и презаредими имена
Съдържание на пета глава :
5.1. Разпределение на свободната памет
5.2. Един пример за свързан списък
5.3. Презаредими имена на функции
5.4. Указатели към функции
5.5. Свързване, безопасно относно типовете
Тази глава разглежда две фундаментални концепции - свободната памет за програмата и презареждането на имената на функциите. Свободната за програмата памет позволява да се отделя памет по време на изпълнение. Реализацията на класа IntArray в глава 1 ни предложи един кратък първи поглед върху този проблем. В тази глава ние ще го разгледаме подробно. Презареждането на имената на функциите позволява на няколко екземпляра на дадена функция, която предлага една обща операция, отнасяща се за аргументи от различен тип, да имат едно и също име. Ако вече сте написали поне един аритметичен израз на някой програмен език, то вие сте използвали предварително дефинирани презаредими функции. В тази глава ние ще видим как да дефинираме наши собствени такива функции.
5.1. Разпределение на свободната памет
Всяка програма разполага с известно количество неразпределена свободна памет, която може да използват по време на изпълнението си. Тази налична памет се нарича свободна памет на програмата и чрез използването на класът IntArray може да отложи разполагането на своите представители масиви в паметта за периода на изпълнението. Нека погледнем как беше направено това
IntArray
IntArray( int sz ) {
size = sz;
ia = new int[ size ];
for ( int i = 0; i < sz; ++i ) ia[ i ] = 0;
}
IntArray има даннови елемента size и ia. ia, който е указател към цяло число, ще адресира разположението на масива в свободната памет. Един от аспектите на използването на свободната памет е, че тя не е именувана. Обектите, разположени в тази памет, се обработват индиректно чрез указатели. Втори аспект на използването на свободната памет, е, че тя не е инициализирана и следователно винаги трябва да й бъде давана стойност преди употреба. Поради това е написан и цикъла for, чрез който на всеки елемент на ia се дава стойност 0. size, разбира се, съдържа размера на масива.
Свободната памет се разпределя чрез прилагане на оператора new към даден типов спецификатор, включително и към име на клас. Може да бъде отделена памет както за единичен обект, така и за масив от обекти. Например,
int *pi = new int;
отделя памет за един обект от тип int. Операторът new връща указател към този обект и чрез него се инициализира pi.
IntArray *pia = new IntArray( 1024 );
отделя памет за обекта клас IntArray. Скобите, които са записани след името на класа, ако ги има, се явяват като аргументи на конструктора на класа. В този случай, pia се инициализира като указател към обект - клас IntArray с 1024 елемента. Ако скобите липсват, както в израза
IntAarray *pia2 = new IntArray;
тогава или класът трябва да дефинира конструктор, който не изисква аргументи или да не дефинира конструктори изобщо.
Нека даден масив е разположен в свободната памет посредством типов спецификатор със затворена в скоби размерност. Размерноста може да бъде зададена чрез произволен сложен израз. Операторът new връща указател към първият елемент на масива. Например,
#include <string.h>
char *copyStr ( const char *s ) {
char *ps = new char[ strlen(s) + 1 ];
strcpy( ps, s );
retunr ps;
}
За масивите като класови обекти може също да бъде отделяна памет. Например,
IntArray *pia = new IntArray[ someSize ];
разполага в свободната памет масив, който е обект на класа IntArray с някакъв размер.
Отделянето на памет по време на изпълнение се нарича динамично разпределяне на паметта. Казваме, че масивът, адресиран чрез ia, е разположен динамично. Отделянето на памет за самия указател ia, обаче, се извършва по време на компилация - това е причината, поради която ia може да бъде именуван обект. Отделянето на памет по време на компилация се нарича статично разпределяне на паметта. Казваме, че указателят ia е разположен статично.
Времето на съществуване на един обект, т.е. този период от време, когато се изпълнява програмата, се нарича период на активност на обекта. За променливите, дефинирани с файлов обхват, се казва, че притежават статичен период на активност. За тях се отделя памет преди започване на изпълнението на програмата и тя остава свързана с променливата докато програмата приключи работата си. За променливите, дефинирани с локален обхват, се казва, че притежават локален период на активност. За тях се отделя памет при всяко навлизане в локалния им обхват; на излизане от него паметта се освобождава. Всяка локална променлива static има статичен период на активност.
За обекти, разположени в свободната памет, се казва, че притежават динамичен период на активност. Паметта, отделена чрез използватнето на оператора new остава свързана с обекта докато не бъде освободена явно от програмиста. Явното освобождаване се осъществява чрез прилагането на оператора delete към указателя, който адресира динамичния обект. Нека разгледаме един пример.
IntArray grow() разширява масива, адресиран чрез ia, с половината от размера му. Първо, трябва да бъде отделена памет за един нов по-голям масив. След това трябва да се копират стойностите на стария масив, а допълнителните елементи трябва да се инициализират със стойност 0. Накрая, паметта, заета от старият масив, трябва да се освободи явно чрез прилагане на оператора delete.
void IntArray grow() {
int *oldia = ia;
int oldSize = size;
size += size/2 + 1;
ia = new int[ size ];// copy elements of old array into new
for ( int i = 0; i < oldSize; ++i ) ia[ i ] = oldia[ i ];
for ( ; i < size; ++i ) ia[ i ] = 0;
delete oldia;
}
oldia има локален период на активност; паметта, отделена за нея, се освобождава автоматично, когато изпълнението на функцията приключи. Това не се отнася, обаче, за адресирания от oldia масив. Неговият период на активност е от динамичен тип и продължава да съществува и извън границите на локалния обхват. Ако паметта, отделена за масива, адресиран чрез oldia, не бъде освободена явно, като се използват оператора delete, тя остава присъединена към програмата. delete oldia; освобождава, отделената за масива памет.
Когато изтриваме масив, който е класов обект, на оператора delete трябва да е известен размера му. Това е необходимо за да бъде извикан подходящия класов деструктор. Например, даден е следния масив
IntArray *pia = new IntArray[ size ];
Тогава операторът delete, приложен към pia изглежда така
delete [size] pia;
Операторът delete трябва да бъде прилаган само за паметта, която е била отделена чрез оператора new. Прилагането на оператора delete към памет, която не е отделена от свободната памет вероятно ще се прояви в не дефинираното поведение на програмата по време на изпълнение. Прилагането на оператора delete върху указател със стойност 0, обаче, не предизвиква никакви опасни последици - т.е. към указател, който не адресира обект. Следват няколко примера за безопасно и не безопасно прилагане на оператора delete
void f(){
int i;
char *str = "dwarves";
int *pi = &i; // dangerous delete pi;
intArray *pia = 0; // dangerous delete pia;
doduble *pd = new double;
delete str;
}
Свободната памет на програмата не е безкрайна; през времето на изпълнение на програмата тя може да бъде изчерпана. (Разбира се, ако обектите, които не са необходими повече, не бъдат изтрити, ще се увеличи скоростта на изчерпване на свободната памет). По подразбиране, new връща 0, когато наличната свободна памет не е достатъчна за да удовлетвори заявката.
Програмистът не може спокойно да игнорира възможността, когато new връща 0. Нашата функция grow(), например, няма да работи ако new не е в състояние да отдели исканата памет. Да припомним, че нашият текст изглеждаше така
ia = new int[ size ]; // trouble
if new returns 0
for ( int i = 0; i < oldSize; ++i )
ia[ i ] = oldia[ i ];
Програмистът трябва да предотврати изпълнението на цикъла for, когато ia има стойност 0. Най-простият метод за да бъде направено това, е да се добави оператор за проверка на стойността на ia, който да следва обръщението към new. Например,
ia = new int[ size ];
if ( !ia ){ error("IntArray
grow()
free store exhausted");
}
където error() е една обща функция, дефинирана от програмиста и предназначена да съобщава за грешки като осигурява елегантен изход.
Ето една малка програма, която илюстрира използването на grow()
#include <stream.h>
#include "IntArray.h"
IntArray ia[ 10 ];
main() {
cout << "size " << ia.getSize() << "n";
for ( int i = 0; i < ia.getSize(); ++i )
ia
= i*2; // initialize ia.grow();
cout << "new size " << ia.getSize() << "n";
for ( i = 0; i < ia.getSize(); ++i )
cout << ia << " ";
}
Когато тази програма бъде компилирана и изпълнена, се получава следния резултат
size 10
new size
16 0 2 4 6 7 10 12 14 16 18 0 0 0 0 0 0
Ето една функция, която е проектирана да илюстрира изчерпването на свободната памет. Тя е реализирана като рекурсивна функция, чието условие за спиране е връщането на стойност 0 от new
#include <stream.h>
viod exhaustFreeStore( unsigned long chunk ) {
static int gepth = 1;
static int report = 0;
++depth; // keep track of invocations
double *ptr = new double[ chunk ];
if ( ptr )
exhaustFreeStore( chunk );// free store exhausted
delete ptr;
if ( !report++)
cout << "Free Store Exhausted" << "tchunk " << chunk
<< "depth " << depth << "n";
}
Четирикратното изпълнение на exhaustFreeStore() С аргументи, които имат различен размер дава следния резултат
Free Store Exhaused
ckunk 1000000 depth 4
Free Store Exhaused
ckunk 100000 depth 22
Free Store Exhaused
ckunk 10000 depth 209
Free Store Exhaused
ckunk 1000 depth 2072
Една от С++ библиотеките предлага известна помощ, като поддържа информация за свободната памет. Манипулаторът, обработващ изключенията _new_handler се разглежда в Раздел 5.4 по-нататък в тази глава.
Програмистът също може да постави обект, разположен в свободната памет на определен адрес. Формата на такова извикване на оператора new има вида
new (place_address) type-specifier
където place_address трябва да бъде указател. За да използвате оператора new по този начин трябва включите заглавния файл new.h. По този начин програмистът може да преразпределя паметта, която в един по-нататъшен момент ще съдържа обекти, определени чрез тази форма на оператора new. Например,
#include <stream.h>
#include <new.h>
const Chunk = 16;
class Foo { public
int val;
Foo() { val = 0; }
};// preallocate memory, but no Foo objects
char *buf = new char[ sizeof( Foo ) * Chunk ];
main() {
// construct Chunk Foo objects for buf
Foo *pb = new (buf) Foo[ Chunk ];
// check that objects were plased in buf
if ( (char*)pb == buf )
cout << "Operator new worked! pb "<< pb << " buf "
<< (void* )buf << "n";
}
Когато тази програма бъде компилирана и изпълнена ще получим следните резултати
Operator new worked!
pb 0x234cc
buf 0x234cc
Възможно е да се появи известно объркване относно тази програма. То е свързано с изпращането на buf към void*. Това е необходимо, понеже когато към оператора за изход се изпраща операнд char* се отпечатва "null terminated string", т.е. низа, който е адресиран. Чрез изпращането на buf към void* операторът за изход знае, че трябва да отпечата адресната стойност на buf. Това се дължи на факта, че операторът за изход се презарежда така, че да използват два различни указателни типове на аргументи char* и void*. Презаредимите функции се разглеждат в един от подразделите на тази глава. Въпреки, че този тип на оператора new се използва главно с типовете class, той може да се използват и за вградените типове данни. Например,
#include <new.h>
int *pi = new int;
main(){
int *pi2 = new (pi) int;
}
5.2. Един пример за свързан списък
В този раздел се реализира един елементарен клас списък от цели числа за да бъде илюстрирана както работата с указатели, така и използването на операторите new и delete. Като минимум IntList трябва да поддържа две стойности - цялата стойност на елемента на списъка и адреса на следващия елемент на списъка. Това може да се представи по следния начин
int val;
ListItem *next;
Един списък представлява последователност от елементи. Всеки елемент съдържа стойност и указател, може и null, към следващия елемент на списъка. Списъкът може да бъде и празен; т.е. да бъде списък без елементи
IntList i1; // the empty list
Списъкът може да нараства чрез добавяне на елементи. Тези елементи могат да бъдат вмъквани в началото на списъка
i1.insert( someValue );
или добавяни към края му
i1.append( someValue );
Списъкът може да бъде намаляван чрез отстраняване на елементи (предполага се, че той не е празен)
i1.remove( someValue );
Потребителят трябва да бъде в състояние да показва елементите на на списъка
i1.display();
Ето една първа програма, която бихме желали да напишем като използваме класа IntList.
#include "IntList.h"
const SZ = 12;
main() {
IntList i1;
i1.display();
for ( int i = 0; i < SZ; ++i )
i1.insert( i );
i1.display();
IntList i12;
for ( i = 0; i <SZ; ++i ) i12.append( i );
i12.display();
return 0;
}
Когато тази програма бъде компилирана и изпълнена се получава следния резултат
( empty )( 11 10 9 8 7 6 5 4 3 2 1 0 )( 0 1 2 3 4 5 6 7 8 9 10 11 )
Първата стъпка за реализацията на тази програма е дефинирането на класа IntList. Това е и първото място, където можем да сгрешим. Неправилен за проекта избор ще бъде да декларираме както val, така и next като членове на IntList. Например,
class IntList {
public IntList ( int = );
// ...private
int val;
IntLIst *next;
};
При този проект възникват няколко проблема. Всичките те произтичат от объркването между обекта списък и елемента на списъка. Например, при този проект не се допуска наличието на празен списък. Не съществува начин за разграничаване на списъка, съдържащ един елемент от празния списък. Въпросителните знаци, в сигнатурата на конструктора на IntList са предназначени да подчертаят този проблем. Няма подразбираща се стойност за инициализиране на val, чрез която да се отбелязва, че списъкът е празен. Другите проблеми възникват от това, че не е определен смисъла на insert() и remove() когато обектът от тип IntList предлага също и първия елемент на списъка.
В пректа на IntList трябва да бъде направено разграничаване между елементите на списъка и самия обект списък като такъв. Един от начините да бъде направено това е да бъде дефиниран както клас IntList, така и клас IntItem. Ето дефиницията на IntItem
class IntList;
class intItem{
friend class IntList;
private IntItem( int v=0 ) { val = v; next = 0 )
IntItem *next;
int val;
};
IntItem се нарича клас private (личен). Само на IntList е разрешено да създава и обработва IntItem обектите. Това е смисъла на декларацията friend. Раздел 6.5 разглежда подробно тази декларация. Раздел 6.1 обяснява разликите между декларациите private и public. IntList е реализиран по следния начин:
class IntItem;
class IntList {
public IntList(int val) { list = new IntItem( val ); }
IntList() { list = 0; }// ...
private
IntItem *list;
};
Упражнение 5-1. Защо IntList се нуждае от два конструктора? Защо, например, да не дефинираме простоIntList( val = 0 );
Упражнение 5-2. Един допълнителен член данни на IntList може да бъде int len; // length of list, който да съдържа броя на елементите на списъка. Разгледайте аргументите за и против тази декларация.
Следващата стъпка се състои в реализирането на член-функции, които поддържат потребителските обработки на IntList обектите. insert() поставя даден нов IntItem в началото на списъка. Това се реализира така
IntList
insert( int val ) {
// add to the front of the list
IntItem *pt = new IntItem( val );
pt->next = list;
list = pt;
return val;
}
append() е малко по-сложна. Тя трябва да добавя нов IntItem в края на списъка. Една помощна функция, atEnd(), връща указател към последния елемент на списъка
IntItem *IntList
atEnd(){ // return pointer to last item on list
IntItem *prv, *pt;
for ( prv=pt=list; pt; prv=pt; pt=pt->next ); // null statement
return prv;
}
append() трябва да проверява специалния случай на празен списък. Реализацията изглежда по следния начин
IntList
append( int val ) {
// add to the back of the list
IntItem *pt = new IntItem( val );
if ( list == 0 ) list = pt;
else (atEnd())->next = pt;
return val;
}
Упражнение 5-3. Разгледайте аргументите за и против поддържането на следния IntList член.
IntItem *endList;
Как това може да се отрази на реализацията на append()?
Потребителите на списъчния клас трябва да бъдат в състояние да показват елементите на списъка. Това е направено посредством член-функцията display(). Елементите на списъка се показват в скоби, по 16 на ред. Това изглежда така
#include <stream.h>
const int lineLength = 16;
IntList
display() { // display val member of list
if ( list == 0 ) {
cout << "( empty )n";
return 0;
}
cout << "( ";
int cnt = 0; // number of items displayed
IntItem *pt = list;
while ( pt ) {
if ( ++cnt % lineLength == 1 && cnt != 1 )
cout << "n ";
cout << pt->val << " "; pt = pt->next;
}
cout << ")n";
return cnt;
}
Проверката
if ( ++cnt % lineLength == 1&& cnt != 1 )
служи да се избегне появата на дясна затваряща скоба на всеки ред от само себе си. Пълната спецификация на заглавния файл IntList.h до този момент изглежда така
class IntList; // forward declaration
class IntItem {
friend class IntList;
private
IntItem(int v=0) { val = v; next = 0; }
IntItem *next;
int val;
};
class IntList {
public IntList(int val){ list = new IntItem( val );}
IntList() { list = 0; )
display();
insert( int = 0 );
append( int = 0 );
private
IntItem *atEnd();
IntItem *list;
};
Потребителят трябва да бъде в състояние да отстранява елементи от списъка или да изтрива целия списък. От проектанта на класа зависи дали опита за отстраняване на елемент от празен списък да се определя като грешка. Но и в двата случая на потребителя на класа е необходима функцията-предикат isEmpty()
class IntItem { /* ... */ ;
class IntList {
public isEmpty() { return list == 0; }
// ...private
IntItem *list;
);
Изтриването на целия списък може да се реализира така. Забележете, че функцията връща броя на отстранените елементи.
IntList
remove() {
// delete the entire list
IntItem *tmp, *pt = list;
int cnt = 0;
while ( pt ) {
tmp = pt;
pt = pt->next;
++cnt;
delete tmp;
}
list = 0;
return cnt;
}
Съответно, потребителят може да иска да отстрани всички елементи, които съдържат определена стойност. Един особен случай при извършването на това, е случаят, когато трябва да бъде отстранен първия елемент на списъка. Тогава трябва да бъде изменен и самият член на list.
IntList
remove( int val ) {
// delete all enries with value val
IntItem *prv, *tmp, *pt = list;
int cnt = 0;
while ( pt & pt->val == val )
// while the first item on list == val {
tmp = pt->next; // save pointer to next
delete pt;
++cnt;
pt = tmp;
};
if ( (list = pt) == 0 ) return cnt; // list empty
prv = pt;
pt = pt->next;
while ( pt ) {
// iterate through list
if ( pt->val == val ) {
tmp = prv->next = pt->next;
delete pt;
++cnt;
pt = tmp;
}
else {
prv = pt;
pt = pt->next;
}
}; // end, while (pt)
return cnt;
}
Една особено полезна член функция e length(). length() връща броя на елементите на списъка. За празния списък, разбира се, трябва да бъде връщана стойност 0.
IntList
length() {
int cnt = 0;
IntItem *pt = list;
for ( ; pt; pt = pt->next, ++cnt );
// null statement
return cnt;
}
Ето една втора малка програма, която демонстрира тези четири член-функции. (разширената спецификацията на заглавния файл IntList.h, беше оставена като упражнение за читателя).
#include "IntList.h"
#include <stream.h>
const SZ = 12;
const ODD = 1;
main() {
IntList i1; // empty lilst
if ( i1.isEmpty() &&i1.length() == 0 &&i1.remove() == 0)
// test that empty list is handled
cout << "Empty List ok.n";
// every odd item is set to value of ODD
for ( int i = 0; i < SZ; ++i )
i1.append( i%2 == 0 ? i ODD );
i1.display(); // illustrate remove( someValue );
cout << i1.remove( ODD ) << " items of value "
<< ODD << " removed ";
i1.display();// illustrate remove()
int len = i1.length();
if ( i1.remove() == len )
cout << "All " << len << " items removed ";
i1.display();
return 0;
}
Когато компилираме и изпълним тази програма ще получим следните резултати
Empty List ok.
( 0 1 2 1 4 1 6 1 8 1 10 1 )
6 items of value 1 removed
( 0 2 4 6 8 10 )
All 6 items removed
( empty )
Упражнение 5-4. Реализирайте IntList removeFirst(). Нека стойността, която връща тази член-функция е стойността на члена val. Уверете се, че обработвате и случая на празен списък.
Упражнение 5-5. Реализирайте IntList removeLast(). Нека отново, стойността, който връща тази член-функция да бъде стойността на члена val. Уверете се, че обработвате и случая на празен списък.
Една много разпространена операция над списъци е обединение. Самата операция е проста, но често се греши при реализацията й. Написаното по-долу вероятно ще причини на потребителя известни неприятности
#include "IntList.h"
void IntLIst
concat( IntList& i1 ) {
( atEnd() )->next = i1.list; }
Проблемът се състои в това, че два IntList обекта ще сочат една и съща последователност от елементи. Много е вероятно двата класови обекта да трият елементи по различно време в програмата. Ако вторият обект се опитва да получи достъп до елементи, които вече са изтрити, ще се появят висящи псевдоними (указатели), които вероятно ще причинят грешки по време на изпълнение на програмата. Ако това не се случи, съществува възможност вторият обект по-късно да се опита да изтрие елемент, чиято памет вече да се окаже отделена за някаква съвсем различна цел. Отново е съвсем вероятно програмата да бъде прекъсната по време на изпълнение. Едно общо решение е да се осигури брояч-псевдоним, за всеки елемент на списъка.
Всеки път, когато се отстранява елемент,броячът-псевдоним се намалява с 1. Когато той стане 0, елементът може да бъде изтрит фактически. Една алтернативна стратегия е да се копира всеки елемент, който участвува в обединението. Тази версия на concat() изглежда така
void IntList
concat( IntList& i1)
{ // append i1.list to invoking list object
IntItem *pt = i1.list;
while ( pt ) {
append( pt->val );
pt = pr->next;
} }
Една интересна операция над списъци е обръщане. В този случай списъкът се обръща като последният елемент застава в началото и обратно. Въпреки, че реализацията на операцията е кратка, указателите се обработват по интересен начин и е лесно да сгрешите ако сега започвате да програмирате със списъци. Ето и реализацията
void IntList
reverse() {
IntItem *pt, *prv, *tmp;
prv = 0;
pt = list;
list = atEnd();
while ( pt != list ) {
tmp = pt->next;
pt->next = prv;
prv = pt;
pt = tmp;
}
list->next = prv;
}
Следната малка програма илюстрира concat() и reverse()
#include "IntList.h"
const SZ = 8;
main() {
IntLIst i1, i12;
for ( int i = 0; i < SZ/2; ++i ) i1.append( i );
for ( i = SZ/2; i < SZ; ++i ) i12.append( i );
i1.display();
i12.display();
i1.concat( i12 );
i1.display(); // concat
i1.reverse();
i1.display(); // reverse
return 0;
}
Когато компилираме и изпълним тази програма ще получим следния резултат
( 0 1 2 3 )
( 4 5 6 7 )
( 0 1 2 3 4 5 6 7 ) ( 7 6 5 4 3 2 1 0 )
Упражнение 5-6. Реализирайте член функция за добавяне на елемент в списъка, така че IntItem, който го следва да има стойност, която да е първата стойност в списъка, по-голяма от стойността на добавяния елемент.
Упражнение 5-7. Променете IntList, така че да притежава и елемент IntItem *endList; Когато изменяте public член функции се уверете, че не нарушавате нещо в съществуващия текст (трите примерни програми в този раздел).
5.3. Презаредими имена на функции
За дадена дума се казва, че е презаредима, ако има две или повече различни значения. Смисълът, в който е употребена думата, се определя в зависимост от контекста. Ако напишем
static int depth;
значението на static се определя от обхвата на появата й. Това е или локална статична променлива или е декларирана с файлов обхват. (В следващия раздел, ние ще въведем едно трето значение на static, което се отнася за статичен член на клас). Във всеки случай значението на static напълно се изяснява от контекста, в който се използват. Когато такъв контекст липсва, казваме, че думата е двусмислена. Такива думи могат да имат две или повече значения, всяко от които да бъде еднакво възможно.
В естествените езици двусмислието често е умишлено. В литературата, например, двусмислието може да обогати нашето разбирането на героите и тематиката на книгите. Едно лице, може да бъде описано като ограничено (задължено, обвързано) и решително (непоколебимо, твърдо). Един от героите може да се обърне към друг и да каже "Хората никога не са справедливи (верни, точни)". Читателят може да възприеме различните значения на думата едновременно.
Двусмислието, обаче, е неподходящо за компилатора. Ако контекстът, в който се появява даден идентификатор или оператор не е достатъчен за да се изясни значението му компилаторът издава съобщение за грешка. Двусмислието, обаче, е особено важно при презаредимите имена на функции, темата на този раздел, както и на наследствеността при класовете, което е предмет на обсъждане на глави 7 и 8.Защо да презареждаме имената на функциите?
В С++ на две или повече функции могат да бъдат дадени едни и същи имена, но с уникална сигнатура, като се различават по броя или типа на аргументите си. Например,
int max( int, int);
double max(double, double );
Complex &( const Complex, const Complex );
Необходима е отделна реализация за всеки уникален набор от аргументи на max(). Всяка от тях, обаче, изпълнява едно и също общо действие - връща по-големия от двата аргумента.
От потребителска гледна точка съществува само една операция, която определя максимална стойност. Детайлите по реализацията относно начина, по който това се извършва, се отнасят до един по-широк кръг интереси. Чрез презареждането на функциите потребителят може просто да напише следното:
int i = max( j, k );
Complex c = max( a, b );
На английски употребените думи са bound and determined. Изречението има вида
"People are never just".
Един аналог ни предлага аритметичният оператор. Изразът 1 + 3 извиква операцията събиране за цели операнди, докато израза 1.0 + 3.0 извиква различна операция за събиране, която обработва операнди с плаваща запетая.
За потребителя реализацията на този механизъм е прозрачна понеже операцията събиране е презаредена така, че да подава различни свои представители. Компилаторът, а не програмистът, се грижи за разграничаването на тези различни представители. Презареждането на имена на функции предлага подобна прозрачност за потребителски дефинирани функции.
Без способността за презареждане на име на функция на всеки нейн представител трябва да бъде дадено собствено уникално име. Например, нашето множество от max() функции ще придобие вида
int max( int, int );
double fmax( double, double );
Complex &Cmax( const Complex&, const Complex& );
Тази лексикална сложност не е присъща на проблема за определяне на по-големия от два обекта от различни типове данни, а по-скоро отразява едно ограничение на програмната среда - всеки оператор, който се явява в определен обхват, трябва да бъде уникален. Тази сложност изправя програмиста пред един практически проблем - той трябва или да помни или да търси всяко име.
Презареждането на имената освобождава програмиста от тази лексикална сложност.
Как да презаредим име на функция
Когато едно име на функция се декларира повече от един път в една програма компилаторът ще интерпретира втората декларация по следния начин:
- ако както типът за връщане, така и сигнатурата на двете функции съвпадат напълно, то втората се разглежда като повторна декларация на първата. Например,
// declares the same function
extern void print( int *ia, int sz );
void print( int *array, int size );
Имената на аргументите не са съществени за сравнението на сигнатурите.
- ако сигнатурите на две функции съвпадат точно, но типовете за връщане са различни, втората декларация се разглежда като неправилна повторна декларация на първата и се отбелязва като грешка по време на компилация. Например,
unsigned int max( int*, int sz );
extern int max( int *ia, int ); // error
- ако сигнатурите на две функции се различават по броя или типа на аргументите си, се счита, че двата представителя на функцията са презаредими. Например,
extern void print( int *, int );
void print(double *da, int sz );
Една декларация typedef предлага алтернативно име за съществуващ тип данни; то не създава нов тип данни. Следните два представителна search() се третират като притежаващи една и съща сигнатура. Декларацията на втория представител ще предизвика грешка по време на компилация понеже въпреки, че притежава същата сигнатура, тя има различен тип за връщане.
// typedef does not introduce a new type
typedef char *string;
extern int search( string );
extern char search( char ); // error
Кога да не използваме презареждането на функции ?
Механизмът на презареждането позволява множество от функции, които изпълняват сходна операция, такава като print(), да бъдат извиквани чрез едно общо мнемонично име. Свързването с подходящия представител на функцията е прозрачно за потребителя, като при това отстранява лексикалната сложност, породена от необходимостта на всяка функция да се дава уникално име, като iPrint() и iaPrint().
След като разгледахме предимствата на този механизъм нека кажем кога не се препоръчва той да бъде използван. Един случай имаме, когато множеството от функции не изпълнява сходна операция. Например, ето един набор от функции, които работят с обща абстрактна съвкупност от данни. Първоначално те могат да ни се сторят като евентуални кандидати за презареждане
void setDate( Date&, int, int, int );
Date& convertDate( char* );
void printDate( const Date& );
Тези функции работят с едни и същи типове данни, но не изпълняват една и съща операция. В този случай лексикалната сложност е програмистко споразумение, което свързва набора от функции с общия даннов тип. Класовият механизъм в С++ прави този тип съглашения ненужни. Тези функции трябва да бъдат направени член функции на класа Date. Например,
class Date{
set( int, int, int );
Date &convert( char* );
void print();
// ...};
Следният набор от пет член функции на класа Screen изпълняват различни операции за движение. Отново те могат да бъдат презареждани чрез едно общо име move().
Screen& moveHome();
Screen& moveAbs( int, int );
Screen& moveRel( int, int, char *direction );
Screen& moveX( int );
Screen& moveY( int );
Последните два представителя не могат да бъдат презареждани; техните сигнатури са едни и същи. За да осигурим уникалност на сигнатурата трябва да обединим двете функции в една
Screen& ( int, char xy );
Така получаваме уникална сигнатура. Освен това, ако някакво проучване покаже, че по оста x или y промените са по-чести, можем да зададем стойност по подразбиране
Screen& move( int, char xy = `xґ);
Проучването може да покаже също, че най-честото движение е преместване напред с една позиция по оста х. Ако се поддържа стойност по подразбиране за първият аргумент, обаче сигнатурата вече не е уникална
Screen& move( int sz = 1, char xy = `xґ );
Сега и двете функции move() и moveHome() могат да бъдат викани без аргументи. Не е необходимо аргумент с инициализатор по подразбиране да бъде разглеждан, когато се опитваме да съпоставим определен представител при извикване на презаредима функция.
В този момент програмистът може да оспори смислеността на презаредимостта на тези две функции. В този случай изглежда, че презареждането е процес на отхвърляне на ненужна информация. Въпреки, че движението на курсора е обща операция за тези функции, специфичното естество на това движение е уникално при всяка от тях. moveHome(), която е един специален случай на движение на курсора, ни дава друг подобен пример. Името moveHome() предлага повече информация отколкото move(). Програмата може да се подобри с едно специално име на функция
inline Screen&Screen
home(){ return move( 0, 0 ); }
Това освобождава втората и третата функция за движение. Отново те могат да бъдат презареждани. Както е по-лесно, обаче, те могат да бъдат обединени в един представител чрез инициализатор на аргумента по подразбиране
move( int, int, char* = 0 );
Най-добре е програмистът да не мисли, че всяка езикова характеристика е следващата планина, която трябва да изкачи. Използуването на дадена характеристика трябва да бъде предизвикано от логиката на приложението, а не просто от факта, че такава съществува.
Свързване на обръщение към презаредима функция
Сигнатурата на функцията разграничава един представител от друг при набор от презаредими функции. Например, ето четири различни представителя на print()
extern void print( unsigned int );
extern void print( char* );
extern void print( char );
extern void print( int );
Едно обръщение към презаредима функция се свързва с подходящ представител по време на процеса, наречен съпоставяне на аргументите, за който може да се мисли като за процес на разрешаване на двусмислието. Съпоставянето на аргументите предизвиква сравняване на фактическите аргументи на повикването с формалните аргументи на всеки деклариран представител.
Съществуват три възможни резултата от обръщението към презаредима функция
1. Успешно съпоставяне. Обръщението се свързва с подходящ представител. Например, например всяко от следните три обръщения към print() има като резултат съпоставяне
unsigned a;
print( `aґ ); // matches print(char);
print( "a" ); // matches print(char*);
print( a ); // matches print(unsigned);
2. Неуспешно съпоставяне. Фактическите аргументи не могат да бъдат поставени в съответствие с аргументите на дефинираните представители. Всяко от следните две обръщения към print() има като резултат неуспешно съпоставяне
int *ip;
SmallInt si; // error no match
print( si )
print( ip ); // error no match
3. Двусмислено съпоставяне. Фактическите аргументи могат да бъдат съпоставени с повече от един дефиниран представител. Следното обръщение е един пример за двусмислие при съпоставянето, понеже такова може да бъде осъществено с всеки от представителите на print(), като изключим този, който получава аргумент от тип char*.
unsigned long u1;
print( u1 ); // error ambiguous
Съпоставянето може да бъде извършено по един от следните три начина, в зависимост от приоритета:
1. Точно съпоставяне. Типът на фактическите аргументи съответства точно на типа на един от дефинираните представители. Например,
extern ff( int );
extern ff( char* );
f( 0 ); // matches
ff( int )0 е от тип int. Обръщението точно съответства на ff(int).
2. Съпоставяне чрез прилагане на стандартни преобразувания. Ако не бъде намерено точно съпоставяне се прави опит да се извърши съпотавяне чрез стандартно преобразуване на фактическия аргумент. Например,
class X;
extern ff( X& );
extern ff( char* );
ff( 0 ); // matches
ff(char*)
3. Съпоставяне чрез прилагане на дефинирани от потребителя преобразувания. Ако не бъде намерено точно съпоставяне или стандартно преобразуване се използват дефинираното от потребителя. Например,
class SmallInt
{ operator int();// ...
SmallInt si;
extern ff( char* );
extern ff( int );
ff( si ); // matches
ff(int);
operator int() се нарича оператор за преобразуване. Той позволява на класа да дефинира собствен набор от "стандартни" преобразувания. Раздел 7.5 разглежда подробно тези дефинирани от потребителя преобразувания.
Особености на точното съпоставяне
Фактическите аргументи от тип char, short и float се обработват като специален случай, като се спазва изискването за точно съпоставяне. Правят се два прегледа на набора от презаредими функции винаги когато съществува фактически аргумент за една от тях.
При първия преглед се прави опит за точно съпоставяне на аргументите. Например,
ff( char );
ff( long );
ff( `aґ ); // ff(char)
Символната константа точно съответства на презаредимия представител, който има формален аргумент от тип char. Търсенето на съответствие приключва.
Ако при първия преглед не бъде намерено точно съответствие се извършва следното
- аргументи от тип char, unsigned char или short се привеждат към тип int. Аргументи от тип unsigned short се привеждат към тип int ако машинния размер на int е по-голям от този на short; иначе се првеждат към тип unsigned int.
- аргументи от тип float се првеждат към тип double.
При втория преглед се прави опит за намиране на точно съответствие за аргументите на основата на извършените преобразувания. Например,
ff( int );
ff( short );
ff( long );
ff( `aґ );
// ff(int);
Символната константа точно съответства на презаредимия представител, който има формален аргумент от тип int. Съпоставянето на някой от типовете short или long изисква прилагане на стандартно преобразуване. Търсенето на съответствие приключва.
Един фактически аргумент от тип int не се съпоставя точно на формални аргументи от тип char или short. Съответно double не съответствува точно на аргумент от тип float. Например, дадена е следната двойка от презаредими функции,
ff( long );
ff( float );
при които следното обръщение предизвиква двусмислие
ff( 3.14 ); // error ambiguous
Литералната константа е от тип double. Тя не съответства точно на нито един представител. С двата представителя се постига съпоставяне чрез прилагане на стандартните преобразувания. Понеже съществуват две възможни преобразувания обръщението се отбелязва като двусмислено. На нито едно стандартно преобразувание не се дава приоритет спрямо друго. Програмистът трябва да разреши проблема с двусмислието или чрез явно конвертиране, такова като
ff( long( 3.14 )); // ff(long)
или като използва суфикс за означаване на константа float
ff( 3.14F ); // ff(float)
В следния пример, където са дадени следните декларации
ff( unsigned );
ff( int );
ff( char );
обръщение с фактически аргумент от тип unsigned char се съпоставя на формален аргумент от тип int. Другите два преставителя изискват прилагането на стандартни преобразу