Программирование — страница 45 из 57

Программирование встроенных систем

“Слово “опасный ” означает, что кто-то может умереть”.

Сотрудник службы безопасности


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

25.1. Встроенные системы

 Большая часть существующих компьютеров не выглядит как компьютеры. Они просто являются частью более крупной системы или устройства. Рассмотрим примеры.

Автомобили. В современный автомобиль могут быть встроены десятки компьютеров, управляющих впрыскиванием топлива, следящих на работой двигателя, настраивающих радио, контролирующих тормоза, наблюдающих за давлением в шинах, управляющих дворниками на ветровом стекле и т.д.

Телефоны. Мобильный телефон содержит как минимум два компьютера; один из них обычно специализируется на обработке сигналов.

Самолеты. Современный самолет оснащен компьютерами, управляющими буквально всем: от системы развлечения пассажиров до закрылок, оптимизирующих подъемную силу крыла.

Фотоаппараты. Существуют фотоаппараты с пятью процессорами, в которых каждый объектив имеет свой собственный процессор.

Кредитные карточки (и все семейство карточек с микропроцессорами).

Мониторы и контроллеры медицинского оборудования (например, сканеры для компьютерной томографии).

Грузоподъемники (лифты).

Карманные компьютеры.

Кухонное оборудование (например, скороварки и хлебопечки).

Телефонные коммутаторы (как правило, состоящие из тысяч специализированных компьютеров).

Контроллеры насосов (например, водяных или нефтяных).

Сварочные роботы, которые используются в труднодоступных или опасных местах, где человек работать не может.

Ветряки. Некоторые из них способны вырабатывать мегаватты электроэнергии и имеют высоту до 70 метров.

Контроллеры шлюзов на дамбах.

Мониторы качества на конвейерах.

Устройства считывания штриховых кодов.

Автосборочные роботы.

Контроллеры центрифуг (используемых во многих процессах медицинского анализа).

Контроллеры дисководов.


 Эти компьютеры являются частью более крупных систем, которые обычно не похожи на компьютеры и о которых мы никогда не думаем как о компьютерах. Когда мы видим автомобиль, проезжающий по улице, мы не говорим: “Смотрите, поехала распределенная компьютерная система!” И хотя автомобиль в том числе является и распределенной компьютерной системой, ее действия настолько тесно связаны с работой механической, электронной и электрической систем, что мы не можем считать ее изолированным компьютером. Ограничения, наложенные на работу этой системы (временные и пространственные), и понятие корректности ее программ не могут быть отделены от содержащей ее более крупной системы. Часто встроенный компьютер управляет физическим устройством, и корректное поведение компьютера определяется как корректное поведение самого физического устройства. Рассмотрим крупный дизельный судовой двигатель.

Обратите внимание на крышку пятого цилиндра, на котором стоит человек. Это большой двигатель, приводящий в движение большой корабль. Если такой двигатель выйдет из строя, мы узнаем об этом в утренних новостях. У такого двигателя в крышке каждого цилиндра находится управляющая система цилиндра, состоящая из трех компьютеров. Каждая система управления цилиндром соединена с системой управления двигателем (еще три компьютера) посредством двух независимых сетей. Кроме того, система управления двигателем связана с центром управления, в котором механики могут отдавать двигателю команды с помощью специализированной системы графического интерфейса. Всю эту систему можно контролировать дистанционно с помощью радиосигналов (через спутники) из центра управления морским движением. Другие примеры использования компьютеров приведены в главе 1.



Итак, что особенного есть в программах, выполняемых такими компьютерами, с точки зрения программиста? Обобщим вопрос: какие проблемы, не беспокоящие нас в “обычных” программах, выходят на первый план в разнообразных встроенных системах?

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

Часто ресурсы (память, циклы процессора, мощность) ограничены. Для компьютера, управляющего двигателем, вероятно, это не проблема, но для мобильных телефонов, сенсоров, карманных компьютеров, компьютеров на космических зондах и так далее это важно. В мире, где двухпроцессорные портативные компьютеры с частотой 2 ГГц и объемом ОЗУ 2 Гбайт уже не редкость, главную роль в работе самолета или космического зонда могут играть компьютеры с частотой процессора 60 МГц и объемом памяти 256 Kбайт и даже маленькие устройства с частотой ниже 1 МГц и объемом оперативной памяти, измеряемой несколькими сотнями слов. Компьютеры, устойчивые к внешним воздействиям (вибрации, ударам, нестабильной поставке электричества, жаре, холоду, влаге, топтанию на нем и т.д.), обычно работают намного медленнее, чем студенческие ноутбуки.

Часто важна реакция в реальном времени. Если инжектор топлива не попадет в инъекционный цикл, то с очень сложной системой мощностью 100 тысяч лошадиных сил может случиться беда; если инжектор пропустит несколько циклов, т.е. будет неисправен около секунды, то с пропеллером 10 метров в диаметре и весом 130 тонн могут произойти странные вещи. Мы бы очень не хотели, чтобы это случилось.

Часто система должна бесперебойно работать много лет. Эти системы могут быть дорогими, как, например, спутник связи, вращающийся на орбите, или настолько дешевыми, что их ремонт не имеет смысла (например, MP3-плееры, кредитные карточки или инжекторы автомобильных двигателей). В США критерием надежности телефонных коммутаторов считается 20 минут простоя за двадцать лет (даже не думайте разбирать его каждый раз, когда захотите изменить его программу).

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


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

Относится ли это к новичкам и к программистам на языке С++? Да, и еще раз да. Встроенных систем намного больше, чем обычных персональных компьютеров. Огромная часть программистской работы связана с программированием именно встроенных систем. Более того, список примеров встроенных систем, приведенный в начале раздела, составлен на основе моего личного опыта программирования на языке С++.

25.2. Основные понятия

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

 • Корректность. Это понятие становится еще более важным, чем обычно. Корректность — это не просто абстрактное понятие. В контексте встроенной системы программа считается корректной не тогда, когда она просто выдает правильные результаты, а тогда, когда она делает это за указанное время, в заданном порядке и с использованием только имеющегося набора ресурсов. В принципе детали понятия корректность тщательно формулируются в каждом конкретном случае, но часто такую спецификацию можно создать только после ряда экспериментов. Часто важные эксперименты можно провести только тогда, когда вся система (вместе с компьютером, на котором будет выполняться программа) уже построена. Исчерпывающая формулировка понятия корректности встроенной системы может быть одновременно чрезвычайно трудной и крайне важной. Слова “чрезвычайно трудная” могут означать “невозможно за имеющееся время и при заданных ресурсах”; мы должны попытаться сделать все возможное с помощью имеющихся средств и методов. К счастью, количество спецификаций, методов моделирования и тестирования и других технологий в заданной области может быть весьма впечатляющим. Слова “крайне важная” могут означать “сбой приводит к повреждению или разрушению”.

 • Устойчивость к сбоям. Мы должны тщательно указать набор условий, которым должна удовлетворять программа. Например, при сдаче обычной студенческой программы вы можете считать совершенно нечестным, если преподаватель во время ее демонстрации выдернет провод питания из розетки. Исчезновение электропитания не входит в список условий, на которые должны реагировать обычные прикладные программы на персональных компьютерах. Однако потеря электропитания во встроенных системах может быть обычным делом и ваша программа должна это учитывать. Например, жизненно важные части системы могут иметь двойное электропитание, резервные батареи и т.д. В некоторых приложениях фраза: “Я предполагал, что аппаратное обеспечение будет работать без сбоев” не считается оправданием. Долгое время и в часто изменяющихся условиях аппаратное обеспечение просто не способно работать без сбоев. Например, программы для некоторых телефонных коммутаторов и аэрокосмических аппаратов написаны в предположении, что рано или поздно часть памяти компьютера просто “решит” изменить свое содержание (например, заменит нуль на единицу). Кроме того, компьютер может “решить”, что ему нравится единица, и игнорировать попытки изменить ее на нуль. Если у вас много памяти и вы используете ее достаточно долгое время, то в конце концов такие ошибки возникнут. Если память компьютера подвергается радиационному облучению за пределами земной атмосферы, то это произойдет намного раньше. Когда мы работаем с системой (встроенной или нет), мы должны решить, как реагировать на сбои оборудования. Обычно по умолчанию считают, что аппаратное обеспечение будет работать без сбоев. Если же мы имеем дело с более требовательными системами, то это предположение следует уточнить.

 • Отсутствие простоев. Встроенные системы обычно должны долго работать без замены программного обеспечения или вмешательства опытного оператора. “Долгое время” может означать дни, месяцы, годы или все время функционирования аппаратного обеспечения. Это обстоятельство вполне характерно для встроенных систем, но не применимо к огромному количеству “обычных приложений”, а также ко всем примерам и упражнениям, приведенным в книге. Требование “должно работать вечно” выдвигает на первый план обработку ошибок и управление ресурсами. Что такое “ресурс”? Ресурс — это нечто такое, что имеется у машины в ограниченном количестве; программа может получить ресурс путем выполнения явного действия (выделить память) и вернуть его системе (освободить память) явно или неявно. Примерами ресурсов являются память, дескрипторы файлов, сетевые соединения (сокеты) и блокировки. Программа, являющаяся частью долговременной системы, должна освобождать свои ресурсы, за исключением тех, которые необходимы ей постоянно. Например, программа, забывающая закрывать файл каждый день, в большинстве операционных систем не выживет более месяца. Программа, не освобождающая каждый день по 100 байтов, за год исчерпает 32 Кбайт — этого достаточно, чтобы через несколько месяцев небольшое устройство перестало работать. Самое ужасное в такой “утечке” ресурсов заключается в том, что многие месяцы такая программа работает идеально, а потом неожиданно дает сбой. Если уж программа обречена потерпеть крах, то хотелось бы, чтобы это произошло пораньше и у нас было время устранить проблему. В частности, было бы лучше, если бы сбой произошел до того, как программа попадет к пользователям.

 • Ограничения реального времени. Встроенную систему можно отнести к системам с жесткими условиями реального времени (hard real time), если она должна всегда давать ответ до наступления заданного срока. Если она должна давать ответ до наступления заданного срока лишь в большинстве случаев, а иногда может позволить себе просрочить время, то такую систему можно отнести к системам с мягкими условиями реального времени. Примерами систем с мягкими условиями реального времени являются контроллеры автомобильных окон и усилитель стереосистемы. Обычный человек все равно не заметит миллисекундной задержки в движении стекол, и только опытный слушатель способен уловить миллисекундное изменение высоты звука. Примером системы с жесткими условиями реального времени является инжектор топлива, который должен впрыскивать бензин в точно заданные моменты времени с учетом движения поршня. Если произойдет хотя бы миллисекундная задержка, то мощность двигателя упадет и он станет портиться; в итоге двигатель может выйти из строя, что, возможно, повлечет за собой дорожное происшествие или катастрофу.

 • Предсказуемость. Это ключевое понятие во встроенных системах. Очевидно, что этот термин имеет много интуитивных толкований, но здесь — в контексте программирования встроенных систем — мы используем лишь техническое значение: операция считается предсказуемой (predictable), если на данном компьютере она всегда выполняется за одно и то же время и если все такие операции выполняются за одно и то же время. Например, если

x
и
y
— целочисленные переменные, то инструкция x+y всегда будет выполняться за фиксированное время, а инструкция
xx+yy
будет выполняться за точно такое же время, при условии, что
xx
и
yy
— две другие целочисленные переменные. Как правило, можно пренебречь небольшими колебаниями скорости выполнения операции, связанными с машинной архитектурой (например, отклонениями, вызванными особенностями кэширования и конвейерной обработки), и просто ориентироваться на верхний предел заданного времени. Непредсказуемые операции (в данном смысле этого слова) нельзя использовать в системах с жесткими условиями реального времени и можно лишь с очень большой осторожностью применять в остальных системах реального времени. Классическим примером непредсказуемой операции является линейный поиск по списку (например, выполнение функции
find()
), если количество элементов списка неизвестно и не может быть легко оценено сверху. Такой поиск можно применять в системах с жесткими условиями реального времени, только если мы можем надежно предсказать количество или хотя бы максимальное количество элементов списка. Иначе говоря, для того чтобы гарантировать, что ответ поступит в течение фиксированного интервала времени, мы должны — возможно, с помощью инструментов анализа кода — вычислить время, необходимое для выполнения любой последовательности команд, приводящих к исчерпанию запаса времени.

Параллелизм. Встроенные системы обычно реагируют на события, происходящие во внешнем мире. Это значит, что в программе многие события могут происходить одновременно, поскольку они соответствуют событиям в реальном мире, которые могут происходить одновременно. Программа, одновременно выполняющая несколько действий, называется параллельной (concurrent, parallel). К сожалению эта очень интересная, трудная и важная тема выходит за рамки рассмотрения нашей книги.

25.2.1. Предсказуемость

 С точки зрения предсказуемости язык С++ очень хорош, но не идеален. Практически все средства языка С++ (включая вызовы виртуальных функций) вполне предсказуемы, за исключением указанных ниже.

• Выделение свободной памяти с помощью операторов

new
и
delete
(см. раздел 25.3).

• Исключения (раздел 19.5).

• Оператор

dynamic_cast
(раздел A.5.7).


В приложениях с жесткими условиями реального времени эти средства использовать не следует. Проблемы, связанные с операторами

new
и
delete
, подробно описаны в разделе 25.3; они носят принципиальный характер. Обратите внимание на то, что класс
string
из стандартной библиотеки и стандартные контейнеры (
vector
,
map
и др.) неявно используют свободную память, поэтому они также непредсказуемы. Проблема с оператором
dynamic_cast
связана с трудностями его параллельной реализации, но не является фундаментальной.

Проблемы с исключениями заключаются в том, что, глядя на конкретный раздел

