Решение на проблема оператора delete



Дата25.02.2017
Размер170.64 Kb.
Управление на паметта и ресурсите. Финализация

Георги Иванов

Автоматично управление на паметта (Automatic Memory Management)

Всяка програма пази инстанции на типовете от данни в паметта. Тази памет за всяка инстанция може да бъде алокирана в дата сегмента, стека или в хиипа. Обикновено стаичните променливи в една програма се пазят в дата сегмента и локалните промениливи за даден метод се пазят в стека. Компилатора сляга подходяща логика в изходния код да се грижи за поддръжката на паметта за типове, които се алокират в дата сегмента и на стека. За типове, които се алокират в хиипа, програмиста трябва експлицитно да се грижи за паметта, която се използва за тяхното инстанциране. Ето и някои типични стъпки, през които програмиста минава за динамични структури от данни:



  1. алкиране на памет с подходящ размер за инстанцията на дадения тип. В С++ и С#, това може да стане като се използва оператора new.

  2. инициализациа на инстанцията. Член-променливите на този тип се инициализират с някаква подходяща стойност. Всички системни ресурси, които инстацията изисква, като отваряне на файл или мрежова връзка, също се осигуряват. В С++ и С#, това може да стане с някакъв конструктор или с метод които се грижи за инициализацията.

  3. използване на инстанцията (и всякакви други ресурси алокирани от нея).

  4. Освобождаване на ресурсите: например, затваряне на файла или мрежовата връзка, отворени преди това.

  5. Освобождане на заетата памет. Под С++, това може да се осъществи с помощта на оператора delete.

Хората правят грешки. Когато програмираме, много е вероятно някой да забрави да мине през стъпка 4. С++ има сигурно решение на проблема – оператора delete. Когато той е извикан за даден обект, компилатора се грижи че деструктора на класа ще бъде извикан, преди паметта да бъде освободена за този обект. Ако логиката за освобождане се премести в деструктор, стъпка 4 се включва автоматично в стъпка 5.

Ако забравите да минете през стъпка 5, това не само ще доведе до memory leak, но и ресурсите които сте отворили няма да бъдат освободени.

Този проблем е преследвал програмистите от много време. Има много помощни програми които ни позволяват да открием точно такива неосвободени блокове от памет или системни ресурси. Съществуват и т.нар. “Smart Pointer Techniques” които са създадени за да се осигури изпълнението на тази важна стъпка 5. когато референциата към някои обект излезе от употреба, обектът автоматично се освобождава.

.Net разглежда и се справя с този проблем по различен начин. Както знаем в С# не съществува оператор delete. Няма никаква нужда да освобождаме обект. CLR автоматично разбира кога обектът вече не се използва и го освобождава. Този механизъм се нарича Garbage Collection (GC). Нека да видим как тои работи.

Струва си да отбележим че GC не е нова техника. Има много GC алгоритми, които се използват в днешно време, но ние ще разгледаме само този който се използва в .Net. Тук също ще покрием някои важни аспекти на memory management-a. Можете да намерите доста подробно обяснение на тази тема в статията на Jeffrey Richter Garbage Collection—Part 2: Automatic Memory Management in the Microsoft .NET Framework. И Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework.

Garbage Collection

Когато CLR е инициализирана в един процес, той резервира последователен блок от памет в хиипа на самия процес. Този блок от памет се нарича managed heap.

Когато едно приложение създаде даден обект използвайки оператора new, CLR алокира необходимиа блок от памет от managed heap за този обект. Този опростен механизъм прави създаването на обект на managed heap много ефиктивно. Всъщност тази операция е почти толкова бърза колкото да създадем променлива в стека. Забележете че дори и да заделим голям блок от памет от хиипа първоначално, това е все пак виртуална памет на самия процес, не реална физическа памет. паметта се заделя при необходимост от нея, когато обектите се създават по време на изпълнение. Трудността е работната големина на виртуалната памет при създаването на самия процес е сравнително малка, а може да нарасне много по време на изпълнение, когато имаме постоянно създаване на най-различни обекти.

