Дядя Миша. Основы организации оперативной памяти и работа с ней в СС++

Цикл статей по программированию

К созданию данного цикла статей (ну или, по крайней мере, первой статьи из цикла) меня подтолкнул тот простой факт, что некоторые наши форумчане, будучи профессиональными программистами (т.е. получающими деньги за свою работу), периодически несут у нас на форуме настолько забавный бред, что его можно смело копировать на какой-нибудь юморной тематический сайт, а один товарищ два дня искал там класс камеры, но так и не смог найти а также периодически просят меня о написании цикла статей подобного рода.

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

HLFX.Ru Forum > Теория и практика > Half-Life SDK > Туториалы

Основы организации оперативной памяти и работа с ней в СС++

Основная проблема начинающих (да и не только) программистов часто заключается в тотальном нежелании обучаться работе с памятью. В ход идут любые уловки: начиная от полного отказа от динамического выделения и до смены языка программирования на более другой, который управляется с памятью самостоятельно. Со стороны разработчиков языка точно так же предлагаются всё более изощренные решения, созданные, казалось бы, для пущего удобства программистов: от языков со встроенным сборщиком мусора, типа Java — до хитрых классов-контейнеров к уже существующим языкам, типа STL.

Всё это, безусловно, облегчает работу программиста, но во-первых — не добавляет понимания, а во-вторых — результат, как правило, имеет перерасход памяти, да и особой скоростью работы не отличается. Причём это даже не является виной выбранного инструмента: без понимания того, как всё это работает, в итоге получается монстр, который разве что не вылетает от каждого чиха, но памяти отжирает под гигабайт, причём, на что именно — непонятно. Отлаживать такое, как вы понимаете, ещё тяжелее, если вообще возможно.

Ситуацию усугубляет тот факт, что специальной литературы по работе с памятью, видимо, не существует в природе (по крайней мере, мне таковая не попадалась), либо авторы книг по программированию полагают, что просветление должно наступить, когда начинающий программист напишет свою маленькую кучу или «пул» памяти. Но поскольку время подобных извращений давно уже прошло, то в итоге мы имеем то, что имеем. Конечно, невозможно в небольшой статье охватить все аспекты работы с памятью, но я и не ставлю перед собой такой задачи. Самое главное — это понять основные принципы, а весь остальной опыт ляжет в основу. И это — самое главное.

1. Организация оперативной памяти в x86 архитектуре

Не вдаваясь в историю и эволюцию самой архитектуры, скажу просто: модель памяти в архитектуре x86 — плоская. Наверняка вам доводилось видеть сообщения от некоторых досовских программ вида: memory model: flat Что это значит? Это значит, что память (условно) представлена в виде одного-единственного адреса, который разом переносит нас к требуемой ячейке оперативной памяти. Возможно, на других архитектурах существуют более сложные, двух- или трёхмерные модели, где надо указывать, например, строку или столбец для доступа к определенной ячейке — нас они не интересуют.

Изначально адреса в памяти были абсолютными. Это значило, что обратившись по первым 64-м килобайтам оперативной памяти, можно было, например, покоцать таблицу векторов прерываний, или, скажем, копию биоса, либо другие интересные вещи. Адреса на тот момент были 16-битными, но их перестало хватать практически сразу же, поскольку 16-битная адресация позволяет получить доступ только к первым 64 килобайтам. Чтобы хоть как-то поправить положение, в языки программирования был введен костыль в виде модификатора FAR (далёкий), который на самом деле позволял объявлять 32-битные переменные и адресовать до 4 гигабайт памяти. Затем в процессоры был введен защищенный режим и трансляция адресов, после чего доступ к памяти стал не абсолютным, а относительным. Ну а сама шина адресации внутри процессора выросла до 36 бит (а то и больше).

Относительная адресация предполагает, что весь 4-х гигабайтный диапазон адресов выделяется одному приложению. Как при этом работает внутренняя трансляция — нас не заботит, но мы совершенно точно уверены, что перемещаясь по выделенной нам памяти, не нарушим работу соседнего приложения, что бы мы там ни вытворяли.