throw
, программист не может сказать, сколько времени займет поиск соответствующего раздела
catch
и даже существует ли такой раздел
catch
, не проанализировав более крупный фрагмент программы. В программах для встроенных систем лучше было бы, если бы такой раздел
catch
существовал, поскольку мы не можем рассчитывать на то, что программист сможет использовать средства отладки языка С++. В принципе проблемы, связанные с исключениями, можно решить с помощью того же механизма, который определяет, какой именно раздел
catch
будет вызван для конкретного раздела
throw
и как долго ему будет передаваться управление, но в настоящее время эта задача еще исследуется, поэтому, если вам нужна предсказуемость, вы должны обрабатывать ошибки, основываясь на возвращаемых кодах и других устаревших и утомительных, но вполне предсказуемых методах. 

25.2.2. Принципы

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

 Как всегда, наша цель — работать на как можно более высоком уровне с учетом поставленных ограничений, связанных с нашей задачей. Не позволяйте себе опускаться до хваленого ассемблерного кода! Всегда стремитесь как можно более прямо выражать ваши идеи в программе (при заданных ограничениях). Всегда старайтесь писать ясный, понятный и легкий в сопровождении код. Не оптимизируйте его, пока вас к этому не вынуждают. Эффективность (по времени или по объему памяти) часто имеет большое значение для встроенных систем, но не следует пытаться выжимать максимум возможного из каждого маленького кусочка кода. Кроме того, во многих встроенных системах в первую очередь требуется, чтобы программа работала правильно и достаточно быстро; пока ваша программа работает достаточно быстро, система просто простаивает, ожидая следующего действия. Постоянные попытки написать несколько строчек кода как можно более эффективно занимают много времени, порождают много ошибок и часто затрудняют оптимизацию программ, поскольку алгоритмы и структуры данных становится трудно понимать и модифицировать. Например, при низкоуровневой оптимизации часто невозможно оптимизировать использование памяти, поскольку во многих местах возникает почти одинаковый код, который остальные части программы не могут использовать совместно из-за второстепенных различий. Джон Бентли (John Bentley), известный своими очень эффективными программами, сформулировал два закона оптимизации.


• Первый закон: “Не делай этого!”

• Второй закон (только для экспертов): “Не делай этого пока!”


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

25.2.3. Сохранение работоспособности после сбоя

Представьте себе, что вы должны разработать и реализовать систему, которая не должна выходить из строя. Под словами “не выходить из строя” мы подразумеваем “месяц работать без вмешательства человека”. Какие сбои мы должны предотвратить? Мы можем не беспокоиться о том, что солнце вдруг потухнет или на систему наступит слон. Однако в целом мы не можем предвидеть, что может пойти не так, как надо. Для конкретной системы мы можем и должны выдвигать предположения о наиболее вероятных ошибках. Перечислим типичные примеры.

• Сбой или исчезновение электропитания.

• Вибрация разъема.

• Попадание в систему тяжелого предмета, приводящее к разрушению процессора.

• Падение системы с высоты (от удара диск может быть поврежден).

• Радиоактивное облучение, вызывающее непредсказуемое изменение некоторых значений, записанных в ячейках памяти.


 Труднее всего найти преходящие ошибки. Преходящей ошибкой (transient error) мы называем событие, которое случается иногда, а не каждый раз при выполнении программы. Например, процессор может работать неправильно, только если температура превысит 54 °C. Такое событие кажется невозможным, однако однажды оно действительно произошло, когда систему случайно забыли в заводском цехе на полу, хотя в лаборатории ничего подобного никогда не случалось.

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

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

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

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

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

 • Быстрый способ выйти из неправильно работающей программы. Составляйте системы из модулей. В основу обработки ошибок должен быть положен модульный принцип: каждый модуль должен иметь свою собственную задачу. Если модуль решит, что не может выполнить свое задание, он может сообщить об этом другому модулю. Обработка ошибок внутри модуля должна быть простой (это повышает вероятность того, что она будет правильной и эффективной), а обработкой серьезных ошибок должен заниматься другой модуль. Высоконадежные системы состоят из модулей и многих уровней. Сообщения о серьезных ошибках, возникших на каждом уровне, передаются на следующий уровень, и в конце концов, возможно, человеку. Модуль, получивший сообщение о серьезной ошибке (которую не может исправить никакой другой модуль), может выполнить соответствующее действие, возможно, связанное с перезагрузкой ошибочного модуля или запуском менее сложного (но более надежного) резервного модуля. Выделить модуль в конкретной системе — задача проектирования, но в принципе модулем может быть класс, библиотека, программа или все программы в компьютере.

Мониторинг подсистем в ситуациях, когда они не могут самостоятельно сообщить о проблеме. В многоуровневой системе за системами более низкого уровня следят системы более высоких уровней. Многие системы, сбой которых недопустим (например, судовые двигатели или контроллеры космической станции), имеют по три резервные копии критических подсистем. Такое утроение означает не просто наличие двух резервных копий, но и то, что решение о том, какая из подсистем вышла из строя, решается голосованием “два против одного”. Утроение особенно полезно, когда многоуровневая организация представляет собой слишком сложную задачу (например, когда самый высокий уровень системы или подсистемы никогда не должен выходить из строя).


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

25.3. Управление памятью

Двумя основными ресурсами компьютера являются время (на выполнение инструкций) и память (для хранения данных и кода). В языке С++ есть три способа выделения памяти для хранения данных (см. разделы 17.4 и A.4.2).

Статическая память. Выделяется редактором связей и существует, пока выполняется программа.

Стековая (автоматическая) память. Выделяется при вызове функции и освобождается после возвращения управления из функции.

Динамическая память (куча). Выделяется оператором

new
и освобождается для возможного повторного использования с помощью оператора
delete
.


Рассмотрим каждую из них с точки зрения программирования встроенных систем. В частности, изучим вопросы управления памятью с точки зрения задач, где важную роль играет предсказуемость (см. раздел 25.2.1), например, при программировании систем с жесткими условиями реального времени и систем с особыми требованиями к обеспечению безопасности.

Статическая память не порождает особых проблем при программировании встроенных систем: вся память тщательно распределяется еще до старта программы и задолго до развертывания системы.

 Стековая память может вызывать проблемы, поскольку ее может оказаться недостаточно, но эту проблему устранить несложно. Разработчики системы должны сделать так, чтобы в ходе выполнения программы стек никогда не превышал допустимый предел. Как правило, это означает, что количество вложенных вызовов функций должно быть ограниченным; иначе говоря, мы должны иметь возможность показать, что цепочки вызовов (например,

f1
вызывает
f2
вызывает ... вызывает
fn
) никогда не станут слишком длинными. В некоторых системах это приводит к запрету на рекурсивные вызовы. В некоторых системах такие запреты в отношении некоторых рекурсивных функций являются вполне оправданными, но их нельзя считать универсальными. Например, я знаю, что вызов инструкция
factorial(10)
вызовет функцию
factorial
не более десяти раз. Однако программист, разрабатывающий встроенную систему, может предпочесть итеративный вариант функции
factorial
(см. раздел 15.5), чтобы избежать сомнений или случайностей.

Динамическое распределение памяти обычно запрещено или строго ограничено; иначе говоря, оператор new либо запрещен, либо его использование ограничено периодом запуска программы, а оператор

delete
запрещен. Укажем основные причины этих ограничений.

Предсказуемость. Размещение данных в свободной памяти непредсказуемо; иначе говоря, нет гарантии, что эта операция будет выполняться за постоянное время. Как правило, это не так: во многих реализациях оператора

new
время, необходимое для размещения нового объекта, может резко возрастать после размещения и удаления многих объектов.

 • Фрагментация. Свободная память может быть фрагментированной; другими словами, после размещения и удаления объектов оставшаяся память может содержать большое количество “дыр”, представляющих собой неиспользуемую память, которая бесполезна, потому что каждая “дыра” слишком мала для того, чтобы в ней поместился хотя бы один объект, используемый в приложении. Таким образом, размер полезной свободной памяти может оказаться намного меньше разности между первоначальным размером и размером размещенных объектов.


В следующем разделе мы продемонстрируем. как может возникнуть такая неприемлемая ситуация. Отсюда следует, что мы должны избегать методов программирования, использующих операторы

new
и
delete
в системах с жесткими условиями реального времени или в системах с особыми требованиями к обеспечению безопасности. В следующем разделе мы покажем, как избежать проблем, связанных со свободной памятью, используя стеки и пулы.

25.3.1. Проблемы со свободной памятью

В чем заключается проблема, связанная с оператором

new
? На самом деле эта проблема порождается операторами
new
и
delete
, использованными вместе. Рассмотрим результат следующей последовательности размещений и удалений объектов.


Message* get_input(Device&); // создаем объект класса Message

                             // в свободной памяти

while(/* ... */) {

  Message* p = get_input(dev);

  // ...

  Node* n1 = new Node(arg1,arg2);

  // ...

  delete p;

  Node* n2 = new Node (arg3,arg4);

  // ...

}


Каждый раз, выполняя этот цикл, мы создаем два объекта класса

Node
, причем в процессе их создания возникает и удаляется объект класса
Message
. Такой фрагмент кода вполне типичен для структур данных, используемых для ввода данных, поступающих от какого-то устройства. Глядя на этот код, можно предположить, что каждый раз при выполнении цикла мы тратим
2*sizeof(Node)
байтов памяти (плюс расходы свободной памяти). К сожалению, нет никаких гарантий, что наши затраты памяти ограничатся ожидаемыми и желательными
2*sizeof(Node)
байтами. В действительности это маловероятно.

Представим себе простой (хотя и вполне вероятный) механизм управления памятью. Допустим также, что объект класса

Message
немного больше, чем объект класса
Node
. Эту ситуацию можно проиллюстрировать следующим образом: темно-серым цветом выделим память, занятую объектом класса
Message
, светло-серым — память, занятую объектами класса
Node
, а белым — “дыры” (т.е. неиспользуемую память).



Итак, каждый раз, проходя цикл, мы оставляем неиспользованную память (“дыру”). Эта память может составлять всего несколько байтов, но если мы не можем использовать их, то это равносильно утечке памяти, а даже малая утечка рано или поздно выводит из строя долговременные системы. Разбиение свободной памяти на многочисленные “дыры”, слишком маленькие для того, чтобы в них можно было разместить объекты, называется фрагментацией памяти (memory fragmentation). В конце концов, механизм управления свободной памятью займет все “дыры”, достаточно большие для того, чтобы разместить объекты, используемые программой, оставив только одну “дыру”, слишком маленькую и потому бесполезную. Это серьезная проблема для всех достаточно долго работающих программ, широко использующих операторы

new
и
delete
; фрагментация памяти встречается довольно часто. Она сильно увеличивает время, необходимое для выполнения оператора new, поскольку он должен выполнить поиск подходящего места для размещения объектов. Совершенно очевидно, что такое поведение для встроенной системы недопустимо. Это может также создать серьезную проблему в небрежно спроектированной невстроенной системе.

Почему ни язык, ни система не может решить эту проблему? А нельзя ли написать программу, которая вообще не создавала бы “дыр” в памяти? Сначала рассмотрим наиболее очевидное решение проблемы маленьких бесполезных “дыр” в памяти: попробуем переместить все объекты класса

Node
так, чтобы вся свободная память была компактной непрерывной областью, в которой можно разместить много объектов.

К сожалению, система не может этого сделать. Причина заключается в том, что код на языке С++ непосредственно ссылается на объекты, размещенные в памяти. Например, указатели

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



Теперь мы уплотняем память, перемещаем объекты так, чтобы неиспользуемая память стала непрерывным фрагментом.



 К сожалению, переместив объекты и не обновив указатели, которые на них ссылались, мы создали путаницу. Почему же мы не обновили указатели, перемещая объекты? Мы могли бы написать такую программу, только зная детали структуры данных. В принципе система (т.е. система динамической поддержки языка С++) не знает, где хранятся указатели; иначе говоря, если у нас есть объект, то вопрос: “Какие указатели ссылаются на данный объект в данный момент?” не имеет ответа. Но даже если бы эту проблему можно было легко решить, такой подход (известный как уплотняющая сборка мусора (compacting garbage collection)) не всегда оправдывает себя. Например, для того чтобы он хорошо работал, обычно требуется, чтобы свободной памяти было в два раза больше, чем памяти, необходимой системе для отслеживания указателей и перемещения объектов. Этой избыточной памяти во встроенной системе может не оказаться. Кроме того, от эффективного механизма уплотняющей сборки мусора трудно добиться предсказуемости.

Можно, конечно, ответить на вопрос “Где находятся указатели?” для наших структур данных и уплотнить их, но проще вообще избежать фрагментации в начале блока. В данном примере мы могли бы просто разместить оба объекта класса

Node
до размещения объектов класса
Message
.


while( ... ) {

  Node* n1 = new Node;

  Node* n2 = new Node;

  Message* p = get_input(dev);

  // ...храним информацию в узлах...

  delete p;

  // ...

}


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


ПОПРОБУЙТЕ

Выполните программу, приведенную выше, и выведите на печать адреса и размеры созданных объектов, чтобы увидеть, как возникают “дыры” в памяти и возникают ли они вообще. Если у вас есть время, можете нарисовать схему памяти, подобную показанным выше, чтобы лучше представить себе, как происходит фрагментация.

25.3.2. Альтернатива универсальной свободной памяти

Итак, мы не должны провоцировать фрагментацию памяти. Что для этого необходимо сделать? Во-первых, сам по себе оператор

new
не может порождать фрагментацию; для того чтобы возникли “дыры”, необходим оператор
delete
. Следовательно, для начала запретим оператор
delete
. В таком случае объект, размещенный в памяти, остается там навсегда.

 Если оператор

delete
запрещен, то оператор
new
становится предсказуемым; иначе говоря, все операторы new выполняются за одинаковое время? Да, это правило выполняется во всех доступных реализациях языка, но оно не гарантируется стандартом. Обычно встроенная система имеет последовательность загрузочных команд, приводящую ее в состояние готовности после включения или перезагрузки. На протяжении периода загрузки мы можем распределять память как нам угодно, вплоть до ее полного исчерпания. Итак, мы можем выполнить оператор
new
на этапе загрузки. В качестве альтернативы (или дополнения) можем также зарезервировать глобальную (статическую память) для использования в будущем. Из-за особенностей структуры программы глобальных данных часто лучше избегать, но иногда благоразумно использовать этот механизм для заблаговременного выделения памяти. Точные правила работы этого механизма устанавливаются стандартами программирования данной системы (см. раздел 25.6).

 Существуют две структуры данных, которые особенно полезны для предсказуемого выделения памяти.