Със създаването на обекти на managed heap, хиипа започва да се пълни. Какво става когато искаме да инстанцираме нов обект, но нямаме достатъчно памет в хиипа? Ето тук е мястото където Garbage Collector (GC) влиза в действие. Ето какво той прави:




  1. с помоща на CLR, GC първо създава списък с обекти които се използват в момента. Останлите обекти могат да бъдат освободени (казва се че те са боклук или garbage) и паметта която те заемат е свободна за други обекти.

  2. GC после пренарежда паметта, като ефективно премахва създалите се “дупки” в хиипа, причинени от освободените обекти. Валидните обекти са изместенени в паметта, при необходимост. След като събирането на неизползваеми обекти приключи, всички обекти се намират в блокове от памет, разположени последователно. Останалата част от хиипа е готова за създаване на нови обекти.

  3. тъй като валидните обекти са били изместени в паметта, всички референции към тях са станали невалидни. GC оправя тези референции да сочат към новите местоположения в паметта за съответните обекти.

  4. оператора new се извиква отново и заявката за памет вече се удовлетворява.

Интересно е как се съставя списъка с обекти които вече не се използват. Всяко приложение има набор от root обекти или обекти които GC може да ползва като начална точка за да определи другите обекти които се използват в момента. Например, всички глобални или статични обекти в едно приложение се считат за root обекти. Местните променливи и аргументите на даден метод също се разглеждат като root обекти. Накрая, вски CPU регистър, съдържащ указател към managed heap е също считан за част от програмните корени. Този набор от корени може да се променя с изпълнението на самата програма. С малка помощ от JIT компилатора, CLR поддържа този списък от активни корени на дървото.

Когато GC се активира, тои преглежда активните корени и построява един граф от всички обекти които са достижими от даден корен. Това са реално обектите които се използват в текущия момент. Останлите обекти са “боклук” и може да се счита че може да бъдат освободени.

Нека отбележим че един обект се счита за кандидат за освобождане тогава когато не е използван повече. Например, променливите, които излизат от техния блок (scope) са кандидати за осбождаване.


Дебъгера променя това поведение


Когато едно приложение е дебъгвано, средата удължава живота на всяка променлива дори след като тя не е вече използвана в този блок от код. Това ви дава възможност да наблюдавате обектите в самия дебъгер.

Изследване на производителността (Performance Considerations)


Поради своята цел и работа, GC е много скъпа операция. Зависейки от дупките оставени в managed heap, преместването и дефрагментирането на паметта може да бъде мноого скъпо. Това дори има по-голямо значение за multithreading приложения. С фрагментирането на пеметта, CLR трябва да спре всички други тредове за да се увери че работищите тредове няма да използват невалидни референции към паметта.

За да се решат тези проблеми, GC агоритъм използва няколко различни маханзми за да осигурни работа на тредовете толкова по-дълго колкото е възможно и да намали overhead-a. тези механизми включват изцяло прекъсване на ипълнявания код (на всеки тред), един вид отвличане на самия тред, и позволяване на JIT компилатора да вмъкне допълнителен GC releated код в някои безопасни места в изпълнявания метод. Този алгоритъм също има оптимизации за мултипроцесорни пратформи. Детайлно описание на тези техники е предоставено в статията на Jeffrey Richter : Garbage Collection—Part 2: Automatic Memory Management in the Microsoft .NET Framework. Нека само отбележим че тези механизми са абсолютно прозрачни за вашето проложение.


GC Performance Counters

CLR предоставя много средства за следене и показване на статуса на managed паметта за определен процес. Тези counter-и са групирани под .Net CLR memory performance object.


По подразбиране, GC се включва тогава когато се създава дадена инстанция на обект, и тогава няма достатъчно памет за да се удовлетвори тази заявка. Въпреки това, възможно е програматично да накарате GC да се включи във вашето приложение. Framework-а предоставя един клас, System.GC, който да се грижи точно за това. Следния ред принуждава GC да се включи:
GC.Collect();
Като цяло, най-добре е да оставите GC да се включи тогава когато CLR реши. Въпреки това, ако вашето приложение знае повече за своето поведение по време на изпълнение, вие можете да извикате този метод в някои стратегически места във вашия код. Например, добро място да извикате GC е когато вашото приложение стои без работа, вероятно чакащо някаква намеса на потребителя.

По подразбиране, средата (CLR) създава допълнителен тред които да пуснат GC конкурентно (същевременно). Възможно е да укажете на средата (CLR) да пусне GC на същия тред от който се придизвиква извикването му. Това става с помощта на конфигурационната настройка gcConcurrent, както е показано тук:











пускането на GC конкурентно, намаля производителността. За проложения базирани на потребителски интерфейс, има смисъл да се пуска конкурентно, така че проженията да се не се блокират и да изглеждат все едно са забили. Въпреки това, за прожения работещи на заден план (background applications), които не зависят от потребителски интерфейс, се препоръчва да не се използва конкурентното изпълнение на GC.
Генерации (Generations)
Алгоритъма на GC има много заложени начини да подобри събиранто на паметта. Една такъв начин се нарича генерации (generations), които се основава на следните предположения:


  • колкото по нов един обект е, толкова по малък живот ще има. Например, променливите локални за даден метод се създават когато се почне изпълнението на метода и не се използват повече след като метода приключи работата си.

  • По новите обекти се счита че имат по здрава връзка един с друг и често се използват по едно и сущо време.

  • Фрагментирането на част от хиипа е доста по-бързо от фрагментирането на целия хиип.

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

В .Net managed хиип-а е логически (не физически!!) организиран в зони наречени генерации (generations). Първия release на .Net средата съдържа 3 поколения (номерирани с 0 – 2, включително) и най-вероятно ще остане така и занапред.

Когато обектите се създават, те се слагат в генерация 0. Когато генеация 0 се запълни, се пуска GC освободи памет. тези обекти, които оцеляват от това (те се изпозлват за текущото изпълнение на програмата), се преместват в генерация 1. след това освобождаване на памет, генерация 0 е свободна изцяло. Когато последващо почистване на паметта се случи, оцелелите обекти от генерация 1 се преместват в генерация 2 и оцелелите обекти от генерация0 се местят в генерация 1. тъй като генерация 2 е за момента най-висшата генерация, при следващи почиствания на паметта, оцелелите обекти от генерация 2 просто си остават там.

Класът System.GC предоставя метод GetGeneration, който може да бъде използван за да определи генерацията в която е един обект. Следващия пример показва как един обект се мести нагоре в генерациите с обекти:
static void Main(string[] args)

{

Object obj = new Object();



Console.WriteLine( GC.GetGeneration( obj ) ); // displays 0
GC.Collect();

Console.WriteLine( GC.GetGeneration( obj ) ); // displays 1


GC.Collect();

Console.WriteLine( GC.GetGeneration( obj ) ); // displays 2

}
Kак точно генерациите подобряват работата на GC? Когато настъпи събира на памет, GC може да избереда анализира само обекти от генерация 0 и да игнорира обектите от по-висока генерация. В края на краищата, колкото един обект е по-нов, толкова неговия живот се очаква да бъде. Освобождаването и фрагментирането само на генерация 0 се очаква да допринесе до освобждаването на солидно количество от памет от хиипа и ще бъде доста по бързо отколкото да разгледаме всички обекти във всички генерации. Разбира се, ако освобождаването на обекти от генерация 0 не ни осигури достатъчно памет, тогава обектите от генерация 1 ще бъдат разгледани за освбождаване. Ако и това не е достатъчно, обектите в генерация 2 ще бъдат изследвани за неизползвани.

Друг плюс за производителността (performance) идва от статистическата вероятност новите обекти да имат силна връзка един с друг и да се използват по приблизително едно и също време. Тъй като новите обекти са алокирани в последователни блокове с памет, вие печелите производителност от бързо използване и намиране на един с друг. Много е вероятно новите обекти да се пазят в кеша на CPU-то. Използването на кеша на CPU-то, е доста по-бързо от използването на RAM памет.

Възможно е да предизвикате освобождаване на памет (garbage collection) на по висока генерация. Това става програмно като използваме метода Collect на System.GC и му подаваме номера на съответната генерация.
GC.Collect( 2 );
Осбождаване на обекти от генерация 2, автоматично изисква такава да се направи в генерации 0 и 1.
Финализация (Finalization)

GC се грижи за освобождането и цялостния мениджмент на паметта, но не и за други ресурси. Важно е да се разбере че GC може да освободи всяка неизползвана памет, но не може да се грижи за други системни ресурси. Ако вие държите ресурси като handle към файлове които са вече отворени, ваша отговорност е да освободите тези ресурси.