Правда, и сама наша программка, будучи замеченной в доступе «невтуда», аварийно завершит свою работу с Unhandled Exception, который любой пользователь компьютера видел неоднократно. Обратите внимание, что ни одна программка не в состоянии потребить более 4 гигабайт оперативной памяти (а на практике — ещё меньше), именно в силу ограничения разрядности указателя. Во многом переход на 64-битные системы состоялся именно благодаря необходимости использовать больше памяти. Подобные вещи, безусловно, важны для баз данных, вполне возможно что в недалёком будущем и видеоигры будут потреблять свыше четырёх гигабайт памяти. Так или иначе, мы рассмотрим классические 32-битные адреса, работу с ними и методы оптимизации.

2. Организация памяти в программе СС++

Память в программах, написанных на языках СС++, можно условно разделить на два блока: основная куча и стёк. Условно — потому, что физически память, конечно, никуда не делится. Ну, с основной кучей понятно — там хранится всё, что было, так или иначе, выделено для работы. А что же такое стёк? Появлением стёка мы обязаны внедрению функционального программирования, когда листинг программы стали разделять на небольшие функции, дабы избежать лишнего дублирования кода и использования оператора goto. Если бы наши программы по-прежнему представляли собой один сплошной листинг с прыжками на тот или иной участок кода через вышеупомянутый goto, то никакой стёк нам и не понадобился бы вовсе. Процессору тоже решительно всё равно, как это дело выполнять. Но программы, написанные таким образом — крайне сложны для понимания, причём эта сложность возрастает прямо пропорционально объему кода. Понять, что и куда прыгает, и что в этот момент записано в глобальных переменных — непросто даже опытным программистам.

Именно поэтому и был возведен один из первых слоёв абстракции — функциональное программирование. Суть его заключается в том, что некие переменные, объявленные внутри функции, будут гарантированно уникальными ДЛЯ КАЖДОГО последующего её вызова. Просто при каждом новом вызове произойдет некая аллокация в памяти новых копий этих переменных, которые будут помещены в особый отдел памяти — стёк.

Обратите внимание, что память в стеке не копируется и не перемещается тудасюда, т.к. это бы значительно уменьшило быстродействие программы. Вместо этого некий бегунок-указатель перемещается по стеку, указывая на кол-во занятых байт. Если его размер превысит размер стека, мы получим занятную ошибку Stack overflow. Легче всего этого достигнуть, вызывая рекурсивную функцию, внутри которой объявлен приличный массив байт локальным образом. Классический пример — компилятор карт q3map. Если же бегунок наш станет меньше нуля, мы получим не менее занятную ошибку Stack Underflow. Как этого достичь при написании программы — я лично не знаю. Однако предполагаю, что подобные вещи куда больше на совести компилятора, нежели программиста.

Теперь рассмотрим следующую ситуацию: мы вызывали какую-либо функцию с локально объявленными переменными. Функция отработала, мы вышли из неё, и наш бегунок в стеке опустился вниз на столько байт, сколько мы потребили при входе в функцию. Т.е. с самими переменными ничего не было сделано. Эта память была просто помечена как неиспользуемая. Теперь мы входим в другую функцию, где точно так же определяем несколько локальных переменных. Вопрос! А что будет в них записано, если их не обнулить? А записано в них будет всё то же самое, что мы писали в прошлые переменные в прошлой функции. Но поскольку в новой мы могли выделить переменные других размеров, этот мусор «разорвался» на отдельные байтики и стал неузнаваемым. Я так подробно описываю данный механизм, поскольку некоторые товарищи, видимо, полагают, что выделенная память замусоривается с каким-то злым умыслом, хотя это сделано исключительно из соображений быстродействия. Безусловно, в WinAPI присутствуют методы для выделения «чистой» памяти, однако все локальные переменные вы обязаны обнулять самостоятельно. Необнуление таких переменных ведет к самым тяжелым последствиям, вплоть до необъяснимого зависания программы, например, при попытке обратиться к непроинициализированной локальной переменной с плавающей точкой, в которой волею случая образовался NAN.