Стеки. Стек (stack) — это структура данных, в которой можно разместить любое количество данных (не превышающее максимального размера), причем удалить можно только данные, которые были размещены последними; т.е. стек может расти и уменьшаться только на вершине. Он не вызывает фрагментации памяти, поскольку между двумя его ячейками не может быть “дыр”.

Пулы. Пул (pool) — это коллекция объектов одинаковых размеров. Мы можем размещать объекты в пуле и удалять их из него, но не можем поместить в нем больше объектов, чем позволяет его размер. Фрагментация памяти при этом не возникает, поскольку объекты имеют одинаковые размеры.


Операции размещения и удаления объектов в стеках и пулах выполняются предсказуемо и быстро.

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

 Обратите внимание на то, что стандартные контейнеры языка С++ (

vector
,
map
и др.), а также стандартный класс
string
не могут использоваться во встроенных системах непосредственно, потому что они неявно используют оператор
new
. Для того чтобы обеспечить предсказуемость, можете создать (купить или позаимствовать) аналогичные стандартным контейнеры, но учтите, что обычные стандартные контейнеры, содержащиеся в вашей реализации языка С++, не предназначены для использования во встроенных системах.

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

25.3.3. Пример пула

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



Класс

Pool
можно определить так:


templateclass Pool {  // Пул из N объектов типа T

public:

  Pool();               // создаем пул из N объектов типа T

  T* get();             // берем объект типа T из пула;

                        // если свободных объектов нет,

                        // возвращаем 0

 void free(T*);         // возвращаем объект типа T, взятый

                        // из пула с помощью функции get()

 int available() const; // количество свободных объектов типа T

private:

 // место для T[N] и данные, позволяющие определить, какие объекты

 // извлечены из пула, а какие нет (например, список свободных

 // объектов)

};


Каждый объект класса

Pool
характеризуется типом элементов и максимальным количеством объектов. Его можно использовать примерно так, как показано ниже.


Pool sb_pool;

Pool indicator_pool;

Small_buffer* p = sb_pool.get();

// ...

sb_pool.free(p);


Гарантировать, что пул никогда не исчерпается, — задача программиста. Точный смысл слова “гарантировать” зависит от приложения. В некоторых системах программист должен написать специальный код, например функцию

get()
, которая никогда не будет вызываться, если объектов в пуле больше нет. В других системах программист может проверить результат работы функции
get()
и сделать какие-то корректировки, если результат равен нулю. Характерным примером второго подхода является телефонная система, разработанная для одновременной обработки более 100 тыс. звонков. Для каждого звонка выделяется некий ресурс, например буфер номеронабирателя. Если система исчерпывает количество номеронабирателей (например, функция
dial_buffer_pool.get()
возвращает
0
), то она запрещает создавать новые соединения (и может прервать несколько существующих соединений, для того чтобы освободить память). В этом случае потенциальный абонент может вновь попытаться установить соединение чуть позднее.

Естественно, наш шаблонный класс

Pool
представляет собой всего лишь один из вариантов общей идеи о пуле. Например, если ограничения на использование памяти не такие строгие, можем определить пулы, в которых количество элементов определяется конструктором, и даже пулы, количество элементов в которых может впоследствии изменяться, если нам потребуется больше объектов, чем было указано вначале.

25.3.4. Пример стека

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



Как показано на рисунке, этот стек “растет” вправо. Стек объектов можно было бы определить как пул.


template class Stack { // стек объектов типа T

 // ...

};


Однако в большинстве систем необходимо выделять память для объектов разных размеров. В стеке это можно сделать, а в пуле нет, поэтому мы покажем определение стека, из которого можно брать “сырую” память для объектов, имеющих разные размеры.


templateclass Stack { // стек из N байтов

public:

  Stack();               // создает стек из N байтов

  void* get(int n);      // выделяет n байтов из стека;

                         // если свободной памяти нет,

                         // возвращает 0

  void free();           // возвращает последнее значение,

                         // возвращенное функцией get()

  int available() const; // количество доступных байтов

private:

  // память для char[N] и данные, позволяющие определить, какие

  // объекты извлечены из стека, а какие нет (например,

  // указатель на вершину)

};


Поскольку функция

get()
возвращает указатель
void*
, ссылающийся на требуемое количество байтов, мы должны конвертировать эту память в тип, требуемый для наших объектов. Этот стек можно использовать, например, так.


Stack<50*1024> my_free_store;    // 50K памяти используется как стек

void* pv1 = my_free_store.get(1024);

int* buffer = static_cast(pv1);


void* pv2 = my_free_store.get(sizeof(Connection));

Connection* pconn = new(pv2) Connection(incoming,outgoing,buffer);


Использование оператора

static_cast
описано в разделе 17.8. Конструкция
new(pv2)
называется синтаксисом размещения. Она означает следующее: “Создать объект в ячейке памяти, на которую ссылается указатель
pv2
”. Сама по себе эта конструкция не размещает в памяти ничего. Предполагается, что в классе Connection есть конструктор со списком аргументов (
incoming,outgoing,buffer
). Если это условие не выполняется, то программа не скомпилируется.

Естественно, наш шаблонный класс

Stack
представляет собой всего лишь один из вариантов общей идеи о стеке. Например, если ограничения на использование памяти не такие строгие, то мы можем определить стек, в котором количество доступных байтов задается конструктором.

25.4. Адреса, указатели и массивы

 Предсказуемость требуется в некоторых встроенных системах, а надежность — во всех. Это заставляет нас избегать некоторых языковых конструкций и методов программирования, уязвимых для ошибок (в контексте программирования встроенных систем). В языке С++ основным источником проблем является неосторожное использование указателей.

Выделим две проблемы.

• Явные (непроверяемые и опасные) преобразования.

• Передача указателей на элементы массива.


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

25.4.1. Непроверяемые преобразования

Физические ресурсы (например, регистры контроллеров во внешних устройствах) и их основные средства управления в низкоуровневой системе имеют конкретные адреса. Мы должны указать эти адреса в наших программах и присвоить этим данных некий тип. Рассмотрим пример.


Device_driver* p = reinterpret_cast(0xffb8);


Эти преобразования описаны также в разделе 17.8. Именно этот вид программирования требует постоянного использования справочников. Между ресурсом аппаратного обеспечения — адресом регистра (выраженного в виде целого числа, часто шестнадцатеричного) — и указателями в программном обеспечении, управляющим аппаратным обеспечением, существует хрупкое соответствие. Вы должны обеспечить его корректность без помощи компилятора (поскольку эта проблема не относится к языку программирования). Обычно простой (ужасный, полностью непроверяемый) оператор

reinterpret_cast
, переводящий тип
int
в указатель, является основным звеном в цепочке связей между приложением и нетривиальными аппаратными ресурсами.

Если явные преобразования (

reinterpret_cast
,
static_cast
и т.д.; см. раздел A.5.7) не являются обязательными, избегайте их. Такие преобразования (приведения) бывают необходимыми намного реже, чем думают программисты, работающие в основном на языках C и C++ (в стиле языка С). 

25.4.2. Проблема: дисфункциональный интерфейс

Как указывалось в разделе 18.5.1, массив часто передается функции как указатель на элемент (часто как указатель на первый элемент). В результате он “теряет” размер, поэтому получающая его функция не может непосредственно определить количество элементов, на которые ссылается указатель. Это может вызвать много трудноуловимых и сложно исправимых ошибок. Здесь мы рассмотрим проблемы, связанные с массивами и указателями, и покажем альтернативу. Начнем с примера очень плохого интерфейса (к сожалению, встречающегося довольно часто) и попытаемся его улучшить.


void poor(Shape* p, int sz) // плохой проект интерфейса