Нека да направим следния пример:
Class MyFile{

Private IntPtr m_hFile; // native win32 handle to the opened file
Public MyFile(string sFileName)

{

// call native win32 api to open a file

m_hFile = CreateFile( sFileName, …);

}
public String ReadLine()

{

….

}

}
class MyApp

{

public static void Main()

{

MyFile file = new MyFile( “readme.txt” );

Console.WriteLine( file.ReadLine() );

}

}

Класът MyFile отваря определен файл в своя конструктор използвайки native win32 API CreateFile. Когато инстанция на MyClass бъде освобождавана от GC, handle към файла никога няма да бъде затворен. Ако използвате такъв обект няколко пъти, скоро ще усетите лиспата на handle-и към файлове.

За късмет, .Net предоставя механизъм наречен фанализация (finalization) който позволява да се изчистим от такива ресурси когато се освобождава дадена инстанция. Основопожника на всички обекти System.Object дефинира метод наречен Finalize(), който всеки наследник може да предефинира според спобствените си нужди. Следва неговия прототип:
protected virtual void Finalize();
така че всичко от което се нуждаем е да предефинираме този метод в MyFile и да затворим handle-а към този файл. Е понякога не е възможно да направите това, както например в С# - тои не ви позволява да предефинирате Finalize() метода – компилатора на С# обявява това като грешка. Вместо да предефинирате този метод, вие се нуждаете да изпозлвате семантиката на деструктора да имплементира освобождаването на ресурсите, както е показано тук:
class MyFile

{

….

~MyFile()

{

CloseHandle( m_hFile );

}

}
Under the hood, компилатора конвертира този деструктор в Finalize() метод. Ето и как изглежда генерирания MSIL за него:
protected override void Finalize()

{

try

{

CloseHandle( m_hFile );

}

finally

{

base.Finalize();

}

}
забелязваме че в генерирания код, компилатора е сложил логика и за извикване на базовиа Finalize() метод. Компилатора няма да ви позволи да извикате метода Finalize() на базовия клас директно. Имплементирането на деструктор е единствения начин да се подсигурите че Finalize() метода на базовия клас ще бъде извикан. Клаузата finally в С# дава гаранция че независимо от ихода на try блока, кода във finally ще бъде извикан.

С# деструктори срещу С++ деструктори

На пръв поглед, деструктора в С# изглежда много подобно на този в С++. Въпреки това има 2 много важни разлики.

Извикването на деструктора в С++ е детерминистично. Те се извикват когато един обект бъде освободен чрез оператора delete или ако обектът е дефиниран на локалниа стек и излезе от обсег (out of scope). В С# пък, няма такава детерминистична финализация. Вие нямата никаква престава кога и в каква точка ще бъде извикан деструктора.

В С++ треда който извиква деструктора е детерминистичен. Следователно, деструкторите в С++ могат да използват спефични своиства на треда, като Thread Local Storage (TLS). Под .Net, деструктора е извикан от специален runtime тред. Следователно деструкторите в С# не трябва да използват свойства характерни за треда който ги изпълнява, тъй като този тред не се знае със сигурност кой ще бъде.

Ако под С++ вие не дефинирате деструктор, компилатора дефинра такъв за вас. Под С#, е точно обратното. Както ще видим по късно, финализацията под .Net е скъпа операция и трябва да бъде намалвана до минимум.

Под С++, редът на извикване на деструкторите е детерминистичен. Например, ако даден клас съдържа инстанции на други класове, деструктора на този клас (външния клас) е извикан преди деструкторите на тези вловени обекти в него (inner objects). В С# редът на извикване на Finalize() не може да се гарантира. Вътрешните обекти могат да бъдат освободени дори преди външния обект. Следователно методът Finalize() никога не трябва да използва каквито и да е било вложени обекти (inner objects).


Въпреки че финалзиацията изглежда твърде лесна на пръв поглед, вътрешната работа която се извършва за нея съвсем не е проста. По специално, един обект който има Finalize() метод е сложен на отделно място (наречено Finalziation Data Queue) в рамките на managed хиипа. Когато стане рециклиране на памет (garbage collection), обектите които трябва да се освободят и които са в тази Finalization Data Queue се преместват в друга опашка наречена Freachable Data Queue. Специален runtime тред се грижи да извика методът Finalize() на всички обекти в тази Freachable Data Queue.