Обратите внимание ещё и на тот факт, что при компиляции в DEBUG-режиме локальные переменные забиваются средствами компилятора особой комбинацией чисел, одинаковой для всех непроинициализированных переменных. Этим, кстати, объясняется тот факт, что программа, прекрасно работавшая в Debug-режиме, вдруг напрочь отказывается работать в Release.

Так же хотелось бы заметить, что у нас в распоряжении нету методов для выделения памяти непосредственно в стёке. Мы можем выделять её только косвенным образом, через объявление локальных переменных и их массивов. Видимо, это было сделано с целью безопасности, ибо забить стёк при отсутствии представления о его работе — плёвое дело. К тому же, не вполне понятно, как такую память высвобождать. Так или иначе, динамическое выделение памяти в стёке отсутствует как метод. Однако, мы можем его сымитировать, объявив внутри функции массив байт нужного нам размера. Правда, если объявить слишком много, компилятор нас может попросту послать куда подальше, заявив, что ему не хватит размеров стека по умолчанию. Размер стека мы можем изменить в настройках компилятора — хотя в большинстве случаев его не надо трогать, тем не менее, такая возможность имеется.

3. Для чего нужно динамическое выделение памяти

Собственно, здесь мы и подходим к тому самому интересному моменту, который многие программисты тщательно стараются избегать — динамическому выделению памяти. С одной стороны, пока мы не выделяем память динамически, мы разом избавляем приложение от проблем утечки памяти, попыток перехода по неверному указателю, и как следствия — остановки приложения с Unhandled Exception. Конечно, вероятность не сводится к нулю, но резко уменьшается. Однако обойтись без динамического выделения в большинстве реальных приложений попросту невозможно.

Начнем с того, что абсолютно все объявленные вами переменные (а также те, которые объявлены не вами), точно так же при старте приложения помещаются в оперативную память, а при завершении его работы — разом высвобождаются. Это целиком лежит на совести компилятора и операционной системы, так что программист не забивает себе голову об их высвобождении. Пока наши переменные хранят какие-то данные вычислений, различные результаты и условия, их общий размер невелик, и выделять подобные вещи динамически никому и в голову не придёт. Но когда мы начинаем использовать ООП, многие объекты желательно выделять динамически, и точно так же их высвобождать. Конечно, все эти классы — чистая абстракция, в скомпилированном приложении (имеется в виду С++) все ваши красивые инкапсулированные классы развернутся в чюдовищный набор обычных функций, что легко проверить, декомпилировав ваше приложение в IDA Pro, например. Таким образом, можно немного подумать, и точно так же обойтись без динамического выделения памяти для ваших объектов.

Но вот когда мы начнём работать с каким-то внешними файлами, будь то изображения, звуки, видео-файлы, наконец, какие-то игровые ресурсы, тут уж нам без динамического выделения памяти точно не обойтись. Хотя бы потому, что мы заранее не можем знать, какого размера будет загружаемый ресурс.

Попытки и тут схитрить, например, выделить память динамически (потому что на статическую аллокацию подобного размера наверняка ругнётся компилятор), но выделить от душы — большой кусищще, чтобы в него наверняка всё влезло (это не шутка, я наблюдал подобный ужос в ранних версиях Quake2XP в менеджере текстур), приведут к совершенно необоснованному потреблению оперативной памяти, которая к тому же ещё и не будет использована в большинстве случаев. Ну представьте себе как если бы человек занял 10-этажный дом исключительно для собственного проживания. Т.е., чисто физически, он больше одной квартиры занять не может, но держит остальные комнаты, на всякий случай. В жизни подобную глупость, кстати, представить довольно сложно — наверняка человек бы сдал пустующие квартиры в аренду. В программе мы память в аренду сдать никому не можем, более того: перевыделяя памяти больше чем требуется, мы, по сути, воруем её сами у себя — ведь другие приложения могут быть частично скинуты в своп, из-за чего их работа существенно замедлится.