{

  for (int i = 0; i

}


void f(Shape* q, vector& s0) // очень плохой код

{

  Polygon s1[10];

  Shape s2[10];

  // инициализация

  Shape* p1 = new Rectangle(Point(0,0),Point(10,20));

  poor(&s0[0],s0.size()); // #1 (передача массива из вектора)

  poor(s1,10);            // #2

  poor(s2,20);            // #3

  poor(p1,1);             // #4

  delete p1;

  p1 = 0;

  poor(p1,1);             // #5

  poor(q,max);            // #6

}


 Функция

poor()
представляет собой пример неудачной разработки интерфейса: она дает вызывающему модулю массу возможностей для ошибок и не оставляет никаких надежд защититься от них на этапе реализации.


ПОПРОБУЙТЕ

Прежде чем читать дальше, попробуйте выяснить, сколько ошибок вы можете найти в функции

f()
? В частности, какой из вызовов функции
poor()
может привести к краху программы?


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

1. Передается элемент неправильного типа (например,

poor(&s0[0],s0.size()
). Кроме того, вектор
s0
может быть пустым, а в этом случае выражение
&s0[0]
является неверным.

2. Используется “магическая константа” (в данном случае правильная):

poor(s1,10)
. И снова тип элемента неправильный.

3. Используется “магическая константа” (в данном случае неправильная):

poor(s2,20)
.

4. Первый вызов

poor(p1,1)
правильный (в чем легко убедиться).

5. Передача нулевого указателя при втором вызове:

poor(p1,1)
.

6. Вызов

poor(q,max)
, возможно, правильный. Об этом трудно судить, глядя лишь на фрагмент кода. Для того чтобы выяснить, ссылается ли указатель
q
на массив, содержащий хотя бы max элементов, мы должны найти определения указателя
q
и переменной
max
и их значения при данном вызове.


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

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

Теоретически компилятор может выявить некоторые из этих ошибок (например, второй вызов

poor(p1,1)
, где
p1==0
), но на практике мы избежали катастрофы в данном конкретном случае только потому, что компилятор предотвратил создание объектов абстрактного класса
Shape
. Однако эта ошибка никак не связана с плохим интерфейсом функции
poor()
, поэтому мы не должны расслабляться. В дальнейшем будем использовать вариант класса
Shape
, который не является абстрактным, так что избежать проблем с интерфейсом нам не удастся.

Как мы пришли к выводу, что вызов

poor(&s0[0],s0.size())
является ошибкой. Адрес
&s0[0]
относится к первому элементу массива объектов класса
Circle
; он является значением указателя
Circle*
. Мы ожидаем аргумент типа
Shape*
и передаем указатель на объект класса, производного от класса
Shape
(в данном случае
Circle*
). Это вполне допустимо: нам необходимо такое преобразование, чтобы можно было обеспечить объектно-ориентированное программирование и доступ к объектам разных типов с помощью общего интерфейса (в данном случае с помощью класса
Shape
) (см. раздел 14.2). Однако функция
poor()
не просто использует переменную
Shape*
как указатель; она использует ее как массив, индексируя ее элементы.


for (int i = 0; i


Иначе говоря, она ищет элементы, начиная с ячеек

&p[0]
,
&p[1]
,
&p[2]
и т.д.



В терминах адресов ячеек памяти эти указатели находятся на расстоянии

sizeof(Shape)
друг от друга (см. раздел 17.3.1). К сожалению для модуля, вызывающего функцию
poor()
, значение
sizeof(Circle)
больше, чем
sizeof(Shape)
, поэтому схему распределения памяти можно проиллюстрировать так.



Другими словами, функция

poor()
вызывает функцию
draw()
с указателем, ссылающимся в середину объекта класса
Circle
! Это скорее всего приведет к немедленной катастрофе (краху)!

 Вызов функции

poor(s1,10)
носит более коварный характер. Он использует “магическую константу”, поэтому сразу возникает подозрение, что могут возникнуть проблемы при сопровождении программы, но это более глубокая проблема. Единственная причина, по которой использование массива объектов класса
Polygon
сразу не привело к проблемам, которые мы обнаружили при использовании объектов класса
Circle
, заключается в том, что класс
Polygon
не добавляет члены класса к базовому классу
Shape
(в отличие от класса
Circle
; см. разделы 13.8 и 13.12), т.е. выполняется условие
sizeof(Shape)==sizeof(Polygon)
и — говоря более общо — класс
Polygon
имеет ту же самую схему распределения памяти, что и класс
Shape
. Иначе говоря, нам просто повезло, так как небольшое изменение определения класса
Polygon
приведет программу к краху. Итак, вызов
poor(s1,10)
работает, но его ошибка похожа на мину замедленного действия. Этот код категорически нельзя назвать качественным.

То, с чем мы столкнулись, является основанием для формулировки универсального правила, согласно которому из утверждения “класс

D
— это разновидность класс
B
” не следует, что “класс
Container
— это разновидность класса
Container
” (см. раздел 19.3.3). Рассмотрим пример.


class Circle:public Shape { /* ... */ };


void fv(vector&);

void f(Shape &);


void g(vector& vd, Circle & d)

{

  f(d);   // OK: неявное преобразование класса Circle в класс Shape

  fv(vd); // ошибка: нет преобразования из класса vector

          // в класс vector

}


 Хорошо, интерфейс функции

poor()
очень плох, но можно ли рассматривать этот код с точки зрения встроенной системы; иначе говоря, следует ли беспокоиться о таких проблемах в приложениях, для которых важным является безопасность или производительность? Можем ли мы объявить этот код опасным при программировании обычных систем и просто сказать им: “Не делайте так”. Многие современные встроенные системы основаны на графическом пользовательском интерфейсе, который практически всегда организован в соответствии с принципами объектно-ориентированного программирования. К таким примерам относятся пользовательский интерфейс устройств iPod, интерфейсы некоторых мобильных телефонов и дисплеи операторов в системах управления полетами. Кроме того, контроллеры аналогичных устройств (например, множество электромоторов) образуют классические иерархии классов. Другими словами, этот вид кода — и, в частности, данный вид объявлений функции — вызывает особые опасения. Нам нужен более безопасный способ передачи информации о коллекциях данных, который не порождал бы значительных проблем.

 Итак, мы не хотим передавать функциям встроенные массивы с помощью указателей и размера массива. Чем это заменить? Проще всего передать ссылку на контейнер, например, на объект класса vector. Проблема, которая возникла в связи с интерфейсом функции


void poor(Shape* p, int sz);


исчезает при использовании функции


void general(vector&);


Если вы программируете систему, в которой допускаются объекты класса

std::vector
(или его эквиваленты), то просто последовательно используйте в интерфейсах класс
vector
(или его эквиваленты) и никогда не передавайте встроенный массив с помощью указателя и количества элементов.

Если вы не можете ограничиться использованием класса

vector
или его эквивалентов, то оказываетесь на территории, где не бывает простых решений, — даже несмотря на то, что использование класса (
Array_ref
) вполне очевидно.

25.4.3. Решение: интерфейсный класс

К сожалению, во многих встроенных системах мы не можем использовать класс

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

 • Он ссылается на объекты в памяти (он не владеет объектами, не размещает их, не удаляет и т.д.).

• Он знает свой размер (а значит, способен проверять выход за пределы допустимого диапазона).

• Он знает точный тип своих элементов (а значит, не может порождать ошибки, связанные с типами).

• Его несложно передать (скопировать) как пару (указатель, счетчик).

• Его нельзя неявно преобразовать в указатель.

• Он позволяет легко выделить поддиапазон в целом диапазоне.

• Его легко использовать как встроенный массив.


Свойство “легко использовать как встроенный массив” можно обеспечить лишь приблизительно. Если бы мы сделали это совершенно точно, то вынуждены были бы смириться с ошибками, которых стремимся избежать.

Рассмотрим пример такого класса.


template

class Array_ref {

public:

  Array_ref(T* pp, int s) :p(pp), sz(s) { }


  T& operator[ ](int n) { return p[n]; }

  const T& operator[ ](int n) const { return p[n]; }


  bool assign(Array_ref a)

  {

    if (a.sz!=sz) return false;

    for (int i=0; i

    return true;

  }


  void reset(Array_ref a) { reset(a.p,a.sz); }

  void reset(T* pp, int s) { p=pp; sz=s; }


  int size() const { return sz; }

  // операции копирования по умолчанию:

  // класс Array_ref не владеет никакими ресурсами

  // класс Array_ref имеет семантику ссылки

private:

  T* p;

  int sz;

};


Класс

Array_ref
близок к минимальному.

• В нем нет функций

push_back()
(для нее нужна динамическая память) и
at()
(для нее нужны исключения).

• Класс Array_ref имеет форму ссылки, поэтому операция копирования просто копирует пары (

p, sz
).

• Инициализируя разные массивы, можем получить объекты класса

Array_ref
, которые имеют один и тот же тип, но разные размеры.

• Обновляя пару (

p, size
) с помощью функции
reset()
, можем изменить размер существующего класса
Array_ref
(многие алгоритмы требуют указания поддиапазонов).

• В классе

Array_ref
нет интерфейса итераторов (но при необходимости этот недостаток легко устранить). Фактически концепция класса
Array_ref
очень напоминает диапазон, заданный двумя итераторами.


Класс

Array_ref
не владеет своими элементами и не управляет памятью, он просто представляет собой механизм для доступа к последовательности элементов и их передачи функциям. Иначе говоря, он отличается от класса
array
из стандартной библиотеки (см. раздел 20.9).

Для того чтобы облегчить создание объектов класса

Array_ref
, напишем несколько вспомогательных функций.


template Array_ref make_ref(T* pp, int s)

{

  return (pp) ? Array_ref(pp,s):Array_ref(0,0);

}


Если мы инициализируем объект класса

Array_ref
указателем, то должны явно указать его размер. Это очевидный недостаток, поскольку, задавая размер, легко ошибиться. Кроме того, он открывает возможности для использования указателя, представляющего собой результат неявного преобразования массива производного класса в указатель базового класса, например указателя
Polygon[10]
в указатель
Shape*
(ужасная проблема, описанная в разделе 25.4.2), но иногда мы должны просто доверять программисту.

Мы решили проявить осторожность в отношении нулевых указателей (поскольку это обычный источник проблем) и пустых векторов.


template Array_ref make_ref(vector& v)

{

  return (v.size()) ? Array_ref(&v[0],v.size()):
Array_ref(0,0);

}


Идея заключается в том, чтобы передавать вектор элементов. Мы выбрали класс

vector
, хотя он часто не подходит для систем, в которых класс
Array_ref
может оказаться полезным. Причина заключается в том, что он обладает ключевыми свойствами, присущими контейнерам, которые здесь можно использовать (например, контейнерам, основанным на пулах; см. раздел 25.3.3).

В заключение предусмотрим обработку встроенных массивов в ситуациях, в которых компилятор знает их размер.


template  Array_ref make_ref(T (&pp)[s])

{

  return Array_ref(pp,s);

}


Забавное выражение

T(&pp)[s]
объявляет аргумент
pp
ссылкой на массив из
s
элементов типа
T
. Это позволяет нам инициализировать объект класса
Array_ref
массивом, запоминая его размер. Мы не можем объявить пустой массив, поэтому не обязаны проверять, есть ли в нем элементы.


Polygon ar[0]; // ошибка: элементов нет


Используя данный вариант класса

Array_ref
, мы можем переписать наш пример.


void better(Array_ref a)

{

  for (int i = 0; i

}


void f(Shape* q, vector& s0)

{

  Polygon s1[10];

  Shape s2[20];

// инициализация

  Shape* p1 = new Rectangle(Point(0,0),Point(10,20));

  better(make_ref(s0));    // ошибка: требуется Array_ref

  better(make_ref(s1));    // ошибка: требуется Array_ref

  better(make_ref(s2));    // OK (преобразование не требуется)

  better(make_ref(p1,1));  // OK: один элемент

  delete p1;

  p1 = 0;

  better(make_ref(p1,1));  // OK: нет элементов

  better(make_ref(q,max)); // OK (если переменная max задана корректно)

}


Мы видим улучшения.

• Код стал проще. Программисту редко приходится заботиться о размерах объектов, но когда это приходится делать, они задаются в специальном месте (при создании объекта класса

Array_ref
), а не в разных местах программы.

• Проблема с типами, связанная с преобразованиями

Circle[]
в
Shape[]
и
Polygon[]
, и
Shape[]
, решена.

• Проблемы с неправильным количеством элементов объектов

s1
и
s2
решаются неявно.

• Потенциальная проблема с переменной max (и другими счетчиками элементов, необходимыми для использования указателей) становится явной — это единственное место, где мы должны явно указать размер.

• Использование нулевых указателей и пустых векторов предотвращается неявно и систематически.

25.4.4. Наследование и контейнеры

Что делать, если мы хотим обрабатывать коллекцию объектов класса

Circle
как коллекцию класса
Shape
, т.е. если действительно хотим, чтобы функция
better()
(представляющая собой вариант нашей старой знакомой функции
draw_all()
; см. разделы 19.3.2 и 22.1.3) реализовала полиморфизм? По существу, мы не можем этого сделать. В разделах 19.3.3 и 25.4.2 показано, что система типов имеет веские основания отказаться воспринимать тип
vector
как
vector
. По той же причине она отказывается принимать тип
Array_ref
как
Array_ref
. Если вы не помните, почему, то перечитайте раздел 19.3.3, поскольку данный момент очень важен, даже если это кажется неудобным.

 Более того, для того чтобы сохранить динамический полиморфизм, мы должны манипулировать нашими полиморфными объектами с помощью указателей (или ссылок): точка в выражении

a[i].draw()
в функции
better()
противоречит этому требованию. Когда мы видим в этом выражении точку, а не стрелку (
–>
), следует ожидать проблем с полиморфизмом

Что нам делать? Во-первых, мы должны работать с указателями (или ссылками), а не с самими объектами, поэтому следует попытаться использовать классы

Array_ref
,
Array_ref
и тому подобные, а не
Array_ref
,
Array_ref
и т.п.

Однако мы по-прежнему не можем конвертировать класс

Array_ref
в класс
Array_ref
, поскольку нам потом может потребоваться поместить в контейнер
Array_ref
элементы, которые не имеют типа
Circle*
. Правда, существует одна лазейка.

• Мы не хотим модифицировать наш объект класса

Array_ref
; мы просто хотим рисовать объекты класса
Shape
! Это интересный и совершенно особый случай: наш аргумент против преобразования типа
Array_ref
в
Array_ref
не относится к ситуациям, в которых мы не хотим модифицировать класс
Array_ref
.

• Все массивы указателей имеют одну и ту же схему (независимо от объектов, на которые они ссылаются), поэтому нас не должна волновать проблема, упомянутая в разделе 25.4.2.


 Иначе говоря, не произойдет ничего плохого, если объект класса

Array_ref
будет интерпретироваться как неизменяемый объект класса
Array_ref
. Итак, нам достаточно просто найти способ это сделать. Рассмотрим пример



Нет никаких логических препятствий интерпретировать данный массив указателей типа

Circle*
как неизменяемый массив указателей типа
Shape*
(из контейнера
Array_ref
).

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

better()
так, чтобы она использовала указатели и гарантировала, что мы ничего не напутаем с аргументами контейнера.


void better2(const Array_ref a)

{

  for (int i = 0; i

    if (a[i])

      a[i]–>draw();

}


Теперь мы работаем с указателями, поэтому должны предусмотреть проверку нулевого показателя. Для того чтобы гарантировать, что функция

better2()
не модифицирует наш массив и векторы находятся под защитой контейнера
Array_ref
, мы добавили несколько квалификаторов
const
. Первый квалификатор
const
гарантирует, что мы не применим к объекту класса
Array_ref
модифицирующие операции, такие как
assign()
и
reset()
. Второй квалификатор
const
размещен после звездочки (
*
). Это значит, что мы хотим иметь константный указатель (а не указатель на константы); иначе говоря, мы не хотим модифицировать указатели на элементы, даже если у нас есть операции, позволяющие это сделать.

Далее, мы должны устранить главную проблему: как выразить идею, что объект класса

Array_ref
можно конвертировать

• в нечто подобное объекту класса

Array_ref
(который можно использовать в функции
better2()
);

• но только если объект класса

Array_ref
является неизменяемым.


Это можно сделать, добавив в класс

Array_ref
оператор преобразования.


template

class Array_ref {

public:

  // как прежде


  template

  operator const Array_ref()

  {

  // проверка неявного преобразования элементов:

  static_cast(*static_cast(0));


  // приведение класса Array_ref:

  return Array_ref(reinterpret_cast(p),sz);

  }

  // как прежде

};


Это похоже на головоломку, но все же перечислим ее основные моменты.

• Оператор приводит каждый тип

Q
к типу
Array_ref
, при условии, что мы можем преобразовать каждый элемент контейнера
Array_ref
в элемент контейнера
Array_ref
(мы не используем результат этого приведения, а только проверяем, что такое приведение возможно).

• Мы создаем новый объект класса

Array_ref
, используя метод решения “в лоб” (оператор
reinterpret_cast
), чтобы получить указатель на элемент желательного типа. Решения, полученные “в лоб”, часто слишком затратные; в данном случае никогда не следует использовать преобразование в класс
Array_ref
, используя множественное наследование (раздел A.12.4).

• Обратите внимание на квалификатор

const
в выражении
Array_ref
: именно он гарантирует, что мы не можем копировать объект класса
Array_ref
в старый, допускающий изменения объект класса
Array_ref
.


Мы предупредили вас о том, что зашли на территорию экспертов и столкнулись с головоломкой. Однако эту версию класса

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


void f(Shape* q, vector& s0)

{

  Polygon* s1[10];

  Shape* s2[20];

  // инициализация

  Shape* p1 = new Rectangle(Point(0,0),10);

  better2(make_ref(s0));    // OK: преобразование

                            // в Array_ref

  better2(make_ref(s1));    // OK: преобразование

                            // в Array_ref

  better2(make_ref(s2));    // OK (преобразование не требуется)

  better2(make_ref(p1,1));  // ошибка

  better2(make_ref(q,max)); // ошибка

}


Попытки использовать указатели приводят к ошибкам, потому что они имеют тип

Shape*
, а функция
better2()
ожидает аргумент типа
Array_ref
; иначе говоря, функция
better2()
ожидает нечто, содержащее указатель, а не сам указатель. Если хотите передать функции
better2()
указатель, то должны поместить его в контейнер (например, во встроенный массив или вектор) и только потом передать его функции. Для отдельного указателя мы можем использовать неуклюжее выражение
make_ref(&p1,1)
. Однако это решение не подходит для массивов (содержащих более одного элемента), поскольку не предусматривает создание контейнера указателей на объекты.

 В заключение отметим, что мы можем создавать простые, безопасные, удобные и эффективные интерфейсы, компенсируя недостатки массивов. Это была основная цель данного раздела. Цитата Дэвида Уилера (David Wheeler): “Каждая проблема решается с помощью новой абстракции” считается первым законом компьютерных наук. Именно так мы решили проблему интерфейса.

25.5. Биты, байты и слова

Выше мы уже упоминали о понятиях, связанных с устройством компьютерной памяти, таких как биты, байты и слова, но в принципе они не относятся к основным концепциям программирования. Вместо этого программисты думают об объектах конкретных типов, таких как

double
,
string
,
Matrix
и
Simple_window
. В этом разделе мы заглянем на уровень программирования, на котором должны лучше разбираться в реальном устройстве памяти компьютера.

Если вы плохо помните двоичное и шестнадцатеричное представления целых чисел, то обратитесь к разделу A.2.1.1.

25.5.1. Операции с битами и байтами

 Байт — это последовательность, состоящая из восьми битов.



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



Нумерация битов в слове также ведется справа налево, т.е. от младшего бита к старшему. Этот рисунок слишком идеализирует реальное положение дел: существуют компьютеры, в которых байт состоит из девяти бит (правда, за последние десять лет мы не видели ни одного такого компьютера), а машины, в которых слово состоит из двух бит, совсем не редкость. Однако будем считать, что в вашем компьютере байт состоит из восьми бит, а слово — из четырех.

Для того чтобы ваша программа была переносимой, используйте заголовок (см. раздел 24.2.1), чтобы гарантировать правильность ваших предположений о размерах.

Как представить набор битов в языке C++? Ответ зависит от того, сколько бит вам требуется и какие операции вы хотите выполнять удобно и эффективно. В качестве наборов битов можно использовать целочисленные типы.

bool
— один бит, правда, занимающий ячейку длиной 8 битов.

char
— восемь битов.

short
— 16 битов.

int
— обычно 32 бита, но во встроенных системах могут быть 16-битовые целые числа.

long int
— 32 или 64 бита.


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

std::vector
— при необходимости иметь больше, чем 8* sizeof(long) битов.

std::bitset
— при необходимости иметь больше, чем 8* sizeof(long) битов.

std::set
— неупорядоченная коллекция именованных битов (см. раздел 21.6.5).

• Файл: много битов (раздел 25.5.6).


Более того, для представления битов можно использовать два средства языка С++.

• Перечисления (

enum
); см. раздел 9.5.

• Битовые поля; см. раздел 25.5.5.


 Это разнообразие способов представления битов объясняется тем, что в конечном счете все, что существует в компьютерной памяти, представляет собой набор битов, поэтому люди испытывают необходимость иметь разные способы их просмотра, именования и выполнения операций над ними. Обратите внимание на то, что все встроенные средства работают с фиксированным количеством битов (например, 8, 16, 32 и 64), чтобы компьютер мог выполнять логические операции над ними с оптимальной скоростью, используя операции, непосредственно обеспечиваемые аппаратным обеспечением. В противоположность им средства стандартной библиотеки позволяют работать с произвольным количеством битов. Это может ограничивать производительность, но не следует беспокоиться об этом заранее: библиотечные средства могут быть — и часто бывают — оптимизированными, если количество выбранных вами битов соответствует требованиям аппаратного обеспечения.

Рассмотрим сначала целые числа. Для них в языке C++ предусмотрены побитовые логические операции, непосредственно реализуемые аппаратным обеспечением. Эти операции применяются к каждому биту своих операндов.



Вам может показаться странным то, что в число фундаментальных операций мы включили “исключительное или” (

^
, которую иногда называют “xor”). Однако эта операция играет важную роль во многих графических и криптографических программах. Компилятор никогда не перепутает побитовый логический оператор
<<
с оператором вывода, а вы можете. Для того чтобы этого не случалось, помните, что левым операндом оператора вывода является объект класса
ostream
, а левым операндом логического оператора — целое число.

Следует подчеркнуть, что оператор

&
отличается от оператора
&&
, а оператор
|
отличается от оператора
||
тем, что они применяются к каждому биту своих операндов по отдельности (раздел A.5.5), а их результат состоит из такого же количества битов, что и операнды. В противоположность этому операторы
&&
и
||
просто возвращают значение
true
или
false
.

Рассмотрим несколько примеров. Обычно битовые комбинации выражаются в шестнадцатеричном виде. Для полубайта (четыре бита) используются следующие коды.



Для представления чисел, не превышающих девяти, можно было бы просто использовать десятичные цифры, но шестнадцатеричное представление позволяет не забывать, что мы работаем с битовыми комбинациями. Для байтов и слов шестнадцатеричное представление становится действительно полезным. Биты, входящие в состав байта, можно выразить с помощью двух шестнадцатеричных цифр.



Итак, используя для простоты тип

unsigned
(раздел 25.5.3), можем написать следующий фрагмент кода:


unsigned char a = 0xaa;

unsigned char x0 = ~a; // дополнение a



unsigned char b = 0x0f;

unsigned char x1 = a&b; // a и b



unsigned char x2 = a^b; // исключительное или: a xor b



unsigned char x3 = a<<1; // сдвиг влево на один разряд



Вместо бита, который был “вытолкнут” с самой старшей позиции, в самой младшей позиции появляется нуль, так что байт остается заполненным, а крайний левый бит (седьмой) просто исчезает.


unsigned char x4 == a>>2; // сдвиг вправо на два разряда



В двух позициях старших битов появились нули, которые обеспечивают заполнение байта, а крайние правые биты (первый и нулевой) просто исчезают.

Мы можем написать много битовых комбинаций и потренироваться в выполнении операций над ними, но это занятие скоро наскучит. Рассмотрим маленькую программу, переводящую целые числа в их битовое представление.


int main()

{

  int i;

  while (cin>>i)

    cout << dec << i << "=="

<< hex << "0x" << i << "=="

<< bitset<8*sizeof(int)>(i) << '\n';

}


Для того чтобы вывести на печать отдельные биты целого числа, используется класс

bitset
из стандартной библиотеки.


bitset<8*sizeof(int)>(i)


Класс

bitset
хранит фиксированное количество битов. В данном случае мы использовали количество битов, равное размеру типа int —
8*sizeof(int)
, — и инициализировали объект класса
bitset
целым числом
i
.


ПОПРОБУЙТЕ

Скомпилируйте программу для работы с битовыми комбинациями и попробуйте создать двоичные и шестнадцатеричные представления нескольких чисел. Если вас затрудняет представление отрицательных чисел, перечитайте раздел 25.5.3 и попробуйте снова.

25.5.2. Класс bitset

Для представления наборов битов и работы с ними используется стандартный шаблонный класс

bitset
из заголовка
. Каждый объект класса
bitset
имеет фиксированный размер, указанный при его создании.


bitset<4> flags;

bitset<128> dword_bits;

bitset<12345> lots;


Объект класса

bitset
по умолчанию инициализируется одними нулями, но обычно у него есть инициализатор. Инициализаторами объектов класса
bitset
могут быть целые числа без знака или строки, состоящие из нулей и единиц:


bitset<4> flags = 0xb;

bitset<128> dword_bits(string("1010101010101010"));

bitset<12345> lots;


Здесь объект

lots
будет содержать одни нули, а
dword_bits
— 112 нулей, за которыми следуют 16 явно заданных битов. Если вы попытаетесь проинициализировать объект класса
bitset
строкой, состоящей из символов, отличающихся от
'0'
и
'1'
, то будет сгенерировано исключение
std::invalid_argument
.


string s;

cin>>s;

bitset<12345> my_bits(s); // может генерировать исключение

                          // std::invalid_argument


К объектам класса

bitset
можно применять обычные операции над битами. Предположим, что переменные
b1
,
b2
и
b3
являются объектами класса
bitset
.


b1 = b2&b3;  // и

b1 = b2|b3;  // или

b1 = b2^b3;  // xor

b1 = ~b2;    // дополнение

b1 = b2<<2;  // сдвиг влево

b1 = b2>>3;  // сдвиг вправо


По существу, при выполнении битовых операций (поразрядных логических операций) объект класса

bitset
ведет себя как переменная типа
unsigned int
(раздел 25.5.3), имеющая произвольный, заданный пользователем размер. Все, что можно делать с переменной типа
unsigned int
(за исключением арифметических операций), вы можете делать и с объектом класса
bitset
. В частности, объекты класса
bitset
полезны при вводе и выводе.


cin>>b; // считываем объект класса bitset

        // из потока ввода

cout<('c'); // выводим битовую комбинацию для символа 'c'


Считывая данные в объект класса

bitset
, поток ввода ищет нули и единицы. Рассмотрим пример.


10121


Число

101
будет введено, а число
21
останется в потоке.

Как в байтах и в словах, биты в объектах класса

bitset
нумеруются справа налево (начиная с самого младшего бита и заканчивая самым старшим), поэтому, например, числовое значение седьмого бита равно
27
.



Для объектов класса

bitset
нумерация является не просто соглашением поскольку класс
bitset
поддерживает индексирование битов. Рассмотрим пример.


int main()

{

  const int max = 10;

  bitset b;

  while (cin>>b) {

    cout << b << '\n';

    for (int i =0; i

                                             // порядок

    cout << '\n';

  }

}

Если вам нужна более полная информация о классе

bitset
, ищите ее в Интернете, в справочниках и учебниках повышенной сложности. 

25.5.3. Целые числа со знаком и без знака

Как и во многих языках программирования, целые числа в языке С++ бывают двух видов: со знаком и без него. Целые числа без знака легко представить в памяти компьютера: нулевой бит означает единицу, первый бит — двойку, второй бит — четверку и т.д. Однако представление целого числа со знаком уже создает проблему: как отличить положительные числа от отрицательных? Язык С++ предоставляет разработчикам аппаратного обеспечения определенную свободу выбора, но практически во всех реализациях используется представление в виде двоичного дополнения. Крайний левый бит (самый старший) считается знаковым.



Если знаковый бит равен единице, то число считается отрицательным. Почти повсюду для представления целых чисел со знаком используется двоичное дополнение. Для того чтобы сэкономить место, рассмотрим представление четырехбитового целого числа со знаком.



Битовую комбинацию числа

–(x+1)
можно описать как дополнение битов числа
x
(известное также как
~x
; см. раздел 25.5.1).

До сих пор мы использовали только целые числа со знаком (например,

int
). Правила использования целых чисел со знаком и без знака можно было бы сформулировать следующим образом.

 • Для числовых расчетов используйте целые числа со знаком (например,

int
).

• Для работы с битовыми наборами используйте целые числа без знака (например,

unsigned int
).


 Это неплохое эмпирическое правило, но ему трудно следовать, потому что есть люди, которые предпочитают в некоторых арифметических вычислениях работать с целыми числами без знака, и нам иногда приходится использовать их программы. В частности, по историческим причинам, которые возникли еще в первые годы существования языка С, когда числа типа

int
состояли всего из 16 битов и каждый бит был на счету, функция-член
v.size()
из класса vector возвращает целое число без знака. 

 Рассмотрим пример.


vector v;

// ...

for (int i = 0; i


“Разумный” компилятор может предупредить, что мы смешиваем значения со знаком (т.е. переменную

i
) и без знака (т.е.,
v.size()
). Такое смешение может привести к катастрофе. Например, счетчик цикла
i
может оказаться переполненным; иначе говоря, значение
v.size()
может оказаться больше, чем максимально большое число типа
int
со знаком. В этом случае переменная
i
может достигнуть максимально возможного положительного значения, которое можно представить с помощью типа
int
со знаком (два в степени, равной количеству битов в типе
int
, минус один, и еще раз минус один, т.е. 215–1). Тогда следующая операция
++
не сможет вычислить следующее за максимальным целое число, а вместо этого вернет отрицательное значение. Этот цикл никогда не закончится! Каждый раз, когда мы будем достигать максимального целого числа, мы будем начинать этот цикл заново с наименьшего отрицательного значения типа
int
. Итак, для 16-битовых чисел типа int этот цикл содержит ошибку (вероятно, очень серьезную), если значение
v.size()
равно 32*1024 или больше; для 32-битовых целых чисел типа
int
эта проблема возникнет, только когда счетчик
i
достигнет значений 2*1024*1024*1024.

 Таким образом, с формальной точки зрения большинство циклов в этой книге было ошибочным и могло вызвать проблемы, т.е. для встроенных систем мы должны либо проверять, что цикл никогда не достигнет критической точки, либо заменить его другой конструкцией. Для того чтобы избежать этой проблемы, мы можем использовать либо тип size_type, предоставленный классом

vector
, либо итераторы.


for (vector::size_type i = 0; i

  cout << v[i] << '\n';

for (vector::iterator p = v.begin(); p!=v.end(); ++p)

  cout << *p << '\n';


Тип

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


ПОПРОБУЙТЕ

Следующий пример может показаться безобидным, но он содержит бесконечный цикл:


void infinite()

{

  unsigned char max = 160; // очень большое

  for (signed char i=0; i

    cout << int(i) << '\n';

}


Выполните его и объясните, почему это происходит.


 По существу, есть две причины, оправдывающие использование для представления обычных целых чисел типа int без знака, а не набора битов (не использующего операции

+
,
,
*
и
/
).

• Позволяет повысить точность на один бит.

• Позволяет отразить логические свойства целых чисел в ситуациях, когда они не могут быть отрицательными.


Из-за причин, указанных выше, программисты отказались от использования счетчиков цикла без знака.

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

 Рассмотрим пример.


unsigned int ui = –1;

int si = ui;

int si2 = ui+2;

unsigned ui2 = ui+2;


Удивительно, но факт: первая инициализация прошла успешно, и переменная

ui
стала равной 4294967295. Это число представляет собой 32-битовое целое число без знака с тем же самым представлением (битовой комбинацией), что и целое число –1 без знака (одни единицы). Одни люди считают это вполне допустимым и используют число –1 как сокращенную запись числа, состоящего из одних единиц, другие считают это проблемой. То же самое правило преобразования применимо к переводу чисел без знака в числа со знаком, поэтому переменная
si
примет значение –1. Можно было ожидать, что переменная
si2
станет равной 1 (–1+2 == 1), как и переменная
ui2
. Однако переменная
ui2
снова нас удивила: почему 4294967295+2 равно 1? Посмотрим на 4294967295 как на шестнадцатеричное число (
0xffffffff
), и ситуация станет понятнее: 4294967295 — это наибольшее 32-битовое целое число без знака, поэтому 4294967297 невозможно представить в виде 32-битового целого числа — неважно, со знаком или без знака. Поэтому либо следует сказать, что операция 4294967295+2 приводит к переполнению или (что точнее), что целые числа без знака поддерживают модулярную арифметику; иначе говоря, арифметика 32-битовых целых чисел является арифметикой по модулю 32.

 Вам все понятно? Даже если так, мы все равно убеждены, что использование целых чисел без знака ради дополнительного повышения точности на один бит — это игра с огнем. Она может привести к путанице и стать источником ошибок.

 Что произойдет при переполнении целого числа? Рассмотрим пример.


Int i = 0;

while (++i) print(i); // выводим i как целое с пробелом


Какая последовательность значений будет выведена на экран? Очевидно, что это зависит от определения типа Int (на всякий случай отметим, что прописная буква I не является опечаткой). Работая с целочисленным типом, имеющим ограниченное количество битов, мы в конечном итоге получим переполнение. Если тип Int не имеет знака (например,

unsigned char
,
unsigned int
или
unsigned long long
), то операция
++
является операцией модулярной арифметики, поэтому после наибольшего числа, которое мы можем представить, мы получим нуль (и цикл завершится). Если же тип
Int
является целым числом со знаком (например,
signed char
), то числа внезапно станут отрицательными и цикл будет продолжаться, пока счетчик не станет равным нулю (и тогда цикл завершится). Например, для типа
signed char
мы увидим на экране числа 1 2 ... 126 127 –128 –127 ... –2–1.

Что происходит при переполнении целых чисел? В этом случае мы работаем так, будто в нашем распоряжении есть достаточное количество битов, и отбрасываем ту часть целого числа, которая не помещается в память, где мы храним результат. Эта стратегия приводит к потере крайних левых (самых старших) битов. Такой же эффект можно получить с помощью следующего кода:


int si = 257; // не помещается в типе char

char c = si;  // неявное преобразование в char

unsigned char uc = si;

signed char sc = si;

print(si); print(c); print(uc); print(sc); cout << '\n';

si = 129;    // не помещается в signed char

c = si;

uc = si;

sc = si;

print(si); print(c); print(uc); print(sc);


Получаем следующий результат:



Объяснение этого результата таково: число 257 на два больше, чем можно представить с помощью восьми битов (255 равно “восемь единиц”), а число 129 на два больше, чем можно представить с помощью семи битов (127 равно “семь единиц”), поэтому устанавливается знаковый бит. Кстати, эта программа демонстрирует, что тип

char
на нашем компьютере имеет знак (переменная c ведет себя как переменная
sc
и отличается от переменной
uc
).


ПОПРОБУЙТЕ

Напишите эти битовые комбинации на листке бумаги. Затем попытайтесь вычислить результат для

si=128
. После этого выполните программу и сравните свое предположение с результатом вычислений на компьютере.


Кстати, почему мы использовали функцию

print()
? Ведь мы могли бы использовать оператор вывода.


cout << i << ' ';


Однако, если бы переменная

i
имела тип
char
, мы увидели бы на экране символ, а не целое число. По этой причине, для того чтобы единообразно обрабатывать все целочисленные типы, мы определили функцию
print()
.


template void print(T i) { cout << i << '\t'; }

void print(char i) { cout << int(i) << '\t'; }

void print(signed char i) { cout << int(i) << '\t'; }

void print(unsigned char i) { cout << int(i) << '\t'; }


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

• Никогда не используйте целые числа без знака просто для того, чтобы получить еще один бит точности.

• Если вам необходим один дополнительный бит, то вскоре вам потребуется еще один.


 К сожалению, мы не можем совершенно избежать использования арифметики целых чисел без знака.

• Индексирование контейнеров в стандартной библиотеке осуществляется целыми числами без знака.

• Некоторые люди любят арифметику чисел без знака. 

25.5.4. Манипулирование битами

 Зачем вообще нужно манипулировать битами? Ведь многие из нас предпочли бы этого не делать. “Возня с битами” относится к низкому уровню и открывает возможности для ошибок, поэтому, если у нас есть альтернатива, следует использовать ее. Однако биты настолько важны и полезны, что многие программисты не могут их игнорировать. Это может звучать довольно грозным и обескураживающим предупреждением, но оно хорошо продумано. Некоторые люди действительно любят возиться с битами и байтами, поэтому следует помнить, что работа с битами иногда необходима (и даже может принести удовольствие), но ею не следует злоупотреблять. Процитируем Джона Бентли: “Люди, развлекающиеся с битами, будут биты” (“People who play with bits will be bitten”).

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

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


void f(short val) // пусть число состоит из 16 битов, т.е. 2 байта

{

  unsigned char left = val>>8;    // крайний левый

                                  // (самый старший) байт

  unsigned char right = val&0xff; // крайний правый

                                  // (самый младший) байт

  // ...

  bool negative = val&0x8000;     // знаковый бит

  // ...

}


Такие операции не редкость. Они известны как “сдвиг и наложение маски” (“shift and mask”). Мы выполняем сдвиг (“shift”), используя операторы

<<
или
>>
, чтобы переместить требуемые биты вправо (в младшую часть слова), где ними легко манипулировать. Мы накладываем маску (“mask”), используя оператор “и” (
&
) вместе с битовой комбинацией (в данном случае
0xff
), чтобы исключить (установить равными нулю) биты, нежелательные в результате.

При необходимости именовать биты часто используются перечисления. Рассмотрим пример.


enum Printer_flags {

  acknowledge=1,

  paper_empty=1<<1,

  busy=1<<2,

  out_of_black=1<<3,

  out_of_color=1<<4,

  // ...

};


Этот код определяет перечисление, в котором каждый элемент равен именно тому значению, которому соответствует его имя.



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


unsigned char x = out_of_color | out_of_black; // x = 24 (16+8)

x |= paper_empty; // x = 26 (24+2)


Отметим, что оператор

|=
можно прочитать как “установить бит” (или “установить некоторый бит”). Значит, оператор
&
можно прочитать как “установлен ли бит?” Рассмотрим пример.


if (x& out_of_color) { // установлен ли out_of_color? (Да, если

                       // установлен)

// ...

}


Оператор

&
по-прежнему можно использовать для наложения маски.


unsigned char y = x &(out_of_color | out_of_black); // y = 24


Теперь переменная

y
содержит копию битов из позиций 4 и 4 числа
x
(
out_of_color
и
out_of_black
).

Очень часть переменные типа

enum
используются как набор битов. При этом необходимо выполнить обратное преобразование, чтобы результат имел вид перечисления. Рассмотрим пример.


// необходимо приведение

Flags z = Printer_flags(out_of_color | out_of_black);


Приведение необходимо потому, что компилятор не может знать, что результат выражения

out_of_color | out_of_black
является корректным значением переменной типа
Flags
. Скептицизм компилятора обоснован: помимо всего прочего, ни один из элементов перечисления не имеет значения, равного 24 (
out_of_color | out_of_black
), но в данном случае мы знаем, что выполненное присваивание имеет смысл (а компилятор — нет).

25.5.5. Битовые поля

 Как указывалось ранее, биты часто встречаются при программировании интерфейсов аппаратного обеспечения. Как правило, такие интерфейсы определяются как смесь битов и чисел, имеющих разные размеры. Эти биты и числа обычно имеют имена и стоят на заданных позициях в слове, которое часто называют регистром устройства (device register). В языке C++ есть специальные конструкции для работы с такими фиксированными схемами: битовые поля (bitfields). Рассмотрим номер страницы, используемый менеджером страниц глубоко внутри операционной системы. Вот как выглядит диаграмма, приведенная в руководстве по работе с операционной системой.



З2-битовое слово состоит из двух числовых полей (одно длиной 22 бита и другое — 3 бита) и четырех флагов (длиной один бит каждый). Размеры и позиции этих фрагментов фиксированы. Внутри слова существует даже неиспользуемое (и неименованное) поле. Эту схему можно описать с помощью следующей структуры:


struct PPN { // Номер физической страницы

  // R6000 Number

  unsigned int PFN:22; // Номер страничного блока

  int:3;               // не используется

  unsigned int CCA:3;  // Алгоритм поддержки

                       // когерентности кэша

                       // (Cache Coherency Algorithm)

  bool nonreachable:1;

  bool dirty:1;

  bool valid:1;

  bool global:1;

};


Для того чтобы узнать, что переменные PFN и CCA должны интерпретироваться как целые числа без знака, необходимо прочитать справочник. Но мы могли бы восстановить структуру непосредственно по диаграмме. Битовые поля заполняют слово слева направо. Количество битов указывается как целое число после двоеточия. Указать абсолютную позицию (например, бит 8) нельзя. Если битовые поля занимают больше памяти, чем слово, то поля, которые не помещаются в первое слово, записываются в следующее. Надеемся, что это не противоречит вашим желаниям. После определения битовое поле используется точно так же, как все остальные переменные.


void part_of_VM_system(PPN * p)

{

  // ...

  if (p–>dirty) { // содержание изменилось

                  // копируем на диск

    p–>dirty = 0;

  }

  // ...

}


Битовые поля позволяют не использовать сдвиги и наложение масок, для того чтобы получить информацию, размещенную в середине слова. Например, если объект класса

PPN
называется
pn
, то битовое поле
CCA
можно извлечь следующим образом:


unsigned int x = pn.CCA; // извлекаем битовое поле CCA


Если бы для представления тех же самых битов мы использовали целое число типа

int
с именем
pni
, то нам пришлось бы написать такой код:


unsigned int y = (pni>>4)&0x7; // извлекаем битовое поле CCA


Иначе говоря, этот код сдвигает структуру

pn
вправо, так чтобы поле
CCA
стало крайним левым битом, а затем накладывает на оставшиеся биты маску
0x7
(т.е. устанавливает последние три бита). Если вы посмотрите на машинный код, то скорее всего обнаружите, что сгенерированный код идентичен двум строкам, приведенным выше.

Смесь аббревиатур (

CCA
,
PPN
,
PFN
) типична для низкоуровневых кодов и мало информативна вне своего контекста.

25.5.6. Пример: простое шифрование

В качестве примера манипулирования данными на уровне битов и байтов рассмотрим простой алгоритм шифрования: Tiny Encryption Algorithm (TEA). Он был изобретен Дэвидом Уилером (David Wheeler) в Кембриджском университете (см. раздел 22.2.1). Он небольшой, но обеспечивает превосходную защиту от несанкционированной расшифровки.

Не следует слишком глубоко вникать в этот код (если вы не слишком любознательны или не хотите заработать головную боль). Мы приводим его просто для того, чтобы вы почувствовали вкус реального приложения и ощутили полезность манипулирования битами. Если хотите изучать вопросы шифрования, найдите другой учебник. Более подробную информацию об этом алгоритме и варианты его реализации на других языках программирования можно найти на веб-странице http://en.wikipedia.org/wiki/Tiny_Encryption_Algorithm или на сайте, посвященному алгоритму TEA и созданному профессором Саймоном Шепердом (Simon Shepherd) из Университета Брэдфорда (Bradford University), Англия. Этот код не является самоочевидным (без комментариев!).

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

Алгоритм TEA получает в качестве аргумента два числа типа

long
без знака (
v[0]
,
v[1]
), представляющие собой восемь символов, которые должны быть зашифрованы; массив, состоящий из двух чисел типа
long
без знака (
w[0]
,
w[1]
), в который будет записан результат шифрования; а также массив из четырех чисел типа
long
без знака (
k[0]..k[3]
), который является ключом.


void encipher(

  const unsigned long *const v,

  unsigned long *const w,

  const unsigned long * const k)

  {

    unsigned long y = v[0];

    unsigned long z = v[1];

    unsigned long sum = 0;

    unsigned long delta = 0x9E3779B9;

    unsigned long n = 32;

    while(n–– > 0) {

      y += (z << 4 ^ z >> 5) + z ^ sum + k[sum&3];

      sum += delta;

      z += (y << 4 ^ y >> 5) + y ^ sum + k[sum>>11 & 3];

    }

    w[0]=y; w[1]=z;

  }

}


Поскольку все данные не имеют знака, мы можем выполнять побитовые операции, не опасаясь сюрпризов, связанных с отрицательными числами. Основные вычисления выполняются с помощью сдвигов (

<<
и
>>
), исключительного “или” (
^
) и побитовой операции “и” (
&
) наряду с обычным сложением (без знака). Этот код написан специально для машины, в которой тип long занимает четыре байта. Код замусорен “магическими” константами (например, он предполагает, что значение
sizeof(long)
равно
4
). Обычно так поступать не рекомендуется, но в данном конкретном коде все это ограничено одной страницей, которую программист с хорошей памятью должен запомнить как математическую формулу. Дэвид Уиллер хотел шифровать свои тексты, путешествуя без ноутбуков и других устройств. Программа кодирования и декодирования должна быть не только маленькой, но и быстрой. Переменная
n
определяет количество итераций: чем больше количество итераций, тем сильнее шифр. Насколько нам известно, при условии
n==32
алгоритм TEA никогда не был взломан.

Приведем соответствующую функцию декодирования.


void decipher(

  const unsigned long *const v,

  unsigned long *const w,

  const unsigned long * const k)

  {

    unsigned long y = v[0];

    unsigned long z = v[1];

    unsigned long sum = 0xC6EF3720;

    unsigned long delta = 0x9E3779B9;

    unsigned long n = 32;

    // sum = delta<<5, в целом sum = delta * n

    while(n–– > 0) {

      z –= (y << 4 ^ y >> 5) + y ^ sum + k[sum>>11 & 3];

      sum –= delta;

      y –= (z << 4 ^ z >> 5) + z ^ sum + k[sum&3];

    }

    w[0]=y; w[1]=z;

  }

}


Мы можем использовать алгоритм TEA для того, чтобы создать файл, который можно передавать по незащищенной линии связи.


int main() // отправитель

{

  const int nchar = 2*sizeof(long); // 64 бита

  const int kchar = 2*nchar; // 128 битов


  string op;

  string key;

  string infile;

  string outfile;

  cout << "введите имя файлов для ввода, для вывода и ключ:\n";

  cin >> infile >> outfile >> key;

  while (key.size()

  ifstream inf(infile.c_str());

  ofstream outf(outfile.c_str());

  if (!inf || !outf) error("Неправильное имя файла");


  const unsigned long* k =

    reinterpret_cast(key.data());


  unsigned long outptr[2];

  char inbuf[nchar];

  unsigned long* inptr = reinterpret_cast

  long*>(inbuf);

  int count = 0;


  while (inf.get(inbuf[count])) {

    outf << hex;       // используется шестнадцатеричный вывод

    if (++count == nchar) {

      encipher(inptr,outptr,k);

      // заполнение ведущими нулями:

      outf << setw(8) << setfill('0') << outptr[0] << ' '

<< setw(8) << setfill('0') << outptr[1] << ' ';

      count = 0;

    }

  }

  if (count) { // заполнение

    while(count != nchar) inbuf[count++] = '0';

    encipher(inptr,outptr,k);

    outf << outptr[0] << ' ' << outptr[1] << ' ';

  }

}


Основной частью кода является цикл

while
; остальная часть носит вспомогательный характер. Цикл
while
считывает символы в буфер ввода
inbuf
и каждый раз, когда алгоритму TEA нужны очередные восемь символов, передает их функции
encipher()
. Алгоритм TEA не проверяет символы; фактически он не имеет представления об информации, которая шифруется. Например, вы можете зашифровать фотографию или телефонный разговор. Алгоритму TEA требуется лишь, чтобы на его вход поступало 64 бита (два числа типа
long
без знака), которые он будет преобразовывать. Итак, берем указатель на строку
inbuf
, превращаем его в указатель типа
unsigned long*
без знака и передаем его алгоритму TEA. То же самое мы делаем с ключом; алгоритм TEA использует первые 128 битов (четыре числа типа
unsigned long
), поэтому мы дополняем вводную информацию, чтобы она занимала 128 битов. Последняя инструкция дополняет текст нулями, чтобы его длина была кратной 64 битам (8 байтов) в соответствии с требованием алгоритма TEA.

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



ПОПРОБУЙТЕ

Ключом было слово

bs
; что представляет собой текст?


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

Мы проверили свою программу, прочитав зашифрованный текст и преобразовав его в исходный. Когда пишете программу, никогда не пренебрегайте простыми проверками ее корректности.

Центральная часть программы расшифровки выглядит следующим образом:


unsigned long inptr[2];

char outbuf[nchar+1];

outbuf[nchar]=0; // терминальный знак

unsigned long* outptr = reinterpret_cast(outbuf);

inf.setf(ios_base::hex,ios_base::basefield); // шестнадцатеричный

                                             // ввод


while (inf>>inptr[0]>>inptr[1]) {

  decipher(inptr,outptr,k);

  outf<

}


Обратите внимание на использование функции


inf.setf(ios_base::hex,ios_base::basefield);


для чтения шестнадцатеричных чисел. Для дешифровки существует буфер вывода

outbuf
, который мы обрабатываем как набор битов, используя приведение.

 Следует ли рассматривать алгоритм TEA как пример программирования встроенной системы? Не обязательно, но мы можем представить себе ситуацию, в которой необходимо обеспечить безопасность или защитить финансовые транзакции с помощью многих устройств. Алгоритм TEA демонстрирует много свойств хорошего встроенного кода: он основан на понятной математической модели, корректность которой не вызывает сомнений; кроме того, он небольшой, быстрый и непосредственно использует особенности аппаратного обеспечения.

Стиль интерфейса функций

encipher()
и
decipher()
не вполне соответствует нашим вкусам. Однако эти функции были разработаны так, чтобы обеспечить совместимость программ, написанных как на языке С, так и на языке С++, поэтому в них нельзя было использовать возможности языка С+, которыми не обладает язык C. Кроме того, многие “магические константы” являются прямым переводом математических формул.

25.6. Стандарты программирования

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

Стандарты программирования пытаются устранить вторую проблему, устанавливая “фирменный стиль”, в соответствии с которым программисты должны использовать средства языка С++, подходящие для конкретного приложения. Например, стандарты программирования для встроенных систем могут запрещать использование оператора

new
. Помимо этого, стандарт программирования нужен также для того, чтобы программы, написанные двумя программистами, были больше похожи друг на друга, чем программы, авторы которых ничем себя не ограничивали, смешивая все возможные стили. Например, стандарт программирования может потребовать, чтобы для организации циклов использовались только операторы
for
, запрещая применение операторов
while
. Благодаря этому программы становятся более единообразными, а в больших проектах вопросы сопровождения могут быть важными. Обратите внимание на то, что стандарты предназначены для улучшения кодов в конкретных областях программирования и устанавливаются узкоспециализированными программистами.

 Не существует одного общего стандарта программирования, приемлемого для всех приложений языка С++ и для всех программистов, работающих на этом языке.

Таким образом, проблемы, для устранения которых предназначены стандарты программирования, порождаются способами, которыми мы пытаемся выразить наши решения, а не внутренней сложностью решаемых задач. Можно сказать, что стандарты программирования пытаются устранить дополнительную сложность, а не внутреннюю.

 Перечислим основные источники дополнительной сложности.

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

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

Необоснованные вариации стилей программирования, в которых для решения похожих задач применяются разные инструменты, запутывающие программистов, занимающихся сопровождением систем.

Неправильный выбор языка программирования, приводящий к использованию языковых конструкций, неподходящих для данного приложения или данной группы программистов.

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

Неправильный выбор стандартов программирования, порождающий дополнительный объем работы или не позволяющий найти наилучшее решение для определенных классов задач, что само по себе становится источников проблем, для устранения которых вводились стандарты программирования.

25.6.1. Каким должен быть стандарт программирования?

 Хороший стандарт программирования должен способствовать написанию хороших программ; т.е. должен давать программистам ответы на множество мелких вопросов, решение которых в каждом конкретном случае привело бы к большой потере времени. Старая поговорка программистов гласит: “Форма освобождает”. В идеале стандарт кодирования должен быть инструктивным, указывая, что следует делать. Это кажется очевидным, но многие стандарты программирования представляют собой простые списки запрещений, не содержащие объяснений, что с ними делать. Простое запрещение редко бывает полезным и часто раздражает.

 Правила хорошего стандарта программирования должны допускать проверку, желательно с помощью программ. Другими словами, как только вы написали программу, вы должны иметь возможность легко ответить на вопрос: “Не нарушил ли я какое-нибудь правило стандарта программирования?” Хороший стандарт программирования должен содержать обоснование своих правил. Нельзя просто заявить программистам: “Потому что вы должны делать именно так!” В ответ на это они возмущаются. И что еще хуже, программисты постоянно стараются опровергнуть те части стандарта программирования, которые они считают бессмысленными, и эти попытки отвлекают их от полезной работы. Не ожидайте, что стандарты программирования ответят на все ваши вопросы. Даже самые хорошие стандарты программирования являются результатом компромиссов и часто запрещают делать то, что лишь может вызвать проблемы, даже если в вашей практике этого никогда не случалось. Например, очень часто источником недоразумений становятся противоречивые правила именования, но люди часто отдают предпочтение определенным соглашениям об именах и категорически отвергают остальные. Например, я считаю, что имена идентификаторов вроде CamelCodingStyle[10] весьма уродливы, и очень люблю имена наподобие underscore_style[11], которые намного понятнее, и многие люди со мной согласны. С другой стороны, многие разумные люди с этим не согласны. Очевидно, ни один стандарт именования не может удовлетворить всех, но в данном случае, как и во многих других, последовательность намного лучше отсутствия какой-либо систематичности.

Подведем итоги.

• Хороший стандарт программирования предназначен для конкретной предметной области и конкретной группы программистов.

• Хороший стандарт программирования должен быть инструктивным, а не запретительным.

• Рекомендация некоторых основных библиотечных возможностей часто является самым эффективным способом применения инструктивных правил.

• Стандарт программирования — это совокупность правил, описывающих желательный образец для кода, в частности:

• регламентирующие способ именования идентификаторов и выравнивания строк, например “Используйте схему Страуструпа”;

• указывающие конкретное подмножество языка, например “Не используйте операторы

new
или
throw
”;

• задающие правила комментирования, например “Каждая функция должна содержать описание того, что она делает”;

• требующие использовать конкретные библиотеки, например “используйте библиотеку

, а не
”, или “используйте классы
vector
и
string
, а не встроенные массивы и строки в стиле языка С”.

• Большинство стандартов программирования имеет общие цели.

• Надежность.

• Переносимость.

• Удобство сопровождения.

• Удобство тестирования.

• Возможность повторного использования.

• Возможность расширения.

• Читабельность.

 • Хороший стандарт программирования лучше, чем отсутствие стандарта.


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

 • Плохой стандарт программирования может оказаться хуже, чем полное отсутствие стандарта. Например, стандарты программирования на языке С++, суживающие его до языка С, таят в себе угрозу. К сожалению, плохие стандарты программирования встречаются чаще, чем хотелось бы.

• Программисты не любят стандарты программирования, даже хорошие. Большинство программистов хотят писать свои программы только так, как им нравится.

25.6.2. Примеры правил

В этом разделе мы хотели бы дать читателям представление о стандартах программирования, перечислив некоторые правила. Естественно, мы выбрали те правила, которые считаем полезными для вас. Однако мы не видели ни одного реального стандарта программирования, который занимал бы меньше 35 страниц. Большинство из них намного длиннее. Итак, не будем пытаться привести здесь полный набор правил. Кроме того, каждый хороший стандарт программирования предназначен для конкретной предметной области и конкретной группы программистов. По этой причине мы ни в коем случае не претендуем на универсальность.

Правила пронумерованы и содержат (краткое) обоснование. Мы провели различия между рекомендациями, которые программист может иногда игнорировать, и твердыми правилами, которым он обязан следовать. Обычно твердые правила обычно нарушаются только с письменного согласия руководителя. Каждое нарушение рекомендации или твердого правила требует отдельного комментария в программе. Любые исключения из правила должны быть перечислены в его описании. Твердое правило выделяется прописной буквой R в его номере. Номер рекомендации содержит строчную букву r.

Правила разделяются на несколько категорий.

• Общие.

• Правила препроцессора.

• Правила использования имен и размещения текста.

• Правила для классов.

• Правила для функций и выражений.

• Правила для систем с жесткими условиями реального времени.

• Правила для систем, предъявляющих особые требования к вопросам безопасности.


Правила для систем с жесткими условиями реального времени и систем, предъявляющих особые требования к вопросам безопасности, применяются только в проектах, которые явно такими объявлены.

По сравнению с хорошими реальными стандартами программирования наша терминология является недостаточно точной (например, что значит, “система, предъявляющая особые требования к вопросам безопасности”), а правила слишком лаконичны. Сходство между этими правилами и правилами JSF++ (см. раздел 25.6.3) не является случайным; я лично помогал формулировать правила JSF++. Однако примеры кодов в этой книге не следуют этим правилам — в конце концов, книга не является программой для систем, предъявляющих особые требования к вопросам безопасности.


Общие правила

R100. Любая функция или класс не должны содержать больше 200 логических строк кода (без учета комментариев).

Причина: длина функции или класса свидетельствует об их сложности, поэтому их трудно понять и протестировать.

r101. Любая функция или класс должны помещаться на экране и решать одну задачу.

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

R102. Любая программа должна соответствовать стандарту языка С++ ISO/IEC 14882:2003(E).

Причина. Расширения языка или отклонения от стандарта ISO/IEC 14882 менее устойчивы, хуже определены и уменьшают переносимость программ.


Правила препроцессора

R200. Нельзя использовать никаких макросов, за исключением директив управления исходными текстами

#ifdef
и
#ifndef
.

Причина. Макрос не учитывает область видимости и не подчиняется правилам работы с типами. Использование макросов трудно определить визуально, просматривая исходный текст.

R201. Директива

#include
должна использоваться только для включения заголовочных файлов (
*.h
).

Причина. Директива

#include
используется для доступа к объявлениям интерфейса, а не к деталям реализации.

R202. Директивы

#include
должны предшествовать всем объявлениям, не относящимся к препроцессору.

Причина. Директива

#include
, находящаяся в середине файла, скорее всего, будет не замечена читателем и вызовет недоразумения, связанные с тем, что область видимости разных имен в разных местах разрешается по-разному.

R203. Заголовочные файлы (

*.h
) не должны содержать определение не константных переменных или не подставляемых нешаблонных функций.

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


Правила использования имен и размещения текста

R300. В пределах одного и того же исходного файла следует использовать согласованное выравнивание.

Причина. Читабельность и стиль.

R301. Каждая новая инструкция должна начинаться с новой строки.

Причина. Читабельность.

Пример:


 int a = 7; x = a+7; f(x,9); // нарушение

 int a = 7;                  // OK

 x = a+7;                    // OK

 f(x,9);                     // OK


Пример:


if (p


Пример:


if (p

  cout << *p; // OK


R302. Идентификаторы должны быть информативными.

Идентификаторы могут состоять из общепринятых аббревиатур и акронимов.

В некоторых ситуациях имена

x
,
y
,
i
,
j
и т.д. являются информативными.

Следует использовать стиль

number_of_elements
, а не
numberOfElements
.

Венгерский стиль использовать не следует.

Только имена типов, шаблонов и пространств имен могут начинаться с прописной буквы.

Избегайте слишком длинных имен.

Пример:

Device_driver
и
Buffer_pool
.

Причина. Читабельность.

Примечание. Идентификаторы, начинающиеся с символа подчеркивания, зарезервированы стандартом языка С++ и, следовательно, запрещены для использования.

Исключение. При вызове функций из используемой библиотеки может потребоваться указать имена, определенные в ней.

Исключение. Названия макросов, которые используются как предохранители для директивы

#include
.

R303. Не следует использовать идентификаторы, которые различаются только по перечисленным ниже признакам.

• Смесь прописных и строчных букв.

• Наличие/отсутствие символа подчеркивания.

• Замена буквы O цифрой 0 или буквой D.

• Замена буквы I цифрой 1 или буквой l.

• Замена буквы S цифрой 5.

• Замена буквы Z цифрой 2.

• Замена буквы n буквой h.

Пример:

Head и head // нарушение

Причина. Читабельность.

R304. Идентификаторы не должны состоять только из прописных букв или прописных букв с подчеркиваниями.

Пример: BLUE и BLUE_CHEESE // нарушение

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


Правила для функций и выражений

r400. Идентификаторы во вложенной области видимости не должны совпадать с идентификаторами во внешней области видимости.

Пример:

int var = 9; { int var = 7; ++var; } // нарушение: var маскирует var

Причина. Читабельность.

R401. Объявления должны иметь как можно более маленькую область видимости.

Причина. Инициализация и использование переменной должны быть как можно ближе друг к другу, чтобы минимизировать вероятность путаницы; выход переменной за пределы области видимости освобождает ее ресурсы.

R402. Переменные должны быть проинициализированы.

Пример:


 int var; // нарушение: переменная var не проинициализирована


Причина. Неинициализированные переменные являются традиционным источником ошибок.

Исключение. Массив или контейнер, который будет немедленно заполнен данными из потока ввода, инициализировать не обязательно.

R403. Не следует использовать операторы приведения.

Причины. Операторы приведения часто бывают источником ошибок.

Исключение. Разрешается использовать оператор

dynamic_cast
.

Исключение. Приведение в новом стиле можно использовать для преобразования адресов аппаратного обеспечения в указатели, а также для преобразования указателей типа

void*
, полученных из внешних источников (например, от библиотеки графического пользовательского интерфейса), в указатели соответствующих типов.

R404. Встроенные массивы нельзя использовать в интерфейсах. Иначе говоря, указатель, используемый как аргумент функции, должен рассматриваться только как указатель на отдельный элемент. Для передачи массивов используйте класс

Array_ref
.

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


Правила для классов

R500. Для классов без открытых данных-членов используйте ключевое слово

class
, а для классов без закрытых данных-членов — ключевое слово
struct
. Не используйте классы, в которых перемешаны открытые и закрытые члены.

Причина. Ясность.

r501. Если класс имеет деструктор или член, являющийся указателем на ссылочный тип, то он должен иметь копирующий конструктор, а копирующий оператор присваивания должен быть либо определен, либо запрещен.

Причина. Деструктор обычно освобождает ресурс. По умолчанию семантика копирования редко бывает правильной по отношению к членам класса, являющимся указателями или ссылками, а также по отношению к классам без деструкторов.

R502. Если класс содержит виртуальную функцию, то он должен иметь виртуальный конструктор.

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

r503. Конструктор, принимающий один аргумент, должен быть объявлен с помощью ключевого слова

explicit
.

Причина. Для того чтобы избежать непредвиденных неявных преобразований.


Правила для систем с жесткими условиями реального времени

R800. Не следует применять исключения.

Причина. Результат непредсказуем.

R801. Оператор

new
можно использовать только на этапе запуска.

Причина. Результат непредсказуем.

Исключение. Для памяти, выделенной из стека, может быть использован синтаксис размещения (в его стандартном значении).

R802. Не следует использовать оператор

delete
.

Причина. Результат непредсказуем; может возникнуть фрагментация памяти.

R803. Не следует использовать оператор

dynamic_cast
.

Причина. Результат непредсказуем (при традиционном способе реализации оператора).

R804. Не следует использовать стандартные библиотечные контейнеры, за исключением класса

std::array
.

Причина. Результат непредсказуем (при традиционном способе реализации оператора).


Правила для систем, предъявляющих особые требования к вопросам безопасности

R900. Операции инкрементации и декрементации не следует использовать как элементы выражений.

Пример:


int x = v[++i]; // нарушение


Пример:


++i;

int x = v[i]; // OK


Причина. Такую инкрементацию легко не заметить.

R901. Код не должен зависеть от правил приоритета операций ниже уровня арифметических выражений.

Пример:


x = a*b+c; // OK


Пример:


if( a

                 // и (c<=d)


Причина. Путаница с приоритетами постоянно встречается в программах, авторы которых слабо знают язык C/C++.


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

25.6.3. Реальные стандарты программирования

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

Henricson, Mats, and Erik Nyquist. Industrial Strength C++: Rules and Recommendations. Prentice Hall, 1996. ISBN 0131209655. Набор правил, разработанных для телекоммуникационных компаний. К сожалению, эти правила несколько устарели: книга была издана до появления стандарта ISO C++. В частности, в них недостаточно широко освещены шаблоны.

Lockheed Martin Corporation. “Joint Strike Fighter Air Vehicle Coding Standards for the System Development and Demonstration Program”. Document Number 2RDU00001 Rev C. December 2005. Широко известен в узких кругах под названием “JSF++”. Это набор правил, написанных в компании Lockheed-Martin Aero, для программного обеспечения летательных аппаратов (самолетов). Эти правила были написаны программистами и для программистов, создающих программное обеспечение, от которого зависит жизнь людей (www.research.att.com/~bs/JSF-AV-rules.pdf).

Programming Research. High-integrity C++ Coding Standard Manual Version 2.4. (www.programmingresearch.com).

Sutter, Herb, and Andrei Alexandrescu. C++ Coding Standards: 101 Rules, Guidelines, and Best Practices. Addison-Wesley, 2004. ISBN 0321113586. Этот труд можно скорее отнести к стандартам метапрограммирования; иначе говоря, вместо формулирования конкретных правил авторы пишут, какие правила являются хорошими и почему.

 Обратите внимание на то, что знания предметной области, языка и технологии программирования не могут заменить друг друга. В большинстве приложений — и особенно в большинстве встроенных систем программирования — необходимо знать как операционную систему, так и/или архитектуру аппаратного обеспечения. Если вам необходимо выполнить низкоуровневое кодирование на языке С++, то изучите отчет комитета ISO по стандартизации, посвященный проблемам производительности (ISO/IEC TR 18015; www.research.att.com/~bs/performanceTR.pdf); под производительностью авторы (и мы) понимают в основном производительность программирования для встроенных систем.

 В мире встроенных систем существует множество языков программирования и их диалектов, но где только можно, вы должны использовать стандартизированные язык (например, ISO C++), инструменты и библиотеки. Это минимизирует время вашего обучения и повысит вероятность того, что вас не скоро уволят.


Задание

1. Выполните следующий фрагмент кода:


int v = 1; for (int i = 0; i

v <<=1;}


2. Выполните этот фрагмент еще раз, но теперь переменную

v
объявите как
unsigned int
.

3. Используя шестнадцатеричные литералы, определите, чему равны следующие переменные типа

short unsigned int
.

3.1. Каждый бит равен единице.

3.2. Самый младший бит равен единице.

3.3. Самый старший бит равен единице.

3.4. Самый младший байт состоит из одних единиц.

3.5. Самый старший байт состоит из одних единиц.

3.6. Каждый второй бит равен единице (самый младший бит также равен единице).

3.7. Каждый второй бит равен единице (а самый младший бит равен нулю).

4. Выведите на печать каждое из перечисленных выше значений в виде десятичного и шестнадцатеричного чисел.

5. Выполните задания 3-4, используя побитовые операции (

|
,
&
,
<<
) и (исключительно) литералы
1
и
0
.


Контрольные вопросы

1. Что такое встроенная система? Приведите десять примеров, не менее трех из которых не упоминались в этой главе.

2. Что есть особенного во встроенных системах? Приведите пять особенностей, присущих всем встроенным системам.

3. Определите понятие предсказуемости в контексте встроенных систем.

4. Почему встроенные системы иногда трудно модифицировать и ремонтировать?

5. Почему оптимизировать производительность системы иногда нецелесообразно?

6. Почему мы предпочитаем оставаться на высоком уровне абстракции, не опускаясь на нижний уровень программирования?

7. Какие ошибки называют преходящими? Чем они особенно опасны?

8. Как разработать систему, которая восстанавливает свою работу после сбоя?

9. Почему невозможно предотвратить сбои?

10. Что такое предметная область? Приведите примеры предметных областей.

11. Для чего необходимо знать предметную область при программировании встроенных систем?

12. Что такое подсистема? Приведите примеры.

13. Назовите три вида памяти с точки зрения языка С++.

14. Почему вы предпочитаете использовать свободную память?

15. Почему использование свободной памяти во встроенных системах часто нецелесообразно?

16. Как безопасно использовать оператор new во встроенной системе?

17. Какие потенциальные проблемы связаны с классом

std::vector
в контексте встроенных систем?

18. Какие потенциальные проблемы связаны с исключениями во встроенных системах?

19. Что такое рекурсивный вызов функции? Почему некоторые программисты, разрабатывающие встроенные системы, избегают исключений? Что они используют вместо них?

20. Что такое фрагментация памяти?

21. Что такое сборщик мусора (в контексте программирования)?

22. Что такое утечка памяти? Почему она может стать проблемой?

23. Что такое ресурс? Приведите примеры.

24. Что такое утечка ресурсов и как ее систематически предотвратить?

25. Почему мы не можем просто переместить объекты из одной области памяти в другую?

26. Что такое стек?

27. Что такое пул?

28. Почему стек и пул не приводят к фрагментации памяти?

29. Зачем нужен оператор

reinterpret_cast
? Чем он плох?

30. Чем опасна передача указателей в качестве аргументов функции? Приведите примеры.

31. Какие проблемы могут возникать при использовании указателей и массивов? Приведите примеры.

32. Перечислите альтернативы использованию указателей (на массивы) в интерфейсах.

33. Что гласит первый закон компьютерных наук?

34. Что такое бит?

35. Что такое байт?

36. Из скольких битов обычно состоит байт?

37. Какие операции мы можем выполнить с наборами битов?

38. Что такое исключающее “или” и чем оно полезно?

39. Как представить набор (или последовательность) битов?

40. Из скольких битов состоит слово?

41. Из скольких байтов состоит слово?

42. Что такое слово?

43. Из скольких битов, как правило, состоит слово?

44. Чему равно десятичное значение числа

0xf7
?

45. Какой последовательности битов соответствует число

0xab
?

46. Что такое класс

bitset
и когда он нужен?

47. Чем тип unsigned

int
отличается от типа
signed int
?

48. В каких ситуациях мы предпочитаем использовать тип

unsigned int
, а не
signed int
?

49. Как написать цикл, если количество элементов в массиве очень велико?

50. Чему равно значение переменной типа

unsigned int
после присвоения ей числа
–3
?

51. Почему мы хотим манипулировать битами и байтами (а не типами более высокого порядка)?

52. Что такое битовое поле?

53. Для чего используются битовые поля?

54. Что такое кодирование (шифрование)? Для чего оно используется?

55. Можно ли зашифровать фотографию?

56. Для чего нужен алгоритм TEA?

57. Как вывести число в шестнадцатеричной системе?

58. Для чего нужны стандарты программирования? Назовите причины.

59. Почему не существует универсального стандарта программирования?

60. Перечислите некоторые свойства хорошего стандарта программирования.

61. Как стандарт программирования может нанести вред?

62. Составьте список, содержащий не менее десяти правил программирования (которые считаете полезными). Чем они полезны?

63. Почему мы не используем идентификаторы вида ALL_CAPITAL?


Термины


Упражнения

1. Выполните упражнения из разделов ПОПРОБУЙТЕ, если вы этого еще не сделали.

2. Составьте список слов, которые можно получить из записи чисел в шестнадцатеричной системе счисления, читая 0 как o, 1 как l, 2 как to и т.д. Например, Foo1 и Beef. Прежде чем сдать их для оценки, тщательно устраните все вульгаризмы.

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

4. Добавьте побитовые логические операторы operators

&
,
|
,
^
и
~
в калькулятор из главы 7.

5. Напишите бесконечный цикл. Выполните его.

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

7. Выведите шестнадцатеричные значения от 0 до 400; выведите шестнадцатеричные значения от –200 до 200.

8. Выведите числовой код каждого символа на вашей клавиатуре.

9. Не используя ни стандартные заголовки (такие как

), ни документацию, вычислите количество битов в типе
int
и определите, имеет ли знак тип
char
в вашей реализации языка С++.

10. Проанализируйте пример битового поля из раздела 25.5.5. Напишите пример, в котором инициализируется структура

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

11. Повторите предыдущее упражнение, сохраняя биты к объекте класса

bitset<32>
.

12. Напишите понятную программу для примера из раздела 25.5.6.

13. Используйте алгоритм TEA (см. раздел 25.5.6) для передачи данных между двумя компьютерами. Использовать электронную почту настоятельно не рекомендуется.

14. Реализуйте простой вектор, в котором могут храниться не более N элементов, память для которых выделена из пула. Протестируйте его при N==1000 и целочисленных элементах.

15. Измерьте время (см. раздел 26.6.1), которое будет затрачено на размещение 10 тысяч объектов случайного размера в диапазоне байтов [1000:0], с помощью оператора

new
; затем измерьте время, которое будет затрачено на удаление этих элементов с помощью оператора
delete
. Сделайте это дважды: один раз освобождая память в обратном порядке, второй раз — случайным образом. Затем выполните эквивалентное задание для 10 тысяч объектов размером 500 байт, выделяя и освобождая память в пуле. Потом разместите в диапазоне байтов [1000:0] 10 тысяч объектов случайного размера, выделяя память в стеке и освобождая ее в обратном порядке. Сравните результаты измерений. Выполните каждое измерение не менее трех раз, чтобы убедиться в согласованности результатов.

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

17. В разделах 25.4.3-25.4.4 мы описали класс

Array_ref
, обеспечивающий более простой и безопасный доступ к элементам массива. В частности, мы заявили, что теперь наследование обрабатывается корректно. Испытайте разные способы получить указатель
Rectangle*
на элемент массива
vector
, используя класс
Array_ref
, не прибегая к приведению типов и другим операциям с непредсказуемым поведением. Это должно оказаться невозможным.


Послесловие

Итак, программирование встроенных систем сводится, по существу, к “набивке битов”? Не совсем, особенно если вы преднамеренно стремитесь минимизировать заполнение битов как источник потенциальных ошибок. Однако иногда биты и байты системы приходится “набивать”; вопрос только в том, где и как. В большинстве систем низкоуровневый код может и должен быть локализован. Многие из наиболее интересных систем, с которыми нам пришлось работать, были встроенными, а самые интересные и сложные задачи программирования возникают именно в этой предметной области.

Глава 26