Важни моменти при имплементиране на финализацията:

  • Не трябва да използвате никакви специфични свойства и характеристики на тредовете. Не трявба да забравяте че финализацията не се изпълнява от текущия ви тред, а то специален системен такъв.

  • Кода за финализацията трябва да бъде възможно най-бърз. Има и други обекти които чакат да бъдат освободени.

  • Трябва да премахните всякакви операции които могат да блокират финализацията, като синхронизиращи методи за тредове.

Важно е да се запомни че викането на метода Finalize() оказва влияние върху прозиводителността на вашата програма и следователно трябва да бъде сведено до минимум. Но това разбира се не означава че трябва да оставите отворените файлови handle и да не се грижите за тях.


Особождаване на ресурси (Disposing Objectresources)

Друг лесен и логичен начин да се обработите подходящо ресурсите които изпозлвате във вашия клас е да добавите метод които потребителя на вашия клас ще вика изрично. За класове, които отварят ресурси (с метод Open() например), използването на метод Close() е съвсем логичен. Чрез него вие ще освобождавате отворените от вас ресурси. Например:


Public void Close()

{

if (INVALID_HANLE_VALUE != m_hFile)

{

CloseHandle( m_hFile );

m_hFile = INVALID_HANLDE_VALUE;

}

}
въпреки че можете да дефинирате метод като този Close() вътре в самия ви клас и тои при това работи доста добре, .Net framwork формализира тази идея за експлицитно затраряне и освобождаване на ресурси. Клас който иска да притежава такава фунционалност, трябва да имплементира интерфейс, дефиниран от самия framework, наречен IDisposable. Ето и неговиа прототип:
interface IDisposable

{

void Dispose();

}
кода по долу показва как може да модифицирате класа MyFile за да поддържа този интерфейс:
class MyFile : Idisposable

{



void Dispose()

{

if (INVALID_HANLE_VALUE != m_hFile)

{

CloseHandle( m_hFile );

m_hFile = INVALID_HANLDE_VALUE;

}

}

}
Потребителят на класа може да извика експилицитно метода Dispose() за да се освободят системните ресурси който класа използва.

Причината за имплементирането на този IDispose интерфейс е да избегнем финализацията. Въпреки това, кода за финализиране е все още там. Ние не сме поправили реално истинския проблем. Нашият проблем не е че сме добавили деструктора реално, а че финализацията ще бъде изпълнена въпреки че сме извикали Dispose() и сме освободили ресурсите, които заемаме. Ако потребителя на класа ни извика Dispose() има смисъл да не се изпълнява финализация на този обект. Точно заради това класът System.GC има метод SuppressFinalize(), който спира финализацията на даден обект. Използвайки този метод, ние можем да модифицираме нашият Dispose() метод:


public void Dispose()

{

if (INVALID_HANDLE_VALUE != m_hFile)

{

CloseHandle( m_hFile );

m_hFile = INVALID_HANLE_VALUE;

}

GC.SuppressFinalization( this );

}
Сега вече имаме подходящо поведение: ако клиентът извика Dispose() финализацията няма да се изпълни за този обект. Ако обаче клиентът забрави да извика Dispose(), тогава GC ще извика Finalize() по добава.

Сега има и друг аспект който ще разгледаме. Имаме 3 метода Close(), Dispose() и Finalize(), които правят кажи речи едно и също нещо. От гледна точка на програмиста, има смисъл всички тези методи да викат един и същ метод вътрешно. Това ни дава само една точка на извикване което е добре при последвали проблеми и поддръжка на кода.

Необжодимо ни е да се запознаем и с подробоностите на Dispose() и Finalize(). Спомнете си че когато Finalize() извикан, вложените обекти може да са вече освободени. Живота на такива обекти се определя от GC. Точно заради това, Finalize() не може да пипа тези обекти. Въпреки това GC няма контрол върху ресурсите които тои не може да осблужва, като в този пример това е handle-а към файла. Точно заради това Finalize() трябва да освобождава само unmanaged ресурси. Всъщност, ако вашият клас не използва никакви unmanaged ресурси, няма никакъв смисъл да имплементирате Finalize(). Друг подход е да обвием тези unmanaged ресурси в класове – обвивки (class wrappers), което спестява на потребителите на тези ресурси да се грижат за освобождаването им.