Вообщем, с какой стороны ни посмотри — все эти ухищрения, призванные обойтись без динамического выделения памяти взаместо того чтобы научиться с ней нормально работать, приводят только к перерасходу памяти и общим тормозам. И, кстати, не дают 100% гарантии от вылетов. Поэтому, я полагаю, уж лучше один раз разобраться во всех тонкостях, нежели каждый раз городить какой-то сомнительный огород с бузиной в Киеве у дядьки. Ну и поскольку вводная часть дала вам уже достаточно информации и частично рассеяла туман в голове, мы переходим непосредственно к практике, к приёмам, описанию распространенных ошибок, хитростям и рекомендациям.

4. Динамическое выделение памяти: первые шаги

Для динамического выделения памяти в WinAPI существует достаточно много методов, но, чтобы не путаться, мы с вами будем использовать самый обычный malloc, или calloc. Последний отличается от первого тем, что автоматически очищает выделенную память — забивает её нулями. Как вы понимаете, сам процесс очищения не мгновенный, он занимает несколько (десятков) процессорных тактов. К тому же очищение памяти не имеет смысла, если мы тут же поместим в выделенную память какой-либо полезный объект.

А для чего же нам помещать этот объект в оперативную память? Ну, например, чтобы изменить его, не трогая оригинал, и сохранить копию. То есть, скажете вы, если мы хотим просто читать наш файл, то можно использовать функции чтения с диска, не перемещая копию файла в память? Можно, отвечу я. А в определенных случаях — даже и нужно, поскольку это удобнее. Но для больших файлов, к которым предполагается регулярное обращение — чтение с диска сильно замедлит скорость работы всего приложения в целом. Поэтому даже для чтения порою удобнее перенести копию файла в оперативную память. В терминологии квейка — закешировать файл.

Но вернемся к нашему примеру. Вот мы выделили память, что-то с ней сделали, сохранили результат на диск (к примеру), что с памятью делать дальше? Можно её просто освободить. Если предполагается новая аллокация такого же размера, то высвобождать особого смысла не имеет — мы можем использовать наш динамически выделенный участок ещё раз, и ещё, и ещё. Вообщем, пока он нам нужен. Такие вещи обычно называются «промежуточным буффером». Но, как я уже говорил, не стоит делать его размер чудовищным, чтобы туда наверняка влез любой файл. Гораздо лучше постепенно увеличивать размер подобного буффера по факту запроса на его увеличение. Приведу пример: допустим, мы используем наш буффер и грузим туда картинки размером 256х256, например, для постобработки.

Во время загрузки первой картинки мы создаём наш буффер точно по размеру первой картинки, последующие обращения буффер не меняют и не высвобождают, поскольку новые картинки имеют точно такой же размер. И вот, рано или поздно, нам, к примеру, попадается картинка размером 512х512. Следовательно, нам требуется изменить наш буффер до размеров новой картинки. Это можно сделать высвобождением старого блока памяти и аллокацией нового, с увеличенным размером, но можно использовать и специальную функцию realloc. Функция эта замечательна тем, что при изменении размера выделенной памяти старые значения по прежнему никуда не деваются, а остаются в ней нетронутыми. Для промежуточного буффера это, конечно, не имеет значения, но, например, когда мы хотим увеличить размер массива выделенного динамически, либо, наоборот, подогнать кусок памяти точно по размеру занимаемых данных — нам без этой функции, или её самодельного аналога, просто не обойтись. Любые попытки проделать вышеописанные действия на более высоком уровне абстракции, как правило, окажутся значительно более медленными. Но большинство программистов предпочтёт именно их, из-за вышеупомянутой боязни работы с памятью.