Dispose() от друга страна, може и трябва да чисти вложените в класа обекти и да вика Dispose() на базовия му клас (ако има такъв).

Използвайки тези особености можем да преработим имплементацията на класа MyFile по следния начин:


namespace FinalizePattern

{

public class MyFile : IDisposable

{

protected IntPtr m_hFile;

public MyFile(String sFilePath)

{

//m_hFile = CreateFile( sFilePath, .. );

m_hFile = (System.IntPtr) 0;

}
protected virtual void Dispose(bool isDisposing)

{

if (true == isDisposing)

{// dispose any managed resources here



}
// dispose any unmanaged resources

if (INVALID_HANDLE_VALUE != m_hFile)

{

CloseHandle( m_hFile );

m_hFile = INVALID_HANDLE_VALUE;

}

}
public void Dispose()

{

// we have dispose requested from the client of the class, not finalziation

Dispose( true );

GC.SuppressFinalize( this );

}
public void Close()

{

Dispose();

}
~MyFile()

{

// we have finalization so only unmanaged resources cleanup is performed here.

Dispose( false );

}

}

}
Този начин на използване на помощен метод Dispose който може да бъде използван от IDisposable.Dispose както и от Finalize() и други методи се счита за модел на освобождаване на ресурси (т.нар. Dispose pattern). Можете да използвате този код като шаблон за дизайна на клас които трябва да освобождава ресурси.

Имплементриане на Dispose() заедно с Finalize()


Изградете си навика винаги да имплементирате Dispose() в един клас когато имплементирате Finalize(). Запомнете че трябва да извикате SuppressFinalization в кода на Dispose() метода. Съществува един страничен ефект, ако имплементирате Finalize() без да имплементирате Dispose(). Един клас наследник на такъв клас, не може да освободи ресурсите на базовия клас от своя Dispose() метод. Има два начина да освободите обект – или да извикате Dispose() или да извикате Finalize(). В случая, базовия клас не имплменетрира Dispose(), и компилатора няма да ви позволи да извикате директно Finalize() на базовия клас. Последсвтието от това, е че класа наследник не трябва да извика SuppressFinalize в своя Dispose()метод. Иначе Finalize() за базовия клас няма да бъде извикан, което от своя страна ще доведе до resource leak.

Използване на IDisposable обекти (using operator)


Веднъж един клас като имплементира IDisposable интерфейса, потребителите на класа могат да викат Dispose() когато са свършили работата си с обекта. Например:

static void Main(string[] args)

{

MyFile file = new MyFile( "readme.txt" );

Console.WriteLine( file.ReadLine() );

file.Dispose();

}
въпреки че този код върви, има все още дребен проблем тук. Програмистите трябва да запомнят че трябва да извикат Dispose() метода, дори и когато се случе exception. Доста лесно е да се забрави да се извика този метод, особено когато метода в който използваме обекта е достатъчно сложен и с много пътища на излизане (return paths).

С# предлага по-добър синтаксис при работа с обекти които поддържат IDisposable интерфейса. Обектът може да бъде създаден с в рамките на using оператора, както е показано тук:



public static void BetterMain()

{

using (MyFile file = new MyFile( "readme.txt" ))

Console.WriteLine( file.ReadLine() );

}

С# компилатора компилира този код до:



public static void BetterMain()

{

MyFile file = new MyFile( "readme.txt" );

try

{

Console.WriteLine( file.ReadLine() );

}

finally

{

if (null != file)

{

((IDisposable) file).Dispose();

}

}

}

Целия код в using блока преместен в try блока. Веднъж този код е изпълнен, кода във finally блока е изпълнен, като това осигурява че Dispose() е извикан за всяка, коректна инстанция на класа.



Последни забележки за начина на викане на Dispose() метода: важно е да се разбере че викането на този метод изисква силна обвръзаност с въпросния обект (strong relationship). Ако този обект се използва от много други обекти, и собствеността върху самия обект не е много ясна, тогава Dispose() не трябва да се извиква. Иначе, някои други обекти могат да се окаже че използват невалиден указател към обект, което да доведе до непредсказуемо поведение. По същата логика, Dispose() не трябва да бъде извикван няколко пъти. Това са основни изисквания за употребата на този метод.


База данных защищена авторским правом ©obuch.info 2016
отнасят до администрацията

    Начална страница