Теперь, пожалуй, поговорим об удалении памяти. Это и есть «главное больное место». Память, как вы помните, доступна по указателю. Если память удалить, то значение указателя — не изменится. Но сама память будет помечена как свободная. И любая попытка перейти по данному указателю приведет к остановке программы и вылету с ошибкой. Поэтому каждый указатель после высвобождения памяти необходимо обнулять. Это одно из самых главных правил при работе с памятью. Правда, если указатель объявлен как локальная переменная а высвобождение памяти происходит в конце функции и сам указатель никуда не передаётся обратно, то его допускается не обнулять.

Правда, тут кроется другая потенциальная проблема — память можно попросту забыть высвободить. Я уже упоминал, что методов для динамического выделения памяти в стёке у нас нет, а значит, то, что мы выделили внутри функции и то, на что указывал наш локальный указатель — будет попросту утеряно после выхода из функции, т.к. сам указатель был локальным, и занимаемое им место освободилось по завершении работы функции. Поэтому остерегайтесь юзать локальные указатели на динамически выделенную память.

Вторая, не менее распространенная ошибка — это многократное выделение памяти в один и тот же глобальный указатель. Всё просто: адрес на старый блок памяти попросту перезатирается, и получается мёртвый блок памяти — утечка, как и в вышеописанном случае.

Коварство утечек заключается ещё и в том, что их возникновение не нарушает работу программы явным образом. Да и компилятор о них обычно не сообщает, поскольку даже самый мощный и продвинутый компилятор не в состоянии уследить за работой абсолютно всей программы, учитывая, что программа как правило использует системные библиотеки и утечка памяти может быть спровоцирована именно в них. Короче говоря, написание анализатора возможных утечек уже на этапе компиляции — столь же сложная вещь, как и детектор каких-нибудь порнографических картинок. Поэтому утечку обычно вычисляют уже по факту её наличия — считают сколько было аллокаций и сколько высвобождений. В идеале их кол-во должно сойтись как дебет с кредитом. Также, как правило, ни к чему страшному не приводит повторное высвобождение объектов, если вы, разумеется, не забываете обнулять указатель после первого высвобождения. А вот когда аллокаций больше чем высвобождений — это может означать только одно: у вас где-то утечка. Вы, конечно, можете сказать — да и фиг с ней, работает же. Безусловно, работает. Вопрос лишь в том — сколько времени? Допустим, наше приложение отъедает по мегабайту памяти в минуту. Через сколько закончится оперативная память, а после неё — и своп тоже? Действительно что вопрос времени. А если ваше приложение запущено на сервере? Или, скажем, это какая-нибудь игра, после выхода из которой компьютер ещё минут 10 шуршит диском, восстанавливая из свопа память вытесненных приложений?

Вообщем, с утечками необходимо бороться самым решительным образом. И бороться уже на этапе проектирования программы, а не лихорадочно искать дырки на стадии финальной отладки. Для сравнительно несложных программ вполне подойдет обёртка на функциями malloc и free, которая, помимо вызова реальных прототипов, будет инкременировать и декременировать переменную вызова этих функций. Если в конце работы программы она равна нулю, значит утечек нет.

Более сложным, но зато и более радикальным методом является использование кастомных менеджеров памяти. Писать их вручную не нужно — можно найти уже готовые и неоднократно проверенные. Так, например, в тех же квейках имеются вполне себе неплохие менеджеры памяти, в клоне первого квейка — движке darkplaces — отличный менеджер пулов памяти, а в третьем дууме (чьи исходники стали нам доступны) — даже настоящая локальная куча 🙂

Правда, использование данных менеджеров вызывает определенные затруднения в С++, как раз-таки в силу объектно-ориентированного подхода. Но в С++ можно взять себе за правило хранить все указатели на динамическую память как защищенные члены класса, и высвобождать всю выделенную память в деструкторе класса. К слову сказать, принципиальное отличие new и delete от malloc и free как раз и заключается в вызове функций конструктора и деструктора класса, соответственно. Однако я крайне не рекомендую использовать new и delete для высвобождения массивов. Дело в том, что массив при помощи функции new выделяется вот так:

ptr = new char[256];

а высвобождается — вот так:

delete [] ptr;

Ошибка большинства новичков заключается в том, что они высвобождают массив вот так

delete ptr;

в результате у нас удаляется ПЕРВЫЙ ЭЛЕМЕНТ МАССИВА, а остальные становятся ликом. Неслыханный идиотизм! Кому и зачем понадобилась такая «фича» — мне решительно непонятно. Вот в данном конкретном случае лично я подозреваю элемент вредительства, пусть даже и задуманного с самыми благими намерениями. При использовании пары mallocfree мы полностью избавлены от такой ситуации. Разумеется, можно создавать и новые классы при помощи malloc, но конструктор-деструктор вам придётся вызывать вручную, а это попросту неудобно.

Вторая распространенная ошибка, ведущая к утечке, заключается в «перематывании» указателя, полученного при выделении памяти. Обычно такая ошибка возникает при парсинге скриптовых файлов. Мы копируем содержимое скрипта в динамически выделенный участок памяти и начинаем попросту перематывать указатель, читая буквы. По окончании парсинга наш указатель будет указывать ориентировочно на конец памяти минус один байт (терминатор). Ну и как вы полагаете, что сделает free, если скормить ему подобный указатель? Либо проигнорирует, либо высвободит последний байт (зависит от реализации). И мы получим самый настоящий лик, хотя и не сильно большого размера. Правильно же при выделении памяти завести два указателя. Один мы будем читать-перематывать, а другой высвободим по завершении парсинга.

Правда, некоторые идиоты (иначе их и не назовешь), вместо того, чтобы завести два указателя, пытаются каким-то чудом отмотать текущий указатель обратно. Делать этого не следует ни в коем случае. Даже если вы всё правильно посчитаете, ваш код будет малопонятен для посторонних, да и намного сложнее в реализации и медленнее в быстродействии, чем простое хранение оригинального указателя.

Теперь хотелось бы поговорить об «обратной утечке». Возможно, данное название не совсем корректно, но оно характеризует принцип проявления: это попытка использования памяти стека в качестве динамической. Я уже упоминал, что динамически выделить память стека не получится, но ведь никто не помешает нам завести локальный массив внутри функции, записать туда, например, какое-нибудь матёрное слово и попытаться вернуть указатель на этот массив при выходе из функции. Правильно, никто не помешает. А вот компилятор нас предупредит, что возвращаемый адресс является локальным или временным. Обратите внимание — именно предупредит, потому что полной уверенности в том, что вы там наваракозили, у него нет. И именно тот факт, что это было простым предупреждением, чаще всего является жуткой головной болью для начинающих программистов, которые не знают этих основ.

Ситуация, на самом деле, неоднозначная. Если после выхода из функции не вызывать тут же какую-либо другую функцию и не объявлять там новые переменные, то наша строка с матёрным словом даже будет некоторое время вполне нормально читаться. А потом «почему-то» похерится. Особенно интересно станет, когда у строки сотрётся терминатор и какая-нибудь функция для работы со строками полезет по мусору в стёке, пытаясь его найти. И тут нас подстерегает полный рандом: потому что среди мусора вполне может оказаться нулевой символ и весь найденный мусор будет успешно использован в качестве всей строки. Либо мы залезем куда-то совсем не туда и программа наша попросту вылетит. Причём вылетит именно в стандартной CRT-функции, типа какого-нибудь strlen или strcmp. А у вас их по программе — сотни. Ну и как такое дебажить?

У этой медали есть и оборотная сторона — попытка исправить ситуацию при помощи модификатора static. Как известно, модификатор static попросту помещает локально объявленную переменную в общую кучу, ну а для глобально объявленных переменных ограничивает область видимости препроцессору в пределах одного файла-исходника, попутно создавая свои копии, если вы, не дай бог, объявили его где-то в хидере. На первый взгляд, такой метод работает: компилятор больше не ругается, память не портится. Но только на первый взгляд! Дело в том, что мы уже привыкли, что для локальных переменных в стеке автоматически создаются их копии и совершенно упускаем из виду тот факт, что наша статически объявленная переменная будет перезатираться каждый раз при новом вызове функции. В том же квейке имеется функция va (VarArgs), где механизм хранения нескольких строк при многократном вызове реализован следующим образом:

char *va( const char *format, ... )
{
	va_list		argptr;
	static char	string[256][1024], *s;
	static int	stringindex = 0;

	s = string[stringindex];
	stringindex = (stringindex + 1) & 255;
	va_start( argptr, format );
	Q_vsnprintf( s, sizeof( string[0] ), format, argptr );
	va_end( argptr );

	return s;
}

Механизм прост — это циркулярный буффер на 256 строк. При каждом новом вызове мы используем новую строку в нашем массиве, но при этом каждый момент храним 255 последних строк. Предполагается, что указатели на них будут помещены в безопасную область раньше, чем данная строка будет перезатёрта свежими данными в совершенно другом месте программы (поскольку данная функция — общего назначения). Но, так или иначе, возвращение подобных адресов — зло, в большинстве его проявлений. Старайтесь не возвращать выделяемую память внутри какой-либо функции, особенно если название самой функции никоим образом не намекает на тот факт, что в ней динамически выделяется память. Пусть даже и таким занятным способом, как в вышеприведённом примере.

Из оставшихся неупомянутых ошибок можно ещё отметить классическое залезание в память «дальше чем выделено». За подобными вещами следует строго следить, особенно в С++, где, например, выход за границы массива не приводит к отработке исключения, и куда вы в итоге попадете и что там прочитаетезапишете — никому заранее неизвестно.

Также избегайте явного приведения динамически выделенного базового класса к дочернему, если не уверены, что это именно он. Чтобы было понятнее — расскажу занятную историю, случившуюся со мной лично, ещё во времена написания Xash3D. В наборе движковых функций присутствует FIND_CLIENT_IN_PVS. Уже из названия должно быть понятно, что функция может возвращать указатели только на эдикты клиентов, однако я в процессе её написания несколько ошибся, в результате чего функция могла возвращать и указатели на другие объекты. В игровом же коде проверки на тот факт, что полученный указатель — это именно указатель на объект — попросту сделано не было (см. название функции). А игровой код, мало того что преобразовывал полученный указатель из кода базового объекта в объект игрока, так ещё и отважно туда пытался что-то записать. Куда он при этом писал на самом деле? Вопрос, безусловно, интересный. Но выглядело это, как ряд необъяснимых глюков с произвольным зависаниемпадением.

Данное явление называется порчей памяти. Явной отработки исключения здесь не возникает, поскольку мы как бы пишем в валидную область памяти, только вот хер его знает куда именно. В процессе такой записи мы вполне можем покорёжить какой-нибудь другой указатель, строку, или испохабить float до полного NAN (что, скорее всего, и приведёт к зависанию). Такая порча памяти особенно опасна, поскольку никоим образом не отслеживается в процессе отладки и крайне слабо ловится на этапе компиляции.

Ну вот пожалуй и всё, для начала. В следующий раз мы поговорим с вами о работе с указателями, их арифметикой, в чём отличие «точки» от «стрелочки» (операторов. и ->), а также научимся правильно пользоваться указателями на указатель и раскроем принципы работы многомерных массивов, которые на самом деле таковыми не являются 😉 Жду ваших вопросов, пожеланий, предложений, а также поправок на фактические неточности, если вы вдруг таковые обнаружите.

P.S.
— Проглядывая твою статью, нашёл любопытный пъорл… Но сначала ответь на вопрос.

Порождает ли этот код утечку памяти?

int* p = new int[100000];
delete p;

— Зависит от генеральной линии партии в данный момент. Ещё вчера — порождал, сегодня — уже нет.

 

Источник

c#, научно-популярное, работа с памятью, туториалы, указатели

Читайте также