Text
                    УДК 681.3
ББК 32.973.26-018.2
У 36
Уилсон М.
C++: практический подход к решению проблем программирования / Пер. с англ. - М.:
КУДИЦ-ОБРАЗ, 2006. - 736 с.
C++ - изумительных язык, но не идеальный. Если вы давно занимаетесь разработками на C++, эта книга по-
может вам по-новому посмотреть на те сложные проблемы, с которыми приходится сталкиваться при программи-
ровании, и освоить мощные методы, которые вы никогда раньше не применяли. Если вы новичок в C++, то научи-
тесь принципам программирования, которые позволят вам более эффективно реализовывать все ваши проекты.
В ходе чтения книги вы научитесь:
•	преодолевать недостатки системы типов C++;
*	обеспечивать выполнение требований проекта программного обеспечения с помощью ограничений,
соглашений и утверждений;
*	правильно обрабатывать ситуации, не оговоренные стандартом, включая проблемы, связанные с ди-
намиче-скими библиотеками, статическими объектами и поточной организацией вычислений;
*	обеспечивать совместимость динамически загружаемых компонентов на уровне двоичных модулей;
*	понимать недостатки неявных преобразований и связанные с ними затраты и применять альтерна-
тивные подходы;
*	повышать совместимость различных компиляторов, библиотек и операционных сред;
•	помогать компилятору обнаруживать больше ошибок и работать более эффективно;
*	понимать, какие аспекты стиля влияют на надежность;
*	применять механизм захвата ресурсов при инициализации при решении различных задач;
•	умело обращаться со странной связью, временами возникающей между массивами и указателями;
•	использовать шаблонное программирование для повышения гибкости и устойчивости;
•	расширять C++, в том числе быстрой конкатенацией строк, настоящими NULL-указателями, гибкими
буферами памяти, свойствами, многомерными массивами и диапазонами.
Прилагаемый компакт-диск содержит много различной ценной информации: компиляторы, библиотеки,
тестовые программы, инструментальные средства и служебные программы, а также подборку журнальных
статей автора.
Мэтью Уилсон
C++: практический подход к решению проблем программирования
Учебно-справочное издание
Перевод с англ. В. Казаченко	Корректор В. Клименко
Научный редактор Е. Петрова	Макет О. Горкина
ООО «КУДИЦ-ПРЕСС»
190068, г. Санкт-Петербург, Вознесенский пр-т., д. 55, лит. А, пом. 4Н
Тел.: (495) 333-65-67, ok@kudits.ru
Подписано в печать 30.01.2006 г.
Формат 70x100/16.
Печать офсетная. Бум. офсетная
Усл. печ. л. 59,3. Тираж 2000. Заказ 225
ISBN 5-91136-006-3 (рус.)
ISBN 0-321-22877-4
Отпечатано в ОАО «Щербинская типография»
117623, Москва, ул. Типографская, д. 10
© 2005 Addison Wesley Professional
Авторизованный перевод англоязычного издания, озаглавленного IMPERFECT C++: PRACTICAL SOLU-
TIONS FOR REAL-LIFE PROGRAMMING, 1ST EDITION, ISBN: 0321228774, by Wilson, Matthew,
опубликованного Pearson Education, Inc, под издательской маркой Addison Wesley Professional Copyright © 2005
All rights reserved. No part of this book may be reproduced or transmitted in any forms or by any means, elec-
tronic or mechanical, including photocopying, recording or by any information storage retrieval system, without
permission from Pearson Education Inc.
Русское издание опубликовано издательством КУДИЦ-ОБРАЗ, © 2006

Пролог Возможно, я люблю C++ не так, как своих детей, и даже не так сильно, как 10%-й подъем по ровной бетонке с применением техники «танцовщицы» в 32° наклоне1, хотя иногда чувство бывает очень похожее. Но я считаю себя счастливым человеком от того, что часть моей жизни посвящается (если перефразировать Фредерика П. Брукса (Frederick Р. Brooks)) практическому «воплощению усилий моего воображения». Я считаю себя вдвойне счастли- вым, потому что в моем распоряжении имеется C++-единственный в своем роде, мощный, опасный и увлекательный язык программирования. Это все выглядит очень сентиментально, но вы можете заинтересоваться данной книгой из-за ее названия, предполагая, что C++ будет подвергнут хорошей «трепке». И действительно, вы можете быть горячим поклонником Java, С или какого-нибудь другого популярного языка, и приобрели экземпляр «C++? практический подход к решению проблем программирования», страстно желая найти свидетельства, оправ- дывающие ваше игнорирование C++. В этом случае вы, возможно, разочаруетесь, поскольку эта книга больше восхваляет C++ и одновременно его критикует. Но все- таки не откладывайте ее в сторону; вероятно, вы обнаружите доводы, по которым вам следует начать относиться к C++ по-другому. Чему вы научитесь Я написал эту книгу, чтобы помочь разработчикам. В ней дается критический, но конструктивный взгляд на C++ и его недостатки, а также предлагаются практиче- ские меры по их устранению или смягчению. После прочтения этой книги, я надеюсь, вы станете лучше понимать: • как преодолевать некоторые из недостатков в системе типов C++; • что шаблонное программирование повышает гибкость и устойчивость програм- много кода; • как выжить в условиях непредсказуемого поведения (то есть когда стандарт не устанавливает определенный режим работы) динамических библиотек, статических объектов и потоков; • затраты и трудности, связанные с неявными преобразованиями, и альтернативу, которую предлагает эффективное и управляемое обобщенное программирование путем применения явных преобразований; Велосипедисты поймут, о чем я говорю.
6 Пролог • как писать программное обеспечение, которое было бы совместимо (или которое не трудно было бы сделать совместимым) с другими компиляторами, библиотеками, моделями поточной организации вычислений и т. п.; • что компиляторы делают «за сценой» и способы воздействия на них; • коварную взаимозаменяемость массивов и указателей, а также методы, позво- ляющие их сделать не похожими друг на друга; • большие возможности C++, связанные с поддержкой механизма захвата ресурсов при инициализации (Resource Acquisition Is Initialization) и различные области его применения; • как свести к минимуму свои усилия, по максимуму увеличивая способность вашего компилятора обнаруживать ошибки. Вы, несомненно, будете оснащены средствами, позволяющими писать более эффек- тивный, легче сопровождаемый, более устойчивый и более гибкий программный код. Я рассчитываю, что даже очень опытные профессиональные программисты C++ обнаружат новые идеи и некоторые новые методы, которые будут стимулировать мыш- ление и позволят усовершенствовать применяемые ими в настоящее время методы. Менее опытные программисты смогут оценить полезность предлагаемых принципов и методов программирования и использовать их в своей собственной работе, посте- пенно заполняя пробелы в понимании всех тонкостей этих методов по мере накопле- ния знаний. Я не ожидаю, что каждый из вас согласится со всем, что я скажу, но я надеюсь, что даже самый спорный материал будет способствовать лучшему пониманию вами этого замечательного языка. На какие ваши знания я рассчитываю Если только вы не собираетесь написать тень большую книгу, вы предполагаете, что читатель должен обладать достаточно обширными знаниями. Было бы неучтиво оговаривать необходимость знакомства с конкретным набором текстов, но предпола- гаю, что вы обладаете достаточными знаниями и опытом, чтобы чувствовать себя ком- фортно с большинством концепций, содержащихся в сериях Скотта Майерса (Scott Meyer) «Effective C++» (Эффективный C++) и Герба Саттера (Herb Sutter) «Exceptional C++» (Необычный C++). Я также рассчитываю на то, что у вас имеется экземпляр «библии» по этому языку: Бьерн Страуструп (Bjame Stroustrup), «The C+ + Programming Language» (Язык программирования C++). Я не предполагаю, что вы прочитали книгу Страуструпа от корки до корки - я сам это (до сих пор) не сделал - но вам следует использовать этот источник как основной справочник языка, т. к. на каждой второй его странице вы найдете драгоценные суждения. Книга «C++: практический подход к решению проблем программирования» содержит достаточно большой объем шаблонного программного кода - а какая совре-
Пролог • менная книга по C++ его не содержит? - но я не предполагаю, что вы гуру1 или имеете глубокие знания по метапрограммированию. Тем не менее, вероятно, было бы очень хорошо, если бы вы, по крайней мере, были знакомы с применением наиболее попу- лярных шаблонов стандартной библиотеки C++. Я старался свести применение шаб- лонов до разумного уровня, но необходимо понимать, что поддержка шаблонов явля- ется именно тем, что позволяет C++ «самовосстанавливаться», то есть благодаря чему в значительной степени и появилась данная книга. Т. к. гибкость и практичность для меня много значат, она не относится к тем книгам, чей программный код может использоваться только на некоторых «сверхсовремен- ных» компиляторах; едва ли не все в книге будет работать почти на любом достаточно современном компиляторе (см. приложение А). Конечно, существуют хорошие свобод- но доступные компиляторы, и вы можете быть уверены, что ваш компилятор нормаль- но обработает этот программный код. По возможности я избегал ссылок на конкретные операционные среды, библиотеки и технологии. Однако в некоторых случаях я это делал, поэтому было бы полезно, но ни в коем случае не обязательно, иметь некоторую подготовку в следующих областях: СОМ и/или CORBA, динамические библиотеки (UNIX и/или Win32), STL, поточная организа- ция вычислений (POSIX и/или Win32), UNIX и Win32. Библиография содержит мно- гочисленные ссылки на хорошие книги и другой материал. Также было бы полезно иметь знакомство с несколькими машинными архитектурами, хотя опять же это не столь существенно. Поскольку С остается средством обеспечения связи между языками и средством разработки операционных систем, он по-прежнему является очень важным языком. Несмотря на то, что эта книга посвящена C++, во многих местах на первый план выхо- дят общие для С и C++ особенности, и я не извиняюсь за смешивание этих двух языков в таких случаях. И действительно, как мы увидим в части 2, нам придется возвращать- ся к С для поддержки некоторых продвинутых применений C++. Мною сделано одно важное предположение на ваш счет. Я полагаю, что вы верите в возможность выполнения качественной работы и стремитесь найти новые способы, с помощью которых вы могли бы делать такую работу. Вовсе не утверждается, что эта книга - единственный источник таких новых подходов применения C++. Скорее она представляет собой практический, и в некоторых случаях еретический, взгляд на проблемы, с которыми нам приходится сталкиваться при использовании этого языка, и, в лучшем случае, она сможет пополнить ту часть вашей библиотеки, где находятся самые важные тексты. В конечном итоге все зависит от вас самих. И вам останется просто выбрать средство, которое лучше всего подойдет. В библиографии приведен список некоторых книг, которые могут помочь стать в перспективе таким гуру, если вы не пожалеете усилий.
8 Пролог Структура книги Основной материал книги разбит на шесть частей. Каждая часть имеет введение, за которым идет от пяти до семи глав, каждая из которых поделена на соответст- вующие разделы. Вдохновляемый названием книги, я старался обращать особое внимание на реаль- ные дефекты, поэтому вы будете их встречать повсюду в тексте. В первых частях книги эти дефекты встречаются достаточно часто, что является отражением относи- тельно простой природы как самих дефектов, так и методов их устранения. Каждый раздел относится к конкретному аспекту языка и дает общее описание дефекта. По возможности рассматривается специальный метод или технология программного обеспечения, позволяющие либо решить проблему, либо дать разработчику средство, обеспечивающее управление ситуацией. С каждой новой главой дефектов становится меньше и они оказываются более существенными, а обсуждение становится, соответ- ственно, более длинным и детальным. Книга не придерживается современного формата «шведского стола»; она также не требует от читателя строго последовательного чтения от начала до конца. Это означа- ет, что большинство последующих глав написаны с учетом (а иногда и строятся на ос- нове) материала предыдущих глав, поэтому лучше всего читать их по порядку, если только у вас нет особых причин поступать по-другому. Однако после однократного прочтения главы вы должны уметь вернуться в любое нужное место для уточнения соответствующего вопроса без необходимости повторного чтения всей главы. Внутри глав разделы обычно логически следуют один за другим, поэтому я бы рекомендовал вам читать материал каждой главы последовательно. Что касается уровня сложности, он, несомненно, возрастает от части 1 до части 4 от достаточно низкого до очень высокого1. Хотя части 5 и 6 учитывают некоторый материал из частей 3 и 4, они значительно проще, и вы можете совершенно свободно «курсировать» по приложениям. После основного материала книги приводятся четыре приложения. В приложении А подробно описываются компиляторы и библиотеки, используемые в исследованиях для книги «C++: практический подход к решению проблем программирования». При- ложение Б порадует вас описанием грубых промахов неопытного молодого инженера- программиста C++, делающего первые шаги по тернистому пути. В приложении В описывается проект Arturius - свободно распространяемый и имеющий открытый исходный код мультиплексор компиляторов, который также входит в состав компакт- диска. В приложении Г описывается содержимое компакт-диска. Я пользуюсь очень последовательным, возможно, строгим стилем кодирования; вы вполне можете назвать его педантичным. Прежние мои коллеги и пользователи моих библиотек, конечно, именно таким его и считали. Но я продолжаю им пользоваться, потому что у меня не возникает вопросов без ответа, типа «куда все делось?», то есть 1 Все-таки в некоторых местах 3 и 4 частей мне пришлось серьезно потрудиться
9 Пролог я могу вернуться к исходному тексту спустя годы и без задержки окунуться в него. Недостаток его в том, что мне приходится иметь 21-дюймовый монитор и высококаче- ственный лазерный принтер. Чтобы свести к минимуму влияние моего стиля кодирования на читаемость книги «C++- практический подход к решению проблем программирования», я позволил себе некоторые вольности в программном коде примеров, которые приводятся повсюду в книге. В этих примерах вы увидите многоточия (...), и это в целом означает что-нибудь такое, что либо было рассмотрено в предыдущем примере, связанным с данным примером, либо являлось заготовкой программного кода, которую мы постоянно используем (напри- мер, запрещение доступа к методам из клиентского программного кода - см. раздел 2.2). В книге рассматриваются только те аспекты стиля, которые относятся к надежности, и это делается в гл. 171. Ссылки на источники То что раздражает меня при чтении книг по C++, - это когда автор ссылается на источник, но не указывает соответствующий раздел стандарта. Поэтому кроме ссылки на статьи или книги, когда это имеет отношение к характерным особенностям языка, я старался дополнительно указывать ссылки на стандарты С (С99) или C++ (С++98). Дополнительный материал Компакт-диск Прилагаемый компакт-диск содержит библиотеки, компиляторы (включая большое количество программного кода, иллюстрирующего описанные в книге методы), програм- мы тестирования, инструментальные средства и другое программное обеспечение, а также подборку статей, опубликованных в различных изданиях. См. приложение Г, где подробно описано содержание компакт-диска. Сетевые ресурсы Дополнительный материал можно также найти в сети Интернет по адресу http://imperfectcplusplus. сот5. Благодарности Если взять почти любую книгу, вы в ней найдете бурное выражение благодарностей семье и друзьям; можете не сомневаться - они идут от самого сердца. Нельзя написать книгу без серьезной поддержки многих людей. Спасибо маме и Сюзанне (Suzanne) за терпение, финансирование и воспитание располневшей «кукушки», которая, наконец, перенесла свое гнездо на другую сторо- Еслн вам совершенно необходимо увидеть во всем великолепии образцы остальных элементов моего стиля кодирования, много примеров программного кода содержится в библиотеках, входящих в состав компакт-диска.
10 Пролог ну Земли в достаточно зрелом возрасте двадцати семи лет. Спасибо Роберту (Robert) за моральную поддержку «кукушки» и ее семьи в течение сложных, но в итоге плодо- творных двух лет. Особая благодарность Роберту за то, что он помог мне сохранить душевное спокойствие в очень важные моменты этого периода. Спасибо Пайкеру (Piker), заполнявшего вакантное место в увеличившейся семье, и не только за его внимание к детям, за поддержку и бесплатные завтраки. Хочу выра- зить аналогичную благодарность Дазлу (Dazzle) за его постоянную веру в меня и за то, что он отрывал себя от увлекательной деятельности гуру администратора баз данных, тратя много сил на скучное рецензирование; теперь он будет смотреть на сценарии, написанные на Perl или Python, совсем по-другому! Выражаю признательность Бессо (Besso) за постоянный интерес, непомерную гордость и одобрение моих планов. И спасибо Элу (А1) и Синту (Cynth) (родственники со стороны жены!) за многочислен- ные бесплатные обеды и бесконечные угощения вкусным шоколадом. (Интересно, где сейчас тот велосипед?) Большое спасибо «808 State», «Aim», Барри Уайту (Barry White), Билли Брэггу (Billy Bragg)» «De La Soul», «Fatboy Slim», Джорджу Майклу (George Michael), «Level 42», «Rush», «Seal», Стиви Уандеру (Stevie Wonder) и «The Brand New Heavies», без которых я бы, наверное, не выжил, находясь как бы в «невесомости» в течение полутора лет. И самое важное - спасибо моей прекрасной жене Cape (Sarah), которой удавалось скрывать свои справедливые сомнения и беспокойство, поддерживая меня с почти безукоризненным внешним спокойствием и уверенностью. Настоящая звезда! Мне бы хотелось выразить признательность замечательным людям, которые повлия- ли на мое образование и карьеру. Спасибо профессору Бобу Крайану (Bob Cryan), который с понимаем относился к природным способностям своего хорошо успевающего аспиранта и позволял ему отлынивать от работы (и кататься на велосипеде) в течение трех лет учебы в аспирантуре. Мне бы хотелось поблагодарить Ричарда Маккормака (Richard McCormack), заста- вившего меня понимать красоту эффективного программного кода, а не только элегантность идеи. В эти дни меня иногда обвиняли в том, что я слишком большое вни- мание уделял эффективности; в этом «вина» Ричарда! Кроме того, спасибо Грэму Джонсу (Graham Jones, «Dukey») за set -о vi и за шесть безумных месяцев дружбы и веселого времяпрепровождения. Это неповторимо! Спасибо Ли (Leigh) и Скотту Перри (Scott Репу) за то, что они познакомили меня с их концепцией «прикрепляемых классов» (bolt-in) и другими отличными техническими приемами. Возможно, они будут возражать и признаются в выполнении не смутившей их автоматической генерации библиотек DLL размером более 16 Мб и в прискорбном увлечении языками, которые используются виртуальными машинами; вероятно, мои комментарии излишни. Особая благодарность Энди Терлингу (Andy Thurling), который проявил великоду- шие и поверил в мои потенциальные возможности, когда я поступал на работу с док- торской диссертацией в руках и навыками разработки программного обеспечения,
Пролог 11 уровень которых был обратно пропорционален моей собственной его оценке1. Энди преподал мне, возможно, единственный величайший урок, необходимый любому, кто пытается овладеть этой удивительной, но и пугающей профессией: все мы всего лишь «skegging it out».2 Чак Эллисон (Chuck Allison) сказал это более понятными словами древней мудрости американских индейцев: «Он тот, кто учится пить из непрерывного потока». Решающее значение для успеха любой книги имеют издатели, рецензенты и кон- сультанты. Спасибо моему редактору, Питеру Гордону (Peter Gordon), который ободрял, успокаивал и сдерживал горячего и импульсивного автора на эмоционально очень медленных русских горках, чем является первая книга. Спасибо также Бернару Гаффни (Bernard Gaffney), умелому помощнику Питера, который управлял процессом и спокойно, не теряя самообладания, справлялся с ежедневной огромной корреспон- денцией, приходящей по электронной почте, а также другим сотрудникам отдела про- изводства и маркетинга издательства «Addison-Wesley»: Эми Флайшер (Amy Fleis- cher), Чанду Лири-Куту (Chanda Leary-Coutu), Хизер Малейн (Heather Mullane), Жаклин Дусетт (Jacquelyn Doucette), Дженнифер Эндрюс (Jennifer Andrews), Ким Боди- гаймер (Kim Boedigheimer) и Кристи Харт (Kristy Hart). Сердечное спасибо (и извине- ния) менеджеру моего проекта Джессике Болч (Jessica Balch) из компании «Pine Tree Composition», которая имела сомнительное удовольствие просеивать текст и исправлять мою скверную грамматику, неудачные шутки и британское произношение (бесчислен- ные «ise» вместо «ize»). Кроме того, следует особо отметить Дебби Лафферти (Debbie Lafferty) за поддержку, когда «C++: практический подход к решению проблем программирования» состоял всего лишь из броской фразы, придуманной мною одним вечером в 2002 году. Спасибо снисходительной команде рецензентов - Чаку Эллисону (Chuck Allison), Дейву Бруксу (Dave Brooks), Даррену Линчу (Darren Lynch), Дуэйну Йейтсу (Duane Yates), Юджину Гершнику (Eugene Gershnik), Гари Пеннигтону (Gary Pennington), Джорджу Фрейзеру (George Frasier), Грегу Питу (Greg Peet), Джону Торьо (John Torjo), Скотту Паттерсону (Scott Patterson) и Уолтеру Брайту (Walter Bright), без которых мне слишком часто пришлось бы попадать впросак. В некоторых случаях они заставляли меня смеяться, в других заставляли задуматься по существу, но общение с ними в конце концов всегда помогало улучшить окончательный вариант. В каком же удиви- тельном мире мы живем, когда знакомство с людьми, проживающими в самых различных странах (с большинством из которых у меня никогда не было физических контактов), может сопровождаться такой взаимопомощью. Браво! Спасибо также рецензентам издательства «Addison-Wesley» - Дану Саксу (Dan Saks), Дж. С. ван Винкелю (JC van Winkel), Джею Рою (Jay Roy), Рону Маккарти (Ron McCarty), Джастин Шоу (Justin Shaw), Невин Либер (Nevin Liber) и Стиву Кламаджу Я даже не знал, в чем разница между реальным режимом и защищенным режимом! Этот термин используют в Северном Йоркшире, и он означает «использование с максимальной выгодой информации, которую ты имеешь в данный момент».
12 Пролог (Steve Clamage), общение с которыми сыграло решающую роль в том, что размер книги не превысил 1000 страниц, и она не усеяна скучными описаниями и небрежны- ми ошибками. Имея опыт участия с другой стороны процесса написания книг, то есть в качестве рецензента, я понимаю, что значит основательно подойти к рецензирова- нию, и очень высоко ценю их усилия. Спасибо Питеру Димову (Peter Dimov) за то, что он великодушно позволил мне воспользоваться его великолепной цитатой в гл. 26, и за некоторые превосходные сужде- ния по поводу содержания глав части 5. Спасибо Кевлину Хенни (Kevlin Henney), за то, что он бросил взгляд на гл. 19 и сделал некоторые интересные замечания относительно «умных» приведений типов, Джо Гудману (Joe Goodman) за помощь в отсеивании лиш- него «шлака» из первоначальной версии обсуждения двоичных интерфейсов приложе- ний C++ и придание ему благопристойного вида (гп. 7 и 8) и Торстену Оттосену (Thorsten Ottosen) за выполнение аналогичной «очистительной»1 работы при обсужде- нии контрактного проектирования в гл. 1. Хочу выразить особую благодарность Чаку Эллисону (Chuck Allison), Гербу Саттеру (Herb Sutter), Джо Касаду (Joe Casad), Джону Дорси (John Dorsey) и Джону Эриксону (Jon Erickson) за большую поддержку, оказанную мне по различным вопро- сам в течение последних нескольких лет. Спасибо Бьерну Страуструпу (Bjame Stroustrup) за тактичную поддержку и за не- большие исторические уроки то здесь то там. И, прежде всего, конечно, за изобретение этого удивительного языка! Спасибо Уолтеру Брайту (Walter Bright) за постоянное совершенствование его вели- колепного компилятора Digital Mars C/C++ в ходе написания этой книги, за его податли- вость постоянному напору самого беспокойного в мире человека и за то, что он любезно согласился на мое участие в разработке языка D, который был одним из источников, вдохновивших меня на написание этой книги. Хочу выразить аналогичную благо- дарность Грегу Комо (Greg Comeau), хотя ему и не так уж часто приходилось улучшать компилятор Comeau: этот компилятор лучше всех остальных соответствует стандарту! Отличные поставщики, чутко реагирующие на запросы пользователей, эти великолеп- ные парни постоянно ободряли меня, давали советы и необходимую информацию по целому ряду вопросов. Спасибо издательству «СМР» за разрешение использовать в книге материал неко- торых моих статей и включить несколько оригинальных статей в состав компакт-диска (см. приложение Г). Спасибо компаниям Borland, CodePlay, Digital Mars, Intel, Metrowerks и Microsoft за предоставленную возможность использовать их компиляторы в моей работе при выполнении исследований, написании книги и создании библиотек с открытым исход- ным кодом. Хочу выразить особую благодарность Digital Mars, Intel и Watcom за возможность включения их компиляторов в состав компакт-диска (см. приложение Г). И Грег Пит 1 В оригинале здесь стоит выделенное курсивом слово denonsensisination. которое можно буквально перевести как «дечепуханизация». - Примеч. пер.
13 Пролог (Greg Peet) заслужил нескольких мужских похлопываний по спине в знак благодарно- сти за его бесценную помощь в проектировании компакт-диска и его содержания. Спасибо читателям журналов «C/C++ User’s Journal», «Dr. Dobbs Journal», «BYTE» и «Windows Developer Network» за их готовность общаться со мной и за под- держку моих статей и обзоров. Хочу выразить аналогичную благодарность всему сообществу C++ и всем добрым людям, которые участвуют в различных сетевых конференциях, имеющих отношение к C++ (см. приложение А). К ним относятся Аттила Фихер (Attila Feher), Карл Янг (Carl Young), Дэниел Спангенберг (Daniel Spangenberg), Илис ван дер Виген (Eelis van der Weegen), Габриел Дос Рейс (Gabriel Dos Reis), Игорь Тандетник (Igor Tandetnik), Джон Поттер (John Potter), Массимилиано Алберти (Massimiliano Alberti), Майкл Низасек (Michal Necasek), Ричард Смит (Richard Smith), Рон Крейн (Ron Crane), Стивен Кечел (Steven Keuchel), Томас Рихтер (Thomas Richter), «tom usenet» и еще очень много других, кто здесь не указан. Особая благодарность Илье Минкову (Ilya Minkov) за то, что он попросил меня реализовать свойства для C++ в библиотеках STLSoft, что первоначально я не собирал- ся делать. Без этой просьбы один из моих любимых методов (см. гл. 35) никогда бы не появился. И, наконец, спасибо всем пользователям библиотек STLSoft, без помощи которых эти библиотеки не обладали бы многими возможностями и было бы сложнее выпол- нить исследования и писать некоторые части данной книги.
Введение: философия неидеального практика Эта книга посвящена практическим методам программирования на языке C++. В ней говорится не только о том, как программировать эффективно и технически корректно, но как это делать более надежно и более практично. Мне бы хотелось доне- сти до читателя следующие четыре основные идеи: Принцип #1 - C++ великолепный язык, но не идеальный. Принцип #2 - будьте смиренны. Принцип #3 - сделайте компилятор вашим ординарцем. Принцип #4 - никогда не сдавайтесь: решение всегда можно найти. Все вместе они составляют то, что я называю философией неидеального практика. C++ не идеален Я очень рано понял (с помощью мамы, смущенной чрезмерной самоуверенностью ее младшего отпрыска), что если вы хотите возвестить людям о чем-то хорошем, вам не мешало бы приготовиться к тому, что вас не правильно поймут. Спасибо, мама! C++ великолепен. Он поддерживает концепции высокого уровня, включая проек- тирование на основе интерфейсов, обобщения, полиморфизм, самоописание элемен- тов программного обеспечения и метапрограммирование. Он также превосходит боль- шинство других языков в том, что касается тонкого управления компьютерами, обес- печивая низкоуровневые средства программирования, включая побитовые операции, указатели и объединения. Благодаря этим разнообразным возможностям при сохра- нившейся принципиальной поддержке высокой эффективности, его справедливо можно назвать языком программирования общего назначения, который на голову пре- восходит все остальные языки нашего времени1. Тем не менее, он не идеален - далеко не идеален - поэтому данная книга имеет такое название. По очень веским причинам - некоторые из них имеют исторические корни, другие возникли в наши дни - C++ представляет собой компромиссный [Stro 1994] и неод- нородный набор несвязанных, а иногда и несовместимых, концепций. Поэтому он имеет ряд изъянов. Некоторые из них несущественны; другие более серьезны. Многие 1 Следует отметить, что я не утверждаю, что C++ самый лучший язык во всех предметных областях. Я бы не советовал его использовать вместо Пролога для создания экспертных систем, либо вместо Python или Ruby для создания системных сценариев, либо вместо Java для разработки работающих в сети Интернет электронных коммерческих систем уровня предприятия.
Введение: философия неидеального практика 15 из них унаследованы. Другие происходят от того, что этот язык главное внимание, слава богу, уделяет эффективности. Некоторые изъяны, вероятно, являются принципи- альными ограничениями, которые характерны для любого языка. Наиболее интерес- ный набор проблем возникает из-за того, что никто и не мог предположить, насколько сложным стал язык и насколько разнообразными стали его возможности. Данная книга в полной мере отражает эту картину и показывает, что ее сложность можно «приручить» и заставить занять подобающее ей место, предоставив соответст- вующие средства в руки знающих и опытных профессиональных разработчиков. Цель - снизить вероятность возникновения достаточно неприятных ситуаций, с которыми ежедневно сталкиваются опытные разработчики программного обеспечения при использовании C++, и придать программистам большую уверенность. В книге «C++: практический подход к решению проблем программирования» рас- сматриваются проблемы, с которыми разработчики программного обеспечения стал- киваются не в результате недостаточного опыта или незнания; эти проблемы прихо- дится решать всем, кто занимается этой профес-сией, от начинающих до самых та- лантливых и самых опытных. Часть этих проблем возникает из-за дефектов самого языка, а другая часть - из-за распространенного неправильного использования концеп- ций, поддерживаемых языком. Они вызывают трудности у всех нас. «C++; практический подход к решению проблем программирования» не является всего лишь трактатом о недостатках языка со списком того, что не следует делать; суще- ствует много великолепных книг, в которых используется такой подход. В этой книге предлагаются методы решения (большинства) обнаруженных изъянов, что делает язык менее «дефективным». Основное внимание в ней уделяется оказанию помощи разра- ботчикам: предоставление им важной информации относительно потенциальных про- блем, которые могут возникать при использовании ими своего инструментария, и обес- печение их рекомендациями, подкрепленными практическими методами и технология- ми программного обеспечения, позволяющими устранять или обходить эти проблемы. Смиренное программирование Многие прочитанные нами руководства, даже очень хорошие, рассказывают о приемах, с помощью которых C++ может помочь решить ваши проблемы, если вы используете его средства в полной мере, но очень часто можно встретить такие фразы, как «на самом деле, это не существенно» или «это будет рассмотрено тщательно немного позднее». Некоторые бывшие мои коллеги вовлекали меня в энергичные дис- куссии, где они действовали аналогично; обычным был аргумент: «Я - опытный про- граммист и не сделал бы ошибок, от которых защищает XYZ, так зачем беспокоиться?» Какая ерунда! Такая позиция порочна по многим причинам. Я опытный программист и делаю, по крайней мере, по одной глупости каждый день; если бы я не привык к дисциплиниро- ванности, их было бы десять. Такая позиция подразумевает, что программный код нико-
16 Введение: философия неидеального практика гда не попадет в руки неопытному программисту. Более того, это подразумевает, что авторы программного кода не будут обучаться ничему новому и не будут менять свою точку зрения, используемые ими идиомы и методологии. Наконец, кто это все-таки такой, «опытный программист»1? Этим людям не нравятся ссылки, константные члены, управление доступом, explicit, конкретные классы, инкапсуляция, инварианты, и при программировании они не думают о переносимости и сопровождении. Однако они любят перегрузки, переопределения, неяв- ные преобразования, приведения в стиле С, применение во всех случаях типа int, глобаль- ные объекты, связанные друг с другом объявления typedef, dynamic_cast, получение информации о типах во время выполнения программы (RTTI), специфические для конкрет- ного компилятора расширения, объявления friend, непоследовательный стиль кодирова- ния, из-за которого программный код сложнее понять. Терпеливо выслушайте меня, пока я буду совершать исторический экскурс. Став с помощью Генриха II в 1162 году архиепископом Кентерберийским, Томас А. Бекетт (Thomas A. Beckett) сильно изменил свое мировоззрение, отойдя от материалистиче- ского, начал проявлять искреннюю заботу о бедных и испытывал сильные угрызения совести из-за своей прежней невоздержанности. Когда его тело готовили к похоронам, было обнаружено, что Бекетт носил грубую власяницу, полную блох. Впоследствии стало известно, что монахи ежедневно били его плетью. Ой! Лично я думаю, что теперь его покаяние принято, и душа, очистившись, стала как душа ребенка. Тем не менее, раньше, когда я только начинал, я старался на полную мощь использовать возможности C++, что приводило к самым разнообразным ужас- ным извращениям (см. приложение Б); в настоящее время я стараюсь быть более сдержанным в выборе средств и тем самым смиренно «надеваю на себя власяницу» при программировании2. Конечно, это не значит, что я склоняюсь на колени на усыпанный гравием пол или что я обрываю ногти о спинку моего «Херман-Миллера», или прекратил слушать громкую танцевальную музыку при программировании. Нет, это означает, что я заставляю мое программное обеспечение реагировать максимально резко (насколько хватает моих способностей) на попытки неправильного его использования мною. Мне нравится ключе- вое слово const - причем сильно - и я использую его при всяком удобном случае. Я закрываю доступ при помощи ключевого слова private. Я предпочитаю применять ссылки. Я навязываю инварианты. Я возвращаю ресурсы туда, откуда их брал, даже если знаю, что это делать необязательно и эту работу сделает за меня компилятор; «Ну, это прекрасно работало в предыдущей версии операционной системы. Не моя вина, что вы ее обновили!» Я усовершенствовал контроль типов C++ для проверки концептуальных В наши дни трудно дать такую оценку, когда в каждой виденной вами автобиографии содержатся таблицы самооценки с отметками 10/10 для каждого показателя. 2 Если аналогия с власяницей вам кажется слишком грубой, возможно, вам понравится сравнение с йогой: трудно, но усилия будут потрачены не зря.
Введение: философия неидеального практика 17 typedef, Я применяю девять компиляторов с помощью инструментального средства (см. приложение В), которое позволяет это делать очень просто. Я использую более мошный NULL. Это делается не для того, чтобы можно было номинироваться на премию «програм- мист года». Это просто следствие моей лености, чем обычно страдают все хорошие ин- женеры. Быть ленивым - значит не стремиться обнаруживать ошибки на этапе выпол- нения программы. Быть ленивым - значит не желать повторения ошибки. Быть лени- вым - значит заставить ваш компилятор (один или несколько) работать максимально плодотворно, чтобы вам оставалось меньше работы. Заставьте компилятор стать вашим ординарцем Термин «ординарец» (batman) - в противоположность супермену (Batman) - возник в то время, когда существовала Британская Империя; так называют помощника воен- ного или личного слугу. При правильном отношении вы можете сделать компилятор вашей правой рукой, помощником, вашей совестью, вашим ординарцем. (Или будьте суперменом, если вам это больше нравится.) Чем грубее «власяница», в которую вы облачаетесь при программировании, тем лучше компилятору придется служить вам. Однако временами, когда вы не оказываете должное уважение языку, компилятор будет препятствовать осуществлению ваших намерений, упрямо отказываясь делать то, что вы считаете разумным (или, по крайней мере, желательным). Книга «C++: практический подход к решению проблем программирования», кроме того, помогает делать окончательный выбор, давая вам методы и технологии, лишающие компилятор инициативы и возвращающие управление в ваши руки, чтобы получить то, что вам нужно, а не то, что вам преподносят. Нельзя думать, что все про- изойдет само собою; к этому необходимо подойти серьезно, с пониманием того факта, что именно разработчики программного обеспечения играют главную роль в процессе разработки; языки, компиляторы и библиотеки просто инструменты, позволяющие им делать свою работу. Никогда не сдаваться Несмотря на мое по большей части теоретическое образование, в действительно- сти, я считаю себя больше инженером, чем теоретиком. Раньше я любил научно-фан- тастические книги, в которых обычно героические инженеры «разруливают» трудные ситуации. Именно этому и посвящена данная книга. Но существует теория, и мы снача- ла займемся ею. Однако слишком часто, когда работаешь на границах возможностей языка, у большинства современных компиляторов возникают проблемы с теорией, поэтому нам приходится программировать в этой реальности. Как сказал йог Берра
18 Введение: философия неидеального практика (Berra), «теоретически нет разницы между теорией и практикой. На практике она существует». Такой подход может давать убедительные результаты. Усилия, связанными с прак- тическими разработками, а не академические знания, сочетаемые с упрямым желани- ем не видеть дефекты C++, привели меня, в конце концов, к некоторым открытиям: • к принципам явного обобщения через прокладки (Shims, гл. 20) и к феномену, по- лучаемому в результате применения тоннелирования типов (Type Tunneling, гл. 34); • к расширению системы типов C++ (гл. 18), позволяющему различать концептуаль- но независимые типы, которые совместно используют общие базовые типы, и пере- гружать их; • к независимому от компилятора механизму, обеспечивающему совместимость дво- ичных модулей динамически загружаемых объектов C++ (гл. 8); • к использованию концепции мощного NULL (из языка С) в рамках правил C++ (гл. 15); • к максимально «безопасному» и переносимому operator bool () (гл. 24); • к уточнению концепции инкапсуляции, ведущему к получению расширенного набора инструментальных средств, предназначенных для эффективного представ- ления и манипулирования базовыми структурами данных (гл. 2 и 3); • к гибкому инструменту эффективного распределения динамических блоков памяти (гл. 32); • к механизму быстрой, неагрессивной конкатенации строк (гл. 25); • к оценке способов программирования при различных моделях обработки ошибок (гл. 19); • к простому механизму управления упорядоченностью объектов-синглетонов (гл. 11); * к эффективной с точки зрения быстродействия и памяти реализации свойств для C++ (гл. 35). Книга «C++; практический подход к решению проблем программирования» не пре- тендует на то, чтобы ответить на все вопросы применения языка C++. Вместо этого она как бы проталкивает программиста сквозь возникающие в процессе разработки препятствия, которые встречаются на пути поиска решений, позволяющих справлять- ся с дефектами языка, стимулируя новый, непривычный образ мышления. Все мы несовершенны. И я делаю плохие вещи, и у меня бывают «еретические» наклон- ности. У меня имеется плохая привычка использовать protected там, где следовало бы писать private. Я предпочитаю использовать printf (), когда, возможно, мне следовало бы отдавать предпочтение IOStreams. Мне нравятся массивы и указатели, и я большой поклонник программных интерфейсов, совместимых с С. Я также не придерживаюсь с ре- лигиозным фанатизмом философии смиренного программирования. Но я верю, что иметь эту философию и следовать ей там, где возможно - самый надежный и самый быстрый способ достичь своих целей.
Введение: философия неидеального практика 19 Дух неидеального С++ Также как и принципы философии неидеального практика, данная книга отражает в целом и мои собственные принципы, которыми я руководствуюсь при написании про- грамм на C++. В их набор, хотя и не полностью, входят двойники принципов «духа С» [Como-SOC]: • доверяйте программисту'. «C++: практический подход к решению проблем программирования» не избегает неуклюжих решений; • не мешайте программисту делать то, что необходимо: «C++, практический подход к решению проблем программирования» фактически поможет вам достиг- нуть того, что вам нужно; • решения должны быть небольшими и простыми: большинство представленного программного кода именно таково, и к тому же этот код обладает высокой степенью независимости и переносимости; • программный код должен быть быстрым, даже если не гарантируется переноси- мость: эффективности придается высокое значение, хотя иногда ради этого нам приходится жертвовать переносимостью. В набор также входят двойники принципов «духа C++» [Como-SOP]: • C++является диалектом С с современными усовершенствованными возможно- стями: в некоторых важных случаях мы полагаемся на взаимодействие с С; • хотя по размеру он больше, чем С, вы не платите за то, что не используете (поэтому дополнительные затраты пространства и времени сведены к минимуму, а те. которые существуют, работают на перспективу, и поэтому надо сравни- вать эквивалентные программы, а не свойство X и свойство Y); • необходимо обнаруживать максимально возможное количество ошибок на этапе компиляции: «C++: практический подход к решению проблем программирования» использует в подходящих местах статические утверждения и ограничения; по мере возможности следует избегать применения препроцессора (в большинст- ве случаев его можно заменить встраиваемым кодом, константными объектами, шаблонами и т. д.): для достижения наших целей мы будем рассматривать разно- образные методы применения языка, заменяющие препроцессор. Кроме этих принципов в книге «C++: практический подход к решению проблем программирования» будет продемонстрирован подход, который по мере возможности: обеспечивает написание программного кода, который не будет зависеть от компи- ляторов (их расширений и специфических особенностей), операционных систем, моделей обработки ошибок, моделей поточной организации вычислений и кодиро- вок символов;
20 Введение: философия неидеального практика • обеспечивает применение проектирования по соглашению (см. раздел 1.3) в тех случаях, когда на этапе компиляции невозможно обнаруживать ошибки. Стиль кодирования Для того чтобы книга имела не слишком большой объем, мне пришлось во многом отказаться от моего обычного строгого - некоторые могут сказать педантичного - стиля кодирования в приводимых примерах. В гл. 17 описываются общие принципы, которых я стараюсь придерживаться при оформлении определений классов. Другие элементы кодирования, такие как стиль использования скобок и промежутков, менее существенны; при желании вы легко можете с ними познакомиться из многочисленно- го материала, включенного в состав компакт-диска. Терминология Поскольку компьютеры применяют точный язык машинного кода, а люди говорят на разнообразных неточных языках, мне бы хотелось определить несколько терминов, которые будут использоваться в остальной части книги: Клиентский программный код (client code). Это такой код, который использует другой программный код и обычно, но не всегда, представляет собой программный код приложения, использующий библиотечные функции. Единица компиляции (compilation unit). Комбинация всех исходных текстов от ис- ходного файла до всех включаемых в него директивой include файлов. Среда компиляции (compile environment). Комбинация компилятора, библиотек и операционной системы, составляющих среду, в которой компилируется данный про- граммный код. Спасибо за этот термин Кернигану (Kemighan) и Пайку (Pike) [Кет 1999]. Функтор (functor). Этот термин широко используется вместо «объекта функции» или «функционального объекта», но он отсутствует в стандарте. В действительности я предпочитаю пользоваться термином «объект функции», но меня убедили1, что функтор лучше, поскольку это всего лишь одно короткое слово, хорошо узнаваемое и, самое главное, по нему удобно осуществлять поиск, особенно в режиме онлайн. Универсальность (generality). Я никогда до конца не понимал термин обобщенность (genericity), по крайней мере, в контексте программирования, хотя я иногда с готовностью обсуждаю этот термин. Я полагаю, он означает способность создавать использующий шаблоны программный код, который применим для различных типов объектов, причем акцент делается на способах использования этих типов, а не на их определении, и я огра- ничиваю его применение этим контекстом. Универсальность [Кет 1999], по-видимому, с одной стороны, лучше отражает то же самое и, с другой стороны, имеет значительно более широкий смысл: я в одинаковой мере заинтересован, чтобы мой программный код Виновны в этом некоторые мои рецензенты.
Введение: философия неидеального практика 21 использовал как заголовочные файлы и библиотеки других разработчиков, так и другие типы (с шаблонной параметризацией). Кроме этих концептуальных терминов я также включил несколько терминов, специ- фичных для языка программирования. Не знаю, как вы, но я считаю, что терминологиче- ская неразбериха языка C++ сильно сбивает с толку, и поэтому я собираюсь потратить немного времени на несколько определений. То что следует ниже, основано на материа- ле стандарта, но представлено в более простой форме, более понятной не только для меня, но и для других. Некоторые из этих определений частично перекрывают друг друга, поскольку они отражают разные концепции, но все они составляют часть терми- нологии практикующего квалифицированного программиста C++, создающего неиде- альные (или идеальные) программы. фундаментальные типы и составные типы Фундаментальными типами (fundamental types) (стандарт С++-98: 3.9.1) являются интегральные типы (char, short, int, long (long long / __int64), их версии (signed и unsigned5 и тип bool), типы чисел с плавающей точкой (float, double и long double) и тип void. К составным типам (compound types, стандарт С++-98: 3.9.2) относится почти все остальное: массивы, функции, указатели (любого вида, в том числе указатели на неста- тические члены), ссылки, классы, объединения и перечисления. Я стараюсь не использовать термин составные типы, т. к., на мой взгляд, этот термин подразумевает, что данный тип состоит из каких-то других элементов - это, в действительности, нельзя сказать об указателях и ссылках. Типы объектов Под типами объектов (object types, стандарт С++-98: 3.9; 9) подразумевается любой тип, который «не является типом функции, типом ссылки или типом void». Это еще один термин, которого я избегаю, поскольку он не означает «экземпляры типов классов», что можно было бы ожидать. В последнем случае везде в книге я при- меняю термин «экземпляры». Скалярные типы и типы классов Скалярные типы включают (стандарт С++-98: 3.9; 10) «арифметические типы, типы перечислений и типы указателей». Типами классов (стандарт С++-98: 9) являют- ся любые объявления, выполненные с помощью трех ключевых слов классов: class, struct или union. Структурой является тип класса, определенный с помощью ключевого слова класса struct; ее члены и базовые классы являются по умолчанию открытыми (public). Объединением является тип класса, определенный при помощи ключевого слова класса union; его члены являются по умолчанию открытыми. Класс представляет
22 Введение: философия неидеального практика собой тип класса, определенный ключевым словом класса class; его члены и базовые классы являются по умолчанию закрытыми (private). Агрегаты Стандарт (С++-98: 8.5.1; 1) описывает агрегат (aggregate) как «массив или класс, который не имеет определенных пользователем конструкторов, закрытых или защи- щенных нестатических данных-членов, базовых классов и виртуальных функций». Поскольку это массив или класс, это означает, что в одном месте собраны несколько элементов - отсюда «агрегат». Агрегаты могут быть инициализированы с помощью инициализирующего выраже- ния в фигурных скобках, например: struct X { int i; short as[2]; } x = { 10, { 20, 30 )}; Хотя агрегаты обычно представляют собой тип POD (см. следующий раздел), это не является необходимым условием. Переменная-член i агрегата X i могла бы рас- сматриваться как тип класса, если бы имела неявный конструктор (см. раздел 2.2.7) с одним аргументом целого типа при наличии конструктора копирования. Типы POD Аббревиатура POD означает plain-old-data (обычный старый тип данных, стандарт С++-98: 1.8; 5) - этот тип данных играет очень важную роль в языке C++, которая часто оказывается недооцененной многими людьми, в том числе и мною самим. Кроме того, этот тип довольно плохо определен. Стандарт дает два ключа к разгадке. В документе (стандарт С++-98: 3.9; 2) нам говорят, что «для любого полного POD-типа Т... состав- ляющие объект байты могут быть скопированы в массив [байтов]. Если делается обрат- ная копия массива... объект будет иметь первоначальное значение». В другом месте (стандарт С++-98: 3.9: 3) нас дополнительно информируют о том, что «для любого типа POD, если два указателя типа Т ссылаются на два различных объекта типа Т, obj 1 и obj 2, и если значение obj 1 копируется в obj 2, используя функцию шетсру (), то значение obj 2 будет совпадать со значением obj 1». Уф! Довольно умело, не так ли? Слава богу, для разъяснения этого определения здесь имеется для нас несколько - пять- десят шесть, если быть точным - других обрывочный сведений, разбросанных по 776 страницам. В [Como-POD] Грег Комо (Greg Comeau) заметил, что большинство книг (по C++) совсем не упоминают о типах POD, и выразил свое отношение: «большинство книг не стоят того, чтобы их покупать». Я постараюсь здесь сделать все, что от меня зависит, чтобы «нахально» улучшить продажи. Это сделать не так уж трудно, поскольку в том же документе, который я щедро цитировал, Грег нашел все существенные атрибуты POD- структуры.
Введение: философия неидеального практика 23 Итак, примемся за работу. Стандарт (С++-98: 3.9; 10) в целом определяет типы POD как «скалярные типы, типы POD-struct, типы POD-union, массивы таких типов и версии этих типов с квали- фикаторами [const и/или volatile]». Достаточно ясно, если не брать в расчет POD-struct и POD-union. POD-struct представляет собой (стандарт С++-98: 9; 4) «агрегатный класс, который не имеет нестатических данных-членов типа указатель, ссылающийся на член, на не POD-struct, на не POD-union или на массив таких типов или на ссылку, и не имеет опре- деленный пользователем оператор копирующего присваивания и не имеет определен- ный пользователем деструктор». POD-union имеет такое же определение за исключе- нием того, что вместо struct используется union. Следует отметить, что агрегат- ный класс может использовать либо ключевое слово класса struct, либо ключевое слово класса class. Все пока хорошо, но все же в чем суть типа POD? Ну, типы POD позволяют нам ис- пользовать функции языка С: эти типы широко применяются для связи языков C++ и С и, следовательно, для связи C++ с внешним миром (см. гл. 7-9). Поэтому POD-struct или POD-union позволяют нам «получить нечто подобное обычной структуре (или объединению) языка С» [Como-POD]. Хотя вам несомненно необходимо раз- бираться в различных аспектах типа POD, но наилучшим кратким его определением будет тип, совместимый с языком С. Другие аспекты типа POD: • тип, для которого макрос of f setof () определен, может быть «структурой POD или объединением POD» (стандарт С++-98:18.1; 5). Использование любого другого типа (см. раздел 2.3.2 и гл. 35) даст неопределенный результат; • тип POD может входить в объединение. Это используется для определения огра- ничений для типов POD (см. раздел 1.2.4); • статический объект типа POD, инициализируемый константными выражениями, инициализируется до входа в его блок и до того, как потребуется динамическая инициализация для любого объекта (типа POD или другого, см. гл. 11); указатели на члены не являются типами POD в отличие от указателей других типов; типы POD-struct или POD-union могут иметь статические члены, члены-typedef, вложенные типы и методы1. Естественно, все эти аспекты отсутствуют в программном коде на С и должны быть исключены с помощью Директив условной компиляции из программного кода на C++, совместимого с С.
Дефекты, ограничения, определения и рекомендации Дефект: C++ не обеспечивает прямую поддержку ограничений, (с. 38) Дефект: C++ не обеспечивает удобную поддержку постусловий, (с. 51) Рекомендация: применяйте утверждения, чтобы убедиться в правильности структуры программного кода, а не в правильности рабочего режима, (с. 59) Определение: освобождение ресурса при уничтожении является механизмом, который использует преимущества поддержки языкам C++ возможности автома- тического уничтожения объектов, чтобы гарантировать детерминированное осво- бождение ресурсов, связанных с экземпляром инкапсулированного типа. (с. 86) Определение: захват ресурсов при инициализации является механизмам, который использует преимущества поддержки языком C++ создания и автоматического уничтожения объектов, чтобы гарантировать детерминированное освобождение ресурсов, связанных с экземпляром инкапсулированного типа. Его можно рассматри- вать как супермножество по отношению к механизму RRID. (с. 92) Определение: инкапсулированные типы обеспечивают доступ и манипулирование состоянием экземпляра через устойчивый открытый интерфейс, и клиентскому программному коду не следует -ив этом нет необходимости - осуществлять доступ к внутреннему состоянию членов экземпляров таких типов. Инкапсулированные типы обеспечивают полное умозрительное отделение логического состояния от физическо- го состояния, (с. 103)
дефекты, ограничения, определения и рекомендации 25 Определение: тип значения, (с. 106) Экземпляры не могут полиморфно заменять или заменяться экземплярами другого типа на этапе выполнения программы. Экземпляры могут создаваться как копии другого экземпляра или копироваться потом на другой экземпляр. Каждый экземпляр логически самодостаточен. Любое изменение логического состояния одного экземпляра не приведет к изменению логического состояния другого. (Физическое состояние может быть взаимозависимым в соответствии с выбранными решениями конкретной реализации, если только это не нарушает логическую независи- мость.) Экземпляры могут сравниваться на равенство или неравенство с любыми другими экземплярами и даже сами с собой. Равенство (и неравенство) является рефлексив- ным, симметричным и транзитивным. Дефект: стандарт языка C++ не определяет двоичный интерфейс приложения, что ведет к широко распространенной несовместимости многих компиляторов на многих платформах, (с. 139) Дефект: C++ не является подходящим языком для программирования интерфей- сов модулей. (с. 175) Дефект: в языках С и C++ ничего не говорится о поточной организации вычислений, (с. 189) Дефект: C++ не обеспечивает механизм управления упорядоченностью глобальных объектов, (с. 221) Рекомендация: не следует полагаться на упорядоченность инициализации глобальных объектов. Тем не менее, следует использовать механизмы трассировки ля определения последовательности инициализации глобальных объектов, (с. 226)
26 Дефекты, ограничения, опред еления и рекомендации Рекомендация: не создавайте потоки во время инициализация глобальных объектов, (с. 231) Дефект: функционально-локальные статические экземпляры классов с нетриви- альными конструкторами не являются потокозащищенными, (с. 237) Дефект: поддержка форм оптимизации ЕВО обеспечивается не всегда и сильно зависит от компилятора, (с. 251) Дефект: в Си C++ отсутствует тип byte. (с. 261) Дефект: в языках С и C++ необходимо предусмотреть целые типы фиксированного размера, (с. 265) Дефект (повторение): язык C++ нуждается в типах фиксированного размера, которые отличаются от встроенных интегральных типов и не могут неявно преобразовываться туда и обратно, (с. 267) Дефект: в языках С и C++ не предусмотрены большие целые типы фиксированного размера, (с. 272) Дефект: тип bool должен иметь такой же размер, как и int. (с. 277) Дефект: С и C++ не обеспечивает оператор dimensionof () (для типов массивов), (с. 279)
дефекты, ограничения, определения и рекомендации 27 Дефект: неспособность отличить массивы от указателей может привести к тому, что операция статического определения размера массива будет применена к указателям или типам классов, что даст неверный результат, (с. 284) Дефект: в языках С и C++ массивы вырождаются в указатели при их передаче функциям, (с. 287) Дефект: дуализм массивов и указателей в C++ в сочетании с поддержкой поли- морфной обработки унаследованных типов представляет собой опасность, и здесь компилятор нам ничем не может помочь, (с. 291) Дефект: C++ не поддерживает многомерные массивы, (с. 298) Дефект: в C++ необходимо добавить ключевое слово null, которое можно приравнивать и с которым можно сравнивать любой тип указателя, но нельзя это делать для любых других типов, (с. 304) Дефект: применение булевым типом более одного дискретного значения является опасным, (с. 313) Дефект: зависимость от реализации размера целых типов в C/C++ ухудшает переносимость целочисленных литералов, (с. 314) Дефект: компиляторы C++ используют неодинаковые суффиксы целочисленных литералов, размер которых не помещается в тип (unsigned) long. (с. 316) Ограничение: С и C++ не гарантируют, что идентичные строковые литералы Ут занимать одно и то же место в памяти в одной единице компоновки, и этого не У ет при их расположении в отдельных единицах компоновки, (с. 319)
28 Дефекты, ограничения, определения и рекомендации Ограничение: результат устранения «константности» константного объекта не определен, и такое действие, вероятно, приведет к непредвиденным последствиям, (с. 322) Ограничение: константы типа класса вычисляются как глобальные объекты, а не как константы, вычисляемые на этапе компиляции. Опасно забывать об этом отличии, (с. 323) Рекомендация: избегайте использования значений епит, размер которых не поме- щается в тип int. (с. 326) Дефект: C++ не поддерживает константы-члены для чисел с плавающей точкой, (с- 327) Ограничение: C++ не поддерживает константы-члены типа класса, (с. 328) Рекомендация: никогда не пытайтесь моделировать константы-члены типа класса с помощью функционально-локальных статических объектов, (с. 330) Дефект: C++ не обеспечивает управление доступом для типов, связанных струк- турными отношениями. (с. 337) Дефект: для введения новых ключевых слов в язык требуются механизмы поддержки переносимости на случай непредвиденных обстоятельств, (с. 349) Дефект: злоупотребление механизмом объединения членов класса C++ по специ- фикаторам доступа ведет к получению программного кода, который трудно исполь- зовать и трудно сопровождать, (с. 352)
дефекты, ограничения, определения и рекомендации 29 Дефект: неявная интерпретация не булевых (под)выражений дает неверный результат для значений определенных типов, что вызывает лишние переделки типов, определяемых пользователем, (с. 356) Дефект: неявная интерпретация скалярных типов как булевых в условных опера- торах способствует применению ошибочного присваивания из-за синтаксических ошибок, (с. 358) Дефект: противоречие старого и нового правил организации оператора for при- водит к созданию непереносимого программного кода. (с. 361) Дефект: операторы for с двумя или большим количеством операторов инициали- зации сводят на нет новое правило, ограничивающее область видимости переменных, объявленных в операторе for. (с. 362) Определение: в определениях концептуальных типов задаются логически различные типы. (с. 372) Определение: в определениях контекстуальных типов соответствующие широко известным концепциям типы определяются в конкретных контекстах. Такие типы выполняют роль описателей особенностей (типа и/или поведения) своих базовых кон- текстов. (с. 374) Дефект: C++ обеспечивает типобезопасное применение концептуальных типов только в там случае, когда их базовые типы несовместимы, (с. 3 78) Дефект: C++ не поддерживает перегрузку для концептуальных типов, исключением тех случаев, когда оказывается, что такие типы имеют разные базовые типы на конкретной платформе, (с. 379)
30 Дефекты, ограничения, определения и рекомендации Рекомендация: избегайте концептуальных typedef, которые включают в себя контекстуальные typedef. (с. 389) Дефект: логически связанные типы могут иметь в C++ и обычно действительно имеют несовместимые интерфейсы и операции, что иногда затрудняет, а часто делает невозможным применение обобщенного подхода при работе с типами, (с. 429) Определение: прокладки атрибутов (Attribute Shims) (с. 432) Прокладки атрибутов предназначены для получения атрибутов или состояний экземпляров типов, для которых они определены. Обозначаются прокладки атрибутов в форме get_xxx, где ххх представляет собой конкретный атрибут, к которому осуществляется доступ (кроме случаев, когда они являются частью концепции составной прокладки). Значения, возвращаемые прокладками атрибутов, всегда достоверны за предела- ми экземпляра прокладки в тех случаях, когда прокладка реализуется посредством создания временного объекта. Определение: логические прокладки (Logical Shims) (с. 434) Логические прокладки являются уточнениями прокладок атрибутов: они сооб- щают о состоянии экземпляра, к которому применяются. Обозначаются логические прокладки в виде вопроса, связанного с конкретным за- прашиваемым атрибутам или состоянием. Примерами могут быть is_open (явля- ется ли открытым), has_element (имеетли элемент), is_null (имеетли нулевое значение). Определение: управляющие прокладки (Control Shims) (с. 435-436) Управляющие прокладки определяют операции, применяемые к экземплярам типов, для которых они определяются. Обозначаются управляющие прокладки в виде императивного элемента, связанного с конкретным атрибутам или состоянием, которое будет модифицировано. Примера- ми могут быть make_empty (сделать пустым), dump__contents (выдать дамп содержимого).
дефекты, ограничения, определения и рекомендации * 31 Определение: прокладки преобразований (Conversion Shims) (с. 438) Прокладки преобразований выполняют преобразование экземпляров типов из совместимого ряда в один целевой тип. Обозначаются прокладки атрибутов в виде to_xxx, где ххх - имя или представ- ления целого типа, например, to_int. Значения, возвращаемые прокладками преобразований, могут обеспечиваться про- межуточными временными объектами и поэтому должны всегда использоваться только с помощью выражения, содержащего эту прокладку. Определение: составные прокладки (Composite Shims) (с. 440) Составные прокладки представляют собой комбинацию из двух или более концеп- ций базовых прокладок. Имена составных прокладок не имеют какой-то фиксированный формат, а просто отражают их назначение. Составные прокладки подчиняются наиболее строгому правилу или их комбинации из тех, которым подчиняются составляющие ее прокладки. Определение: прокладки доступа (Access Shims) (с. 440-441) Прокладкой доступа являются комбинация прокладки атрибутов и прокладки пре- образований, которые используются для обеспечения доступа к значениям экземп- ляров типов, для которых они определены. Значения могут формироваться с помощью прокладки преобразований. Значения, возвращаемые прокладками доступа, могут обеспечиваться промежу- точными временными объектами и поэтому должны всегда использоваться только в рамках выражения, содержащего эту прокладку. Определение: облицовочные классы (veneers) (с. 456) Облицовочный класс - это шаблонный класс со следующими свойствами: 1- Он является производным от своего основного типа параметризации (обычно С опгкРытым доступом к нему). Он приспосабливается к полиморфной природе своего основного типа пара- метризации и твердо ее придерживается. Это означает, что облицовочный класс не т определять никакие свои собственные виртуальные методы, хотя он может пРеделять методы своего основного типа параметризации. • Он не может определять никакие нестатические переменные-члены.
32 Дефекты, ограничения, определения и рекомендации Следствием свойств 2 и 3 является невозможность изменения в облицовочном классе отображения в памяти своего основного типа параметризации, и это дости- гается в силу оптимизации пустых производных классов (Empty Derived Optimization - EDO; см. раздел 12.4), очень широко поддерживаемой оптимизацией. Другими слова- ми, размер экземпляров облицовочного класса совпадает с размером основного типа параметризации. Определение: прикрепляемые классы (bolt-ins), (с. 469) Прикрепляемые классы - это шаблонные классы со следующими свойствами: 1. Они являются производными от своего основного типа параметризации (обычно с открытым доступом к нему). 2. Они приспосабливаются к полиморфной природе своего основного типа пара- метризации. Обычно они твердо ее придерживаются, но не всегда, и могут опреде- лять свои собственные виртуальные методы в дополнение к переопределяемым мето- дам своего основного типа параметризации. 3. Они могут усилить возможности основного типа параметризации путем опре- деления переменных-членов, виртуальных функций и дополнительного наследования непустых типов. Дефект: механизм инстанциирования шаблонов в C++ использует принятые аргу- менты без учета того, как и в какой форме эти аргументы впоследствии применяют- ся внутри шаблонов. Это может привести к генерации неэффективного и/или ошибочного программного кода, поскольку временные экземпляры типа класса могут создаваться по ходу продвижения аргументов по шаблону, (с. 483) Дефект: перегрузка оператора operator &() нарушает инкапсуляцию, (с. 523) Дефект: обеспечение операторов неявного преобразования в указатели совместно с операторами индексации нарушает переносимость, (с. 533) Дефект: некоторые компиляторы C++ будут подставлять префиксную форму перегруженных операторов инкремента/декремента вместо отсутствующих пост- фиксных эквивалентов, (с. 540)
дефекты, ограничения, определения и рекомендации 33 Дефект: перегрузка операторов && и || для типов классов приводит к незаметному нарушению механизма быстрого вычисления, (с. 556) Дефект: С и C++ заставляют делать выбор между производительностью и гиб- костью при распределении памяти, (с. 581) Дефект: С и C++ не поддерживают динамические многомерные массивы, (с. 600) Рекомендация: прокладка размера массива должна всегда использоваться при определении размеров массивов, т. к. все другие подходы не распространяются одно- временно на встроенные и пользовательские типы массивов, (с. 615) Дефект: C++ не поддерживает локальные классы функторов (используемых с шаблонными алгоритмами), (с. 626) Определение: тоннелирование типов - это механизм обеспечения взаимодействия через прокладки доступа двух логически связанных, но физически несвязанных типов. Такая прокладка позволяет внешнему типу пройти сквозь интерфейс как по тоннель- ному переходу и предстать перед внутренним типом в понятной и совместимой форме, (с. 630) Определение: диапазон представляет собой коллекцию связанных элементов, доступ к которым может осуществляться по очереди. Он включает в себя логиче- ский диапазон, то есть точки начала и конца вместе с правилами прохода по нему (перемещения от начальной до конечной точки) - и представляет собой одну сущность, при использовании которой клиентский программный код может получать оступ к значениям из этого диапазона, (с. 634) Дефект: C++ не обеспечивает свойства, (с. 646)
Часть 1 Базовые концепции Примерно первый час велосипедной шоссейной гонки обычно проходит доста- точно спокойно, когда все гонщики начинают чувствовать свое место в гонке, разогре- вая натренированные мышцы, сжигая калории, вырабатывая стратегию и тактику на дальнейшие часы гонки и возобновляя знакомство со своими друзьями и коллегами, с которыми они могли не видеться какое-то время. Первая часть вашего путешествия с книгой «C++: практический подход к решению проблем программирования» будет проходить в том же духе. Вы встретитесь с некоторы- ми вещами, которые вам уже знакомы или, по крайней мере, о которых вы слышали, - многие из них будут играть важную роль в следующих частях книги. Темп задан, настрое- ние бодрое - в конце концов, перед нами вся дорога, и на ней пока не следует ожидать слишком много неприятных участков. Нельзя сказать, что чтение совсем не потребует усилий, но они, по крайней мере, будут незначительными1. И действительно, в шести главах этой части книги будут определены только два дефекта. Конечно, будут затронуты многие вопросы, связанные с несовершенством языка, но основное внимание уделяется некоторым базовым кон- цепциям языка C++, в частности, онтологическим проблемам, причем они рассматри- ваются по-новому. Поскольку данная книга откровенно претендует на формирование высококачественных решений проблем реального программирования, мы ее начинаем с рассмотрения механиз- мов, которые заставляют нас исполнять проектные решения, принимаемые нами при созда- нии классов, - как на этапе компиляции, так и на этапе выполнения программы. После этого мы возвращаемся к базовым концепциям и рассматриваем жизненный цикл объектов, а также механизмы управления доступом клиента к данным-членам класса. Затем мы проповедуем достоинства применения списков, регламентирующих инициализацию пере- менных-членов. После этого наступает очередь инкапсуляции ресурсов. Поскольку аббревиатур, имеющихся в вычислительной технике, нам не достаточно, я ввожу новую, а именно RRID (раздел 3.4), которая является единокровным братом сокращения RAII (Resource Acquisition Is Initialization - захват ресурса при инициализации-, раздел 3.5), который, * Вам придется проехать остальную часть гонки, где будут жуткие подъемы, крутые спуски и, в некоторых случаях, действительно резкие повороты, и поэтому вам следует сохранить силы на остальную часть пути.
Часть 1. Базовые концепции возможно, играет наиболее важную в языке C++ роль и более чем на голову возвышает последнего над своим собратом, языком С1. Обсуждается понятие типа значения - от простых структур данных до типов арифметических значений, способных эмулиро- вать синтаксис встроенных операций для реализации сложных операций. Мы также рассматриваем инкапсуляцию данных, которая работает гладко до тех пор, пока мы не покажем, как трудно обеспечить действительно инкапсулированный тип при решении практических задач. Обсуждение моделей доступа к объектам должно прояснить терминологическую путаницу (по крайней мере, в моей голове) относительно совместного использования данных, их копирования, заимствования и получения одного объекта на базе другого. Поскольку в языке C++ эти понятия определены довольно нечетко, мы завершаем эту тему обсуждением классов, управляющих диапазоном действия ресурсов. Нам пред- стоит узнать, как далеко мы сможем проникнуть в затаенные уголки «здания» проекта программного обеспечения, ограничиваясь этим удивительно простым средством языка, позволяющим еще больше освободить нас от лишней работы за счет работы компилятора. В данной части шесть глав: глава 1, «Принудительное проектирование: ограниче- ния, соглашения и утверждения»', глава 2, «Проблемы жизненного цикла объектов»-, глава 3, «Инкапсуляция ресурсов»; глава 4, «Инкапсуляция данных и типы значений»; глава 5, «Модели доступа к объектам»; и глава 6, «Классы, управляющие диапазоном действия ресурсов». Если вы дойдете до конца этой части и вашей реакцией будут слова «ну и что?», я переживу. Это означает, что вы давно почувствовали силу досто- инств RAII и пользуетесь этим подходом везде, где он приносит пользу (и это проис- ходит достаточно часто). Это также означает, что вы усвоили принципы проектирова- ния по соглашению {Design by Contract, см. гл. 1) и применяете их в своей работе. Это великолепно, поскольку вы будете писать устойчивый, ясный и легко сопровождае- мый программный код. Если вы еще не являетесь сторонником такого подхода, я надеюсь, что эта часть книги даст вам пищу для размышлений. Результат для вас, тем не менее, будет все тот же: вы будете радоваться большим возможностям подхода RAII, применять его везде, где он может быть полезен, и распространять весть о его достоинствах; жизнь каждого программиста станет немного легче (не говоря уже о том, что этот подход является хорошим ответом тем сторонникам других популярных языков программирования, которые акцентируют внимание на устойчивости языка). Вь " 111 _ можете сказать, что это обеспечивают шаблоны, но я полагаю, что у нас хватит здравого смысла ИТись без них; то же самое нельзя сказать о RAIL
Глава 1 Принудительное проектирование: ограничения, соглашения и утверждения Когда мы проектируем программное обеспечение, мы все же рассчитываем, что оно будет использоваться в соответствии с нашим проектом. Наши опасения не беспочвен- ны. В большинстве случаев достаточно легко использовать программное обеспечение не в соответствии с его назначением, и результатом таких действий всегда будет раз- очарование. Я уверен в том, что вы уже знаете из личного опыта - документация программного обеспечения в большинстве случаев неполная и/или устаревшая. Не очевидно, что лучше - неверная документация или ее отсутствие: «хуже отсутствия документации может быть только неверная документация» [Меуе 1997]. Документация не требуется, когда используемые компоненты просты, удобны в применении и либо стандартны, либо широко распространены. Наприь ер, я был бы удивлен, если стало бы известно, что многим программистам более двух раз приходилось обращаться к описанию функ- ции malloc () из системной библиотеки языка С. Однако такое происходит не часто. Я встречал много очень опытных программистов, которые не были достаточно знакомы с нюансами на первый взгляд очень простых собратьев функции malloc (), а именно, realloc() и free(). Существует несколько решений данных проблем. Один способ заключается в повыше- нии устойчивости компонентов программного обеспечения к ошибкам за счет направления дополнительных усилий на проверку достоверности параметров, но в целом такой подход нежелателен, поскольку отрицательно влияет на производительность и закрепляет плохие привычки. Обеспечение своевременной и хорошей документации, несомненно, помогает решать эту проблему, но само по себе этого не достаточно, поскольку документация не обя- зательно окажется именно такой. Более того, очень сложно написать хорошую документа- цию [Hunt 2000], и чем сложнее программное обеспечение, тем труднее независимому техническому писателю раскрыть все его нюансы или ее автору поставить себя в положе- ние пользователя, не знакомого с данным программным обеспечением. В нетривиальных случаях требуется иметь более надежные способы обеспечения надлежащего применения программного кода.
Глава 1- Принудительное проектирование: ограничения, соглашения и утверждения 37 Гораздо предпочтительнее, если сам компилятор выполнит за нас работу и найдет ошибки. Фактически большая часть представленного в данной книге материала должна способствовать тому, чтобы компилятор мог стать эффективным препятствием для плохого программного кода. Надеюсь, вы понимаете, что лучше потратить пару минут на исправление ошибок, выявленных компилятором, чем провести несколько часов, работая с отладчиком. Как указывают Керниган и Пайк в книге «The Practice of Programming» (Практика программирования) [Kern 1999], «нравится нам это или нет, отладка является искусством, в котором мы практикуемся регулярно... Было бы очень хорошо, если бы ошибок не было, поэтому мы пытаемся их избегать, прежде всего, создавая хороший программный код». Поскольку я столь же ленив, как и всякий другой разработчик, я стараюсь заставить компилятор сделать наибольшую часть моей работы. «Смиренное» отношение к программированию (programming by hairshirt), в конце концов, является самым простым выбором. Но во многих случаях источники ошибок не могут быть обнаружены на этапе4 компиляции. В таких случаях нам необхо- димо искать механизмы их обнаружения на этапе выполнения программы. Некоторые языки - например, D, Eiffel - обеспечивают встроенные механизмы, которые гаран- тируют, что программное обеспечение будет соответствовать проекту, выполненному по методике проектирования «по соглашению» {Design by Contract - DbC), впервые предложенной Бертрандом Майером (Bertrand Meyer) [Meye 1997], которая своими корнями уходит в формальные методы подтверждения правильности программ. DbC определяет соглашения (contracts), которым должны следовать компоненты программ- ного обеспечения, и выполнение этих соглашений контролируется в определенных точках работающего программного обеспечения. Эти соглашения во многом заменяют документацию, поскольку они не могут быть проигнорированы и проверяются автома- тически. Более того, применение при формулировании соглашений соответствующего синтаксиса позволяет использовать инструментарий по автоматической генерации документации. Мы обсуждаем это в разделе 1.3. Одним из механизмов принуждения являются утверждения (assertions), как широко известные, проверяемые во время выполнения, так и менее известные, но, возможно, Даже более полезные утверждения, проверяемые при компиляции. Оба подхода широко используются повсюду в книге, и поэтому мы их подробно рассмотрим в разделе 1.4. 1 -1. «Яичница с ветчиной» Можно не сомневаться, что приводимые ниже утверждения ничего нового Не скажут уважаемому читателю, но, тем не менее, они играют важную роль. Позвольте мне приступить к делу:
38 Часть 1. Базовые концепции • ошибку лучше обнаружить во время проектирования, чем во время кодирования и компиляции1; ошибку лучше обнаружить во время кодирования и компиляции, чем во время блочного тестирования2; • ошибку лучше обнаружить во время блочного тестирования, чем во время ком- плексного тестирования системы; • ошибку лучше обнаружить во время комплексного тестирования системы, чем во время бета-тестирования системы; • ошибку лучше обнаружить во время бета-тестирования системы, чем это сделает ваш пользователь; • пусть лучше ошибку обнаружит ваш пользователь (действуя достаточно изощренно и элегантно), чем совсем не иметь пользователей. Все это вполне очевидно, хотя пользователи, вероятно, не согласятся с последним утверждением; его лучше иметь в виду для себя. Существует два способа принудительного обнаружения ошибок: на этапе компиля- ции и на этапе выполнения программы, и они являются основными темами этой главы. 1.2. Соглашения времени компиляции: ограничения Данный раздел посвящен принудительным соглашениям, проверяемым на этапе компиляции, которые широко известны под названием ограничений (constraints). К сожалению, C++ не обеспечивает прямую поддержку ограничений. Дефект: C++ не обеспечивает прямую поддержку ограничений. Поскольку C++ является очень мощным и гибким языком, многие его сторонники (включая некоторых самых заслуженных исследователей в области программирования на C++) считают, что вполне достаточно реализовывать такие ограничения, как описанные в данной главе. Однако, являясь одновременно большим поклонником как языка C++, так и ограничений, я должен возразить по очень простой причине. Я не покупаюсь на критику сторонников других языков, но я думаю, что не следует укло- няться от обсуждения сложностей (иногда очень серьезных) понимания сообщений, выводимых компилятором при нарушении ограничений, которые выглядят для непосвя- щенного человека чрезвычайно таинственно. Если вы являетесь автором программного 1 Я не являюсь чрезвычайно плодовитым кодировщиком, и поэтому для меня время кодирования программы и время ее компиляции совпадают. Но несмотря на мою любовь к блочному тестированию и на то. что я принимал участие в парном программировании с очень быстрыми результатами, я также не отношу себя к поклонникам экстремального программирования [Beck 2000]. 2 Я полагаю, вы используете блочное тестирование. Если нет, то вам необходимо начать его применять прямо сейчас!
39 г ара 1 Принудительное проектирование: ограничения, соглашения и утверждения где возникло нарушение ограничения, то соответствующее сообщение, как правило, ^вызовет у вас затруднений, но понять сообщения, вызванные попыткой многоуровнево- Не инстанниирования шаблона даже очень хорошим компилятором, настолько трудно, они кажутся вообще за пределами понимания. В оставшейся части данного разде- а мы рассмотрим некоторые ограничения и сообщения, которые они генерируют в случае их нарушения, а также ряд мер, которые следует предпринять, чтобы сделать эти сообщения более понятными. 1.2.1. must_have_base() Данное ограничение почти дословно заимствовано из сообщения Бьерна Страу- струпа в сетевой конференции comp. lang.C++ .moderated, хотя он назвал свое ограничение Has_base (имеет базовый тип). Оно также описывается в [Sutt 2002], где имеет название IsDerivedFrom (является производным от). Я предпочитаю обозначать ограничения, начиная со слова must_, (должен), и поэтому я назвал его must_have_base (должен иметь базовый тип). Листинги. template< typename D , typename В > struct must_have_base ( ~must_have_base() ( void(*p)(D*, B‘) = constraints; } private: static void constraints(D* pd, B* pb) ( pb pd; } }; Здесь требуется, чтобы указатель шаблонного параметра D мог быть присвоен указателю шаблонного параметра В. Это выполняется в отдельной статической функ- ции constraints (), чтобы не было дополнительных затрат во время выполнения программы за счет того, что она никогда не будет вызываться и поэтому не будет ^енерироваться программный код. Деструктор объявляет указатель на эту функцию, Ции СаМЫМ ^печивая, как минимум, проверку компилятором наличия данной функ- контроль правильности используемого в ней оператора присваивания. ставл СйМ0М деле обозначение данного ограничения отчасти неверно. Если D и В пред- и поГ С°б0Й °ДИН И Т0Т же тип’то эт0 0ГРаничение’ тем не менее, будет выполнено, (лолже°МУ еГ°’ Вероятно’ слеДУет назвать must_have_base_or_be_same_type поступ ИМеть базовый тип или типы должны совпадать) или нечто подобное. Можно к°гда ти ПО’другомУ и уточнить ограничение must_have_base, отвергая вариант, Лайте поП ПараметР°в D и в совпадает. Полученные результаты, пожалуйста, присы-
40 Часть 1. Базовые концепции Кроме того, если D не является открытым наследником в, то ограничение не выпол- нится. По моему мнению, проблема лежит в наименовании, а не в неадекватности ограничения, поскольку это ограничение мне необходимо только для контроля типов с открытым наследованием1. Поскольку при попытке проверить ограничение по его определению, выполняется действие, непосредственно связанное с семантикой ограничения, сообщения об ошиб- ках, сгенерированные в результате нарушения ограничения, вполне понятны. Фактиче- ски все наши компиляторы (см. приложение А) выдают вполне осмысленные сообще- ния в случае его нарушения, либо указывая на отсутствие наследования соответст- вующих типов, либо указывая на невозможность преобразования типа, либо сообщая нечто подобное. 1.2.2. must_be_subscriptable() Еще одно полезное отраничение требует, чтобы тип был индексируемым (см. раздел 14.2), и его понимание не должно вызывать затруднений: template* typename Т> struct must_be_subscriptable ( static void constraints(T const &T_is_not_subscriptable) { aizaof(T_ia_not_aubacriptabla[0]); ) Чтобы было более понятно, переменная здесь называется T_is_not_subscriptable (тип Т не является индексируемым) с расчетом на то, что это будет способствовать понима- нию сообщения, полученного в случае злополучного нарушения данного ограничения. Рассмотрим следующий пример: struct subs { public: int operator [](size_t index) const; ) struct not_subs {); must_be_subscriptable<int[]> a; // int* является индексируемым must_be_subscriptable<int*> b; // int* является индексируемым must_be_subscriptable<subs> c; // subs является индексируемым must_be_subscriptable<not_subs> d; // not_subs не является индексируемым // ошибка компиляции Может показаться, что в этом определении заключен порочный круг, но, поверьте мне, это не так.
Глава 1 Принудительное проектирование: ограничения, соглашения и утверждения 41 Компилятор Borland 5.6 выводит сообщение, которое очень сильно вводит в заблуж- дение: "'operator+' not implemented in type '<type>' for arguments of type 'int' in function must_be_subscriptable<not_subs>::con- straints (const not_subs &)" ('operaton-' не реализован в типе '<type>' для аргу- ментов типа 'int' в функции must_be_subscriptable<not_subs>: : con- straints (const not_subs &)). Когда вам приходится углубляться на пятнадцать уровней в реализацию шаблонного объекта, вам никак не обойтись без очень больших умственных затрат! Компилятор Digital Mars более корректен, но все же от него мало помощи: " Error: array or pointer required before ' ['; Had: const not_subs" (ошибка: массив или указатель требуется перед'['; было: const not_subs). Некоторые другие компиляторы включают имя переменной T_is_not_subscriptable. Самое лучшее сообщение, по-видимому, выводит компиля- тор Visual C++: "binary '[' : 'const struct not_subs' does not define this operator or a conversion to a type acceptable to the pre- defined operator while compiling class-template member function 'void must_be_subscriptable<struct not_subs>::constraints (const struct not_subs &)" (бинарный оператор '[': в структуре 'const struct not_subs' данный оператор не определен или не определено преобразование в тип, при- емлемый для данного оператора при компиляции функции-члена шаблонного класса 'void must_be_subscriptable<struct not_subs>::constraints (const struct not_subs &)). 1.2.3. must_be_subscriptable_as_decayable_pointer() В главе 14 мы будем детально исследовать взаимоотношение между массивами и указателями и покажем, что благодаря скрытой природе смещения указателя можно законно пользоваться записью смещение (указатель), которая полностью эквивалентна обычной записи указатель (смещение). (Это может заставить компилятор Borland выдать озадачи- вающее сообщение об ошибке для ограничения must_be_subscriptable (должен был» индексируемым), которое кажется чуть менее абсурдным, но этого будет достаточно, чтобы мы могли отследить источник ошибки и понять, что произошло нарушение данного ограниче- ния.) Поскольку этот зеркальный вариант нельзя применять для тех типов классов, которые пере- гружают оператор индексации, можно уточнить ограничение must_be_subscriptable, чтобы ограничить параметры шаблонов только типами указателей. Листинг 1.2. template <typename Т> struct must_be_subscriptable_as_decayable_pointer static void constraints(T const &T_is_not_decay_subscriptable)
42 Часть 1. Базовые концепции •izeof(0[T_iz_not_decay_«ub«criptable]); } }; Не надо доказывать, что если допускается запись смещение (указатель), то также допускается запись указатель (смещение), и поэтому не требуется использовать ограничение must_be_subscriptable в рамках ограничения must_be_subscriptable_as_decayable_pointer (должен быть индексируе- мым указателем). Несмотря на то, что эти ограничения реализованы с использованием разных подходов, по-видимому, имеет смысл применить наследование при их опреде- лении, чтобы показать связь между ними. Теперь мы можем различать указатели и другие индексированные типы: must_be_subscriptable<subs> а; // компилируется нормально must_be_subscriptable_as_decayable_pointer<subs> Ь; // ошибка компиляции 1.2.4. must_be_pod() Мы столкнемся с применением ограничения must_be_pod () (должен иметь тип POD) в нескольких местах данной книги (см. разделы 19.5, 19.7, 21.2.1, 32.2.3). Оно было моим первым ограничениемв и было написано задолго до того, как я вообще узнал о существовании ограничений на этапе компиляции, и даже до того, как я узнал смысл POD-типов (см. «Введение»). Это ограничение очень простое. Стандарт языка (С++-98:9.5; 1) устанавливает, что «объект класса с нетривиальным конструктором, нетривиальным конструктором копирования, нетривиальным деструк- тором или нетривиальным оператором присваивания не может быть членом объедине- ния». Это достаточно хорошо подходит для нашего случая, и мы можем рассчитывать, что наше ограничение будет похоже на уже рассмотренные ограничения, имеющие метод constraints (), с добавлением объединения: struct must_be_pod { static void constraints() { union T T_i«_not_POD_type; } К сожалению, в данном случае компиляторы, как правило, ведут себя немного странно, и поэтому реальное определение не будет столь простым, требуя применения многих препроцессорных директив (см. раздел 1.2.6). Но эффект будет такой же.
Глава 1 Принудительное проектирование: ограничения, соглашения и утверждения В разделе 19.7 мы увидим, как данное ограничение используется совместно с более специализированным must_be_pod_or_void() (должен иметь тип POD или void) чтобы можно было убедиться в не тривиальности базовых типов указателей. При этом применяется специализация [Vand 2003] шаблона must_be_pod_or_void, определение которого в целом идентично определению must_be_pod: terrplate < typename Т> struct must_be_pod_or_void { . . . // Совпадает с шаблоном must_be_jpod }; template <> struct muBtJbe_pod_or_yoid<void> { 11 Ничего не содержит и поэтому не вызывает затруднений у компилятора И вновь сообщения, генерируемые при нарушении ограничений must_be_pod / must_be_pod_or_void, имеют достаточно большой объем: class NonPOD { public: virtual -NonPOD(); }; must_be_pod<int> a; 11 int является POD-типом (см. «Введение*) must_be_pod<not_subs> b; // not_subs является POD-типом (см. «Введение*) must_be_pod<NonPOD> с; 11 NonPOD не является POD-типом: ошибка компиляции В данном случае привычная лаконичность компилятора Digital Mars нас подвела, поскольку мы получили сообщение «Error: union members cannot have ctors or dtors» (Ошибка: члены объединения не могут иметь секции ctors или dtors) для соответствующей строки в ограничении. При использовании данно- го ограничения в достаточно большом проекте будет очень трудно отследить место расположения «виновника» нарушения. Вероятно, в данном случае самое небольшое по объему информативное сообщение было получено компилятором Watcom: «Error! Е183: col(10) unions cannot have members with constructors; Note! N633: col(10) template class instantiation for must-be_pod<NonPOD>' was in: . .\constraints_test.cpp(106) (col 48)»(Ошибка! столбец (10) - объединения не имеют члены с конструкторами; , Римечание! N633: столбец (10) - инстанциирование шаблонного класса must_be_pod<NonPOD>' выполнялось в: .Aconstraints_test.cpp(106) (столбец 48)).
44 Часть 1. Базовые концепции 1.2.5. must_be_same_size() Последнее ограничение, must_be_same_size () (должны иметь одинаковый размер), также используется в последующем материале книги (см. разделы 21.2.1 и 25.5.5). Класс этого ограничения просто использует статическое утверждение, контро- лирующее правильность размерности массива, с которым мы вскоре познакомимся (раздел 1.4.8) и которое гарантирует одинаковый размер типов: Листинг 1.3. template* typename Т1 , typename Т2 > struct must_be_same_size { private: static void constraints() { const int Tl_not_Bame_«ize_a«_T2 «izeof(Tl) aizeof(T2); int i[Tl_not_«ame_Bize_a«_T2]; } }; Если объекты имеют разные размеры, тогда переменная Tl_not_same_size_as_T2 (размер Т1 отличается от размера Т2) заменяется препроцессором на константу 0, задавая недопустимый размер массиву i. Мы видели при работе с must_be_pod_or_void, что нам необходимо учиты- вать возможность применения ограничения, когда один или оба параметра могут иметь тип void. Поскольку sizeof(void) является недопустимым выражением, мы должны обеспечить некоторый дополнительный программный код, обрабатывающий эту ситуацию на этапе компиляции. Это легко достигается, если оба параметра имеют тип void, поскольку мы можем следующим образом специализировать шаблон: template о struct must_be_same_size<void, void> {}; Однако не столь просто обеспечить вариант, когда только один из параметров имеет тип void. Это можно сделать, например, с использованием частичной специализации [Vand2003], но не все популярные компиляторы поддерживают ее. Более того, нам придется обеспечить шаблон с одной полной специализацией и двумя частичными специализациями, где один шаблонный параметр специализируется на тип void, а другой - на тип другого параметра, причем нам придется придумать какой-то способ обеспечения получения на этапе компиляции хотя бы полупонятного сообщения. Вместо того, чтобы пойти этим путем, я решил расширить оператор size_of на тип void. Это делается очень просто, и при этом не требуется прибегать к частичной специализации:
Глава! Принудительное проектирование: ограничения, соглашения и утверждения 45 Листинг 1.4. template <typename Т> struct size_of { enum { value = sizeof(T) }; }; template <> struct size_of<void> { enum { value = 0 }; Все, что теперь требуется - это применить size_of вместо sizeof из огра- ничения mus t_be_same_si ze: template* . . . > struct must_be_same_size { static void constraints() { const int Tl_nxust_bs_sams_size_as_T2 size_of<Tl>xxvalue size_of<T2>xxvalue; int i [Tl_xnust_be_same_size_as_T2]; }; Теперь мы можем проверять размер объектов любых типов: must_be_same_size<int, int> а; // ограничение выполняется must_be_same_size<int, long> b; // зависит от компилятора must_be_same_size<void, void> с; // ограничение выполняется must_be_same_size<void, int> d; // ошибка компиляции: void «равен» О Как и в случаях применения других ограничений, здесь также наблюдается значи- тельный разброс в отношении объема предоставляемой программисту информации. Компиляторы Borland и Digital Mars вновь выделяются, совсем не выдавая контекстной информации или выдавая ее достаточно мало. В данном случае, по моему мнению, самое лучшее сообщение выдает компилятор Intel: «zero-length bit field must be unnamed» (битовое поле нулевой длины не должно быть поименовано), показывая строку, где произошло нарушение, и обеспечивая контексты двух непосредственных вызовов, включающих действительные типы шаблонных параметров Т1 и Т2, - и все это в четырех строках сообщения компилятора.
46 Часть 1. Базовые концепции 1.2.6. Применение ограничений Я предпочитаю использовать мои ограничения при помощи макросов, имена которых имеют вид сопБЁга1пЪ_<имя_ограничения>1. Например, constraint_must_have_base (). Такие имена полезны по нескольким причинам. Во-первых, их легко можно отыскать и тем самым обеспечить однозначную интерпретацию сообщений. Для этого я зарезервировал must_ для ограничений, чтобы можно было быть уверенным в том, что данное ограничение уже встречалось. Но такое имя макроса также немного лучше описывает само ограничение. Читатель, увидев макрос constraint_must_be_pod() в каком-нибудь программном коде, достаточно однозначно поймет его смысл. Вторая причина заключается в том, что применение формы макроса позволяет мне быть последовательным. Хотя я не написал ни одного нешаблонного ограничения, ничто не мешает кому-нибудь сделать это. Более того, по моему мнению, угловые скобки лишь только раздражают глаз. В-третьих, если ограничения определяются в рамках пространства имен, их при- менение будет сопровождаться нудным уточнением имени путем добавления квали- фикатора пространства имен. Это легко можно скрыть внутри макроса, освобождая пользователей ограничений от соблазна применения рискованной директивы using (см. раздел 34.2.2). И наконец, такой подход очень практичен. Обработка ограничений различными компиляторами немного отличается, что может потребовать немалых дополнитель- ных усилий. Например, в зависимости от используемого компилятора макрос constraint_must_be_pod() определяется одним из трех способов: do { must_be_pod<T>::func_ptr_type const pfn = must_be_pod<T>::constraint(); } while(0) ИЛИ do { int i = sizeof(must_be_pod<T>::constraint()); } while(O) ИЛИ STATIC_ASSERT(sizeof(must_be_pod<T>::constraint()) != 0) Вместо того чтобы загромождать использующий ограничения клиентский про- граммный код очень непривлекательными вставками, проще и аккуратнее использо- вать макрос. 1 По причинам, описанным в разделе 12.4.4, всегда имеется хорошее основание для написания имен макросов прописными буквами. Я так не поступил по той причине, что хотел сохранить регистр наименования ограничения в макросах ограничений. Теперь, оглядываясь на прошлое, это мне представляется не столь обязательным, и вы можете использовать прописные буквы, если вам так больше нравится.
47 Глава 1 Принудительное проектирование: ограничения, соглашения и утверждения 1.2.7. Ограничения и шаблонное метапрограммирование Один из рецензентов указал на то, что некоторые из этих ограничений могут быть ализованы при помощи методов шаблонного метапрограммирования (ШМП)1, и он был прав. Например, ограничение must_be_pointer может быть реализовано как статическое утверждение (см. раздел 1.4.7), связанное с шаблоном свойств (trait template) is_pointer_type, используемым в разделе 33.3.2: #define constraint_must_be_pointer(Т) \ STATIC_ASSERT(О != is_pointer_type<T>::value) Существует несколько причин, почему я не пользуюсь таким подходом. Во-первых, кодирование ограничения всегда осуществляется достаточно просто, поскольку огра- ничение лишь эмулирует обычные (ограниченные) свойства типа. То же самое нельзя сказать о шаблонах свойств, некоторые из которых могут быть очень сложными. Поэтому ограничения очень легко читаются. Во-вторых, во многих случаях, хотя и не во всех, легче убедить компилятор выда- вать относительно удобоваримые сообщения при нарушении ограничений, чем при нарушении шаблонов свойств или статических утверждений. Наконец, существует несколько случаев, когда применение ШМП невозможно или, по крайней мере, допустимо только на небольшом подмножестве компиляторов. Как это ни странно, по-видимому, чем проще ограничение, тем сложнее будет эквивалент- ная ему реализация на базе шаблонов свойств - must_be_pod служит великолепным примером этого. Герб Саттер (Herb Sutter) демонстрирует комбинацию ограничений и шаблонов свойств в работе [Sutt 2002], и нет причин, которые мешали бы вам делать то же самое в своей работе, построенной на базе применения нескольких концепций; я просто предпочитаю упрощать работу, используя ограниченный набор концепций. 1.2.8. Ограничения: заключение Ни в коем случае нельзя считать, что представленные в данной главе ограничения по- крывают весь диапазон их возможностей. Однако они должны дать вам хорошее представ- ление о том, чего можно достичь с их помощью. Недостаток применения ограничений сов- падает с недостатком использования статических утверждений (см. раздел 1.4.8) и заключа- ется в том, что не так-то просто разобраться в сгенерированных сообщениях об ошибках. В зависимости от конкретного механизма реализации ограничения, вы можете получать сообщения от понятного: «type cannot be converted» (тип не может быть преобра- сознательно стремился, по мере возможности, оставить методы ШМП за рамками данной книги, ьку они представляют собой объемную тему и непосредственно не связаны ни с одним из ,атриваемых мною недостатков языка. Вы можете найти множество примеров в библиотеках Boost, Ее 1Ь 11 STLSoft, включенных в состав компакт-диска, а также в нескольких книгах [Vand 2003, Alex 2001].
48 Часть 1. Базовые концепции зован), до озадачивающего: «Destructor for 'Т' is not accessible in func- tion <non-existent-function>» (деструктор T не доступен в функции <не-суще- ствующая-функция>). В определенных случаях вы можете улучшить ситуацию с неудачной параметризацией вашего программного кода при выводе сообщения об ошибке путем задания соответст- вующих имен переменным и константам. В данном разделе мы видели примеры этого - T_is_not_subscriptable, T_is_.not_POD_type и Tl_not_same_size_as_T2. Необходимо только обеспечить, чтобы эти имена правильно отражали условие ошибки. Жаль беднягу, который нарушит ваше ограничение и будет проинформирован о том, что T_is_ valid_type_for_constraint (Т является допустимым типом для данного ограничения)! Здесь существует очень важный аспект, который необходимо лишний раз подчерк- нуть: мы можем свободно обновлять ограничения по мере того, как больше узнаем о работе компилятора и о методах ШМП. Из приводимых в книге примеров вы, вероят- но, поймете, что я вовсе не гуру по методам ШМП, но дело в том, что при проектирова- нии ограничений, применяемых в клиентских классах, мы можем незаметно для поль- зователя обновлять ограничения после освоения нами новых приемов. Я не постес- няюсь признать, что делал это много раз, хотя, вероятно, я постесняюсь показать вам не- которые мои ранние попытки создания ограничений. (Ни одно из них не представлено в приложении Б, поскольку они недостаточно легкомысленные - все-таки не совсем!) 1.3. Соглашения времени выполнения: предусловия, постусловия и инварианты «Если при вызове процедуры выполняются все предусловия, то это гарантирует, что будут выполнены все постусловия (и (любые) инварианты) по завершению данной процедуры» - Хант (Hunt) и Томас (Thomas), «The Pragmatic Programmers» (Прагма- тичные программисты) [Hunt 2000]. Если мы не можем обеспечить реализацию принудительных соглашений с помо- щью ограничений на этапе компиляции, то мы можем попытаться это сделать при выполнении программы. Систематический подход к реализации принудительных соглашений на этапе выполнения программы предусматривает применение соглаше- ний, связанных с функцией (function contracts). Предъявляемое к функции требование определяет точный набор условий, которые должны выполняться в вызывающей про- грамме перед вызовом этой функции (предусловия функции), и точный набор условий, которые будут выполняться в вызывающей программе после выхода из функции (постусловия функции). Определение этих соглашений и принудительное их исполне- ние являются основой DbC [Меуе 1997]. Предусловие определяет условие, необходимое для выполнения функцией соглаше- ния, с которым она связана. Вызывающая программа отвечает за выполнение преду-
Глава 1 Принудительное проектирование: ограничения, соглашения и утверждения 49 словий Вызываемый объект может рассчитывать на то, что предусловие выполняется, и обязан обеспечить правильный режим работы только в этом случае. Этот момент очень важен, как подчеркивается в работе [Меуе 1997]. Если вызывающая программа не обеспечивает предусловия, вызываемый объект может законно выполнять любые дей- ствия Обычно это подразумевает применение утверждений (см. раздел 1.4), которые могут привести к прекращению работы программы. Создается впечатление, что эта перспектива действует устрашающе, и программисты, мало знакомые с DbC, часто чувствуют себя совсем неуверенно, когда вы спрашиваете их, что они собираются делать в функции, когда нарушаются условия связанного с нею соглашения. Дело в том, что чем эже соглашение и чем более резкая реакция на его нарушение, тем лучше качество программного обеспечения. Понимание этого является самым труд- ным (и, вероятно, единственно трудным) шагом на пути к переходу на методы DbC. Постусловие определяет условие, которое должно выполняться после заверше- ния функции. Вызываемый объект отвечает за выполнения требований постусловия. Вызывающая программа может рассчитывать на удовлетворение постусловий, когда функция возвращает ей управление. Вызывающая программа не отвечает за обес- печение выполнения вызываемым объектом условий своего соглашения. В реальных условиях иногда необходимо подстраховаться, например, при вызове подключаемых модулей независимых разработчиков в условиях реальной эксплуатации приклад- ных серверов. Однако я полагаю, что этот принцип все же остается верным. На самом деле можно возразить, что правильной реакцией на неправильно рабо- тающий подключаемый модуль будет его выгрузка и отсылка сообщения по элек- тронной почте менеджерам хостинговой компании и независимого поставщика под- ключаемого модуля. Раз уж при нарушении постусловия мы можем выполнять любые действия - почему бы и нет? Предусловия и постусловия могут применяться к функциям-членам класса, а также к свободным функциям, что является хорошей чертой языка C++ (и в целом объектно- ориентированного программирования). Фактически имеется третий компонент DbC, который связан только с классами: инвариант класса. Инвариант класса определяет условие или набор условий, которые всегда удовлетворяются для объекта этого класса, находящегося в определенном состоянии. По определению именно конструктор Должен гарантировать, что созданный экземпляр объекта принимает состояние, удов- летворяющее условиям инварианта, а (открытые) функции-члены должны гарантиро- ать’ что экземпляр объекта будет оставаться вплоть до конца своего существования состоянии, удовлетворяющем инварианту. Инвариант может нарушаться только конструкторе, деструкторе или при выполнении функций-членов. При некоторых обстоятельствах имеет смысл определить инварианты, область дей- Цгщ18 К0ТОрых не буДет ограничиваться лишь состоянием отдельных объектов. В прин- е инвариант может охватывать состояние всей рабочей среды. На практике, однако, «-225
50 Часть 1. Базовые концепции такое случается очень редко, и обычно мы имеем дело с инвариантами классов. В остальной части этой главы и остальной части книги под инвариантами подразуме- ваются инварианты классов. Можно обеспечить инварианты для типов, которые частично или совсем не инкапсулированы (см. разделы 3.2 и 4.4.1), что принудительно навязывается в соответ- ствующих функциях программного интерфейса (совместно с предусловиями функций). На самом деле применение инвариантов для таких типов является очень хорошей идеей, поскольку отсутствие инкапсуляции повышает вероятность их неверного использова- ния. Но легкость, с которой такие инварианты могут быть обойдены, иллюстрирует в целом нежелательность применения таких типов. На самом деле некоторые авторы [Stro 2003] могут сказать, что если существует инвариант, не имеет смысла применять при объектно-ориентированном программировании открытые данные; инкапсуляция обеспечивает и сокрытие реализации, и защиту инвариантов. Использование свойств (см. гл. 35) является одним из способов получения аналога открытых переменных-членов, возможно, с целью обеспечения структурного единства (см. раздел 20.9), но все же допус- кая применение инвариантов. Вам решать, какие действия предпринимать при нарушении предусловий, посту- словий и инвариантов. Можно записать информацию об этих нарушениях в файл журнала событий, выбросить исключение или отослать супруге сообщение SMS, уведомляя ее о том, что вы задержитесь до поздней ночи для отладки программного кода. Обычно эти действия принимают форму утверждения. 1.3.1. Предусловия В языке C++ проверка предусловий осуществляется просто. Мы уже видели несколько примеров в данной книге. Их также просто использовать, как и утверждения: template* ...» typename pod_vector<. .>::reference pod_vector<. . .>::front() { MESSAGE_ASSERT("Vector la emptyl", 0 l- aizeO); assert(is_valid()); return m_buffer.data()[0]; } 1.3.2. Постусловия В этом месте C++ серьезно «спотыкается». Достаточно сложной является задача перехвата значений выходных параметров и их возврата по выходу из функции- Конечно, C++ обеспечивает исключительно полезный механизм RAII (Resource Acqui' sition Is Initialization - захват ресурса при инициализации’, см. раздел 3.5), который гарантирует, что деструктор находящегося в стеке объекта будет вызван в коние жизненного цикла объекта. Это означает, что мы можем приблизиться к работающему решению, - по крайней мере, отчасти.
Глава 1 Принудительное проектирование: ограничения, соглашения и утверждения 61 Один способ заключается в объявлении контролируемых объектов, которые ссылаются на выходные параметры и возвращают значение. int f(char const *name, Value **ppVal, size_t *pLen) { int retVal; retval_jnonitor rvm(retVal, . • . policy . . . ); outparam_jnonitor opml(ppVal, . . . policy . . . ); outparam_jnonitor opm2(pLen, . . . policy . . . ); ...II Тело функции return retVal; } Политики (policy) могли бы обеспечивать контроль значений переменных на NULL или не NULL, а также на их попадание в определенный диапазон значений или на сов- падение с некоторым значением, или на попадание в некоторое множество значений и т. д. Несмотря на то, что мы, преодолевая трудности, зашли так далеко, еще остаются две проблемы. Во-первых, деструктор rvm будет навязывать свое ограничение посред- ством ссылки, которую он хранит для возврата значения переменной retVal. Если любая часть этой функции возвращает другую переменную (или константу), rvm неиз- бежно выдаст сообщение об ошибке. Чтобы все работало корректно, мы вынуждены будем обеспечить во всех функциях возврат единственной переменной, что не каждо- му понравится и не во всех случаях будет возможно. Главная проблема, однако, заключается в том, что мониторы различных постусло- вия не связаны друг с другом. Постусловия большинства функций имеют сложную структуру: значения отдельных выходных параметров и возвращаемое значение взаи- мозависимы, например, assert(retVal > 0 || (NULL == *ppVal && 0 == *pLen)); Я даже не собираюсь начинать дискуссию относительно возможности объединения трех отдельных объектов-мониторов, чтобы обеспечить контроль широкого диапазона постусловий; подобные проблемы интересны для аспиранта, выполняющего исследо- вательскую работу по шаблонному мета-программированию, но для остальных нас предпочтительнее находиться на уровне, когда затраты на решение проблемы прак- тически оправданы. Дефект: C++ не обеспечивает удобную поддержку постусловий По моему мнению, единственно разумное, хотя и прозаическое, решение заключа- ется в создании отдельной функции и обеспечение проверок путем применения РегпРанслирующей функции (forwardingfunction), как показано в листинге 1.5. Листинг 1.5. int f(char const *name, Value **ppVal, size_t *pLen) { . .11 Проверить предусловия функции £()
52 Часть 1. Базовые концепции int retVal = f„unchecked(name, ppVal, pLen); . . . // Проверить постусловия функции f() return retVal; } int f„unchecked(char const ‘name, Value **ppVal, size_t *pLen) { . . . // Семантика функции f } При написании реального программного кода вы, возможно, захотите убрать все проверки при построении исполнительного модуля, в котором не будет активирован принудительный контроль DbC, и для этого вам потребуется использовать препроцес- сорные инструкции. Листинг 1.6. int f(char const *name, Value **ppVal, size_t *pLen) ♦ifdef ACMELIB_DBC { ... II Проверить предусловия функции f() int retVal = f_unchecked(name, ppVal, pLen); ... II Проверить постусловия функции f() return retVal; } int f„unchecked(char const ‘name, Value **ppVal, size_t *pLen) ♦endif /* ACMELIBDBC */ ( ...II Семантика функции f } He очень привлекательно, но работает; и такой подход легко может использоваться в генераторах программного кода. Проблема усложняется при работе с перегруженны- ми методами классов, поскольку перед вами встанет вопрос о том, надо ли принуди- тельно контролировать пред- и постусловия для родительских классов. Каждый такой случай следует разбирать отдельно, и это выходит за рамки данного обсуждения1. 1.3.3. Инварианты классов Инварианты классов почти также легко реализуются в языке C++, как и предусловия. Обычно я определяю для классов метод is_valid (), который имеет следующий вид: template*:. . . > inline bool pod_vector<. , .>::is_valid() const { if(m_buffer.size() < m_cltems) 1 Здесь я признаюсь в некоторой «трусости», и этому есть соответствующее объяснение. Даже в тех языках программирования, которые хорошо подходят для применения методики DbC, существует неправильное понимание полезности и реальных механизмов установки соглашений между иерархическими уровнями наследования. Более того, предложение использовать эту методику в языке C++ в момент написания книги только начало обсуждаться [Otto 2004], и поэтому я считаю неуместным более подробно рассматривать здесь этот вопрос.
Глава 1 Принудительное проектирование: ограничения, соглашения и утверждения 53 { return false; } ...II Здесь выполняются дальнейшие проверки return true; } Затем он вызывается в утверждении во всех открытых методах класса - на входе метода и на его выходе. Обычно я выполняю проверку инварианта класса непосредст- венно после проверки предусловия (как показано в разделе 1.3.1): template* ...» inline void pod_vector<. . .>::clear() { assert(is_valid()); m_buffer.resize(0); m_cltems = 0; assert(is_valid()); ) Альтернативной является стратегия размещения утверждений непосредственно функции инварианта. Если вы не пользуетесь сложной организацией утверждений (см. раздел 1.4), это позволяет вам обеспечивать информацию об утверждении (файл+строка+сообшение) либо для условия-нарушителя, либо для метода-нарушите- ля. Я предпочитаю последнее, потому что инварианты нарушаются редко. Однако вы можете выбрать первый способ, и в этом случае вам придется размещать инварианты внутри функции-члена is_valid(). Фактически существует разумное компромиссное решение, которым я часто поль- зуюсь, когда в моем распоряжении имеется какой-нибудь известный интерфейс реги- страции и отслеживания событий (см. раздел 21.2), позволяющий выводить подроб- ную информацию о нарушении утверждения в функции-члене is_valid () и запус- кать утверждение в методе-нарушителе. В отличие от проверки выходных параметров и возвращаемого значения можно просто использовать RAII (см. раздел 3.5) для автоматизации проверки инварианта класса, выполняемой в ходе подтверждения постусловий на выходе метода, как это де- лается в следующем примере: template* ...» inline void pod_vector<. . .>::clear() { check_invariant<class_type> check(this); m_buf fer.resize(0); m_cltems = 0; ) Недостатком такой принудительной проверки является ее выполнение в конструк- Т?₽е (одном или нескольких) и деструкторе шаблонной реализации объекта ес — invariant - а это означает, что используемый препроцессором простой ме- ханизм реализации утверждений, связанный с выводом данных _____________FILE___ —-Line— (см. раздел 1.4), может привести к выводу вводящих в заблуждение сооб-
54 Часть 1. Базовые концепции шений. Однако не так уж сложно реализовать макрос + шаблонные формы, позво- ляющие выводить правильные сведения относительно места нарушения утверждений, даже используя нестандартный препроцессорный символ___FUNCTION в тех ком- пиляторах, которые его поддерживают. 1.3.4. Выполнять проверку? Да, и делать это постоянно! В [Stro 2003] Бьерн Страуструп пришел к важному выводу, что инварианты необхо- димы только для классов, которые имеют методы, и они не нужны для простых струк- тур, которые используются лишь для группировки переменных. (Например, для типа Patron, который мы рассмотрим в разделе 4.4.2, инвариант не требуется.) По моему мнению, обратное утверждение также верно: я бы сказал, что любой класс, имеющий методы, должен иметь инвариант класса. Однако на практике имеется некоторое огра- ничение. Если ваш класс содержит единственный указатель, ссылающийся на какой- нибудь ресурс, то он будет иметь либо значение NULL, либо не NULL. Если только ваш метод инварианта класса не имеет доступ к какому-то внешнему источнику, позво- ляющему убедиться в достоверности ненулевого указателя, вы мало что можете здесь сделать. В этом случае вам решать, что лучше: использовать фактически пустой метод инварианта, или ничего не делать. Если ваш класс развивается, вы, вероятно, можете немного облегчить будущие затраты по его уточнению путем размещения в нем метода-заглушки, содержательное наполнение которого будет сделано впоследствии. Если вы используете генератор программного кода, то я бы предложил вам просто во всех случаях генерировать метод инварианта класса и его вызовы. Преимущество применения метода инварианта класса по отношению к отдельным утверждениям, связанных с реализацией класса, вполне очевидно. Ваш программный код лучше читается, различия в реализации классов лучше воспринимаются и его легче сопровождать, потому что вы определяяете одно или несколько условий инвари- анта класса в одном месте каждого класса. 1.3.5. Быть DbC или не быть DbC? Картина, которую я изображал до сих пор относительно соглашений времени выпол- нения, неявно подразумевает, что система с элементами DbC после проведения соответ- ствующего тестирования при окончательном ее построении удаляет эти элементы с по- мощью препроцессора. Фактически достаточно много лукавства в том, когда сомневаются в целесоообраз- ности построения любого варианта системы, в котором не использовались бы методы DbC, обеспечивающие принудительный контроль [Same 2003]. Аргументируя необхо- димость принудительного контроля, говорят (я позаимствовал заимствованную други- ми аналогию [Same 2003]), что принудительные соглашения в DbC эквивалентны предохранителям в электрической системе и никому не приходит в голову удалять все предохранители из сложного электрооборудования непосредственно перед его раз- вертыванием.
Глава 1 Принудительное проектирование: ограничения, соглашения и утверждения 55 Утверждения отличаются от предохранителей тем, что они связаны с проверками, одимыми при работе программы и имеющими определенную ненулевую стои- мость Хотя удельное сопротивление используемого в предохранителе сплава может немного отличаться от параметров остальной системы, где он используется, было бы неправомерно утверждать, что стоимость их применения аналогична. Точное соотно- шение, по моему мнению, можно определить, отдельно разбирая каждый конкретный случай. По этой причине в примерах программного кода, приводимого в данном разделе, содержится символ ACMELIB_DBC, а не NDEBUG (или _DEBUG), поскольку примене- ние DbC не должно непосредственно связываться с версией вашего программного обеспечения: отладочной или рабочей. Когда использовать контроль DbC, а когда его исключать - этот выбор вы делаете сами1. 1.3.6. Соглашения времени выполнения: заключение Хотя мы видели, что C++ довольно плохо справляется с постусловиями, в нем дос- таточно удобно выполнять контроль предусловий и инвариантов классов. На практике совместное применение этих двух методов позволяет обеспечить основные преимуще- ства методики DbC. Отсутствие контроля постусловий для возвращаемых значений и выходных параметров огорчает, но в целом не является серьезным препятствием. Если вам необходимо его обеспечить, вы можете обратиться к помощи препроцессора, как мы это делали в разделе 1.3.3. Для инвариантов, как и для ограничений этапа компиляции, мы облегчаем себе жизнь путем применения промежуточного уровня: макроса для ограничений, функ- ции-члена для инвариантов. Таким образом достаточно просто добавить поддержку новых компиляторов или модифицировать внутреннее содержание класса, скрывая все детали механизма контроля внутри метода инварианта класса. 1.4. Утверждения Я бы не называл утверждения истинным механизмом обнаружения ошибок и вывода соответствующих сообщений, поскольку их поведение обычно сильно отличается при работе в отладочной и рабочей версиях одного и того же программного обеспечения, есмотря на это, утверждения являются одним из самых важных инструментов в руках программиста C++, обеспечивающих высокое качество программного обеспечения, частности, важную роль играет их широкое применение в качестве механизма изации принудительных ограничений и инвариантов. Любую главу, посвященную ханизмам вывода сообщений об ошибках, несомненно, можно считать неполной, если Неи Не Рассматриваются утверждения. -----_ тем, ^1СПользовании компилятора ISE Eiffel 4.5 вы не можете удалять предусловия; вероятно, это объясняется и, след нредусловия могут срабатывать до того, как состояние программы становится неопределенным вательно» имеет смысл продолжать выполнение программы в режиме перехвата исключений.
56 Часть 1. Базовые концепции В основном утверждение предназначено для обеспечения контроля на этапе выпол- нения, причем обычно только отладочной или тестовой версии программного обес- печения, и оно, как правило, имеет следующий вид: tifdef NDEBUG # define assert(х) ((void)(0)) #elif /* ? NDEBUG */ extern "C" void assert_function(char const *expression); # define assert(x) ((lx) ? assert_function(#x) s ((void)O)) tendif /* NDEBUG */ Утверждение используется в клиентском программном коде для обнаружения любых условий, которые никогда, по вашему мнению, не должны выполняться: class buffer { void methodi() { assert((NULL != m_p) == (0 != m_size)); } private: void *m_p; size_t m_size; }; Утверждение в этом классе является отражением проектных предположений автора об этом классе: если m_size не равен 0, то ш_р не равно NULL, и обратно. Когда оценка условий утверждения дает результат «ложь», говорят, что утвержде- ние «срабатывает». Это может означать вывод на экран окна с соответствующим сооб- щением, если вы работаете в графической среде, либо это может означать прекраще- ние работы программы, либо генерацию специфичного для системы исключения. Однако в любом случае желательно вывести на экран текст условия, которое оказа- лось нарушенным, и поскольку такие сообщения прежде всего предназначены для разработчиков программного обеспечения, имя файла и номер строки, где это про- изошло. Большинство макросов, реализующих утверждения, обладают такими воз- можностямй: ttifdef NDEBUG # define assert(х) ((void)(0)) #elif /* ? NDEBUG */ extern "C" void assert_function( char const *expression , char const *file , int line); # define assert(x) ((!x) ? ? assert_function(#x,____FILE___,____LINE___) : ((void)0)) #endif /* NDEBUG */ Поскольку используемое в утверждении выражение исключается из рабочей версии программного обеспечения, очень важно убедиться, что вычисление данного выражения не приводит к побочным эффектам. В противном случае вы столкнетесь со странной и непри- ятной ситуацией, когда ваша отладочная версия работоспособна, а рабочая версия - нет.
Глава 1 Принудительное проектирование: ограничения, соглашения и утверждения 1.4- 1- Формирование сообщения Действия, выполняемые в утверждении, могут быть самыми разнообразными. Однако в большинстве утверждений при выводе сообщения используется строковая форМа проверяемого выражения. В определенной степени это не плохо, но это может смутить «бедного» тестировщика (на месте которого можете быть и вы), поскольку все что вы имеете - некое краткое сообщение, например, следующего вида: "assertion failed in file stuff.h, line 293: (NULL != m_p) __ (0 != m_size));" (утверждение нарушено в файле stuff.h, строка 293: (NULL != m_p) == (0 ! = m_size))) Но мы можем воспользоваться достоинствами этого простого механизма для того, чтобы сделать наши утверждения немного более осмысленными. В тех случаях, когда вы используете в утверждении состояние переключателя, которое, как вы полагаете, никогда не возникнет, вы можете значительно улучшить сообщение путем применения поименованной константы 0, например: switch(. . .) { case CantHappen: { const int AcmeApi_experienced_CantHappen_condition = 0; assert (AcmeApi_experienced_CantHappen_condition) ; Теперь при срабатывании утверждения сообщение будет заметно понятнее, чем: "assertion failed in file acmeapi.cpp, line 101: 0" (утверждение нарушено в файле acmeapi.срр, строка 101: 0) Существует другой способ, позволяющий нам выдавать более подробную информацию и избавиться от символа подчеркивания, который делает сообщение менее привлекательным. Поскольку в языках С и C++ допускается неявная интерпре- тация указателей как булевых выражений (см. раздел 15.3), мы можем использовать тот факт, что литеральные строки имеют ненулевое значение, и объединить читаемое сообщение с проверяемым выражением: ^define MESSAGE_ASSERT(m, е) assert((m && e)) Его следует использовать следующим образом: MESSAGE_ASSERT(’Inconsistency in internal storage. Pointer should be null when size is 0, or non-null when size is non-0", (NULL != 1ц_р) == (0 != iRjsize)); Теперь мы имеем гораздо больше информации относительно нарушения утвержде- Ия- И поскольку строка является частью выражения, она будет удалена из рабочей Рсии программного обеспечения. Добавлять можно любую информацию.
58 Часть 1. Базовые концепции 1.4.2. Неуместные утверждения Утверждения полезны для проверки инвариантов в отладочной версии программ- ного обеспечения. Если вы это понимаете, вам едва ли удастся каким-то образом зайти слишком далеко в неверном направлении. Увы, очень часто можно видеть, как утверждения используются для контроля ошибок на этапе выполнения. Канонический пример такого подхода, который, по-ви- димому, является результатом программистских упражнений студента первого курса, связан с проверкой неудачного завершения операции выделения памяти: char *my_strdup(char const *s) { char *s_copy = (char*)malloc(1 + strlen(s)); assert(NULL i s_copy); return strcpy(s_copy, s); } Трудно представить, что кто-то может додуматься до такого. В таком случае вам, вероятно, следует потратить какое-то время на просмотр кода функции grep в какой- нибудь любимой вами библиотеке, где вы найдете подобные примеры контроля выде- ления памяти, файловой обработки и других ошибок времени выполнения. К сожалению, существует своего рода компромиссное, а на самом деле неудачное решение, когда многие программисты стремятся использовать утверждение в дополне- ние к корректной обработке условия отказа выделения памяти: char *my_strdup(char const *s) { char *s_copy = (char*Jmalloc(1 + strlen(s)); assert(NULL != s_copy); return (NULL s_copy) ? NULL s strcpy(s_copy, s); } В действительности, я не могу понять, как можно додуматься до такого решения. Учиты- вая то, что почти каждый программист разрабатывает программное обеспечение на настольном компьютере, имеющем системы виртуальной памяти, существует единствен- ная возможность столкнуться при отладке с проблемами управления памяти - когда встраи- ваемые модули требуют использования механизма выделения памяти в ограниченной облас- ти или когда такой механизм вам навязан программными интерфейсами вашей библиотеки времени выполнения. Но влияние таких отклонений более существенно, когда они применяются совмест- но с другим, обычным подходом к контролю условий. Когда подобный подход, напри- мер, применяется при обработке файлов, вы просто привыкаете к добавлению опера- торов контроля состояния файлов в определенных местах программного кода, а не пы- таетесь добиться действительной защищенности от возможных ошибок. Это фактиче- ски гарантирует неправильную работу системы после ее развертывания. Если проблема возникает из-за нарушения условия во время выполнения про- граммного обеспечения, зачем вам нужно перехватывать нарушение условия утверждения? Разве не должна программа завершиться аварийно, если вам не удалось
59 Глава 1 Принудительное проектирование: ограничения, соглашения и утверждения создать корректный программный код, и тем самым не удалось обеспечить рабочий м программы? Если ваш программный код будет содержать даже «супер-умное» И вождение [Robb 2003], это характеризует вас не с лучшей стороны, и любой, кто пензирует программный код, созданный вашей командой, увидит это. По моему мнению, применение утверждений для контроля условий отказа во время рабочего выполнения, даже если это сопровождается программным кодом, обеспечи- вающим соответствующую обработку, в лучшем случае отвлекает внимание, а в худшем случае является очень плохой практикой. Нельзя так делать! Рекомендация: применяйте утверждения, чтобы убедиться в правильности структуры программного кода, а не в правильности рабочего режима. 1.4.3. Синтаксис и 64-битовые указатели Другие проблемы1 связаны с применение указателей в утверждениях. В средах, где тип int имеет 32-битовый формат, а указатели - 64-битовый, применение в чистом виде указателей в утверждениях при определенном определении макроса assert () может привести к выводу предупреждения об усечении результата2: void *р = . . .; assert(р); // Предупреждение: усечение Конечно, здесь сделана ошибка, относящаяся к проблемам синтаксиса, которые обсуждаются в разделе 17.2.1, и фактически именно с подобного примера я стал про- являть повышенное внимание к проблемам булевых выражений. Необходимо четко оформлять свои мысли: void *р = . . . ; assert(NULL ! р); // Теперь замечательно 1.4.4. Избегайте применения макроса verify!) Какое-то время назад я обсуждал с одним человеком его собственное определение макроса утверждений, который предполагалось назвать verify (), чтобы избежать конфликтов с макросом стандартной библиотеки. Увы, такое решение проблематично По Двум причинам. ^^норвых, макрос VERIFY () входит в известную библиотеку Microsoft’s Founda- ion Classes (MFC). Он используется для тех же целей, как и assert (), но оператор Ня Овн°й компиляции в действительности не обеспечит его удаление, и он будет выпол- Ся в любом случае, как, например, в следующем примере: 2 Я На,°' ЧТ° ВСе ЭТ° °ченЬ интеРесно’ но я полагаю, что это стоит знать. ивсообТаЛКИВаЛСЯ С такой ситуацией, когда работал раньше на компьютере Alpha компании «Dec», ениях сетевых конференций я встречал подобные примеры для других платформ.
60 Часть 1. Базовые концепции tifdef NDEBUG « define verify(x) ((void)(х)) /* x все же «имеет» значение */ #elif /* ? NDEBUG */ # define verify(х) assert(x) #endif /* NDEBUG */ Программисты, привыкшие пользоваться макросом verify (), пытаясь его пере- определить на макрос assert (), будут очень изумлены, когда хорошо оттестирован- ный в отладочном режиме программный код будет вдруг аварийно завершаться в ра- бочем режиме. И пройдет немало времени, прежде чем они поймут, что хотя макрос verify () сначала удаляется из построенного рабочего модуля, но всего несколько минут спустя он опять будет выслежен подключаемыми туповатыми инструменталь- ными средствами. Вторая проблема заключается в том, что для утверждений следует использовать только слово «assert». В этом случае вы можете использовать grep или подобные средства для поиска ваших утверждений, почти всегда получая однозначный результат, и такой подход привычен для программиста. Обнаружив его в программном коде, можно сразу сделать обоснованный вывод, что здесь проверяется некий инвариант (см. раздел 1.3.3). Попытка использовать утверждения с другими именами почти на- верняка испортит эту достаточно ясную картину. Хотя в прошлом я внес свою лепту в макрос verify(), - с семантикой VERIFY () библиотеки MFC, - теперь я считаю его применение опасным. Выражение утверждений не должно вызывать побочные эффекты, и не так уж трудно приучить себя к такой практике: я думаю, что прошло уже несколько лет, как я перестал делать эту ошибку. Но если вы используете одновременно разные макросы утверждений, некоторые из которых могут давать побочный эффект, а другие - нет, очень просто запутаться и очень трудно выработать стабильные привычки. Теперь я не пользуюсь макросом verify (), и я бы посоветовал вам поступать так же. 1.4.5. Именование ваших утверждений Поскольку вопрос именования утверждений был поднят в прошлом разделе, позвольте продолжить обсуждение этой темы. Как я уже говорил, макрос утверждений должен содержать слово «assert». Я встречал и пользовался макросами _ASSERTE (), ASSERT (), ATLASSERTO, AuAssertO, stlsoft_assert (), SyAssertO, атакже многими другими. Стандартный макрос утверждений в языках С и C++ имеет название assert ()• Из библиотек STLSoft я использовал макрос stlsof t_assert () и пару других, и во всех случаях для их обозначения использовались строчные буквы. В библиотеках Synesis этот макрос называется SyAssert (). По моему мнению, во всех случаях обозначение этого макроса задается неверно. По принятым соглашениям все макросы должны задаваться прописными буквами, и это правильно, поскольку они отличаются от функций и методов. Хотя вполне допус- тимо использовать assert () для обозначения функции:
Глава 1 Принудительное проектирование: ограничения, соглашения и утверждения 61 // предполагается компиляция только программного кода С++ flifdef ACMELIB_ASSERT_IS_ACTIVE extern "С" void assert(bool expression); #else /* ? ACMELIB_ASSERT_IS_ACTIVE */ inline void assert(bool ) {} #endif /* ACMELIB_ASSERT_IS_ACTIVE */ He видно, чтобы такой подход имел какие-то преимущества перед стандартным макросом утверждений. Во-первых, компилятор не сможет оптимизировать выраже- ние утверждения. Ну, строго говоря, он способен это сделать во многих случаях, но не во всех, даже если вы пользуетесь самыми лучшими оптимизирующими компи- ляторами. Насколько бы сильно не отличались компиляторы и проекты, в принципе, всегда будет создаваться много лишнего программного кода. Другая проблема заключается в том, что некоторые типы не могут неявно преобра- зовываться в тип bool и int или какой-то другой выбранный вами тип выражения. Поскольку канонический макрос assert () включает заданное выражение в опера- торы if или while, а также в условное выражение оператора for или в условный оператор (?:), то все неявные преобразования в булев тип (см. раздел 13.4.2 и гл. 24) осуществятся. Ситуация будет совсем другой, когда вы передаете такие условные выражения функции, принимающей тип bool или int. И, наконец, вы не сможете выводить на экран выражение, как часть сообщения о нарушении утверждения, полученного на этапе выполнения программы, поскольку такое преобразование входит в обязанности препроцессора, но не является харак- терной чертой собственно языка C++ (или С). Утверждения в настоящее время и, возможно, навсегда останутся макросами, и поэтому их имена следует задавать прописными буквами. Это не только обеспечива- ет единый стиль кодирования программы, но в таком случае их легче выделять, и это всегда облегчает жизнь программиста. 1.4.6. Избегайте применения директивы #ifdef _DEBUG В разделе 25.1.4 я указываю на то, что условие default выносится за оператор переключателя switch из соображения эффективности программного кода, и что вместо него используется утверждение. Один из моих уважаемых рецензентов, чей послужной список гораздо больше моего, подверг сомнению такой подход и предло- жил использовать более простой: switch(type) #ifdef _DEBUG default: assert(0); break; *endif // DEBUG
62 Часть 1. Базовые концепции Этот пример показывает, как легко все мы, даже самые опытные, становимся жерт- вами допущений относительно нашей собственной среды разработки. Этот пример имеет несколько незначительных недостатков. Во-первых, assert (0) может стать причиной довольно неинформативных сообщений об ошибках, что зависит от под. держки данным компилятором утверждений. Это можно легко исправить: default: { const int unr«cognized_switch_cas« 0; assert(unrecognized_switch_case); } Но все же маловероятно, что будет получено более информативное сообщение, чем при исходной, но более многословной форме: assert( type == cstring || type == single || type == concat || type == seed); Главная проблема применения символа _DEBUG заключается в том, что он не обяза- тельно заставит компилятор сгенерировать утверждение. Прежде всего, _DEBUG, насколько мне известно, преимущественно используется компиляторами персональных компьютеров. Многие компиляторы по умолчанию строят отладочный модуль, и только определение символа NDEBUG заставляет компилятор перейти в режим построения рабочей версии программы и исключить утверждения. Естественно, правильный подход заключается в применении независящей от компилятора абстракции режима построения программы, чтобы можно было обойтись следующей конструкцией: ftifdef ACMELIB_BUILD_IS_DEBUQ default: assert(0); #endif // ACMELIB_BUIbD_IS_DEBUO Но даже это решает не все. Вполне разумно обеспечить поддержку различных уровней отладочной функциональности при построении предварительных версий вашего программного продукта. Вы могли бы использовать свои собственные утверждения, которые включались бы и отключались вне зависимости от определения СИМВОЛОВ _DEBUG, NDEBUG И даже ACMELIB_BUILD_IS_DEBUG. 1.4.7. DebugAssert() и int 3 Хотя это является особенностью архитектуры Win32 + Intel, ее стоит упомянуть- поскольку она очень полезна и на удивление очень мало известна. Функция Debug* Break () программного интерфейса Win32 вызывает прерывание процедуры вызовов- генерируя исключение контрольной точки. Это позволяет отлаживать автономный процесс или прервать текущий процесс вашей среды IDDE (Integrated Developm#11 & Debugging Environment - интегрированная среда разработки и отладки), тем самЫ*' позволяя вам изучить стек вызовов или предпринять любые другие отладочные дейсТ' вия, которые вам по душе.
Глава 1 принудительное проектирование: ограничения, соглашения и утверждения При использовании архитектуры Intel эта функция просто исполняет машинную инструкцию int 3, которая приводит к генерации исключения контрольной точки Intel-npoueccopa. Небольшое неудобство заключается в том, что в тот момент, когда управление переда- ется вашему отладчику, точка выполнения находится внутри функции DebugBreak (), а не в программном коде, который стал причиной исключения. Простое решение заключа- ется в использовании встроенного ассемблера при компиляции программы на платформе Intel. Библиотека программ языка С этапа исполнения компилятора Visual C++ обеспечива- ет функцию _CrtDebugBreak () как часть инфрастуктуры отладки, которая определяет- ся для платформы Intel следующим образом: #define _CrtDbgBreak() __asm { int 3 } Применение инструкции int 3 означает, что отладчик останавливается точно в том месте, где необходимо, то есть в строке программного кода с данной инструкцией. 1.4.8. Статические утверждения времени компиляции До сих пор мы рассматривали утверждения, осуществляющие контроль на этапе выполнения программы. Но гораздо лучше обнаруживать ошибки не на этом этапе, а при компиляции. Во многих местах книги мы упоминаем о статических утверждени- ях, называемых также утверждениями времени компиляции, и сейчас настало время подробного их рассмотрения. В основном статическое утверждение предназначено для проверки правильности выра- жения на этапе компиляции. Поскольку такое утверждение подтверждается при компиля- ции, необходимо обеспечить возможность его оценки на этом этапе. Это уменьшает диапа- зон выражений, допустимых при реализации статических утверждений. Например, вы могли бы использовать статическое утверждение, чтобы убедиться в правильности ваших предположений относительно размеров типов int и long вашего компилятора: STATIC_ASSERT(sizeof(int) == sizeof(long)); Но следует отметить, что они не могут использоваться для оценки выражений вре- мени выполнения: • • . Thing::operator [](index_type n) STATIC_ASSERT(n <= size()); // Ошибка компиляции - // это действительно так! Если статическое утверждение не выполняется, компиляция прекращается. Сис++КУ статические утверждения, как и многие другие возможности языков том ' Не являются характерной особенностью самого языка, но побочным эффек- оЧе ПРИменения других средств языка, смысл сообщения об ошибке будет далеко не иДен. Вскоре мы увидим, какими странными они могут быть. зуя Обычно статическое утверждение реализуется через определение массива, исполь- Качестве его размерности логическое выражение. Поскольку в языках С и C++
64 Часть 1. Базовые концепции логическому значению «истина» при его преобразовании в целое число присваивается значение 1, а логическому значению «ложь» - значение 0, логическое выражение может задавать размер массива 1 или 0. Нулевая размерность массива не допускается в языках С и C++, и поэтому компилятор в данном случае выдаст сообщение об ошиб- ке. Рассмотрим следующий пример: «define STATIC_ASSERT(x) int ar[x] STATIC_ASSERT(sizeof(int) < sizeof(short)); Поскольку размер типа int никогда не может быть меньше размера типа short (стандарт С++-983.9.1; 2), выражение sizeoflint) < sizeoffshort) даст значение 0. Поэто- му строка с макросом STATIC_ASSERT() преобразуется в: int аг[0]; Это не допустимо в языках С и C++. Конечно, такой подход приводит к некоторым проблемам. Массив аг объявляется, но не используется, что заставляет многие компиляторы вывести предупреждение и прекратить формирование исполнительного модуля1. Во-вторых, применение макроса STATIC_ASSERT () два или более раз в рамках одного модуля приводит к многократному определению массива аг. Чтобы избежать этих проблем, я определяю статические утверждения следующим образом: «define STATIC_ASSERT(ex) \ do { typedef int ai[(ex) ? 1 : 0]; } while(O) Это прекрасно срабатывает для большинства компиляторов. Однако некоторые компиляторы не пропускают массив нулевой размерности, и поэтому приходится использовать директивы условной компиляции: «if defined(ACMELIB_COMPILER_IS_GCC) || \ de fined(ACMELIB_COMPILER_IS_INTEL) # define STATIC_ASSERT(ex) \ Ao { typedef int ai[(ex) ? 1 : -1]; } while(0) «else /* ? compiler */ # define STATIC_ASSERT(ex) \ do { typedef int ai[(ex) ? 1 : 0]; } while(0) «endif /* compiler */ Применение недопустимой размерности массива не является единственным меха- низмом реализации статического утверждения. Мне известно два других интересных механизма [Jagg 1999], хотя я их не использую, за что я себя сильно ругаю. Первый способ основан на том, что каждый вариант переключателя должен рас' сматривать различные значения: «define STATIC_ASSERT(ex) \ switch(0) { case 0: case ex:; } Вы задаете предупреждениям уровень серьезности «высокий» и рассматриваете их как ошибки, не так ли9
65 Г ава 1 Принудительное проектирование: ограничения, соглашения и утверждения Второй способ использует тот факт, что битовые поля должны иметь размерность, не меньше единицы: «define STATIC_ASSERT(ex) \ struct х { unsigned int v : ex; } Все три формы приводят к выводу одинаково непонятных сообщений об ошибке, когда утверждение «срабатывает». Вы увидите, например, такие сообщения, как "case label value has already appeared in this switch" (повторение варианта переключателя) или " the size of an array must be greater than zero" (размер массива должен быть больше нуля), и поэтому вам не просто будет понять суть проблемы, когда вы находитесь в структуре вложенных шаблонов. Пытаясь устранить подобную путаницу Андрей Алексадреску (Andrei Alexan- drescu) в работе [Alex 2001] описывает метод, улучшающий сообщения об ошибке, и добивается максимального результата, который возможен в пределах текущих огра- ничений языка1. Что касается меня, я стремлюсь избегать подобных сложных решений по трем причинам. Во-первых, я ленив и мне нравится избегать сложностей по мере возмож- ности2. Во-вторых, я написал много программного кода на С и также на C++, и я предпочитаю пользоваться одинаковыми средствами в обоих языках, если это возможно. Наконец, статические утверждения срабатывают в результате неправильного кодирования компонента. Это означает, что они редко встречаются и находятся в ком- петенции программиста, который их нарушил. Поэтому я полагаю, что разработчику потребуется затратить лишь пару минут, чтобы отследить ошибку (однако вовсе не обязательно, что этого будет достаточно для нахождения решения), и такая цена только в редких случаях может рассматриваться как слишком большая. Прежде чем мы завершим данную тему, стоит отметить, что формы как с недопус- тимым индексом массива, так и с размером битового поля обладают общим преимуще- ством - они могут применяться вне функции, а вариант с переключателем (и утвержде- ния времени выполнения) не может. 1.4.9. Утверждения: заключение Этот раздел описывает основные принципы использования утверждений, но они имеют много других интересных применений, которые остались за рамками данной книги. Существует два совершенно различных, но одинаково полезных метода: SMART ASSERT [Toij 2003] и SUPER_ASSERT [Robb 2003], и я советую вам познако- Миться с каждым из них. 1 Ва ’----------------- 2 СЛеДует самому убедиться. Подход достаточно остроумный. в Также полагаю, что имеет смысл поддерживать наиболее простые методы, хотя я должен признать, что Мозго ЬКИХ частях Данной книги мне приходилось временами достаточно сильно нагружать триллионы моих случа- п КЛеток’ и поэтому я не могу серьезно утверждать, что это является реальной причиной в данном Дело просто в лености.
Глава 2 Проблемы жизненного цикла объекта 2.1. Жизненный цикл объекта Каждый объект C++ в процессе жизненного цикла проходит четыре этапа: несуще- ствование, частичное построение, инстанциирование и частичное уничтожение [Stro 1997]. Более того, пространство, занимаемое объектом, должно быть выделено еще до его построения и освобождено после его уничтожения1. Объекты могут создаваться четырьмя стандартными способами: • глобальные объекты (глобальные переменные, пространства имен и статические классы) существуют за рамками любой функции. Они (обычно) создаются до выполнения функции main () и автоматически уничтожаются после ее заверше- ния (раздел 11.1). Память, которую они занимают, выделяется компилятором или компоновщиком; • объекты стека существуют ограниченное время, пока выполняется функция. Они создаются в точках своего объявления - являющихся также точками определения - и автоматически уничтожаются, когда управление выходит из диапазона их види- мости. Память, которую они занимают, выделяется путем соответствующей настройки указателя стека компилятором или компоновщиком; • динамические объекты (heap objects) существуют в свободной области динамиче- ской памяти [Stro 1997]. Они создаются с помощью оператора new и уничтожаются при явном применении оператора delete. Память, которую они занимают, выде- ляется из свободной памяти, которой может оказаться недостаточно, что приводит к невозможности создания нового объекта (см. раздел 32.2, где подробно рас- сматривается такая ситуация и возможные последующие действия). Инфраструк- тура языка гарантирует, что выделение памяти и вызов конструктора осуществ- ляются совместно, и то же самое происходит при освобождении памяти, когда вызы- вается деструктор; • объект может быть частью другого объекта. В данном случае жизненный цикл такого объекта будет привязан к жизненному циклу составных объектов (см. гл. 5). 1 Это теоретически. Занимаемое объектом пространство может не освобождаться после его уничтожения и повтор1* использоваться. Например, так происходит в контейнерах STL.
Глава 2. Проблемы жизненного цикла объекта 67 2 .1.1- Построение объекта «по месту» Кроме указанных стандартных способов вы можете непосредственно управлять памятью и жизненным циклом экземпляров объектов, используя оператор new и уничтожая их явным образом, как, например, в следующем примере: byce_t *р = . . • // Корректно выровненный блок памяти для SomeClass SomeClass &sc = *new(p) SomeClassО; И Создание экземпляра sc.SomeMethod(); sc.-SomeClass О ; // Явное уничтожение экземпляра; указатель р сохраняет // значение Естественно, это опасная практика. Если не брать в расчет реализацию контейнеров (которые хранят значения элементов, а не их ссылки), законные основания применения этого метода возникают редко. 2.2. Контроль ваших клиентов Одним из важных и мощных средств языка C++ является его способность огра- ничивать доступ к объектам на этапе компиляции. Используя ключевые слова специ- фикаторов доступа public, protected и private [Stro 1997] совместно с ключе- вым словом friend, мы можем контролировать использование типов клиентским программным кодом. Такой контроль доступа к типам во многих случаях может быть очень полезен, и он применяется в различных методах, описанных в данной книге. 2.2.1. Типы-члены Мощным средством управления манипулированием экземплярами вашего класса является объявление членов класса с модификатором const и/или применение типа ссылки. В связи с тем, что константы так же, как и ссылки (в том числе ссылки с ключе- вым словом const) могут инициализироваться только один раз и им впоследствии не может быть присвоено никакое значение, использование констант и ссылок в опреде- лении класса не позволяет компилятору определить оператор копирующего присваива- ния. И, кроме того, это помогает вам, автору программного кода, и любому, кто его со- провождает, поскольку принуждает выполнять ваши первоначальные проектные реше- ния. Если нельзя осуществить изменение класса без нарушения этих ограничений - чему способствует ваш компилятор - то это указывает на то, что, по-видимому, необ- ходимо внести изменения в структуру проекта, и подчеркивает важность любых изме- нений, которые могут потребоваться для вашего класса. Это классический пример смиренного программирования. (Следует отметить, что это, прежде всего, метод принудительного навязывания про- ектных решений, позволяющий такие решения как бы передавать тем, кто будет сопро- в°ждать этот программный код в будущем. Будет неправильно, если пользователи ВаШего класса узнают от компилятора о том, что не следует использовать копирующее
68 Часть 1. Базовые концепции присваивание, получая предупреждения о членах-константах. Вместо этого они должны понять это, благодаря применению вами явной зашиты от использования копирующего присваивания, как описано ниже.) 2.2.2. Конструктор по умолчанию Этот конструктор будет автоматически «скрыт», если вы определяете любой другой конструктор, и поэтому вам особо не следует стараться это делать самому. Такое поведение имеет смысл, если вы определяете класс, конструктор которого имеет параметры, и поэтому вполне разумно, что не требуется конструктор по умолчанию (то есть конструктор без параметров). Рассмотрим класс, который контролирует диапа- зон действия какого-нибудь ресурса (см. гл. 6): ему нет смысла иметь конструктор по умолчанию. К каким потерям это приведет? Сокрытие конструктора по умолчанию в классе, в котором не определен никакой другой конструктор, приводит к тому, что ни один экземпляр этого класса не будет создан (если не брать в расчет методы-друзья и статические методы самого класса), хотя это не предотвращает их уничтожение. 2.2.3. Копирующий конструктор по умолчанию Вне зависимости от того, определили вы или нет конструктор по умолчанию или любой другой конструктор, компилятор будет всегда генерировать конструктор копиро- вания, если вы сами его не предоставите. Для классов определенного вида? - например, которые содержат указатели, ссылающиеся на выделенные ресурсы, - применение предоставленного компилятором конструктора копирования по умолчанию может привести к наличию двух экземпляров класса, которые, казалось бы, должны соответст- вовать одному и тому же ресурсу. Это ни к чему хорошему не приведет. Если вам не нужен конструктор копирования, следует обеспечить его недоступ- ность, используя форму без реализации [Stro 1997]: class Abe ( // Реализация не требуется private: Abe(Abe const &); }; Рекомендуется применять сгенерированную компилятором реализацию копирующе- го конструктора только в том случае, когда вы имеете простой тип значения (см. гл. 4), который не является владельцем никаких ресурсов (см. гл. 3).
Глава 2. Проблемы жизненного цикла объекта 69. 2.2.4. Копирующее присваивание Как и в случае с копирующим конструкторам, компилятор будет генерировать для вас если вы сами его не определите. И снова вам следует это допускать только в тех случаях, когда вы имеете дело с простыми типами данных. Если у вас имеются константные члены или члены-ссылки, компилятор не сможет сгенерировать версию по умолчанию, но вам не следует пользоваться этим для запре- щения применения копирующего присваивания по двум причинам. Во-первых, вы по- лучите много неприятных предупреждений компилятора, который попытается преду- предить о совершенном вами умышленном недосмотре. Во-вторых, применение константных членов в качестве механизма принудительно- го проектирования может не только запрещать копирование, но очень часто это являет- ся отражением общей невосприимчивости. Если вы изменяете допустимый уровень невосприимчивости, то можете по-прежнему запрещать применение копирующего присваивания. Поэтому лучше явно определить этот оператор, используя форму без реализации [Stro 1997]: class Abe ( // Реализация не требуется private: Abe boperator «(Abe const &); }; Копирующее присваивание и копирующий конструктор запрещаются чаще совме- стно, чем по отдельности. Если вам необходимо иметь только что-то одно, с помощью данного метода добиться этого достаточно просто. 2.2.5. Операторы new и delete Эти операторы используются для создания элемента в динамической памяти и его уничтожения. Ограничение их применения означает, что экземпляры класса должны будут создаваться в определенных границах видимости (глобальных и/или переменных стека). Каноническая форма сокрытия операторов new и delete напоминает то, как осуществлялось сокрытие копирующего конструктора и оператора копирующего при- сваивания. Если вы хотите спрятать в данном классе и в его производных классах эти ^оераторы (разумеется, кроме случая тех классов, которые его переопределяют), 0 ьтчно вы не предоставляете его реализацию, как, например, в следующем примере: class Shy 1! Реализация не требуется Private: void ‘operator new(size_t) ; void operator delete(void *);
70 Часть 1. Базовые концепции Кроме управления доступом к ним мы можем также сами успешно реализовать собственную версию этих операторов для каждого класса [Меуе 1998, Stro 1997] и каждой единицы компоновки (link-unit) (см. разделы 9.5 и 32.3). Однако существуют ограничения по управлению доступом к этим операторам, поскольку они могут переопределяться в любом производном классе. Если вы даже сделаете их закрытыми в базовом классе, ничто не помешает в производных классах сделать их открытыми. Поэтому ограничение доступа к операторам new и delete в действительности носит лишь формальный характер. Несмотря на это, определенную пользу дает их объявление (и определение) в базо- вом классе как защищенных (protected), что обеспечивает общую схему выделения ресурсов и позволяет любым производным классам, использующим динамическую память, переопределять их собственные открытые версии исходя из защищенных версий, которые они наследуют. 2.2.6. Виртуальный оператор Delete Оператор delete обладает еще одним интересным свойством, проявляющимся при использовании в вашем классе виртуального деструктора. Стандарт (С++-98: 12.4; 11) устанавливает, что «при отсутствии в классе оператора delete его поиск будет выпол- нен в области видимости класса деструктора». Хотя применение виртуального деструк- тора и сокрытие оператора delete является довольно существенным отклонением от нормы, но если вы все же этим пользуетесь, вам следует учитывать необходимость обеспечения фиктивной реализации, чтобы избежать ошибок при компоновке системы. 2.2.7. Ключевое слово explicit Ключевое слово explicit применяется для того, чтобы компилятор не мог использо- вал, данный конструктор класса в неявных преобразованиях. Например, функция f () может принимать аргумент типа String и реализовывал, вызов f ("Literal С-string") путем неявного преобразования литеральной строки в тип String, если строка имеет такой конструктор, как: class String { public: String(char const *); Отсутствие ключевого слова explicit провоцирует выполнение компилятором преобразования, которое может оказаться нежелательным или дорогостоящим. Приме- нение для конструктора ключевого слова explicit указывает компилятору на невоз- можность неявного преобразования: public: explicit String(char conat *);
глава 2. Проблемы жизненного цикла объекта 71 Это широко освящается в литературе [Dewh 2003, Stro 1997] и является достаточно понятной концепцией. Ключевое слово explicit рекомендуется использовать в так называемых конструкторах преобразований (conversion costructors) за исключением тех случаев, когда автор класса сознательно хочет применять неявное преобразование. 2.2.8. Деструктор Скрывая деструктор, мы принудительно запрещаем применение фреймовых и гло- бальных переменных, и мы также предотвращаем использование оператора delete по отношению к экземплярам, на которых у нас имеется ссылка. Этим мы можем поль- зоваться, когда нам требуется осуществлять доступ к объекту, принадлежащему к чему-то еще, что мы можем использовать, но не можем уничтожить. Это особенно удобно применять для предотвращения ненадлежащего использования указателей, подсчитывающих ссылки. Следует также отметить, что деструктор предпочтительнее делать таким, чтобы в нем можно было разместить ограничения методов (см. раздел 1.2) шаблонных клас- сов. Рассмотрим шаблон, показанный в листинге 2.1. Листинг 2.1. template <typename Т> class SomeT { public: SomeT(char const *) ( // Ограничение работает, если инстанциируется данная // секция ctor constraintjnust_be_pointer_type(Т); ) SomeT(double ) ( // Ограничение работает, если инстанциируется данная // секция ctor constraintjnust_be_pointer_type(Т); } в Размещенное в любом конкретном конструкторе ограничение проверяется только Том случае, если инстанциируется этот конструктор, а C++ инстанциирует только ходимые члены шаблона. Это хорошо, поскольку позволяет воспользоваться неко- Рыми действительно полезными методами (см. раздел 33.2). его Т° ы гарантировать применение ограничения, вам потребовалось бы поместить МОж ВСе к°нструкторы. Однако существует только один деструктор, и поэтому вы таких 6 СЭКОНОМИТЬ на вводе Данных и избежать головной боли при сопровождении классов, если поместите ограничение в деструктор.
72 Часть 1. Базовые концепции -SomeT() { // Ограничение всегда проверяется, если создан экземпляр класса constraint_piust_be_pointer_type (Т) ; } }; Естественно, это все же не сработает в том случае, если вы в действительности не создаете ни одного экземпляра типа и только вызываете его статические методы. Но почти во всех случаях вы сможете обеспечить основную часть вашего программного кода ограничениями, размещенными в деструкторе. 2.2.9. Ключевое слово friend Необдуманное применение ключевого слова friend (друг) может свести на нет все наши усилия по управлению доступом. У данной директивы есть сторонники, которые со мной могут не соглашаться, но я полагаю, что ее применение должно ограничиваться только классами, совместно использующими один и тот же заголовочный файл. Поскольку именно хорошо выполненный проект [Dewh 2003, Lako 1996, Stro 1997] устанавливает, что классы и наборы функций должны располагаться в их собственных заголовочных файлах, то только сильно взаимозависимые - как, например, классы последовательностей и их класс итератора или тип значения и их операторы свободных функций - должны совместно использовать один заголовочный файл. Поэтому нежела- ние избежать здесь применения ключевых слов friend, в конце концов, создаст для вас определенные трудности. Если вы в максимальной степени придерживаетесь практики определения свобод- ных функций при реализации методов класса, потребность в этих ключевых словах существенно снижается. Хорошим примером служит определение свободной функции operator +( X const &, X const) в виде X::operator +=(Х const &)• Мы рассмотрим это подробно в гл. 25. Редактируя этот раздел, я решил отметить и подсчитать количество моего собствен- ного (ненадлежащего) использования ключевого слова friend. Это ключевое слово встречалось 41 раз в библиотеках STLSoft на момент написания книги. Из них 29 опре- деляют связь последовательность-итератор-тип значения, а 8 связаны с индексными подтипами классов многомерных массивов (см. раздел 33.2.4). Все остальные связаны с классами одного файла, иногда с вложенными классами и их внешними классами- Я был удивлен такому количеству упоминаний этого ключевого слова, но, по крайней мере, я не поступил вопреки своей собственной рекомендации. 2.3. Списки инициализации членов и их достоинства Существует семь различных типов, которые вы, возможно, захотите инициализировав в теле конструктора. Ими являются:
Глава 2. Проблемы жизненного цикла объекта 73 1 Классы непосредственных родителей. 2 Виртуальные базовые классы1. 3 Константные переменные-члены. 4 Ссылочные переменные-члены. 5 Неконстантные, нессылочные переменные-члены пользовательских типов, которые имеют конструкторы не по умолчанию. 6 Неконстантные, нессылочные скалярные переменные-члены, то есть те, которые рассматриваются нами как «нормальные» переменные-члены. 7 . Переменные-члены массивов. Из всех семи только последний тип, переменные-члены массивов, не может быть инициализирован в списке инициализации членов (member initializer list или MIL), а первые пять - должны. Обычные неконстантные, нессылочные скалярные перемен- ные-члены могут быть «инициализированы» либо в списке инициализации, либо в пределах тела конструктора. Фактически в данном случае над ними выполняется операция присваивания, а не инициализации. Хотя в клиентском программном коде класса это может выглядеть как инициализация и не требовать применения других инструкций в случае скалярных типов, последовательность «инициализации» будет отличаться от последовательности объявления членов. В тех редких случаях, когда вы можете положиться на упорядоченность операторов, объявляющих и инициали- зирующих члены, - что является плохой практикой (см. раздел 2.3.2), - вас может ожи- дать неприятный сюрприз. Независимо от корректности таких крайних случаев их применения, существуют хорошие основания использования списков инициализации [Stro 1997], поскольку может оказаться так, что вы станете выполнять достаточно тру- доемкое присваивание тому, что уже имеет свой нетривиальный конструктор, тем самым расплачиваясь лишними затратами времени и ничего не получая взамен. Если вы, также как и я, отдаете предпочтение константным переменным, то вы, без сомнения, будете часто использовать списки инициализации, но я бы советовал всем при- менять их в любом случае. Это не только поможет вам избежать ненадлежащего примене- ния переменных-членов нетривиальных типов (то есть дорогого конструктора по умолча- нию плюс присваивание), но также улучшит согласованность программного кода: это сильно недооцененный аспект разработки программного обеспечения, хотя и является уличительной чертой хорошо разработанного программного обеспечения [Кет 1999] также помогает при наличии исключений, как мы увидим в разделе 32.2. И|©НсттГ способа, позволяющего иметь виртуальный базовый класс с какими-либо данными ^Укторами, не так ли? [Меуе 1998, Dewh 2003, Stro 1997].
74 Часть 1. Базовые концепции Если исходить из методики смиренного программирования, списки инициализации членов также предпочтительнее константных членов, как это описано в предыдущем подразделе. Более того, вы можете не допускать использования программного кода, подобного представленному в примере листинга 2.2, который построен на основе реального программного кода, который был отдан мне «на улучшение». Листинг 2.2. class Abe : public Base { // Члены protected: CString m_strl; int m_int 1 ; int m_int2; CString m_str2; CString m_str3; int m_int3; CString m_str4; int m_int4; CString m_str5; int m_int5; int m_int6; . . . // и так далее m_strl; m_int1; m_int2; m_str2; m_str3; m_int3; m_str4; m_int4; m_str5; m_int5; m_int6; Abe::Abe(int il, int i2, int i3, int i4, int i4 , LPCTSTR pcszl, LPCTSTR pcsz2 , LPCTSTR pcsz3, LPCTSTR pcsz4) : Base(int i) , m_strl(pcszl) , m_str2(pcsz2) m_str3 = pcsz3; m_intl = il; m_int2 = i2; m_int3 = i3; nK_atr2 - pcsz2;. . . // через много строк m_int3 = i3; m_«tr2 - pcsz4; // Уф! m_int2 i2; m_int6 = i6; . // и так далее В теле конструктора было более 20 лишних и потенциально опасных присваива- ний. В остальной части реализации класса некоторые переменные-члены никогда не изменялись после выполнения конструктора. Применение модификатора const немедленно приводит к выявлению некоторых из таких проблем. Прояснив ситуацию» я перевел все в список инициализации (используя автоматизированное средство), и компилятор указал на повторения. На это ушло 10 минут и избавило класс от неиз- бежных потерь, не говоря уже о паре ошибок.
75 Глава 2. Проблемы жизненного цикла объекта —2X1. Увеличение области применения списков инициализации Один из аргументов против применения списков инициализации членов заключается том чт0 область его применения достаточно мала. Обычно жалуются на трудности в Штативного и эффективного манипулирования аргументами и на подтверждения их достоверности при ограничениях, накладываемых на списки инициализации членов. Этим обосновывают неприменение как списков инициализации, так и константных и ссылочных членов. Однако я считаю, что это, в основном, неверно, и при небольшом воображении мы можем обеспечивать устойчивую, разумно ограниченную (то есть оста- ваясь в рамках смиренного программирования) инициализацию. Рассмотрим следующий класс: class String { и Конструктор public: String(char const *s); И Члены private: char *m_s; }; В своей книге «C++ Gotchas» {Особенности языка C++) [Dewh2003] Стив Дьюхерст (Steve Dewhurst) отмечает две версии реализации этого конструктора (в раз- деле, где он ярко демонстрирует и другие случаи предпочтительности инициализации членов над применением оператора присваивания). String::String(char const *s) : m_s(strcpy(new char[strlen(s ? s : +1], s ? s : "")) (} String::String(char const *s) ( if(s == NULL) ( S = " " ; } m_s = strcpy(new char[strlen(s) + 1], s); Стив утверждает, что первая форма заходит слишком далеко - с чем, я полагаю, многие согласятся - и отдает предпочтение второй форме. Откровенно говоря, я бы не м ьзовался ни одной из этих форм. Почему бы не вспомнить о смиренном програм- му вании (попытка которого делается в первой форме), но, воспользовавшись ’ Дать себе передышку. Решение (листинг 2.3) очень простое. Листинг 2.3. String Реализация Private: tabic char *create_atring_(char const *);
76 Часть 1. Базовые концепции // Члены private: char const *const m_s; }; /* static */ char *Stringxxcreate_string_(char const *s) < i£(s -- NULL) s - } return strcpy(new char[strlen(s) ♦ 1], s); } String::String(char const *s) x хц_8 (creat e_s t ring_ (s ) ) {} Вместо того, чтобы запутаться в непонятной белиберде или скатиться до примене- ния неудачных практических методов, мы можем достичь ясности выражений и пра- вильной инициализации, помещая логику во вспомогательную функцию, определен- ную с ключевыми словами private static. Все теперь оказывается простым, не так ли? Следует отметить, что особое поведение конструктора строк String не ме- няется. Это является важным аспектом применения данного метода. Экземпляр String может быть сконструирован либо из указателя, ссылающегося на строку, либо из литеральной пустой строки. (Как мы увидим в разделе 15.4.3, литеральные константы могут быть или не быть составными - такие литералы объеди- няются компоновщиком в один литерал - поэтому такая реализация является час- тичной, а более полная столкнулась бы с данной проблемой.) Также отметим изменение определения m_s. Поскольку в примере Стива нет операций, связанных с каким-нибудь изменением его класса String, мы можем рас- ширить диапазон действия нашей «закрытости», определяя m_s как константный указатель, ссылающийся на строку-константу. Если впоследствии мы изменим опреде- ление этого класса, добавив операции изменения либо содержимого буфера, либо указателя буфера, компилятор напомнит о нарушении нами первоначальных наших проектных решений. Это очень хорошо, и получение сообщения о такой ошибке не означает, что мы сделали что-то неверно просто потому, что мы можем сделать нечто- нарушающее первоначальные проектные решения. Дело в том, что мы будем выну*" дены думать об этом, и это как раз хорошо. Новая форма имеет два других преимущества. Менее важное заключается в том- что определение теперь имеет ясную форму, и мы можем хорошо видеть намерени” конструктора, который «create_string_()» (создает строку). Более важным преимуществом является централизация создания содержимого строк11' которое предположительно может использоваться в другом месте расширенного опреде^' ния типа String. (И в самом деле, в реальных, написанных мною классах строк час^ существует несколько конструкторов, использующих одну статическую функцию создан*1’’ строки. Естественно, это упрощает сопровождение программного кода и уменьшает егС
Глава 2 Проблемы жизненного цикла объекта 77 но не за счет снижения эффективности.) Часто можно увидеть, как одна и та же Р ^^ повторяется в многочисленных конструкторах одного класса. Это иногда централи- л° ся в методе Init (), который вызывается из каждого тела конструктора, но не обес- печивает эффективности, достигаемой с помощью списков инициализации, и снижает об- ласть видимости константных членов и членов-ссылок. Применение такой статической вспомогательной функции способствует централизации операций, связанных с конструк- тором, при этом не жертвуя ни эффективностью, ни надежностью. Этот метод фактически может использоваться для моделирования средств языка Java, обеспечивающих вызов одного конструктора из другого. Следует отметить, что данный метод не обязан быть статическим для того, чтобы произвести желаемый эффект. Однако если он нестатический, это позволяет использо- вать такое состояние члена в методе, когда оно неопределенно, поскольку конструиро- вание экземпляра еще не завершено. Поэтому лучше применять более смиренный подход и всегда пользоваться статической вспомогательной функцией. Мы рассмотрим несколько более сложных примеров ее применения в гл. 11, когда дойдем до обсуждения методов создания адаптивного программного кода. 2.3.2. Порядок инициализации членов Одна особенность применения списков инициализации заключается в том, что пере- менные-члены инициализируются в порядке их объявления, независимо от порядка списка инициализации. Естественно, рекомендуется [Stro 1997, Dewh 2003] в списке указывать их в том порядке, в каком они объявляются в классе. На самом деле вам придется потратить большие усилия при сопровождении программного кода на то, чтобы обеспечить соответ- ствие порядку объявлений в списке инициализации, и вам следует проконтролировать это, когда вас попросят внести изменения в программный код других авторов. Возможность обеспечить себе трудности вам гарантирована [Dewh 2003, Меуе 1998], как и неудачный конец. Struct Fatal ( Fatal(int i) Y(i) . x(y * 2) (} int x; int y; Несмотря Fatal будут на обманчиво-безобидный вид списка инициализации, экземпляры ,.v . иметь произвольный «мусор» в своих членах х, поскольку на момент зав ализации х переменная у еще не инициализирована. Вам следует избегать таких ивь И считать это правилом. Только компилятор GCC обнаруживает их ^Дает предупреждение (при использовании опции -Wall). Уело МОтРя на 37:07 здравый совет, данная книга посвящена выживанию в реальных х» когда в отдельных случаях имеет смысл использовать столь опасные мето-
78 Часть 1. Базовые концепции ды. Что может сделать неидеальный практик, чтобы защитить свой программный код в котором играет важную роль порядок инициализации переменных-членов, от опас. ных изменений при сопровождении? Ответ заключается в использовании утверждение этапа компиляции (раздел 1.4). Пример такой защиты порядка инициализации членов можно найти в первоначальной реализации шаблона auto_buffer, который мы подробно рассмотрим в разделе 32.2. Конструктор содержал следующее защищающее утверждение: auto_buffer:: auto_buffer(size_type cltems) : m_buffer((space < cltems) ? allocator_type::allocate(cltems, 0) : m_internal) , m_cltems((m_buffer != 0) ? cltems : 0) ( STATIC_ASSERT( offsetof(class_type, m_buffer) < offsetof(class_type. m_clterns)); Этот программный код не будет компилироваться, если изменить в определении класса порядок членов m_buffer и m_cItems. Это означает, что сомнительная практика расчета на определенный порядок инициализации членов оказалась надеж- ной, а реализация класса устойчивой и переносимой. Увы, этот конкретный класс не является лучшим примером, позволяющим нам «обойти закон», поскольку это достигается путем использования макроса of f setof для констант- ных переменных-членов, что само по себе в данных обстоятельствах является нестандарт- ным подходом, как мы показываем в разделе 2.3.3. Самая последняя версия этого класса допускает изменение размера, и поэтому преимущество константности m_cItems спорно. Поэтому, скорее всего, лучше пере- писать конструктор следующим образом: auto_buffer:: auto_buffer(size_type cltems) : m_buffer((space < cltems) ? allocator_type::allocate(cltems, 0) : m_internal) { m_cltems = (m_buffer != 0) ? cltems : 0; Теперь нет необходимости придерживаться определенной упорядоченности членов и, следовательно, применять не совсем законный макрос of f setof (). 2.3.3. Макрос offsetof() Макрос of f setof используется для получения на этапе компиляции констан?^ представляющей собой количество байтов смещения члена структуры от ее начал3 Его каноническая реализация выглядит следующим образом: #define offsetof(S, m) (size_t)&(((S*)0)->m)
Глава 2. Проблемы жизненного цикла объекта 79 Это очень полезная вещь, и без него нам приходится испытывать большие трудно- сти при выполнении многих умных и полезных вещей, например, метод обеспечения свойств в языке C++ (см. гл. 35) без него был бы не так эффективен Увы, его использование законно только по отношению к типам POD: стандарт уста- навливает, что типы, к которым он применим, «должны быть структурой POD или объединением POD» (стандарт С++-98: 18.1). Это означает, что как и в случае cauto_buffer, его использование недопустимо для типов классов, и это потенци- ально может привести к неопределенному результату. Тем не менее, он широко используется, в том числе в нескольких популярных библиотеках, и его применение с такими типами, как auto_buf f er, полностью обосновано. Основная причина того, что стандарт регламентирует его использование только с POD, объясняется невозмож- ностью получения точного значения на этапе компиляции, когда применяется множе- ственное виртуальное наследование. Текущие правила, вероятно, чрезмерно строги, но даже в этом случае компоновка в памяти типов зависит от конкретной реализации. Поскольку я прагматик, я продолжаю его использовать там, где он, по моему мнению, необходим, хотя я предпринимаю защитные меры: утверждения времени выполнения и статические утверждения (см. гл. 1), а также тестирование. Если вы решили следовать тем же путем, просто сознательно проявляйте осторожность, чтобы когда сторонник применения законных средств языка возвестит вашим коллегам о несоответствии вашего программного кода, вы могли разоружить его вашим знанием этого несоответствия и затем дезориентировать рациональностью его применения в данном конкретном случае с предоставлением списка протестированных сред, где вы доказали правильность его работы. 2.3.4. Список инициализации членов: заключение Если не брать в расчет крайние случаи, то рекомендация отдавать предпочтение спискам инициализации будет правильной, и это справедливо почти во всех случаях, если не учитывать массивы. Я всегда удивлялся, когда разработчики оправдывают использование оператора присваивания для инициализации, утверждая, что они стре- мятся создавать программный код, соответствующий мастеру (плохо написанному) инструментальных средств разработки. Ирония в том, что поколение разработчиков программного обеспечения выросло с вредной привычкой, навязанной недостатками применения инструментальных средств, спроектированных для упрощения и усо- вершенствования практических приемов работы с ними.
Глава 3 Инкапсуляция ресурсов Вместе с абстракцией и полиморфизмом инкапсуляция является одним из основных принципов объектно-ориентированного программирования. Инкапсуляция ресурсов уточняет это понятие, и она подразумевает, что инкапсулированные данные в действитель- ности представляют собой ссылки на выделенные ресурсы, причем термин «ресурсы» здесь имеет широкий смысл. В качестве ресурса могут выступать экземпляры других клас- сов, выделенная память, системные объекты, состояния программного интерфейса или библиотеки и состояния объектов. Инкапсуляция данных используется для защиты внутреннего состояния инкапсу- лированного типа, чтобы гарантировать непротиворечивость этого типа или обес- печить абстрактный открытый интерфейс к нему. Инкапсуляция ресурсов, с другой стороны, создает оболочку ссылки на ресурс для обеспечения более устойчивого интерфейса доступа к этому ресурсу и лучшего управления им; другими словами, она является средством защиты самого ресурса. Отличие тонкое, и в реальных условиях эти понятия, несомненно, часто перекрываются, но понимать их отличие все же необ- ходимо. В данной главе и в гл. 4, которая посвящена инкапсуляции данных и типам значений, будут рассмотрены оба типа инкапсуляции. Хотя многие современные языки программирования очень широко пользуются инкапсуляцией данных, C++ более чем на голову опережает своих собратьев из семей- ства языков С в отношении поддержки инкапсуляции ресурсов благодаря применению двуединого механизма конструирования и автоматического, детерминированного уничтожения. В данной главе мы рассмотрим детали механизма инкапсуляции ресурсов и исследуем различные уровни инкапсуляции, допускаемые в языке. 3.1. Таксономия инкапсуляции ресурсов В классических работах [Stro 1997] инкапсуляция ресурсов обсуждается исход” из механизма захвата ресурсов при инициализации (RAII, см. раздел 3.5). Однако, каЬ и для многих других понятий, связанных с разработкой программного обеспечен11”' существует несколько уровней инкапсуляции ресурсов. Первичный уровень инкапО ляции ресурсов, конечно, означает полное ее отсутствие. Кроме того, нам необхоДиМ<? рассмотреть службы, обеспечивающие инкапсуляцию ресурсов:
Глава 3. Инкапсуляция ресурсов 81 автоматический захват одного или нескольких ресурсов; удобный интерфейс манипулирования одним или несколькими ресурсами; автоматическое освобождение ресурсов. Имея этот список, мы можем постулировать следующую таксономию инкапсуля- ции ресурсов: 1. Отсутствие инкапсуляции. 2. Типы POD (раздел 3.2). 3. Прокси-оболочки (раздел 3.3). 4. Типы RRID (раздел 3.4). 5. Типы RAII (раздел 3.5). В данной главе в ходе нашего исследования этой концепции мы собираемся исполь- зовать два примера. Прежде чем перейти к ним, я должен сказать, что эта и следующая главы потребовали больших затрат времени на обдумывание (в отличие от времени, потраченного на записи), чем любые другие главы этой книги. Это произошло не оттого, что эти главы имеют дело с очень сложными концепциями, а потому что обсу- ждаемые в них два понятия очень похожи, и попытка обрисовать отличия между ними представляет собой, как минимум, очень сложную задачу. Как следствие, вы можете посчитать, что используемые в данной главе примеры являются немного запутанными, но прошу вас отнестись к этому с пониманием и учитывать тот факт, что основное внимание здесь уделяется демонстрации четкого отличия этих концепций. 3.2. Типы POD Инкапсуляция типов POD представляет собой самую простую форму некой попытки повышения устойчивости, достигаемой благодаря агрегированию одной или нескольких ссылок на ресурсы в рамках структуры. Давайте представим, что мы имеем некоторый воображаемый сетевой сервер, который сспечивает выбор служб на основе анализа различных требований клиентов. Каждая слУжба представляется следующей структурой: struct Service { socket_t txChannel; 11 сокет передачи данных socket_t rxChannel; // сокет приема данных mutex_t ‘lock; // обслуживание конкурирующих объектов byte_t ‘txBuffer; // буфер передачи size_t txBufferSize; // кол-во байтов в буфере передачи byte_t ‘rxBuffer; // буфер приема size_t rxBufferSize; // кол-во байтов в буфере приема
82 Часть 1. Базовые концепции 3.2.1. Прямое манипулирование В самой примитивной форме манипулирование службой Service могло бы выгля- деть примерно следующим образом: Service service; ...II Инициализировать службу и выполнить некоторые соединения pthread_jnutex_lock(service. lock); int i = recv( service.rxChannel, service.rxBuffer , service.rxBufferSize); ...II Много других строк низкоуровневого программного кода pthread_jnutex_lock(bservice.unlock); На самом деле создание такого программного кода вызывает определенные трудно- сти, и сомнительно, чтобы он имел высокое качество и мог легко сопровождаться. Если изменяется определение Service, то потребуется также изменить тысячи строк программного кода. Существует несколько хорошо известных программных интерфей- сов, позволяющих вручную выполнить большую часть этой работы, но, к счастью, большинство программных интерфейсов языка С имеют много функций, которые могут помочь вам в этой работе. 3.2.2. Функции программного интерфейса и прозрачные типы Естественно, если ли бы вы использовали программный интерфейс этой службы, то, по-видимому, применяли бы (или написали, если бы их еще не было) функции про- граммного интерфейса для манипулирования структурой Service на более высоком уровне, например: int i = Service_GuardedReceive(&service, . . .); Такой подход представляет собой одну из самых распространенных моделей про- граммирования. Он достаточно удобен и вполне надежен. Многие операционные сис- темы построены подобным образом. Тем не менее, все же существуют проблемы - здесь недостаточно используется инкапсуляция. Поскольку клиентский программный код осуществляет доступ к структуре, у пользователей этого программного интерфей- са возникает очень большой соблазн применять его везде там, где надо и не надо. Если даже они всего лишь бросают беглый взгляд на структуру и ничего не меняют, зависи- мость от формата структуры все же делает такой программный код достаточно уязви- мым, и его поддержка может вызывать достаточно большие проблемы. 3.2.3. Функции программного интерфейса и непрозрачные типы Если в клиентском программном коде не требуется осуществлять доступ к этой структуре или риск прямого доступа к ней слишком велик, функциональная модель программного интерфейса значительно улучшится, если сделать структуру «непро- зрачной». Это можно осуществить несколькими способами; структура обычно объяв- ляется, но не определяется для открытого доступа.
Глава 3. Инкапсуляция ресурсов 83 // serviceAPI.h struct Service; int Service_Create(. . . init parameters . . , Service “svc); int Service_Open(. . . init parameters . . . Service “svc); int Service_Destroy(Service ‘svc); int Service_GuardedReceive(Service ‘svc, . . . ); Структура Service определяется только в реализации программного интерфейса, и поэтому клиентский программный код о ней ничего не знает, и ему безразличен ее формат. И, что важнее, он не имеет непосредственного доступа к содержимому струк- туры и не может установить нежелательные зависимости. Ниже представлено небольшое усовершенствование этого интерфейса, усили- вающее синтаксическую непрозрачность, что может быть полезно, хотя здесь это не имеет особого значения. // ServiceAPI.h struct Servicelnternal; typedef struct Servicelnternal ‘Service; int Service_Create(. . . init parameters ...» Service *svc); int Service_Open(. . . init parameters .... Service ‘svc); int Service_Destroy(Service svc); int Service_GuardedReceive(Service svc, . . . ); Теперь Service не является структурой, указателями которой можно манипу- лировать, - она теперь является непрозрачным типом. Для тех, кто любит, чтобы в син- таксисе отражались особенности объектов, эту структуру можно обозначить как HService, имея в виду, что она является дескриптором службы. 3.3. Прокси-оболочки Небольшим, но часто полезным шагом вперед по сравнению с типами POD являют- ся прокси-оболочки. Такие типы ничего не предусматривают в отношении автоматиче- ского захвата или освобождения ресурсов; они лишь упрощают применение типов, которые содержат. Допустим, мы работаем с подключаемым программным продуктом, выпол- няющим синтаксический анализ исходного текста, который требует регистрации ^ами динамической библиотеки (см. гл. 9), принимающей уведомления от анализа- а при возникновении определенных событий. Точка входа может выглядеть как в листинге 3.1 |°ц>Рыс^ веР°ятно, что программный интерфейс обратного вызова будет написан на «чистом» С по причинам, суждаются во второй части книги.
84 Часть 1. Базовые конц епции Листинг 3.1. enum SPEvent struct ParseContext { // Функции обратного вызова void *(‘pfnAlloc)(size_t cb); void (‘pfnFree)(void *p); void *(*pfnRealloc)(void *p, size_t cb); int (‘pfnLookupSymbol)( char const ‘name, char *dest , size_t ‘pcchDest); 11 Данные-члены char const *currTokBegin; char const ‘currTokEnd; }; int SrcParse_LibEntry(SPEvent event, ParseContext ‘context); Конечно, работать co структурой ParseContext не очень удобно, как видно из листинга 3.2. Листинг 3.2. // MyPlugln.cpp int SrcParse_LibEntry(SPEvent event, ParseContext ‘context) { switch (event) { case SPE_PARSE_SYMBOL: MyPlugIix_ParseSymbol (context) ; } void MyPlugIn_ParseSyiribol(ParseContext ‘context) { size_t tokLength; char ‘tokName; size_t cchDest; tokLength = 1 + (context->currTokEnd -context->currTokBegin) ; tokName = strncpy( (char*) (‘context->pfnAlloc) (sizeof (char) * tokLength), context->currTokBegin, tokLength) • size_t cchDest = 0; int cch = (*context->pfnLookupSynibol) (tokenName, NULL, &cchDest);
85 Глава 3. Инкапсуляция ресурсов Не очень элегантно, не так ли? Кроме того, что работа с таким программным кодом вязана с большой головной болью, нам приходится идти на риск непосредственного менения значений указателей для организации логики нашего программного кода и в лучшем случае, схематичной обработки ошибок и исключений. Все это преодолено языке C++, и поэтому мы несомненно можем улучшить данный вариант. Решение проблемы заключается в том, что почти все сложности можно легко заключить в оболочку прокси. Листинг 3.3. class ParseContextWrapper { public: ParseContextWrapper(ParseContext ‘context) : m_context(context) (} void ‘Alloc(size_t cb) ( return (*m_context->pfnAlloc)(cb); ) string CurrentToken() ( return string(m_context->currTokBegin, m_context->currTokEnd); ) string LookupSymbol(string const &token) ( int OnParseSymbol(); private: ParseContext *m_context; ); Что приводит к получению значительно более простого пользовательского про- граммного кода Листинг 3.4. int SrcParse_LibEntry(SPEvent event, ParseContext ‘context) ParseContextWrapper cw(context); switch(event) ( case SPE_PARSE_SYMBOL: return OnParseSymbol () ) catch(ParseException &x) (
86 Часть 1. Базовые концепции Подобные программные интерфейсы представляют собой типичные примеры прокси-оболочек. Тип ParseContextWrapper, которому передается указатель Раг- s eContext, ни коем образом не является его владельцем - он просто позволяет в клиентском программном коде применять более простой и, по-видимому, лучше оттес- тированный интерфейс по сравнению с базовым программным интерфейсом. Следует отметить, что я сделал член m_context закрытым по привычке; вы можете при жела- нии оставить его открытым, поскольку клиентский программный код так или иначе осуществляет непосредственный доступ к структуре ParseContext, а от старых при- вычек трудно отказаться1. 3.4. Типы RRID Предыдущие механизмы относятся к компетенции программиста языка С [Lind 1994]. Даже там, где мы использовали C++ для улучшения работы с прокси-оболочками, инкап- суляция позволяла лишь сделать их применение более удобным. Когда нам необходимо управлять жизненным циклом ресурсов, программист C++ имеет больше возможностей. Когда я объяснял «облицовочный» класс sequence_container_veneer (veneer class, см. гл. 21), который мы вскоре обсудим, моему другу Скопу Паттерсону (Scott Patterson), он заметил, что описанный мною механизм в действительности не относится к RAII (см. раздел 3.5), поскольку нет захвата класса - только его освобождение при уничтожении. Он тогда предложил производный термин освобождение ресурса при уничтожении (Resource Release Is Destruction - RRID). Он мне понравился, и именно по этой причине (а не по какой другой, более существенной) этот термин столь же сложно понять, как и его более полный старший аналог2. Определение: освобождение ресурса при уничтожении является механизмом, который использует преимущества поддержки языком C++ возможности автома- тического уничтожения объектов, чтобы гарантировать детерминированное осво- бождение ресурсов, связанных с экземпляром инкапсулированного типа. Несмотря на то, что RRID никак не регламентирует захват ресурса, он все же может быть очень полезным. Это объясняется тем, что именно уничтожение объекта и осво- бождение выделенных ему ресурсов могут привести к их потере при преждевремен- ном или ненадлежащем выходе из области их видимости, из-за чего нам придется надеяться на инфраструктуру очистки памяти, которая обеспечивается компилятором- 1 Скажите спасибо, что я все же не параноик, - я не стал использовать ссылку на указатель в конструкт0ре и устанавливать этот указатель в значение NULL, чтобы никто не мог ею воспользоваться. 2 Лично я думаю, что здесь слово Is (является, есть) следует заменить на At (при), и тогда эти термины буЛ) более осмысленными, но не я их придумал (RRID буквально означает «освобождение ресурса есГЬ уничтожение», a RAII - «захват ресурса есть инициализация», но при переводе используется именно указаНн‘'1Я автором более осмысленная интерпретация этих терминов. - Примеч. пер.
Глава3. Инкапсуляция ресурсов 87 ( int device; if( . . . ) // Некоторый критерий выборки ( device = open(. . .); } else ( device = socket(. . .); ) ... 11 Если вы здесь вернете управление или выбросите исключение, // устройство останется открытым close(device); } Пока объект не создан, ничего нельзя потерять, и поэтому нет необходимости авто- матизировать конструирование объекта. Поэтому типы RRID характеризуются встроенной семантикой их уничтожения. Однако дело не просто в пропуске этапа инициализации; результаты выполнения деструктора при неинициализированных данных будут неопределенными, что означает почти наверняка его аварийное завершение при первой вашей важной демонстрации - именно той, когда ваш работодатель надеется получить контракт, благодаря которому компания останется на плаву. Нет, я вовсе ничего не преувеличиваю. Наличие или отсутствие (выполняемой по умолчанию) инициализации определяет две особенности типов RRID, которые обсуждаются в последующих подразделах. 3.4.1. Инициализация по умолчанию: «ленивая» инициализация Используемая по умолчанию форма инициализации типов RRID подразумевает ус- тановку ссылки на ресурс в инкапсулированном типе на некоторое нулевое значение, что приведет к корректному ее уничтожению, которое обычно означает отсутствие каких-либо действий. Минимальная форма инициализации по умолчанию типа RRID могла бы выглядеть следующим образом: struct DeviceCloser { DeviceCloser() // Инициализация по умолчанию : m_device(NULL) (} -DeviceCloser() // Деструктор ( close(m_device); // Закрыть дескриптор устройства } int m_device;
88 Часть 1. Базовые концепции Это можно вставить в программный код, который приводился ранее, и предотвра- тить утечку памяти. При желании вы можете переменную device (устройство) уста- новить на ссылку экземпляра указателя DeviceCloser («закрыватель» устройства) для минимизации изменений программного кода: { DeviceCloser de; int *&device = dc.m_file; iff ) // Какой-нибудь критерий выборки £ device = open(. . .); else device = socket(. . .); ...II Что бы здесь ни произошло, устройство будет закрыто } // Теперь устройство закрыто Структура DeviceCloser имеет конструктор, который инициализирует свой член m_device допустимым значением, но она не использует никаких ресурсов и поэтому не является типом RAII (см. раздел 3.5). (Я использовал структуру, посколь- ку все ее члены открытые.) Естественно, предпочтительнее инициализировать обработчик ресурсов в его кон- структоре, но это не всегда возможно или, по крайней мере, удобно. Рассмотрим случай, когда нам требуется выполнить некоторые другие действия при определенном разветвлении логики программного кода, когда мы открываем устройство из сокета. Эти действия могут привести к выбрасыванию исключения. Один из вариантов даль- нейших действий - поместить весь программный код этого разветвления в отдельную функцию, которая сама либо вернет открытый сокет, либо перехватит выброшенное исключение, закроет дескриптор сокета и выбросит еще раз это исключение. Но это не всегда удобно. Классы, подобные DeviceCloser, могут быть полезными (конечно, обобщенные в форму шаблона), но в большинстве случаев лучшие результаты даст применение инкапсуляции более высокого уровня, в чем мы вскоре убедимся. 3.4.2. Форма без инициализации Вторая форма типа RRID вообще не предусматривает инициализацию. Естественно, это достаточно опасно, и поэтому эта форма не очень привлекательная. Из-за отсутст- вия инициализации мы не несем никаких затрат на ее выполнение. Однако это справед- ливо только в том случае, когда инициализация гарантированно будет выполняться в клиентском программном коде. В противном случае произойдет аварийное заверив' ние, когда дело дойдет до выполнения деструктора экземпляра RRID, и это произойди
— ле чем состояние этого экземпляра ^олько редко встречается, что следует о СТ ыт!» Я воспользовался этим только i когда мы рассматриваем облицовочные вформе шаблона pod_veneer, которы деляюших семантику инициализации и Глава3. Инкалсуляция ресурсов______________________________________________89 примет допустимое значение. Этот случай на- беспечить броское предупреждение: «Проезд в одном случае, который я описываю в гл. 21, классы. В данном случае это представлено [й принимает два специальных класса, опре- ее отсутствие при заданной параметризации шаблона. После всего вышесказанного рассмотрим данный метод с другой стороны, когда до- полнительный тип используется в качестве оболочки существующего типа, ресурсы которого хорошо управляемы. В таких случаях этот метод совсем не опасен и чрез- вычайно удобен. Одно обстоятельство, которое всегда раздражает меня в классах контейнеров стан- дартной библиотеки, связано с неправильным освобождением памяти после их ис- пользования. В действительности я не совсем справедлив; они отлично выполняют эту работу, если только обеспечивается правильное управление жизненным циклом соот- ветствующих экземпляров. Проблема возникает в том случае, когда они работают с указателями, поскольку они ничего не делают с объектами, на которые ссылаются указатели, и только обрабатывают непосредственно указатели, а деструктор указателя ничем не отличается от деструктора типов, не являющихся классами: просто ничего не делается. Конечно, с таким фантастическим смешением обобщенности и эффективности нельзя рассчитывать, что будут приниматься в расчет такие особые случаи, но все же это вызывает досаду. И, особенно, когда нельзя использовать в контейнерах определен- ные классы, управляющие жизненным циклом объектов. Если только ссылающиеся на объект указатели не используют подсчет количества ссылок или не могут быть приспо- соблены к применению данного механизма1, нам не останется ничего другого, как обрабатывать содержимое контейнера, непосредственно используя указатели. Вопреки подобным «чудесам» во многих случаях все работает нормально, и когда элементы явным образом удаляются из контейнера, не так уж обременительно не забыть удалить элемент, когда ссылающийся на него указатель удаляется из контей- нера. Это всегда можно упростить в специальном методе класса, использующего кон- тейнер, как показано в листинге 3.5: Листинг 3.5. class Resource; class ResourceManager ( ^СЛЯеМый Указатель _ptr системы Boost может использоваться для обеспечения неагрессивного подсчета
90 Часть 1. Базовые концепции private; typedef std::vector<Resource*> Resources_t; Resources_t m_resources; ); void ResourceManager::EraseResource(size_t index) { delete m_resources[index]; m_resources.erase(&m_resources[index]); ) Остается одна проблема, которую не просто разрешить подобным образом, а именно, при удалении в деструкторе элементов из контейнера где-то в гиперпространстве оста- нутся «болтаться» элементы, на которые были ненулевые ссылки элементов контейнера: утечка памяти (и кто знает, что еще)! Чтобы предотвратить это, автору ResourceMan- ager придется включить следующий программный код в деструктор этого класса: void del_Resource(Resource *); void ResourceManager::-ResourceManager() { std::for_each(m_resources.begin(),m_resources.end (), del_Resource): } Это выглядит не так уж плохо, но, в конце концов, это всего лишь очень простой пример. Я видел, как этот подход использовался в реальных условиях, когда в одном классе применялось до десяти управляющий контейнеров. Даже не рассматривая вопрос о том, что такой подход, возможно, свидетельствует о наличии проблем в про- ектировании, можно сказать, что он вызовет кошмары у тех, кому придется его сопро- вождать, и фактически гарантирует плачевный финал. Шаблон sequence_container_veneer использует в качестве своих пара- метров тип контейнера, для которого должна быть создана оболочка, и тип функтора, который будет применяться для освобождения памяти оставшихся ресурсов (см. лис- тинг 3.6). Листинг 3.6. tempiate< typename С , typename F > class sequence_container_veneer : public C ( public: -sequence_container_veneer() { 11 удалить все оставшиеся элементы функцией F()
91 Глава 3. Инкапсуляция ресурсов ------ std::for_each(begin(), end(), F()); }; Необходимо определить единственный метод, а именно, деструктор; поэтому этот класс является чистым типом RRID. Он использует параметр шаблона, являющийся типом последовательности, на базе которой строится текущий класс. Как показано в листинге 3.7, простое изменение проекта класса ResourceManager в какой-то степени может избавить от головной боли программиста, сопровождающего данный программный код. Листинг 3.7. struct RelRasource void operator ()(Resource *r) { del_Resource(r); } П class ResourceManager ( // Члены private: typedef std::vector<Resource*» Resource*_t; typedef seguence_container_veneer< Resource*—t , RelResource > Resource_vec_rrid_t ; Resource_vec_rrid_t m_resources; }; void ResourceManager::~ResourceManager() ( // Теперь ничего не надо делать ) Ляя^еПеРЬ контейнеР автоматически выполнит в деструкторе все необходимое, избав- с ВаС °7 РУЧН0Г0 кодирования и использования подверженных ошибкам заготовок, подлРИРОВаННЫХ инстРУментальнь1ми средствами. Мы можем усовершенствовать 3°ваннЖКУ автоматического уничтожения, которую обеспечивает C++ для параметри- 1013003 se<3uence—container—veneer, чтобы обеспечить требуемый работы класса, а не создавать специальный класс для решения нашей задачи.
92 Часть 1. Базовые концепции Важно отметить, что само по себе уничтожение оставшихся экземпляров ресурса Re- source обеспечивается благодаря параметризации типа RelResource, а не с помощью такого искусственного образования, как класс sequence_container_veneer. В каче- стве полезного альтернативного сценария можно рассмотреть возможность управления жизненным циклом экземпляров класса Resource в другом месте приложения, выполняя для ResourceManager административную функцию, и при завершении потребуется иметь трассу вызовов, чтобы определить, какие ресурсы не были использованы в ходе выполнения приложения. В действительности любые действия, которые вы можете считать допустимыми для оставшегося ресурса Resource, применимы в ходе уничто- жения ResourceManager с помощью шаблона sequence_container_veneer. Прежде чем мы завершим данную тему, мне бы хотелось подчеркнуть, что если вы хотите использовать какой-нибудь контейнер стандартной библиотеки для размеще- ния в нем ресурсов, а не просто ссылки на них, иногда при определенных условиях можно воспользоваться лучшим подходом, чем применением таких компонент, как sequence_container_veneer. Например, если при работе с вашими объектами производится подсчет ссылок, то вы можете для управления ими использовать класс, подсчитывающий ссылки. Альтернативным является подход, обеспечивающий под- счет количества ссылок на эти объекты внешними средствами. 3.5. Типы RAII Термин захват ресурсов при инициализации (RAII5) является, возможно, самым не- уклюжим1 в области разработки программного обеспечения, но на самом деле в нем заключен в высшей степени простой смысл. Он означает всего лишь то, что инициали- зация (с помощью одного из конструкторов) объекта подразумевает захват ресурса, которым этот объект будет управлять. Неявное его дополнение заключается в том, что отсутствие инициализации (через вызов конструктора) объекта приведет (автоматически) к освобождению ресурса (см. RRID). Определение: захват ресурсов при инициализации является механизмам, который использует преимущества поддержки языком C++ создания и автоматической уничтожения объектов, чтобы гарантировать детерминированное освобожден^ ресурсов, связанных с экземплярам инкапсулированного типа. Его можно рассмотри вать как супермножество по отношению к механизму RRID. 1 Как мы указывали ранее, этот термин буквально переводится как «захват ресурса есть инициализация»- Примеч. пер.
Глава 3- Инкапсуляция ресурсов 93 —Именно этот механизм позволяет объектам освобождать память после их использо- _ в действительности, вместо вас это делает компилятор, автор класса и иногда 83 машина. В этом проявляется мощь поддержки RAII в языке C++. Однако для С ичтожения объекта необходимо вызывать деструктор. УН Объекты, размещенные в стеке, такие как автоматические переменные, уничто- жаются автоматически при выходе из области их видимости. Более того, порядок ичтожения является зеркальным отражением порядка конструирования. Объекты, распределенные в области динамической памяти, уничтожаются при явном их удале- нии (с помощью оператора delete или другим способом явного уничтожения). Уничтожение автоматических переменных осуществляется независимо от способа выхода из области видимости. Это может происходить либо при достижении конца области видимости, либо при встрече оператора возврата из функции, либо при выбра- сывании исключения, либо в результате выполнения оператора goto; во всех случаях выполняются деструкторы. Конечно, здесь от компилятора требуется некоторая допол- нительная работа, но генерируемый код достаточно оптимизирован и очень эффекти- вен. Существенно то, что это гарантированное и детерминированное уничтожение объектов представляет собой очень мощный механизм, который будет применяться во многих местах данной книги. Теперь мы рассмотрим другие особенности RAIL 3.5.1. Неизменяемые и изменяемые типы В некоторых случаях типы RAII захватывают ресурс в конструкторе и освобож- дают его в деструкторе, причем никакие действия по отношению к инкапсулированно- му экземпляру, выполненные в промежутке между этими двумя событиями, не вызы- вают изменение состояния инкапсулированного экземпляра. Такой тип RAII называет- ся неизменяемым (immutable) и, по моему мнению, он является наилучшей формой инкапсуляции ресурсов, поскольку обеспечивает простейшую семантику для создания и применения типов, подобных представленным в листинге 3.8. Листинг 3.8. template <typename Т> class scoping_ptr ( Public: scoping_ptr(T *p) : m__ptr(p) {} ~scoping_ptr() ( delete m_ptr; } T boperator * (); T *operator ->(); Private:
94 Часть 1. Базовые концепции, Т *const m_ptr; private: scoping_ptr(scoping_ptr<T> const &) ; scoping_ptr boperator = (scoping_ptr<T> const &); }; Ресурсы для этого типа выделяются и передаются конструктору, и затем они стано- вятся неинициализированными в деструкторе, и здесь же освобождается занимаемая ими память. В остальной период жизненного цикла экземпляра его содержимое не может быть изменено. Следует отметить, что сокрытие конструктора копирования и операторов копирующего присваивания обеспечивает принудительную неизменяе- мость типа (см. раздел 2.2); указатель m_ptr определяется как константный указатель, очень напоминающий дополнительное ограничение, применяемое для надежности. Классы, управляющие диапазоном действия ресурсов (см. гл. 6), обычно являются неизменяемыми типами RAII, но эта концепция излишне ограничительная в большин- стве применений, где желательно использовать типы значений. С другой стороны, изменяемые (mutable) типы RAII работают таким образом, что они могут инкапсулировать другой ресурс или никакой ресурс. Хорошим примером яв- ляется шаблон std: :auto_ptr<>, поскольку он обеспечивает метод reset () и оператор присваивания для изменения значения управляемого указателя std: :auto_ptr<int> api(new int(D); 11 Содержит тип int api.reset(new int(2)); // Содержит новое значение типа int api.reset(); 11 Ничего не содержит Изменяемый RAII очень полезен, но он приводит к усложнениям семантики таких типов - необходимо поддерживать на протяжении всего времени жизни экземпляра упорядоченную последовательность любого количества отдельных циклов выделения и освобождения памяти. На первый взгляд, это усложнение выглядит как необязательное, но часто необходимое с логической точки зрения. Более того, это может быть более эффек- тивным, когда дорого стоит создание и/или уничтожение управляемого ресурса, а его экземпляры часто не используются. 3.5.2. Внутренняя и внешняя инициализация Как только что мы видели при рассмотрении классов auto_ptr и scoping ресурс создается внешними средствами и передается конструктору экземпляра ‘ который затем рассматривается в качестве его владельца. Это внешняя инициал изаи,,я Противоположна ей внутренняя инициализация, где класс отвечает как за зах ресурса, так и за его уничтожение (см. листинг 3.9).
Глава 3- Инкапсуляция ресурсов_________________________________________ ^Листинг 3.9. class mem_buffer ( public: mem_buffer(size_t size) // Выделить память под буфер : m_size(cb) , m_buffer(new byte_t[size]) (} ~mem_buffer() // Освободить буфер ( delete [] xn_buffer; } public: operator byte_t *(); II Доступ к буферу size_t size() const; private: size_t m_size; byce_t *m_buffer; 3.5.3. Перечисление подтипов RAII Естественно, что четыре сочетания свойств изменяемости и инициализированности со- ответствуют классам различного вида. Например, неизменяемые типы с внутренней ини- циализаций представляют собой «чистую» форму RAII: они имеют самый простой про- граммный код, их легче всего понять, и они достаточно гибкие (что может оказаться по- лезным). Здесь не приходится беспокоиться о плохих начальных значениях, но конструк- тор должен учитывать возможность отказа выделения памяти под ресурсы. Простые вспомогательные классы часто попадают в эту категорию; например, auto_buf fer (см. раздел 32.2). Неизменяемые типы с внешней инициализацией также имеют простой программ- ный код и понятны, но обеспечивают обработку отказов выделения памяти под ресурс 33 счет повышения сложности при работе с нулевыми и недостоверными ссылками на Ресурсы. Большинство классов контроля диапазона действия ресурсов (см. гл. 6) попа- дают в эту категорию. Сложность изменяемых типов значительно выше сложности их неизменяемых и ратьев- На самом деле, во многих случаях такое увеличение сложности излишнее является одним из тех свойств языка C++, которое часто используется для упреков tq4Ho активными энтузиастами объектно-ориентированного подхода. Пока доста- но будет сказать, что такие типы обладают заметно большей гибкостью, но за это Ко ТСЯ платить некоторым усложнением реализации, обеспечивая семантику ваемы аНИЯ И В03деРживаясь от таких полезных ограничений, как, например, наклады- в°зМож применением константных членов и членов-ссылок (раздел 2.2.1). Чем больше МцН0СТеЙ ввшего класса, тем более вероятно его попадание в эту категорию. Увидим примеры всех этих типов классов повсюду в книге.
96 Часть 1. Базовые концепции 3.6. RAII: заключение Немного можно добавить относительно сути концепции RAII6, но существует мно- жество применений, где она может и должна использоваться, и некоторые из них рас. сматриваются в гл. 6. Существует два момента, которые мне бы хотелось подчеркнуть до того, как мы перейдем к другой теме. 3.6.1. Инварианты Первый момент связан с инвариантами классов (раздел 1.3), которые встречаются повсюду в книге. Проще говоря, инвариантом является условие, которое должно выполняться для любого экземпляра класса на протяжении всей его жизни. В С+4 доступ к инвариантам осуществляется либо через применение специальных функций- членов, либо с помощью отдельных проверок, и они могут приводить либо к наруше- нию утверждений, либо к выбрасыванию исключения. Инвариант должен выполняться, начиная с момента завершения работы конструк- тора и до момента начала работы деструктора. Инвариант может временно не выпол- няться только при изменении состояния в функции-члене. Поэтому оценка инварианта делается в конце конструкторов, в начале деструктора, а также в начале и конце функ- ций-членов, особенно не константных. Уровень инкапсуляции ресурсов влияет на действенность инвариантов. Если ваш класс полностью инкапсулирует свои ресурсы, экземпляры могут навязывать инвари- анты на протяжении всего времени их жизни. Степень нарушения инкапсуляции вашим классом соответствует степени применения инвариантов, которая приблизи- тельно соответствует вашей степени уверенности в корректности своего программного кода. Классы RAII могут применять инварианты на протяжении всего времени жизни экземпляров. Классы RRID могут применять инварианты только при уничтожении экземпляров. Типы POD не могут иметь инварианты, поскольку они не имеют доступа через методы, и поэтому негде устанавливать проверки. В действительности, это не совсем так. Потому как типы POD (открытые или непрозрачные) могут обрабатывать- ся функциями программного интерфейса, эти функции могут (и должны) проверять инварианты, но все же такое решение будет неполным. 3.6.2. Обработка ошибок Последний момент, относящийся к типам RRID и RAII, в некоторой степени связан с дискуссией по поводу инициализации при конструировании экземпляра и ужасно «функции создания» (см. раздел 6.3.1). В настоящий момент можно сказать, что неизменяемые типы RAII с внутренн6'1 инициализацией хорошо подходят для применения в условиях, когда учитывается во3 можность генерации исключений при отказе выделения памяти под ресурсы. В ДрУгИ случаях мы попадаем в сферу действия неприятной парадигмы функции создав с контролем успешного ее завершения (раздел 6.3.1).
Глава 4 Инкапсуляция данных и типы значений В прошлой главе мы рассматривали инкапсуляцию ресурсов, подчеркивая ее от- личие от инкапсуляции данных. Если понятие «инкапсуляция ресурсов» больше отно- сится к механизму и меньше касается сути содержимого, то можно сказать, что «инкап- суляция данных» обладает противоположным свойством (хотя их отличие в отдельных конкретных случаях может быть достаточно зыбким). Инкапсуляция данных обеспечивает преимущества классической интерпретации инкапсуляции в объектно-ориентированном подходе: 1. Согласованность данных (coherence of data). Состояние экземпляра объекта может быть инициализировано осмысленным целостным значением, и последующие мани- пуляции над этим экземпляром с помощью методов его интерфейса выполняются как неделимые операции; экземпляр будет иметь непротиворечивые члены перед вызовом метода и после завершения метода. 2. Уменьшение сложности. Клиентский программный код непосредственно исполь- зует открытый интерфейс для манипулирования экземплярами объектов, ничего не зная или не заботясь об уровне внутренней сложности. 3. Невосприимчивость к изменению. Клиентский программный код не зависит от изме- нений внутренней реализации типа; кроме того, это означает поддержку обобщенных методов, оперирующих несколькими типами с использованием подобных открытых интерфейсов, но при различном их внутреннем представлении. Классы, в которых реализуется инкапсуляция данных, могут также реализовать капсуляцию ресурсов - отличным примером являются строки - но инкапсуляция УРсов отвечает на вопрос «как?», а здесь мы собираемся основное внимание делить вопросу «что?». Вд °Лее Т0Г0’ ВОПРОС инкапсуляции данных связан с концепцией типов значений. сущнН° ГЛаве мы собираемся установить отличие между типами значений и типами влияли ТеЙ И ПОдробно исследовать смысл типа значения. Мы также рассмотрим, какое е Различные уровни инкапсуляции оказывают на определения типов значений.
98 Часть 1. Базовые концепции 4.1. Таксономия инкапсуляции данных Мы видели в прошлой главе, что существуют различные уровни инкапсуляции ресурсов: от открытых структур с функциями программного интерфейса для манипу. лирования ими до полностью инкапсулированных классов. Нарушение инкапсуляции данных в языке C++ обеспечивают спецификаторы дос. тупа. Неинкапсулированные данные, которые определены в открытой секции класса (или в секции, которая задается по умолчанию в типах struct или union), доступны в любом другом контексте. (Полностью) инкапсулированные данные определяются в секции класса private или protected. При анализе небольшого набора выбранных типов может показаться, что уровень инкапсуляции и концептуальный уровень типа значения сильно связаны друг с другом. Однако это не всегда так, и не обязательно между двумя понятиями существует какая- то связь. Естественно, это всего лишь еше один пример подмены понятий, способной запутать всех, и нам не следует забывать об их отличии. 4.2. Типы значений и типы сущностей С упрощенной точки зрения - это является моим собственным определением - мы можем характеризовать типы значений как нечто «существующее», а типы сущно- стей как нечто «действующее». Бьерн Страуструп дает отличное определение типам значений - он называет их конкретными типами1: «Цель - ...сделать хорошо и эффек- тивно нечто небольшое... и особенное. Сами они обычно не имеют средств, позво- ляющих модифицировать их поведение». Лангер (Langer) и Крефт (Kreft) [Lang 2002] дают развернутые определения. Типы значений - это «типы, которые имеют содержание, и их свойства существенно зависят от этого содержания. Например, две строки ведут себя по-разному, когда они обладают различным содержанием, и они ведут себя одинаково (и при сравнении считаются оди- наковыми), если имеют то же самое содержание. Это содержание является их самой главной чертой». Они отмечают, что равенство более важно, чем идентичность, что. по моему мнению, является очень важным аспектом концепция типа значений. Лангер и Крефт определяют типы сущностей как типы, «поведение которь'* в значительной мере не зависит от их содержания. Такое поведение является их самой главной чертой». Сравнение типов сущностей на равенство в целом бессмысленно Признаюсь, мне больше нравится простота моего собственного определения (несомненно- это сюрприз!), но уточнение, данное Лангером и Крефтом, существенно. 1 Для меня конкретные типы - это те, которые могут быть проиллюстрированы конкретным пРиме^р другими словами, полные (вы можете видеть определение), а не абстрактные (не имеют чистых виртУаЛ1’ ч методов, которые должны быть заполнены неким содержанием). Ситуация тем более запутанная, поск°- су шествуют даже различные определения абстрактного типа.
99 Глава 4 Инкапсуляция данных и типы значений Понятие типа сущности несет в себе большой спектр свойств, - по крайней мере, включает в себя конкретные типы, абстрактные типы, а также полиморфные и не- олиморфные типы, но в контексте этой главы я рассматриваю их как нечто единое, и «гие из этих понятий используются, а некоторые и разъясняются в последующем ДОНО* нс материале книги. В остальной части этой главы рассматривается концепция типов значений, причем детально разбирается вопрос, связанный с возможностью существования всего лишь одной разновидности типа значения. По привычке, я собираюсь утверждать, что тип значения имеет несколько разновидностей. 4.3. Таксономия типов значений В [Stro 1997] Бьерн Страуструп определяет семантику значения, в отличие от семанти- ки указателей, как не зависящую от копируемых сущностей. Отличная основа, но я пола- гаю, что нам требуется еще кое-что. Один из рецензентов книги «C++; практический подход к решению проблем программирования», Юджин Гершник (Eugene Gershnik) приводит определение типов значений, которое не зависит от языка. Тип является типом значения, если: 1. Экземпляры могут создаваться путем копирования другого экземпляра, и они также могут быть скопированы с другого экземпляра впоследствии. 2. Каждый экземпляр самодостаточен. Любое изменение одного экземпляра не при- ведет к изменению другого. 3. Экземпляры не могут полиморфно заменять или заменяться экземплярами другого типа на этапе выполнения программы. Это определение достаточно привлекательно, однако заключает в себе очень широкий смысл: на мой взгляд, слишком широкий. Позднее в данной главе мы его уточним. Один из подходов - посмотреть можно ли и до какой степени считать их поведение естественным. Например, какой результат можно ожидать при выполнении выражений 8 следующем программном коде? String strl("Original String"); String str2("Imperfect"); String str3("C++"); char const "csl = strl.c_str(); strl = str2 + - if(!str3) ( . . str2.Empty() ++Str; + str3; // 1 } // 2 // 3 // 4
100 Часть 1. Базовые концепции Я бы сказал, что в выражении 1 будет выполняться конкатенация str2, " . и str3 - именно в таком порядке - с помещением результата в переменную stri, причем с перезаписью, расширением или заменой памяти, которая используется ддя представления строки "Original String* после конструирования строки strl2. Я бы также сказал, что в точке завершения выражения 1 указатель csl ока- зывается недостоверным, и его применение впоследствии может дать только неопре- деленный результат. (Конечно, если бы метод String: : c_str () объявлялся с ключевым словом temporary (см. раздел 16.2), то-это не вызвало бы проблем, поскольку присваивание было бы запрещено.) Выражение 2, вероятно, должно означать выполнение содержимого соответст- вующего блока, если строка str3 «отсутствует». Следует отметить, что смысл «отсут- ствия» строки, вообще говоря, неясен: это может означать отсутствие значения или пустое значение строки (" *), а возможно, и то и другое. Это даже может означать, что строка содержит значение "false" (ложь)! Такое выражение имеет неоднозначную интерпретацию, и эта неоднозначность является врагом точности и простоты сопрово- ждения. К сожалению, примеры именно такого программного кода я видел в промыш- ленном программном обеспечении. Третье выражение может означать: сделать пустым содержимое строки str2. Однако это может означать также следующее: вернуть значение, показывающее, пуста ли строка str2 или нет. Если бы это зависело от меня, я бы всегда выбирал первое (Типы и переменные - имена существительные, а методы и функции - сказуемые.) Увы, стандартная библиотека не согласна со мной, и трудно не соглашаться со стан- дартной библиотекой1. Выражение 4 бессмысленно. Я не могу представить, что опера- тору инкремента (++) можно придать какой-то разумный смысл для строки в языке C++2. (См. приложение Б, где даны свидетельства далекого-далекого прошлого в даль- ней-дальней галактике, когда это было не так.) Ожидается, что встроенные типы ведут себя именно так, как им предписано, и это нельзя изменить. И поэтому мы должны обеспечить, чтобы наши типы вели себя ожи- даемым образом в рамках определяемых для них операторов. Если вы создаете целый тип с повышенной точностью, чей оператор operator - = () выполняет деление по модулю, вы не будете поняты. Поведение типов в максимальной степени должно напоминать поведение целых типов [Меуе 1996]. В остальной части данной главы мы исследуем то, что я называю спектром концеп- туальных уровней типов значений. По моему мнению, существует четыре уровня: 1 В библиотеках STLSoft мне пришлось поступиться принципами и пойти в общем русле: строчные бук®14 подчеркивания, empty() и т. д. 2 В Perl и в некоторых других языках создания сценариев строка может увеличиваться на единицу, когда интерпретируется как числовой тип, ее значение увеличивается и затем число преобразуется в строк? удобно для языка Perl, но сомневаюсь, что это хорошо подходит для программного кода на C++.
Глава 4. Инкапсуляция данных и типы значений 101 1. Открытые типы: простые структуры плюс функции программного интерфейса. Инкапсулированные типы: частично или полностью инкапсулированные типы классов, работа с которыми ведется с помощью методов. 3 Типы значений: полностью инкапсулированные типы классов, включая операторы присваивания и сравнения (на равенство и неравенство). 4 Арифметические типы значений: для числовых значений, и они включают все арифметические операторы. Типами значений можно считать все или только два последних типа - это зависит от того, что вам больше нравится. Какую бы точку зрения вы не приняли, они сущест- вуют, поскольку занимают вполне определенные уровни в этом спектре и используются в реальных условиях. 4.4. Открытые типы 4.4.1. Открытые типы POD Открытые типы являются простыми типами, данные-члены которых свободно дос- тупны и обычно вообще не имеют методов; другими словами, это агрегатные типы (стандарт С++-98: 8.5.1; 1). Рассмотрим THnuinteger64: struct uinteger64 { uint32_t lowerVal; uint32_t upperVal; }; Данная структура действительно очень простая. Однако от этого она не становится полезнее и поэтому является превосходным примером открытого типа значения, поскольку такие типы, в основном и по преимуществу, достаточно бесполезны. ^Применять открытые типы в качестве типов значений, как минимум, накладно. нельзя использовать в простых операциях сравнения, а выполнять над ними арифме- ские операции можно только непосредственно оперируя их составными частями. uinteger64 il = . . .; uinteger64 i2 = . . .; bool bLess = il < i2; // bool bEqual = il == i2; // uinteger64 i3 = il + i2; 11 °ПеРагор°ЛЖНЬ1 сказать спасиб° языку Ошибка компиляции! Ошибка компиляции! Ошибка компиляции! C++, за то, что он по умолчанию отвергает эти Л’Чии п™’ ПОсколькУ мы, по крайней мере, можем избавиться от них на этапе компи- 3tqc ,Полнение поэлементного сравнения членов типа было бы очень опасно. НОоткУДа В б°льшинстве случпев при сравнении на равенство и неравенство, Компилятор может знать приоритет переменных-членов в операции сравнения
102 Часть 1. Базовые концепции «меньше, чем»? (Однако следует отметить, что для обеспечения обратной совместимости со структурами компилятором допускаются копирующий конструктор и копирующее присваивание(см. раздел 2.2.) Несмотря на серьезные неудобства их применения, существуют случаи, когда мы используем такие типы, как правило, при взаимодействии с операционной системой или библиотечным программным интерфейсом, например, для выполнения арифме- тических операций повышенной точности [Hans 1997]. В иллюстративных целях мы предположим, что у нас имеются веские основания для продолжения работы с 64-би- товыми целыми типами, и определим программный интерфейс для работы с ними. void Ul64_Assign( uinteger64 *lhs, uint32_t higher , uint32_t lower); void UI64_Add( uinteger64 ‘result, uinteger64 const *lhs , uinteger64 const *rhs); void UI64_Divide( uinteger64 ‘result, uinteger64 const *lhs , uinteger64 const *rhs); int Ul64_Compare(uinteger64 const *lhs, uinteger64 const *rhs); fdefine Ul64_IsLessThan(pil, pi2) (Ul64_Compare(pil, pi2) < 0) #define Ul64_IsEqual(pil, pi2) (0 == UI64_Compare(pil, pi2)) fdefine UI64_IsGreaterThan(pil, pi2) (0 < UI64_Compare(pil, pi2)) Применяя этот программный интерфейс, операции предыдущего программного кода можно использовать на законных основаниях: uinteger64 il = . . .; uinteger64 i2 = . . . ; bool bLess = Ul64_IsLessThan(il, i2); bool bEqual = UI64_IsEqual(il, i2) ; uinteger64 i3t U!64_Add(&i3, &il, &i2); Но выглядит это очень не привлекательно. Естественно, C++ позволяет нам все сделать гораздо лучше. 4.4.2. Структуры данных С++ Прежде чем мы, с «кровожадным безумством» следуя объектно-ориентированным принципам, энергично возьмемся за преобразование во всех наших исходных текста' каждой структуры в класс (struct в class), важно отметить, что открытые типы (о которых мы до сих пор говорили) имеют составные элементы, образующие некий логически цельный объект, и поэтому независимая обработка одной из составляют”4 представляет очевидный риск нарушения логической целостности всего типа. Существуют также такие открытые типы, которые не представляют никакой опасно сти, и с ними вполне надежно можно работать подобным образом. Важно установит1” может ли манипулирование отдельными составляющими палями привести к существе” ному нарушению смысла содержимого.
Инкапсуляция данных и типы значений 103 —^смотрим следующее представление типа значения для валюты: Struct Currency { int majorUnit; 11 Доллары, фунты, рубли int minorUnit; II Центы, пенсы, копейки }; Данный открытый тип опасен, поскольку можно добавить значение к minorUnit, которое приведет к тому, что значение экземпляра Currency станет логически не допустимым. Однако в высшей степени разумно применить следующий открытый тип: Struct Patron { String name; Currency wallet; ); Поля названия валюты (name) и кошелька (wallet) внутренне не связаны, и изме- нение одного из этих полей, на первый взгляд, не приведет к нарушению логической целостности типа Patron. Такие типы называются (в языке C++) структурами данных [Stro 1997]. Естественно, существуют и промежуточные варианты, но приведенные выше два примера являются двумя крайностями: одна «черная», другая «белая». 4.5. Инкапсулированные типы Открытые типы значений POD являются «хрупкими» типами, и полезность их при- менения ограничивается обстоятельствами, при которых использование более высокого концептуального уровня очевидно приведет к нарушению каких-то других требований, например, связанных с производительностью, взаимодействием языков или их совме- стным использованием. Очевидно, что большинство типов не будут достаточно при- годны для применения и не будут достаточно устойчивы, пока они не будут соответст- вовать нашему следующему уровню - инкапсулированному типу. (Следует отметить, что это определение в равной степени применимо для типов сущностей.) Определение: инкапсулированные типы обеспечивают доступ и манипулирование ^стоянием экземпляра через устойчивый открытый интерфейс, и клиентскому РОммному коду не следует -ив этом нет необходимости - осуществлять доступ ^Реи"^у состоянию членов экземпляров таких типов. Инкапсулированные типы го со еЧивают полное умозрительное отделение логического состояния от физическо- вцС0НКапсУЛиРованные типы в целом обеспечивают более высокий (обычно самый КиЮ и, что особенно важно отметить, надежный уровень сокрытия деталей реали-
104 Часть 1. Базовые концепции зации, и они содержат методы надежного доступа к внутреннему представлению значения. Например, члены lowerVal и upperVal типа uinteger64 могут обра- батываться в методах Add(), Assign О и в подобных методах этого класса. И по- скольку методы используются для разграничения доступа к внутреннему состоянию экземпляра и для манипулирования им, могут принудительно навязываться инвариан- ты классов (раздел 1.3), значительно улучшая качество программного кода. Типы также могут обеспечивать дополнительные методы для того, чтобы в клиент- ском программном коде не пришлось вручную программировать обычные и ожидае- мые операции. К сожалению, это открывает лазейки для произвола, даже если мы явля- емся добропорядочными гражданами и любим, чтобы везде был порядок. Положение дел можно улучшить, если, по мере возможности, всегда отдавать предпочтение свободным функциям, а не методам класса. Поэтому нашу class’Hyio реализацию можно было бы представить в виде класса UInteger64 (см. листинг 4.1), который включает uinteger64 в качестве перемен- ной-члена (чтобы можно было использовать программный интерфейс UI64_* ()). Листинг 4.1. class UInteger64 { public: UInteger64(); UInteger64(uint32_t low); UInteger64(uint32_t high, uint32_t low); #ifdef ACMELIB_CCMPILER_SUPPORTS_64BIT_INT UInteger64(uint64_t low); #endif /* ACMELIB_COMPILER_SUPPORTS_64BIT_INT */ UInteger64(UInteger64 const &rhs); 11 Операции сравнения public: static bool lsLessThan(Ulnteger64 const &il, UInteger64 const &i2); static bool IsEqual(UInteger64 const &il, Ulnteger64 const &i2); static bool IsGreaterThan(UInteger64 const &il. UInteger64 const Ы2В II Арифметические операции public: static UInteger64 Multiply(UInteger64 const &il, UInteger64 const &i2)> static UInteger64 Divide (UInteger64 const &il, UInteger64 const &i2)» private: uinteger64 revalue; }; 4.6. Типы значений Находясь на уровне инкапсулированных типов необходимо сделать небольШ0’1 шаг, чтобы получить то, что я называют действительно типами значений. По мое1*1' мнению, отличительной особенностью типа значения является возможность его срав' нения на равенство [Aust 1999, Muss 2001]: это обеспечивает значимые результаты '
Глава 4 Инкапсуляция данных и типы значений 105 смысле определения Лангера-Крефта [Lang 2002] (см. раздел 4.2) - операции сравне- 8 на павенство и неравенство. Поскольку компилятор не обеспечивает по умолчанию НИЯ на Р эти операторы для типов классов, нам необходимо самим их обеспечить. По сути, нам необходимо иметь типы, которые позволяют выполнять осмысленные действия. Трудность работы с инкапсулированными типами заключается в том, что они не могут применяться в программном коде, который использует операции сравне- ния на равенство. Типы значений важно размещать в контейнерах, которых хранят значения элементов (а не ссылки на них), в том числе в контейнерах данного типа, предоставляемых стандартной библиотекой. Хотя мы можем объявлять и манипулиро- вать экземплярами std; : vector <UInteger64>, поскольку нет ограничений на определение неравенства хранимых элементов, мы не можем найти заданный тип, используя стандартный алгоритм std: : f ind<> (): std;:vector<UInteger64> vi; vi.push_back(i1); vi.push_back(i2); vi.push_back(i3); std::find( vi.beginO, vi.endO , UInteger64(1, 2)); //’Ошибка! Операция == не определена для UInteger64 (Следует помнить, что std: : vectoro обеспечивает неупорядоченные последо- вательности, и поэтому здесь не требуется ни упорядочивающее сравнение, ни опера- тор <.) Этот тип очень просто преобразовать в тип значения в полном смысле. Поскольку предыдущее определение типа UInteger64 обеспечивало метод IsEqualf), мы можем реализовать операторы сравнения на равенство и неравенство для него следующим образом: inline bool operator ==(UInteger64 const &il, UInteger64 const &±2) { return UInteger64::IsEqual(il, i2); } inline bool operator !=(UInteger64 const &il, UInteger64 const &±2) } return ioperator ==(il, i2); чле^РенмУ11160™0 это^ реализации в том, что путем добавления функций, не являющихся ПоскпМИ ^еуе 2000], мы «поспособствовали» тому, что этот тип значения стал полным. завИс ЛЬКУ ЭТИ Функции Ранее были недоступны, у нас нет программного кода, который без ка Ы °Т ИХ наличия (или отсутствия), и поэтому мы добились усовершенствования СтРемлИХ ТО НИ ®ыло Ухищрений. (Ворчливая реплика автора: именно бессмысленное и НезаСдНИе 806 вложить в классы лежит в основе очень многих массивных фреймворков Ученной репутации неэффективности языка C++.)
106 Часть 1. Базовые концепции Итак, теперь мы можем вновь обратиться к определению типа значения и предло- жить следующее1: Определение: тип значения. Экземпляры не могут полиморфно заменять или заменяться экземплярами другого типа на этапе выполнения программы. Экземпляры могут создаваться как копии другого экземпляра или копироваться потам на другой экземпляр. Каждый экземпляр логически самодостаточен. Любое изменение логического состояния одного экземпляра не приведет к изменению логического состояния друго- го. (Физическое состояние может быть взаимозависимым в соответствии с выбран- ными решениями конкретной реализации, если только это не нарушает логическую независимость). Экземпляры могут сравниваться на равенство или неравенство с любыми другими экземплярами и даже сами с собой. Равенство (и неравенство) является рефлексив- ным, симметричным и транзитивным. 4.7. Арифметические типы значений Осталось сделать последний шаг вверх по нашей лестнице и обеспечить поддержку других операторов, чтобы наши типы могли принимать участие в следующих естест- венных выражениях: uinteger64 il = . . . uinteger64 i2 = . . . ; bool bLess = il < i2; //Ok bool bEqual = il == i2; // Ok uinteger64 i3 = il + i2; // Ok i3 %= 11; il = i2 *= i3; Прежде всего, мы собираемся рассмотреть оператор сравнения «меньше, чем» (operator < ()), поскольку именно благодаря ему можно упорядочивать типы. Тип. который допустимо использовать в операции «меньше, чем» называется LessT- hanComparable [сравнимый-меныие-чем, Aust 1999]; это является характерной особенностью типов, поддерживаемых библиотекой STL. И действительно, обычно для упорядочения требуется всего лишь один оператор. Я должен признать, что обычно стремлюсь пользоваться исключительно им одним, даже если мне приходится создавать менее понятный программный код, как, например, assert (! (sizeО z index) ), а не assert (index <= size () ); я не уверен, что именно такой подход следует рекомендовать. 1 Юджин настаивает, что сравнение на равенство не является обязательным, и поэтому мы не можем отнес11 его к определению типа Гершника-Уилсона (Gershnik-Wilson), как бы приятно это ни звучало.
Глава 4 Инкапсуляция данных и типы значений 107 —Имея определение UInteger64, мы можем последовать примеру с операторами нения на равенство и неравенство, добавляя функцию, не являющуюся членом. Теперь мы можем добавлять экземпляры UInteger64 в std: : set или std: :map, если в этом возникает потребность. Существует много других арифметических операторов, которых, в принципе, можно было бы обеспечить. Все зависит от конкретного назначения типа. Для нашего типа Ulnteger64 нам, вероятно, потребуются все операторы: +, -, ♦, /, %, ~, «, », & । А и все соответствующие операторы присваивания, потому что наш тип является целым. (Мы рассмотрим лучшие способы реализации подобных операторов в гл. 29.) jvfbi увидим отличный пример предоставления свободных операторов в шаблонном классе true_typedef (раздел 18.4), где все арифметические операторы класса обес- печиваются как свободные функции, и мы можем делать то же самое для других типов. Важно определять только присущие классу операторы. Если бы мы имели тип значения для валюты, нам несомненно потребовалось бы добавлять и вычитать один экземпляр Currency из другого, используя операторы + и -, но нам не нужно будет их умножать: чему будет равно $6.50*J2.93? И напротив, нам потребуется перемно- жать число и экземпляр Currency, но не складывать или вычитать их. Конечно, C++ позволяет вам определить любые операторы для любых типов клас- сов и выполнять с их помощью любые действия. Это открывает возможность для боль- ших злоупотреблений (см. приложение Б). И действительно, мы видели в начале данной главы, что в классе String были определены операторы + и ++. Неправильно использовать оператор + для конкатенации строк1, поскольку он не выполняет арифме- тическое сложение операндов. Однако на практике мы все, почти до единого, замалчи- ваем недостатки такой фривольности и упиваемся получаемым удобством. (Возможно, удобно, но не эффективно. Мы увидим в гл. 25, как можно сделать значительно эффек- тивнее.) Несмотря на то, что ненадлежащее применение оператора санкционировано на высших уровнях, это удручает, и вам не следует поддаваться этому соблазну. Я знаю, что я тоже вел себя плохо. Очень плохо (см. приложение Б). *•8. Типы значений: заключение Интересно отметить, насколько этот спектр типов значений аналогичен тому, РЬ1й используется в концепциях итератора STL. При применении концепции ров сложнее всего эмулировать итератор произвольного доступа при работе Мц еДеленными пользователем типами, но этот итератор поддерживается указателя- > которые легче всего реализовать, поскольку это уже сделал для нас язык. °пЛоце^г^еР>КДение является еще одним «спорным» утверждением, благодаря которому я приобрету много ’^ется В Не следУет забывать, что все широко распространенное и даже полезное не обязательно пРавцдьным g данной книге приводится множество таковых примеров.
108 Часть 1. Базовые концепции Аналогично, из всех концептуальных уровней типов значений труднее всего эмулировать арифметический тип значения, который снова является типом значения по умолчанию для фундаментальных интегральных типов. Мы увидим, каким образом мощный C++ позволяет нам делать это, и насколько он загадочен, что синтаксис фун. даментальных типов оказывается верхней планкой наших достижений. 4.9. Инкапсуляция: заключение Мы рассмотрели, какие возможности теоретически предоставляет концепция типа значения, но остается вопрос способов инкапсулирования таких типов. В учебных пособиях нам рекомендуют быть последовательными приверженцами объектно- ориентированного подхода и полностью инкапсулировать наши типы. Однако в реаль- ных условиях в редких случаях все оказывается столь очевидным. Сейчас я предложу семь возможных реализаций типа UInteger64, причем только в трех из них применяется полная инкапсуляция. Чтобы выбрать соответст- вующую форму, нам необходимо ответить на несколько скрытых вопросов: Действительно ли нам необходимо взаимодействовать с программным интерфей- сом языка С (илиреализовывать типы исходя из его возможностей)? Если ответ «да», мы сможем использовать (форма 2) или наследовать (форма 3) существующую струк- туру, а в противном случае мы не сможем передавать соответствующие части внутрен- ней структуры класса программному интерфейсу языка С1. Если ответ «нет», то мы можем просто применить основную форму членов (форма 1). Листинг 4.2. II форма #1 class UInteger64 { . II Методы и операторы типа значения private: uint32_t lowerVal; uint32_t upperVal; }; II форма #2 class UInteger64 { . II Методы и операторы типа значения private: ulnteger64 m_value; }; * Ну, не без многочисленных pragma-директив, приведения типов и других сомнительных приемов. -ПУ4111 просто использовать тип структуры языка С.
Глава* Инкапсуляция данных и типы значений 109 // форма #3 class Ulnteger64 : private uinteger64 ( ...II Методы и операторы типа значения }; Как вы помните, я сознательно выбрал 64-битовые целые числа, потому что я могу «обмануть» существующую реализацию, используя преобразования с реальными 64-би- товыми целыми числами, которые используются для реализации реальных арифметиче- ских операций. Если нам требуется обеспечить целые числа произвольного размера как дополнительный уровень над программными интерфейсами языка С (как в [Hans 1997]), то ничего не остается, как работать со структурами С. Могут ли все операции быть инкапсулированы в типе? Если да, то мы, вероятно, можем обеспечить полную инкапсуляцию с помощью внутренней реализации закры- тых членов (формы 1-3). Если нет, то нам придется раскрыть в некоторой степени внутреннее состояние для того, чтобы обеспечить взаимодействие с другими функция- ми и/или типами, хотя в последнем случае можно использовать «дружеские отноше- ния» (раздел 2.2.9). С этим связано несколько других вопросов относительно необходи- мости выносить наружу подробности реализации. Является ли создаваемый нами тип «самым правильным типам» или он просто один из многих? Классическим примером, где хорошо проявляется этот вопрос, являет- ся обработка времени. Существуют типы time_t, struct tm, struct timeval, DATE, FILETIME, SYSTEMTIME - это далеко не все типы времени. И они являются типами языка С. Если мы включим классы C++, список станет практически беско- нечным. Кому не приходилось реализовывать свой собственный тип времени или даты? Он один из многих, и мы должны рассматривать возможность его взаимодейст- вие с другими типами и взаимное их преобразование друг в друга. Действительно ли нам необходимо взаимодействовать с совместимым с С интерфей- CQM? Конечно, идеальным ответом всегда является «нет», но, к сожалению, в действи- тельности, часто ответом является «да». Если мы рассмотрим класс даты и времени, нам придется построить его на основе одного из типов языка С, поскольку в противном кам Эе МЫ Вь,нуждены 6УД£М переписать весь сложный, нудный и подверженный ошиб- скийПР°ГРаММНЫЙ К°Д П° манипулиР°ванию календарем. Високосные годы, григориан- Условия1еНДаРЬ> К°РРекция °Р®ИТ» что-то еше? Спасибо, достаточно. Разве можно в таких С ?ЫТЬ увеРенным в ТОМ’ что мы обеспечим инкапсулирование всей функцио- ные с ° Т°Т случай» когда сообщество C++, как правило, игнорирует проблемы, связан- -'Тцерб ТИМ КОнкРетным вопросом, что, по моему личному мнению, наносит большой Вн°сят ВСему сообществу разработчиков1. Поставщики программных продуктов лепту в это «жульничество», поскольку чем сильнее разработчик будет ***Ие-Лцб0 п ЬН°’ некотоРые рецензенты данной книги часто отмечали, что мне не следует включать в нее ИМеРЫ программного кода, совместимого с С, выдавая его якобы за код C++.
110 Часть 1. Базовые концепции привязан к «действительно полезному классу», тем менее вероятно, что он буд^ искать какие-нибудь альтернативные подходы. И такая зависимость может означать нечто большее, чем просто отдавать некоторое предпочтение одному прикладному фреймворку по сравнению с другим. Несомненно, мы все знаем проекты, которЫе были привязаны к определенной операционной системе, потому что разработчикам было неудобно или они оказались не в состоянии преодолеть ограничения своего при. кладного фреймворка. Это является большим дефектом языка C++, который мы рас. смотрим достаточно подробно во второй части книги. Очевидно, что во многих практических случаях мы вынуждены применять лишь частичную инкапсуляцию Сделать это можно несколькими способами. Простой способ заключается в изменении спецификатора доступа private на public (формы 4-6), но это фактически открывает доступ ко всему содержимому типа. Листинг 4.3. II форма #4 class UInteger64 { ...II Методы и операторы типа значения public: uint32_t lowerVal; uint32_t upperVal; }; II форма #5 class UInteger64 { . // Методы и операторы типа значения public: uinteger64 m_value; }; II форма #6 class UInteger64 : public uinteger64 { . II Методы и операторы типа значения }; Существует два способа ограничения открытости типа. Эффективный, но совершеН' но не эстетичный способ заключается в предоставлении функций доступа: II форма #6 class UInteger64 : private uinteger64 { . II Методы и операторы типа значения public: uinteger64 bget_uinteger64(); uinteger64 const bget_uinteger64()const; // ужасно! };
Глава 4. Инкапсуляция данных и типы значений 111 —Существует другой способ, позволяющий этот «ужасный» код сделать немного плекательнее: оператор explicit_cast, который мы подробно рассмотрим ^язделах 16.4 и 19.5. Листинг 4.4. // форма #7 class UInteger64 : private uinteger64 { . // Методы и операторы типа значения public; operator explicit_cast<uinteger64 ь>(); operator •xplicit_cast<uinteger64 const &>() const; }; Неявный доступ к внутреннему содержимому типа отвергается, но явный доступ допускается. Можно поступить по-другому и добавить в класс методы для каждого нового свойства базового программного интерфейса, который вы хотите предоставить в распоряжение пользователей класса более высокого уровня. С этим решением сильно связаны два вопроса, ответы на которые могут еще больше прояснить наш выбор: Потребует ли от нас отступления от традиционного подхода заинтересован- ность в обеспечении необходимой эффективности? Некоторые необходимые для наших типов операции можно представить как логическую комбинацию двух более простых операций, но объединение их в одной операции может дать заметное повыше- ние эффективности. Но плохо то, что это иногда делается просто ради удобства. В обоих случаях это является первым шагом на пути чрезмерного наполнения классов большим количеством методов (например, std: :basic_string<>). В каждом кон- кретном случае должно приниматься свое решение, но не забывайте о серьезном ухуд- шении эффективности из-за методов, которые реально представляют собой узкое место в результате чрезмерного или частого их применения, и такое решение должно базироваться на измерениях, а не на, как правило, ненадежных (в данном случае) инстинктах. Л/олсеИ ли мы быть уверены в возможности избегать связывания (coupling) этапах компоновки и компиляции? И вновь важность этого аспекта проектирования при °В СИЛЬН0 недо°Ценивается. Если вся реализация типа может быть выполнена вани^°М°ЩИ встР°енного определения, то нам вообще не стоит беспокоиться о связы- 1ипо НЙ Этапе компоновки - то есть о связывании реализаций функций как откомпи- них ЭННЬ1Х в отдельную текущую единицу компоновки, так и находящихся во внеш- беСПдТЭТИЧеских и Динамических библиотеках. В любом случае нам все же придется ^Р:°ИПСЯ ° связывании на этапе компиляции - о количестве других файлов, етСя в 6 ДОлжны быть включены для компиляции нашего класса. Ирония часто заключа- ванщ ’что ослабление связывания на этапе компоновки приводит к усилению связы- На этапе компиляции.
112 Часть 1. Базовые концепц^ К сожалению, трудно избежать связывания на этапе компиляции и ирония опять в том, что чем больше вы пытаетесь добиться переносимости, тем сильнее оказывается связывание на этапе компиляции. Это происходит из-за необходимости обработки не. стандартных типов (раздел 13.2), использования особенностей компилятора, соглаще. ний о форматах вызова (гл. 7) и т. д. Это часто приводит к применению широко ис. пользуемых, хорошо оттестированных заголовочных файлов, имеющих сначала разум, ные размеры, которые впоследствии по мере развития системы обязательно разрас. таются для того, чтобы обеспечить централизованный учет различий архитектуры компиляторов, операционных систем и библиотек. Как вы можете видеть, обеспечить инкапсуляцию данных не так уж просто, и, как и для многих других вопросов, поднимаемых в данной книге, единственное реальное решение заключается во всестороннем рассмотрении этого вопроса. Вашим классам необходимо заботиться не только о том, чтобы они работали в какой-то конкретной среде, для которой они специально создаются, а поскольку классы не обладают «сообразительностью», вам придется самим ее проявить.
Глава 5 Модели доступа к объектам Мы много говорили о содержании жизненного цикла объекта, рассматривая эТОм как те действия, которые выполняет компилятор в ответ на наши действия (заданные нами функции), так и детали используемых механизмов. Теперь мне хоте- лось бы поговорить о проблемах жизненного цикла, вызванных особенностями взаи- мосвязей экземпляров объектов, в частности, объектов контейнера и клиентским программным кодом, который их использует. (Следует отметить, что когда я исполь- зую в данном разделе термин «контейнер», я имею в виду как стандартное значение этого термина - список, вектор, отображение, - так и составные типы, которые содержат свои переменные-члены.) 5.1. Ограниченное время жизни объектов Это самый простой и наиболее эффективный способ, с помощью которого один объект может регламентировать доступ к своему содержимому со стороны другого объекта. Проще говоря, он является частью документированной семантики типа, гарантирующей, что содержащиеся в нем объекты не будут существовать вне рамок его времени жизни. Это очевидно в примере составного типа, показанного в лис- тинге 5.1. Листинг 5.1. class Environmentvariable { // Методы доступа public: String const &GetName()const { return m_name; } String const *GetValuePart(size_t index) const { } return index < m_valueParts.size() ? &n\_valueParts[index] : NULL; 1I Члены Private: String m_name; . Vector<String> m_valueParts; 22S
114 Часть 1. Базовые концецц^ Это самая эффективная модель доступа, потому что вызывающей программе пре доставляется прямая ссылка (или указатель) на экземпляр требуемого объекта. В при веденном выше примере переменная-член m_name и строка из коллекц^ m_valueParts одинаково доступны, то есть доступ к ним ограничен только вреМе. нем жизни структуры. Однако эта модель уязвима в том смысле, что она дает неопределенный результат в ситуациях, когда клиентский программный код поддерживает и пытается использо- вать указатель (или ссылку) после того, как контейнер прекратил свое существование Но до тех пор, пока будет создаваться корректный по отношению к этому ограничению клиентский программный код, такая модель вполне допустима, и она является одной из самых распространенных. 5.1.1. Модель с регистрацией доступа Модель с регистрацией доступа - это слегка измененная модель с регламентиро- ванным временем жизни; она требует больше затрат, но помогает обеспечить надлежа- щее поведение программ, которые ее используют. Это может помочь при реализации контейнера, поскольку регистрационный счетчик можно использовать в качестве счетчика ссылок или как дополнение к уже имеющемуся какому-нибудь механизму подсчета ссылок. В зависимости от ситуации при этом может допускаться монополь- ный доступ к экземпляру, содержащемуся в контейнере (отвергая последующие запро- сы или блокируя их), или совместное использование экземпляра. В модели с регистрацией доступа предусматривается передача специального маркера клиентскому программному коду, если запрашиваемый ресурс доступен, и этот маркер должен использоваться для регистрации «освобождения» ресурса после того, как он становится ненужным в этом программном коде. Это немного похоже на парковку с контролером (valet parking): используемый вами ресурс - это парковочное пространство, а маркер - небольшой кусочек бумаги, который вы получаете и который должен гарантировать, что никто не сможет прокатиться на вашем автомобиле, пока вы принимаете пищу. Естественно, что все полученное должно возвращаться - иначе все в мире станет неправильным - и, конечно, эти приобретения должны защищаться с помощью классов, управляющих диапазоном действия ресурсов (см. гл. 6), при46-1 так, что уничтожение талона гарантирует возврат ресурса без явного вызова соответст вующего метода. Иногда значение, к которому осуществляется доступ, выполняет двойную фУнЬ цию и используется также в качестве маркера, что может все упростить. ПрограмМ ный интерфейс Bufferstore системы Synesis обеспечивает эффективные ко^ тейнеры фиксированного размера с фиксированным количеством блоков - полезны в многопоточных высокоскоростных сетях для обеспечения эффектив захвата (возможно совместно используемых) блоков памяти из заранее сформ1^н ванного пула. Он содержит класс-оболочку C++, примерный вид которого пока в листинге 5.2.
Глава 5. додели доступа к объектам 115 Листинг 5.2. class Bufferstore { // конструирование public: BufferStore(Size siBuff, UInt32 initial, UInt32 maximum); // операции public: // Захват одного или нескольких буферов Ulnt32 Allocate(PVoid buffers!], UInt32 eBuffers); // Совместное использование многих буферов. Каждый буфер ДОЛЖЕН быть // уже захваченным Ulnt32 Share(PCVoid srcBuffers[], PVoid destBuffers[] , UInt32 cBuffers); 11 Освобождение одного или нескольких буферов void Release(PVoid buffers!], UInt32 eBuffers); В данном случае указатели буфера имеют значения типа void* и выполняют также роль маркеров, используемых для освобождения памяти, которая может быть впоследствии захвачена другим клиентским программным кодом. Регистрируете вы экземпляры или нет - в любом случае применение модели доступа с регламентирован- ным временем жизни требует использования обычного проверенного подхода: прочи- тать документацию, создать программный код в соответствии с документацией, провести тестирование и выразить свое возмущение любому разработчику, который изменяет семантику, но не документацию. 5.2. Копирование объектов Контейнеры стандартной библиотеки используют модель доступа, при которой в распоряжении вызывающей программы предоставляются копии объектов. Если наш предыдущий пример контейнера изменить в соответствии с этой моделью, то он будет выглядеть как в листинге 5.3. Листинг 5.3. class Environmentvariable { // Методы доступа Public: String GetNaine () const { return m_name; } String GetValuePart(size_t index) const ( ’ 8trUg(); return index < m_valueParts.size() ? suvalueParts[index] } 11 Члены Private: String m_name; Vector<String> m_valueParts;
116 Часть 1. Базовые концепции Эта модель имеет привлекательное свойство: ее не сложно понять и в ней никак не связано время жизни контейнера и клиентского программного кода. Однако воз. врат всего, что не относится к значениям фундаментальных типов или простых типов, приводит к генерации приличного объема программного кода, снижающего эффективность. 5.3. Непосредственный доступ Возможна простая модель (там, где она допустима): экземпляры объектов непосред. ственно предоставляются в распоряжение вызывающей программы. Она не во всех случаях полезна, но при некоторых обстоятельствах она оказывается привлекательной. Примером может служить реализация контейнера, который в редких случаях приходится просматривать более одного раза и элементы которого занимают большой объем памяти и/или других ресурсов. Поддержка копий в этих условиях, по-видимому, будет связана с лишними затратами, и поэтому они могут быть просто предоставлены в непосредст- венное распоряжение вызывающей программы. Поэтому эта модель более эффективна, чем модель с регламентированным временем жизни (см. листинг 5.4). Листинг 5.4. class Environmentvariable { // Методы доступа public: String const *GetvaluePart(size_t index) const { String *item; if(index < m_cParts) item “ m__store .Load (index) ; > else item - 0; > return item; } // Члены private: int m_cParts; Diskstore m_store; };
117 Гл^а 5. Модели доступа к объектам Т^Совместно используемые объекты э»*** Этой модели, которая основана на подсчете ссыпок (см. листинг 5.5), я отдаю наиболь- ее предпочтение там, где она удобна в использовании. Контейнер содержит счетчик ссылок для своих элементов и обеспечивает запрос указателями элементов с подсчитан- ным количеством ссылок. Листинг 5.5. class Environmentvariable { public: typedef ref_ptr<RCString> String_ref_type; // Методы доступа public: String_ref_type GetValuePart(»ize_t index) { return index < m_valuePart».»ize() ? m_valueParta[index] i String_re£_type(O); } // Члены private: Vector<String_ref_type> m_valueParts; }; Это обеспечивается неожиданно просто при наличии соответствующего класса «умных» указателей, подсчитывающих количество ссылок. Семантика указателя ref_ptr гарантирует, что при каждом его копировании увеличивается счетчик ссы- лок, а когда экземпляры уничтожаются, счетчик ссылок уменьшается. При достижении счетчиком ссылок значения 0, экземпляр RCString удаляет сам себя. Это не так сильно сказывается на эффективности программного кода, как правило, это влияние незначительное, но не настолько низкое, чтобы им пренебречь в системах, где требуется обеспечить очень высокую производительность. Герб Саттер (Herb Sut- ter) сообщает в [Sutt 2002] результаты тестирования эффективности применения строк, которых реализован подсчет ссылок и технология копирования при записи, показы- ’ что П0Дсчет ссылок во многих случаях может завершаться пессимистическими Дами [Sutt 2002]. Здесь мною представлен немного другой сценарий, где подсчи- будет Тся ссылки на сам объект строки, и поэтому влияние на производительность Другим, но важно иметь представление о стоимости различных моделей доступа, знач °ТРЯ На ЭТИ пРедУпРеждения» данная модель дает очень мощный метод, и ее е не следует недооценивать из-за снижения производительности. Я использо- работа V С подсчетом ссылок в быстродействующем анализаторе данных, который Разных платформах на порядок эффективнее предыдущей версии1. *еРсия «спт»11 ЧесТно признаться, что это была та же самая система, которую я упоминаю в разделе 22.6 и первая ₽°и 15% процессорного времени ничего не делала из-за моей «оплошности»
118 Часть 1. Базовые концепции Это решение может работать даже для типов, в которых не используется подсчет ссылок, при применении типа «умного» указателя (smart pointer), который сам обес. печивает логику подсчета ссылок, как, например, указатели shared_ptr библиотеки Boost. Более того, подсчет ссылок может использоваться для устранения зависимости времени жизни содержащихся в контейнере элементов от времени жизни своего (первоначального) контейнера. Например, вы имеете компонент, рассчитывающий объем, который занимает файловая система. Если он применяет подсчет ссылок ддя объектов данных, представляющих элементы файловой системы, клиентский про. граммный код мог бы использовать эти объекты на протяжении любого требуемого периода времени вместо того, чтобы копировать все конкретные атрибуты файлов, которые потенциально могут ему потребоваться до уничтожения данного компонента. Это может привести к существенной экономии затрат.
Глава 6 Классы, контролирующие диапазон действия ресурсов В разделе 3.5 мы рассмотрели определение метода захвата ресурсов при инициали- зации (RAII), и в данной главе мы собираемся продемонстрировать некоторые способы его применения. Метод RAII особенно эффективен при управлении ресурсами. Мы рассчитываем, что когда управление выходит из области видимости контейнера vector<string>, вызывается его деструктор, и он освобождает используемые им ресурсы. В ходе этого процесса все содержащиеся в контейнере экземпляры строк string также уничто- жаются и освобождают занимаемые ими ресурсы, после чего блоки памяти, используе- мые для хранения экземпляров строк в контейнере, возвращаются в свободную память. Вся эта функциональность обеспечивается всего лишь одной закрывающей фигурной скобкой (}). Здесь метод RAII представлен в чистом виде: экземпляр, управляющий диапазоном действия ресурса, захватывает его в своем конструкторе и обязательно освобождает его в своем деструкторе. Тем не менее - что, вероятно, не очень широко известно - этот механизм может поддерживать «ресурсы» в самом широком смысле этого термина. В данной главе я надеюсь продемонстрировать, что RAII вполне подходит и действи- тельно удобен при применении не только для блоков памяти и дескрипторов файлов. ы Рассм°трим, как в качестве таких ресурсов могут выступать значения, состояния, программные интерфейсы и даже специальные возможности языка. 6*1* Значение nepe^51 ОТ вРемени возникает ситуация, когда нам требуется изменить значение и затемНН°й’ Использовать новое значение в течение определенного периода времени, вернуть прежнее значение. Это может выглядеть следующим образом: lnt sentinel = . . . ; ++sentinel; • II Выполнить что-то существенное "sentinel;
120 Часть 1. Базовые концепц^ Подобная процедура может применяться для отметки использования квоты ресурСа или глубины рекурсии и даже (внутреннего) состояния объекта. Я видел (и сам писал1) вызывающий легкий ужас программный код, показанный в листинге 6.1. Листинг 6.1. class DatestampPlugln { private: int m_saveCount; }; HRESULT DatestampPlugln::OnSave(. . .) { HRESULT hr; if(m_nSaveCount != 0) { hr = S_OK; } else { ++m_nSaveCount; Выполнить какие-то действия с содержимым файла // при его сохранении! --m_nSaveCount; } return hr; } Обработчик OnSave () используется для обновления содержимого файла при его сохранении. Для того чтобы сделать это в рамках IDE (integrated development environ- ment - интегрированная среда разработки), где работает класс DatestampPlugln- приходится обновлять содержимое соответствующего документа и вновь активиро- вать операцию сохранения файла. Чтобы не допустить бесконечный цикл, использует- ся счетчик m_nSaveCount в качестве «часового» (sentinel), который контролируй повторное попадание в метод OnSave (), в случае чего этап обновления пропускается (Эта возмутительная стратегия навязана ограничениями IDE, и всякому, кто ею поль- зуется, должно быть стыдно.) Естественно, никакой из таких примеров программного кода не является заши,иеН ным от исключений или от многократного применения оператора return. Если програмМ ный код в промежутке между изменениями значения этого счетчика-часового выбрав вает исключение или осуществляет возврат из функции, то этот подход не срабаты Очевидно, здесь необходимо воспользоваться методом RAII, простым и понятн Точно также очевидно, что с этим хорошо справится шаблон (см. листинг 6.2). --------------------------------------- ,тпе»,нС1' Меня извиняло то, что это было применено в средстве разработки, предназначенном для вН5 пользования, и поэтому никто его никогда не увидит. Все мы знаем, что внутренние средства ра’Р никогда не облагораживаются и не выпускаются во внешний мир, и поэтому мой поступок совсем не не так ли?
121 Глава 6 Классы, контролирующие диапазон действия ресурсов Листинг 6.2. template <typename Т> class Incrementscope { public: explicit IncrementScope(T &var) : m_var(var) { ++m_var; ) -Incrementscope() { --m_var; } private: T &m_var; }; Вполне очевидно, что у вас появится желание дальше обобщить и усовершенствовать функциональность с помощью дополняющего класса DecrementScope. Вскоре мы увидим, как это делается, а также некоторые другие улучшения и некоторые сценарии правомерного применения этого подхода. Вы можете сказать, что ситуация немного надуманная, и на первый взгляд кажется, что это действительно так. Однако пару лет назад мне понадобилось именно такое средство при реализации высокопроизводительного многопоточного сетевого сервера. Мне требовалось обеспечить наблюдение за некоторыми параметрами сервера при ми- нимальном вмешательстве в его работу (за что полагалось высокое вознаграждение), и сначала я пришел к созданию двух классов, показанных в листинге 6.3. Листинг 6.3. class AtomicIncrementScope { public: AtomicIncrementScope(int *var) : m_var(var) ( atomic_inc(bm_var); } -AtomicIncrementScope() ( atomic_dec(£m_var); } И Члены Private: int *m_var; class AtomicDecrementScope Public-
122 Часть 1. Базовые концепции AtomicDecrementScope(int * var) : m_var(var) ( atcmic_dec(&n_var); ) -AtomicDecrementScope() ( atomic_inc(bm_var); ) Применение этих классов позволяло изменять системные счетчики в форме, обес- печивающей потокозашишенность и защищенность от исключений. Поскольку отлад- ка была невозможна, применение этого метода означало, что я мог наблюдать текущие значения важных системных параметров из низкоприоритетного потока, который активируется лишь раз в секунду, и выводить данные на дежурную консоль. Это суще- ственно помогло «отполировать» приложение, поскольку позволило упростить карти- ну большого числа потоков, сокетов и совместно используемых блоков памяти, пред- ставив ее в виде вполне управляемого набора статистических данных1. Как и следовало ожидать, следующий раз я захотел использовать эти классы уже не в многопоточной среде, и поэтому у меня было время, необходимое для получения более общего решения, показанного в листинге 6.4. Листинг 6.4. template <typename Т> struct simple_incrementer { void operator О(T &t) { } }; template <typename T> struct simple_decrementer { void operator ()(T &t) { — t; } }; template< typename T , typename A = simple_incrementer<T> , typename R = simple_decrementer<T> > class increment_scope { public: explicit increment_scope(T &var) И это помогло мне исправить несколько грубых оплошностей.
Глава б- Классы, контролирующие диапазон действия ресурсов 123 : ni_var(var) ( А()(m_var); } - increment_scope() { RO (m_var) ; } private: T &m_var; Иногда нас интересует не просто увеличение (или уменьшение) переменной на еди- нииу, а ее изменение на заданное значение. В данном случае наш предыдущий шаблон нам не поможет, как бы мы ни старались его обобщить. Но мы можем легко обеспечить эти наши требования, как показано в листинге 6.5: Листинг 6.5. template ctypename Т> class ValueScope { public: template <typename V> ValueScope(T &var, V const &set) : m_var(var) , m_revert(var) { m_var = set; } template <typename VI, typename V2> ValueScope(T &var, VI const &set, V2 const fcrevert) : m_var(var) , m_revert(revert) { ni_var = set; ) -ValueScope() { m_var = m_revert; ) Private: V &m_var; V m_revert; здесь два конструктора позволяют выбрать необходимое вам действие: Ни- 0Вить в Деструкторе прежнее значение переменной или изменить его на какое- ибУДь друГОе ЗНачение. и класс можно было бы использовать, например, следующим образом: string si = "Original"; c°ut « "si: " « si « endl; // Выводит первоначальное значение: // "si: Original* ValueScope<string> vs(si, "Temporary");
124 Часть 1. Базовые концепции cout « "si: " « si « endl; 11 Выводит временное значение; 11 "si: Temporary" } cout « "si: " « sl « endl; // Выводит первоначальное значение: // "sl: Original" 6.2. Состояние Надежность управления диапазоном действия ресурсов такими классами может быть неоценима при захвате ресурсов, которые отражают состояние программы. Классический пример - контроль области захвата объектов синхронизации [Schm 2000]. Неспособность освобождения захваченного объекта синхронизации имеет очевидные последствия - взаимная блокировка синхронизируемых потоков, их аварийное завершение, прекращение работы - и поэтому так важно пользоваться надежным механизмом. Можно определить простой шаблон, подобный показанному в листинге 6.6: Листинг 6.6. template <typename L> class LockScope { public: LockScope(L &1) : m_l(D { m_l.Lock(); } -LockScope() { m_l.Unlock(); } protected: L &m_l; }; Здесь управление состоянием блокируемого типа L ограничивается только метода- ми Lock () и Unlock () (методами блокировки и разблокировки). На первый взгляд это кажется вполне разумным, но существует много вещей, которые эти методы (С>,;1Я по их названиям) не обеспечивают, и поэтому мы могли бы еще больше обобщить эт°т класс (см. листинг 6.7). Листинг 6.7. templatectypename L> struct lock_traits { public: static void lock(L &c) ( lock—instance(c);
6 Классы, контролирующие диапазон действия ресурсов } static void unlock(L &c) { unlock_inatance(c) ; } 125 template< typename L , typename T = lock_traits<L> > class lock_scope { public: lock_scope(L &1) : m_l(l) { T: :lOCk(m_l) ; } -lock_scope() { T: : unlock (in_l) i } // Члены private: L &m_l; Для краткости я пропустил здесь этап, где фактически обеспечивается возможность использования в шаблоне типов из других пространств имен. Мы могли бы обеспечить лишь вырожденную версию lock_traits, которая могла бы, вероятно, вызывать методы 1оск() и unlock () для соответствующих экземпляров типов. Но если бы мы имели класс блокировки в другом пространстве имен, нам пришлось бы посредст- вом специализации вернуться к пространству времен lock_scope, а это болезнен- ный процесс, как описано в гл. 20, где рассматриваются классы-прокладки (shims). Поэтому lock_traits определяется с помощью функций lock_instance () и unlock_instance (). Вместо использования специализации со всевозможной «ерундой» и сопутствующими дополнительными усилиями вы можете просто опреде- Лить функции lock_instance () и unlock_instance () в дополнении к вашему классу и поиску Кенига (Koenig)1 (см. разделы 20.7 and 25.3.3), позволяющих добиться же решения, которое дает класс thread_mutex, реализованный для плат- ам Win32 (см. листинг 6.8). * * **®*ор(^аСТавдяет собой механизм поиска компилятором функции в пространстве имен экземпляра, °на применяется, а не требует, чтобы вы явным образом специфицировали это пространство имен; **10к»п<^:помните свободные функции типа std::string, реализующие операторы сравнения — и !=, которые Ки используются при сравнении вами экземпляров.
126 Часть 1. Базовые концепции Листинг 6.8. class thread_jnutex { 11 Конструирование public: thread_jnutex () { ::InitializeCriticalSection(&m_cs); } ~thread_jnutex () { ::DeleteCriticalSection(&m_cs); } public: void lock() { ::EnterCriticalSection(&m_cs); } void unlock() { ::LeaveCriticalSection(&m_cs); } private: CRITICAL_SECTION m_cs; inline void lock_instance(thread_jnutex tax) { mx.lockO; } inline void unlock_instance(thread_jnutex bmx) { . mx.unlock(); } Теперь вы легко можете использовать свой собственный синхронизационный класс. thread_mutex s_mx; { // Войти в критическую область, охраняемую мьютексом s_jmx lock_scope<thread_nutex> scope(s_nx); , ...II Выполнить в этом месте критические для потока оперли } // Теперь охрана снята. «Что же», - вы скажете, - «совсем неплохо, я могу управлять диапазоном действ»” критических областей моего программного кода при помощи любого выбрани^ мною объекта синхронизации». Однако гибкость, которой мы наделили нашу моДеЛЬ позволяет нам пойти еще дальше. . При анализе параметров работы упомянутого мною ранее многопоточного сер® было выявлено существование некоторых критических областей, которые были сл ком большими. В таких случаях, если стоимость применения блокировок монопоДь го доступа не высокая по сравнению с затратами, обусловленными конкур611^, (что часто наблюдается при внутрипроцессорных блокировках), улучшить произ®
127 Гл^а 6 Классы, контролирующие диапазон действия ресурсов " ^можно путем разбиения одной критической области на более мелкие участки 76 ственно, это должно быть логически обосновано. Если вы нарушаете логическую -едовательность действий при работе вашего приложения, увеличение его быстро- П йствия, на самом деле, не будет иметь никакого значения. В представленном ниже примере приводится монолитная критическая область: typedef lock_scope<MX_t> lock_scope_t; { // Войти в контролируемую область: либо в функцию, либо в явно заданный блок в lock_scope_t scope ; . . . // Затратная операция #1 . // потоко-нейтральные операции #1 . // Затратная операция #2 ...II потоко-нейтральные операции #2 . . . // Затратная операция #3 } // выход из контролируемой области Данная область может быть разбита на три отдельные области (см. листинг 6.9): Листинг 6.9. typedef lock_scope<MX_t> lock_scope_t; { // Войти в контролируемую область: либо в функцию, либо //в явно заданный блок lock_scope_t scope(m_mx); . . . // Затратим операция *1 } // выход из контролируемой области ...II потоко-нейтральные операции #1 { // Войти в контролируемую область: либо в функцию, либо // в явно заданный блок lock_scope_t scope(m_mx); ... 11 Затратим операция *2 } // выход из контролируемой области ...II потоко-нейтральные операции #2 { // Войти в контролируемую область: либо в функцию, либо // в явно заданный блок lock_scope_t scope(m_mx); • . . // Затратная операция #3 ) // выход из контролируемой области Однако здесь скрываются опасности. Во-первых, с практической точки зрения ком просто вы или, скорее всего, ваши коллеги, поскольку у вас не было времени ать суть проведенных изменений и распространить этот документ в нескольких За ЛяРах в команде разработчиков - можете добавить в промежуток между тремя тате аеМь,Ми блоками программный код, который также требует защиты. И в резуль- ^клиент получает дамп памяти! б.10ки ВТОРЬ,Х’с более абстрактной точки зрения нашей целью является освобождение нУ,вы ВКИ НЭ опРеделенный период времени с последующим ее возобновлением. СИ1Уаци*0ЖеТе сказать’ чт0 я повторяюсь как попугай, читающий нравоучения, но эта и элега Я Настоятельно требует применения подхода RAIL Ответ столь же прост, как нтен: lock_invert_traits<> (см. листинг 6.10).
128 Часть 1. Базовые концепции Листинг 6.10. templatectypename L> struct lock_invert_traits { // Операции public: static void lock(lock.type &c) { ux>lock_inatance (c); } static void unlock(lock—type &c) { lock—instance(c); } }; В этом классе смысл блокировки инвертирован путем замены семантики функ- ций lock О и unlock () на обратную. Теперь мы можем вновь установить одну критическую область программного кода и вставить в нее некритические области (см. листинг 6.11). Листинг 6.11. typedef lock_scope<MX_t> lock—scope_t; typedef lock—scope< MX_t , lock—invert—traits<MX_t> > unlock—scope_t; { // Войти в контролируемую область: либо в функцию, либо // в явно заданный блок lock—scope_t scope ...II Затратная операция #1 { // выход из контролируемой области unlock_scope_t scope (щ_шх); ... II потоко-нейтральные операции #1 } // Повторно войти в основную область ...II Затратная операция #2 { Ц -вяахол, из контролируемой области unlock—scope.t scope(m_mx)/ ...II потоко-нейтральные операции #2 } // Повторно войти в основную область ...II Затратная операция #3 } // выход из контролируемой области Существуют другие классы, управляющие диапазоном действия состояний и не нс пользующие счетчики. В разделе 20.1 мы кратко познакомимся с моим любимым к*1** сом, контролирующим состояние текущего каталога. В свое время я написал нескол таких классов, и они оказались чрезвычайно полезными. Средства обработки Фай используют их для входа в подкаталоги и обработки содержащихся в них Фа^л
6- Классы, контролирующие диапазон действия ресурсов 129 возвращения к стартовой позиции. Однако нельзя сказать, что контроль состоя- 3цЙ здесь не вызывает проблем, поскольку текущий рабочий каталог обычно является бутом процесса, а не потока, и поэтому в многопоточной среде могут возникнуть неприятности, хотя это больше зависит от поведения программы, а не от класса, управ- ляющего состоянием каталога. Более того, операция изменения каталога легко может ^вершиться неудачей, поскольку слишком просто получить неверный путь (при вводе данных пользователем, смене файловой системы и т. д.). Как и в большинстве других случаев, следует больше консультироваться с документацией, чем полагаться на со- ображения здравого смысла. 6.3. Программные интерфейсы и службы 6.3.1. Программные интерфейсы Как мы увидим в гл. 11, единственный надежный способ применения программного интерфейса языка С - это вызов функции инициализации до использования каких-либо его средств и затем вызов функции деинициализации после завершения их использова- ния. Это может иметь следующий вид: int main(. . .) { Acme_Init(); //В одних случаях может быть аварийное завершение, //в других - нет. В первом случае необходимо выполнять И проверку. ...II Основная обработка данных приложения с применением II программного // интерфейса Acme Acme_Uninit(); } В данном случае несомненно необходимо применять RAIL В таких программных интерфейсах часто используют подсчет ссылок, и поэтому функция Acme_Uninit () Должна вызываться для каждого вызова Acme_Init (). Только в этом месте гаран- ™РУется освобождение данным программным интерфейсом своих ресурсов. Если под- ает вызовов не сбалансирован, то освобождение ресурса не произойдет, и в результате МогУт возникнуть любые неприятности: от «утечек» определенных ресурсов до аварийного завершения обработки данных. ри работе в C++ с подобными совместимыми с С программными интерфейсами 1Изац^Ланс °®ычно обеспечивается очень просто. В тех случаях, когда функция инициа- Но и ПРогРаммного интерфейса не может завершаться аварийно, решение тривиаль- к ВиДно из листинга 6.12. Листинг 6.12. ---cplusplus extern "С" { Kv*. *endif /*_____cplusplus */
130 Часть 1. Базовые концепции void Acme_Init(void); void Acme_Uninit(void); #ifdef __cplusplus } /* extern "C" */ class Acme_API_Scope { public: Acme_API_Scope() { Acme_Init(); ) ~Acme_API_Scope() { Acme_Uninit(); } // Реализация не требуется ); tendif /* ___cplusplus */ Все оказывается не столь простым, когда функция инициализации может завершаться аварийно, а именно с таким вариантом чаще всего приходится встречать- ся. Выбрасывать исключения не всегда правильно из-за того, что некоторые программ- ные интерфейсы требуют инициализации до применения других служб, включая те, которые поддерживают возможности C++. И напротив, отказ программного интерфей- са является действительно исключительным событием [Кет 1999], и там, где они под- держиваются, по-видимому, лучше выбрасывать исключения. Вы можете реализовать тестируемый класс, управляющий диапазоном действия ресурса, в котором либо опре- делены операторы operator bool() const и operator ! () const или эквива- лентные им операторы (см. гл. 24), либо всегда выбрасывается конкретное исключе- ние. Теперь мы рассмотрим достоинства и недостатки этих двух подходов. Если функция инициализации программного интерфейса Acme может завершаться аварийно, то вы можете запрограммировать класс, управляющий диапазоном действия ресурса, как показано в листинге 6.13: Листинг 6.13. int Acme_Init(void); // Возвращает 0 при успешном завершении и не-0 //в противном случае class Acme_API_Scope { public: Acme_API_Scope() : m_bInltialiaed(O Acme_Init()) {) -Acme_API_Scope() ( if (m_blnitialiaed)
Глава 6. Классы, контролирующие диапазон действия ресурсов 131 Acme_Uninit{); public: operator bool {) const { return m_blnitialised; } private: bool m_blnitialised; }; Этот класс можно использовать следующим образом: int main(. . .) ( Acme_API_Scope acme_scope; i f(acme_scope) { . . . // Выполнить основную обработку данных программы } Прекрасно, но не трудно представить, какие неприятности может доставить такой подход, когда придется инициализировать много различных программных интерфей- сов. Контроль диапазона действия всех экземпляров приводит к выводу недостаточно конкретизированного сообщения об ошибке: scope_l_t scope_l(. • .); scope_2_t scope_2(. . .) ; scope_3_t scope_3(. . .); scope_4_t scope_4{. . .) ; if( !scope_l || !scope_2 j j !scope_3 || !scope_4) { ...II только сообщение общего характера: «что-то не сработало» Он также приводит к скучному и многословному кодированию: scope_l_t scope_l(. . .); scope_3_t scope_3(. . .) ; scope_4_t scope_4(. . .) ; if(!scope_l) { } else { ...II зарегистрировать ошибку или сигнализировать о ней, // прекратить работу или сделать что-то еще
132 Часть 1. Базовые концещ^ scope_3_t scope_3(. . .); if(!scope_2) ...II зарегистрировать ошибку или сигнализировать о ней // прекратить работу или сделать что-то еще } else { . . . // и так далее Выполнять такую работу очень утомительно, и данная ситуация очень близка к ка. ионической, когда имеются «веские доводы в пользу использования исключений» В этих обстоятельствах значительно проще обеспечить выбрасывание исключений при применении инициализирующих классов, контролирующих диапазон действия ресурсов, при помощи следующего клиентского программного кода: int main(. . .) { try { scope_l_t scope_l(. .); scope_2_t scope_2(. .); scope_3_t scope_3(. .); scope_4_t scope_4(. .); . . .11 Выполнить основную обработку данных программы } catch(std::exception &x) { ...II Обработка исключения } Однако некоторые программные интерфейсы приходится настраивать независимо от механизмов обработки исключений, и поэтому указанная форма не всегда достижи- ма и/или желательна. Поэтому третья и, вероятно, наилучшая возможность заключает- ся в применении классов стратегий, которые мы рассмотрим в разделе 19.8. Какой бы способ вы ни выбрали (или ни пришлось бы вам выбрать), применение классов, управляющих диапазоном действия ресурсов программных интерфейсов в течение требуемого периода, повышает надежность и снижает объем программного кода. Так пользуйтесь этим подходом! 6.3.2. Службы Кроме контроля временем «активной жизни» программного интерфейса, класС^> управляющие диапазоном действия ресурсов, могут использоваться для времени изменения состояний программных интерфейсов. Например, библиотека врем выполнении языка С компании Microsoft имеет отладочный программный интерЯ* который может быть включен с помощью вызова функции _CrtSetDbgFla^ Иногда удобно изменить на короткий период отладочную информацию, выдаваеМ-^ библиотекой, и снова мы попадаем на «территорию» классического класса, Уп? ляющего диапазоном действия ресурса:
6 Классы, контролирующие диапазон действия ресурсов 133 { // Отсрочить освобождение блока для увеличения нагрузки // на систему памяти CrtDbgScope scope(_CRTDBG_DELAY_FREE_MEM_DF, 0); ...II Критическая секция программного кода } // возврат в нормальный режим 6.4. Специальные возможности языка Я не сомневаюсь, что к настоящему моменту вы немного пресытились классами, управляющими диапазоном действия ресурсов, но прежде чем мы завершим эту тему, я собираюсь показать вам еще один, последний интересный пример. Язык C++ позволяет вам настраивать обработку ситуации, когда обнаруживается недостаток требуемой памяти, с помощью вызова свободной функции std:: set_new_handler () (стандарт С++-98: 18.4). Вы передаете функцию с сиг- натурой, в которой тип new_handler определяется следующим образом: typedef void (*new_handler)(); Функция set_new_handler () возвращает текущий зарегистрированный обра- ботчик или нулевой указатель, если он еще не был зарегистрирован. Когда оператору new () не удается выделить память, он вызывает новый обработчик, если он зареги- стрирован, или выбрасывает экземпляр исключения std: :bad_alloc в противном случае. Функция нового обработчика либо должна успешно получить дополнительную память, чтобы удовлетворить запрос, либо сама должна выбросить экземпляр ис- ключения std::bad_alloc5. Задавая свои собственные обработчики, вы можете настроить режим работы дина- мической памяти, когда возникает ее недостаток, или обеспечить расширенную обра- ботку ошибок, например, зарегистрировать возникшее условие (конечно, если только само это действие не требует выделения участка из динамической памяти). Проблема в том, как обеспечить здесь непротиворечивость режима работы нашего п^граммного кода. Например, если мы собираемся подключить еще один новый обра- аполн^ Как/где^когда нам следует это делать? Если мы это сделаем в функции main (), нап веР°ятн°, что мы пропустим достаточно много программного кода - как, имен ef>’ КОНСТРУКТОРЫ глобальных типов (глобальных переменных, пространств действ СТатических объектов классов; см. раздел 11.1) - который попадет в область МоЖет ИЯ ДРУГОГО нового обработчика. В принципе, ситуация с нехваткой памяти Ктаком^ОЗНИКаТЬ из"за ^предусмотренного поведения программы или приводить Н°как П0ведению> и мы можем оказаться не готовы к обработке такой ситуации. •Фоизойд МОЖем гарантировать подключение нашего нового обработчика, прежде чем 67 что’то? И как нам определить, какой бит программного кода отвечает за ус- Нов°га обработчика?
134 Часть 1. Базовые концепции Ну, разумеется, решение подразумевает применение классов, управляющих диапа- зоном действия ресурсов. Например, корневой обработчик библиотек системы Synesjs содержит программный код, подобный показанному в листинге 6.14. Листинг 6.14. #ifdef __cplusplus void custom_nh(void); // Пользовательский обработчик class NewHandlerlnitiator ( public: NewHandlerlnitiator() : m_oldHandler(set_new_handler(custom_nh)) {} -NewHandlerlnitiator() { set_new_handler(m_oldHandler); } private: new_handler_t m_oldHandler; // Реализация не требуется static NewHandlerlnitiator s_newHandler!nitiator; #endif /* ___cplusplus */ NewHandlerlnitiator является классом, управляющим диапазоном действия нового обработчика. В конструкторе выполняется установка custom_nh () в качестве следующего нового обработчика, а предыдущий обработчик запоминается в m_oldHandler. В деструкторе происходит «обмен любезностями», и в качестве нового обработчика снова устанавливается прежний обработчик. Существенную роль здесь играет следующая строка, в которой определяется статический экземпляр класса. Это заставляет s_newHandlerInitiator обес- печить внутреннюю связь1 * * (стандарт С++-98: 3.5.3; см. также разделы 11.1.2 и 11-2-3). а это означает, что каждая единица компиляции, где встретится это определение, буДеТ иметь только единственный его экземпляр. Поскольку этот программный код находится в корневом обработчике, ка»ДаЯ единица компиляции C++, которая использует библиотеки системы Synesis, буде1 держать экземпляр NewHandlerlnitiator. Поэтому не имеет значения, какой ” них будет скомпонован первым (и, следовательно, какие глобальные объекты первыми сконструированы), поскольку s_newHandlerInitiator гарантий установку нового обработчика. Конечно, это не относится к любому программ14 1 Здесь используется статическая переменная, а не анонимное пространство имен (стандарт С++-9^: ,нце из-за возраста и обратной совместимости этого программного кода. Это является анахронизмом, и прИ*,еН анонимного пространства имен более предпочтительно [Stro 1997], как показано в разделе 11.1.2.
135 & Классы, контролирующие диапазон действия ресурсов -—' ^торый «нами» не создается, например, находящемуся в исходных и/или ^тических библиотеках независимых разработчиков. Но это не имеет значения, СТ льку они и не рассчитаны на активацию нашего специального нового обработчи- П если у них возникнет нехватка памяти, причем даже в том случае, если они окажут- *я первыми при компоновке и поэтому сконструируют глобальные объекты, простран- ства имен и статические классы до замены обработчика. Конечно, это все не играло бы заметной роли, если бы программисты избегали пользоваться глобальными объектами, сознавая, как и все мы, что именно так следует поступать, но я предоставляю другим право писать программный код, предназначен- ный для идеального мира; нам приходится иметь дело с реальным миром. Этот метод определения статических экземпляров в заголовках является очень мощным - конечно, не без недостатков - и мы рассмотрим его снова в части 2, где узна- ем как опасно в действительности пользоваться глобальными объектами.
Часть 2 Выживание в условиях реального мира Очень многие свойства языка C++, как и С, являются открытыми. Если пользоваться терминологией стандарта (С++-98: 13), приспосабливаемая (conformant) реализация может иметь свойства, которые определяются конкретной реализацией или не специ- фицируются. Оба эти термина означают зависимость особенностей реализации таких свойств от разработчика компилятора и/или от ограничений операционной среды. Они отличаются друг от друга тем, что определяемый реализацией режим работы доку- ментируется в отличие от неспецифицированного поведения, которое не документиру- ется. Для простоты я собираюсь в обоих случаях пользоваться термином «свойство, определяемое реализацией», поскольку почти все ценные неспецифицированные свой- ства на самом деле заслуживают особого внимания. Следует отметить, что данный термин сильно отличается от «неопределенного поведения», которое обычно приводит к «недостойному» краху на глазах всего руководства. В некоторых случаях существуют веские причины предоставления свободы при реализации; в других случаях языку не удается в достаточной мере использовать современные методы. И то и другое становится причинами большой головной боли при программировании на C++ в реальных условиях. Почти повсюду в данной книге мы будем встречать недостатки, методы и стратегии, относящиеся, как правило, к отдельным исходным файлам или отдельным классам- Однако в главах этой части рассматриваются свойства целых программ, состоящих из множества исходных файлов и, возможно, двоичных компонентов. Программы должны надежно и предсказуемо взаимодействовать с операционной средой и в некоторых случа ях должны быть устойчивы к присутствию других процессов и потоков вычислений Поэтому в этих главах рассматриваются некоторые наиболее сушественнь'е недостатки языка C++. Дело не в том, что отсутствуют подходы к преодолению эти* недостатков (а они имеются), но невозможность справиться с недостатками веДеТ к серьезным последствиям, а сами эти подходы с трудом поддаются пониманию, и не просто применять. Именно это является благодатной почвой для критики языка-
137 Часть 2 Выживание в условиях реального мира Хотя теоретически рассматриваемые вопросы не связаны друг с другом, на самом они обычно влияют друг на друга, и поэтому я бы рекомендовал читать эти главы деЛе’еДОвательно, и надо иметь в виду, что некоторые вопросы получают исчерпы- раюшее рассмотрение только в одной из последующих глав. Мы начинаем с обсуждения отсутствия двоичного стандарта для взаимодейст- Их компонентов C++, что налагает серьезные ограничения на разработку боль- ших систем и существенно ограничивает возможности обеспечения повторного использования компонент программного обеспечения, написанных на C++. Затем рас- сматриваются вопросы обеспечения полиморфного поведения, причем независимо от используемого компилятора, включая обсуждение стратегий размещения объектов в памяти несколькими компиляторами и методов, позволяющих устранять различия этих стратегий. Использование динамической компоновки для построения исполнительных моду- лей в современных операционных системах также не освещается в C++, и это услож- няет работу большинства разработчиков-практиков. Аналогично, многопоточная среда является той большой областью, где C++ оказывается недостаточно адекватным; фактически он не обеспечивает поддержки этого популярного и мощного подхода. Эти два вопроса являются темами следующих двух глав. Затем обсуждаются статические объекты с иллюстрацией проблем, возникающих при использовании как локальных статических объектов, так и нелокальных статиче- ских объектов. Хотя оба типа объектов сильно связаны, они в определенном смысле существенно отличаются друг от друга, в частности, при разработке системы в много- поточной и многомодульной среде. Затратив некоторое время на обсуждение серьезных недостатков языка, мы заверша- ем эту часть немного менее содержательной темой, обращая свой взор на оптимизацию: что компиляторы убирают из программы (часто не спрашивая нас). Имеется шесть глав: глава 7, «Двоичный интерфейс приложения», глава 8, «Объек- ты, переносимые через границы», глава 9, «Динамические библиотеки», глава 10, оточная организация вычислений», глава 11, «Статические объекты» и глава 12, $У^имизаг{ия>>- Естественно, нельзя рассчитывать, что в одной части одной книги Дана полная картина всех проблем, но я надеюсь, что это поможет вам лучше ^овиться к встрече с этими недостатками в своей работе. ное Рассчитываю’ что после прочтения первой части у вас возникло теплое и радост- необХо аНИе рассматРивать C++ почти исключительно с положительной стороны. Вам т. к мьГ° ПОДДерживать это настроение при чтении данной и последующих частей, ^Дросо УДеМ подвергать С4-*" безжалостной критике. Некоторые из рассматриваемых даря эт В °ТНОСЯТСЯ не только к языку C++, а характерны и для других языков. Благо- му существуют практические решения большинства рассмотренных проблем.
Глава 7 Двоичный интерфейс приложения 7.1. Совместно используемый программный код Предположим, что вы разработали библиотеку каких-то полезных классов и хотите сделать ее доступной другим программистам. Более того, представим, что некоторые из этих программистов используют другие операционные системы, и/или используе- мые ими компиляторы отличаются от компилятора, применяемого при разработке про- граммного обеспечения. Как вам это сделать? Ну, самое очевидное решение, хотя не обязательно самое лучшее, просто передать всем исходный программный код, и они сами скомпонуют библиотеку. Теоретически, это вполне подходящий способ, поскольку считается, что любой компилятор должен справляться с вашим программным кодом на C++. К сожалению, как, вероятно, вы уже испытали на собственном опыте или, по крайней мере, знаете об этом из книг, язык С+- настолько сложен, что компиляторы имеют некоторые диалектные отличия, и вполне вероятно, что вам придется предпринять довольно-таки не тривиальные действия, чтобы заставить ваш исходный код работать с другими компиляторами: особенно это относится к таким самым современным методам, как, например, шаблонное мета-про- граммирование (ШМП). Если ваш программный код должен использоваться в другой операционной системе, почти наверняка вам потребуется осуществлять его перенос - модифицировать некоторые вызовы, алгоритмы и даже подсистемы - на новую плат- форму, и поэтому вопрос совместимости двоичного кода становится актуальным Однако давайте на минуту забудем об этом и согласимся, что распространение программного обеспечения на уровне исходных текстов в принципе является доста- точно очевидным подходом, если даже на практике все оказывается не совсем так . Преимущество предоставления только двоичных модулей заключается не толы® в защите интеллектуальной собственности, но также в том, что вы сохраняй контроль над исправлениями ошибок и обновлениями программного кода. НедостаТ ком такого подхода является необходимость получения пользователями новых библиотек и перекомпоновки своих проектов для того, чтобы сделанные вами улУ4 т ния были учтены. Напротив, при предоставлении исходных текстов ничто не ме вашим клиентам самим исправлять ошибки или вносить улучшения (или иногда Д 1 В действительности все может оказаться совсем не так: команда разработчиков одного из мои* потратила четыре месяца на перенос программного кода, работавшего корректно на трех вар операционной системы UNIX, на четвертый вариант этой системы. Вот так!
139 Слава 7 Двоичный интерфейс приложения —*\противоположного РезУльтата)- Однако если они не проинформируют вас об 83 корре^нР081^ пРогРаммного кода и вы не позаботитесь о включении их в буду- ЭТИХверсии, при последующем обновлении вашего программного обеспечения коррек- и клиентов будут перекрыты и потребуют повторного внесения. Какой бы ни была причина - паранойя, защита интеллектуальной собственности, удобство, стыд - вы не хотите показывать внутреннее содержание вашей библиотеки "внешнему миру и предпочитаете предоставить ее в двоичной форме. В идеальном мире вы бы откомпилировали файлы вашей реализации в двоичную библиотеку и передавали бы ее вашим клиентам вместе с заголовочными файлами. Они бы с помо- щью директивы #include включали заголовочные файлы в свой клиентский программный код и осуществляли его компоновку вместе с вашей двоичной библиоте- кой. Что может быть проще? Увы, эта радужная картина далека от действительности. Для того чтобы все было именно так, необходимо иметь согласованное всеми представление компилятором форм и механизмов для типов, функций и классов, описанных в ваших заголовочных файлах. Подобная вещь вызывается двоичным интерфейсом приложения (Application Binary Interface или ABI), а C++ его не имеет! Дефект: стандарт языка C++ не определяет двоичный интерфейс приложения, что ведет к широко распространенной несовместимости многих компиляторов на многих платформах. Недавно были предприняты некоторые действия по стандартизации двоичных интерфейсов для C++, а именно, для процессора Itanium [Itan-ABI]. Но в целом указан- ный дефект существует. Поскольку программный код C++ компилируется в коды машинного языка, для по- кроенных на одной платформе двоичных модулей не устанавливаются требования По их выполнению на другой платформе, и поэтому более точно можно сказать, что отсутствует двоичный интерфейс для конкретных платформ, то есть комбина- ций архитектуры и операционной системы. В данной главе мы собираемся рас- пан^еТЬ Э™ вопросы в Рамках платформы Win32-Intel х86. В некотором смысле ком- с *** Microsoft де-факто представляет стандарт для разработки в среде Windows, по- эпиляторы, как минимум, должны взаимодействовать с функциями С, нахо- ися в библиотеках Win32. Но это едва ли можно представить как полноправный к°мп Ь,и интерФейс’ и существует много несовместимости в подходах поставщиков ^иляторов только одной этой платформы. нц ддяТаКОГо сложного языка, как C++, требуется стандартизовать многие его сторо- ПоддеРжки двоичного интерфейса. Сюда входит расположение объектов Имен, И (вкл*очая базовые классы), соглашения по вызову функций и расширение Нце 1116 mangling), механизм реализации виртуальных функций, инстанциирова- иЧеских объектов, поддержка языка C++ (например, set_unexpected(),
140 Часть 2. Выживание в условиях реального мирд operator new [ ] О и т. д.), обработка исключений и информация о типах, получаемая в процессе выполнения программы (RTTI - Run Time Type Information; C++-98: 18.5) а также механизмы инстанциирования шаблонов. Кроме того, существуют менее оче- видные вопросы, например, связанные со средствами отладки операций с памятью библиотеки программ этапа выполнения, которые сами по себе не является частью языка, но, тем не менее, имеют большое практическое значение. Не имея рекомендаций и оставленные без контроля, различные поставщики компи- ляторов неизбежно предлагают различные реализации форматов выполняемых моду, лей C++ (и С), их режимов работы и инфраструктуры этапа выполнения; это может быть результатом естественных отклонений или тщательно продуманной коммерче- ской тактики, но выяснение этого вопроса выходит за рамки данной книги. Однако какой бы ни была причина, мы можем сказать, что здесь существует большая пробле- ма. Один мой хороший друг1 резюмирует это следующим образом: «Самым большим недостатком C++, по моему мнению, является отсутствие стандарта дво- ичного интерфейса. Прямым следствием является то, что программный код C++, скомпилиро- ванный с помощью GCC, не будет работать с программным кодом, скомпилированным с помо- щью MSVC, если вы не обеспечите интерфейс «С» (используя формат «С» для разрешения внешних ссылок). Это заставляет всех поставщиков программного обеспечения на C++, стремя- щихся обеспечивать обобщенный, повторно используемый программный код, использовать на каком-то этапе формат компоновки языка «С». Он прав и сформулировал проблему очень точно. Если я предоставляю двоичную библиотеку, построенную одним компилятором, существует очень большая вероят- ность,что она не будет совместима с программным кодом, скомпилированным другим компилятором. Другими словами, я не могу ограничиваться лишь одной двоичной версией моей библиотеки; напротив, мне необходимо предоставить версии для всех компиляторов, которыми могут пользоваться потенциальные клиенты. Естественно, коммерческая зависимость некоторых поставщиков компиляторов стала причиной образования неких коалиций, обеспечивающих совместимость компи- ляторов. Поэтому я могу построить библиотеки C++, например, используя компиля- торы Code Warrior и Intel, и подключить их двоичную форму в исполнительный модуль- построенный при помощи Visual C++. Но частичное решение по сути не является решением, если вы не собираетесь ограничивать себя одним компилятором или не большим набором компиляторов. Пока стремление к получению универсальных для каждой платформы двоичны* интерфейсов, как, например, проект Itanium, не станет нормой, нам придется иметь дело со сложной проблемой, на которую указал мой эрудированный друг. В ДаН и последующей главе признается наличие этой проблемы, но не признается подрав ваемое полное поражение. Давайте рассмотрим меры, которые мы можем едприН для уменьшения нашего дискомфорта. ~. KOPoU'11 Грустно оттого, что теперь из-за этого и нескольких других серьезных недостатков он не очень относится к C++.
Гл^а7. Д₽оичный интеРФейс пряжения 141 у^дребования для двоичного интерфейса языка С Поскольку C++ является почти полным супермножеством С, любой двоичный нтерфейс C++ будет заключать в себе двоичный интерфейс языка С. Естественным пе вым этапом в исследовании вопросов, связанных с двоичным интерфейсом языка C++ является рассмотрение вопросов, характерных для С. Язык С гораздо проще языка C++, сохранившего дух С (см. «Введение»). Он не имеет классов, объектовы, виртуальных функций, обработки исключений, доступа к информации типов на этапе выполнения и шаблонов. Однако, некоторые из указанных выше вопросов имеют отношение и к С. С имеет структуры, которые компилятор может выравнивать, сообразуясь только со своими собственными критериями, если мы не вмешаемся в процесс упаковки с помощью применения специфичных для данного компилятора опций и директив pragma (см. раздел 8.2). Могут различаться даже способ вызова функций на этапе выполнения программы и формат имен, используемый на этапе компоновки. Все функции в языке С используют те или иные соглашения по их вызову, которые в зависимости от применяемой плат- формы могут быть или не быть совместимыми для разных компиляторов. Аналогично, имена откомпилированных функций - символические имена - могут не совпадать для разных компиляторов. Как раз в этих двух случаях компиляторы операционной систе- мы Win32 более разнятся, чем, например, компиляторы операционных систем UNIX, что является одной из причин моего особого внимания к компиляторам Win32 в данной главе. Те из вас, кто работает исключительно в среде UNIX, вероятно, могут вздохнуть с облегчением, поскольку для выбранной вами операционной системы эти проблемы не столь серьезны. Будем надеяться, что вы хотя бы на мгновение пожалеете бедного программиста Win32, пытающегося обеспечить возможность взаимодействия Двоичных модулей C++ (и С). С^Язык С имеет проблемы поддержки языка, хотя они и значительно проше проблем ’ - например, семантика повторного использования для функции atexitO ex 2001] - и это означает, что двоичный интерфейс языка С зависит от того, на- ^олько одинаково интерпретируется функциональность таких библиотек различными Эпиляторами. 7*2.1. Расположение структур в памяти Упаковки в С достаточно хорошо понятны. Рассмотрим следующую struct s long 1; int i; short S; char ch;
142 Часть 2. Выживание в условиях реального мира Размер этой структуры полностью определяется реализацией. На практике он завц. сит от используемых компилятором соглашений упаковки, которая всегда осуществля. ется по простому алгоритму. Если структура упаковывается с выравниванием на гра. ницу байта, то все ее члены будут упаковываться без промежутков. Предположим, чт0 типы long, int, short и char имеют соответственно размеры 8,4, 2 и 1 байт, тогда структура S будет занимать в памяти 15 байтов. Однако если при упаковке использует, ся выравнивание на границе двух байтов, она будет иметь размер 16 байтов. При выравнивании на границе четырех байтов ее размер будет равен 24 байтам, а при выравнивании на границе восьми байтов ее размер составит 32 байта. Если два компилятора используют разное выравнивание при упаковке структур тогда построенные ими двоичные компоненты не будут совместимы. Фактически тип выравнивания при упаковке обычно может задаваться опцией компилятора, и поэтому вполне возможно построение различных двоичных компонентов одного приложения одним компилятором, которые, тем не менее, окажутся несовместимыми. 7.2.2. Соглашения по вызову функций, символические имена и форматы объектных файлов После того, как вы тем или иным способом добились от компиляторов одинакового выравнивания структур, возникает следующая проблема при совместной компоновке двоичных компонентов. При этом приходится решать два вопроса: один связан с сим- волическими именами, а другой - с соглашениями по вызову функций. В первые дни применения языка С на платформе UNIX [Lind 1994] все компиля- торы использовали одни и те же соглашения по вызову функций, называемые соглаше- ниями С по вызову функций, по которым аргументы помещаются в стек, начиная с самого правого и завершая самым левым, и вызывающая программа очищает стек Ближе к нашим дням другие компиляторы, особенно на платформах Windows, стали поддерживать другие соглашения, например, stdcall (стандартный вызов), pascal или fastcall. В таких условиях соглашения С по вызову функций стали называться cdecl. Если два компилятора используют различные соглашения по вызову функш*11- то генерируемые ими двоичные компоненты не будут совместимыми. С этим связан также вопрос формирования символических имен в объектны* файлах, то есть в двоичных файлах, сгенерированных при компиляции соответс* вующей единицы компиляции (см. «Введение»). Символические имена - это имена- задаваемые функциям и переменным, имеющим внешние ссылки в объект*1^ файлах для того, чтобы при компоновке программы можно было найти различ символы и связать их друг с другом. Классический подход заключается просто в пользовании собственного имени функций [Кет 1999]. Многие поставщики ляторов используют свои собственные схемы именования символов для С++’11 ------------------------------ м ' 1 Это объясняет то, как поддерживается список аргументов функций с переменным их количество printf(char const ♦, ...); - поскольку вызывающая программа «знает», какое количество аргументов функция, тогда как сама функция в общем случае не может этого знать.
143 Гл^»7 Д₽оичнЫЙ интерфейс ИРН™*61^ —~ся одним из факторов плохой совместимости языка C++ (см. раздел 7.3.3). ЯВЛ еше более усугубляется тем фактом, что в некоторых компиляторах символиче- ^г0 йМеНа зависят от используемой модели поточной организации вычислений С*ходе построения конкретной системы. В Наконец объектные файлы могут иметь различные форматы, что является еще дним источником несовместимости платформы Win32. Существует несколько раз- личных форматов - то есть OML, COFF и т. д. - и в целом они несовместимы, хотя большинство компиляторов поддерживают более одного формата. 7.2.3* Статическая компоновка На практике некоторые компиляторы способны использовать одни и те же статиче- ские библиотеки. Таблица 7.1 показывает совместимость при построении и примене- нии простых статических библиотек С для некоторых популярных компиляторов плат- формы Win32. Как вы можете видеть, совместимость достигается в нескольких случаях, но в целом картина не очень радостная. Для получения этой информации я, как правило, пользовал- ся опциями, которые применяются по умолчанию для генерации кода каждым компиля- тором. Существуют некоторые дополнительные возможности улучшения ситуации, но, несомненно, нет способа, позволяющего добиться их полной взаимозаменяемости. Таблица 7.1. Совместимость статической компоновки (программный код С) на платформе Win32; знак # представляет совместимую комбинацию Компилятор, формирующий библиотеку _________________Borland CodeWarrior Digital Mars GCC Intel Visual C++ Borland # CodeWarrior Digital Mars GCC Intel Дёиа1с++ Компилятор, использующий библиотеку # # # # # # # # # # # # ^ияЧеВИДНО’ ЧТ° отсУтствие Двоичного интерфейса является препятствием существо- библИоНа ПлатФ°Рме Win32 единственной статически скомпонованной версии вашей стан КИ’ аналогичная несовместимость наблюдается на других платформах, хотя Плата*, ИЗЭЦИя ^tan^urn означает, что компиляторы GCC и Intel могут сотрудничать на HMe^ Р * 6 ^пих- Существует прозаическое решение проблемы. Вам необходимо к кажДому компилятору (или к совместимому с ним), который могут зоц, Вать ваши клиенты, и сгенерировать ваши библиотеки для него. Таким обра- Могпи бы создавать mystuf f_gcc .a (mystuf f_a.. lib для Win32),
144 Часть 2. Выживание в условиях реального мий mystuff_cw.a (mystuf f_cw. lib для Win32) и т. д. На практике едва ли уВас будет доступ ко всем компиляторам, которые вам могут потребоваться, особенно есди вы собираетесь поддерживать несколько операционных систем. Если вы даже сумеет этого добиться, вам придется столкнуться с неприятной проблемой поддержки всех файлов проектов и файлов makefile - и это больше похоже на тяжелый физически труд, чем на разработку программного обеспечения. Такой подход можно считать разумным для небольших проектов, но он неприемлем для любого большого проекта трудно представить, что таким способом можно писать операционные системы! 7.2.4. Динамическая компоновка Современные операционные системы и многие современные приложения исполь- зуют метод, называемый динамической компоновкой, который мы подробно рас- смотрим в гл. 9. В данном случае вы компонуете библиотеку аналогичным образом, но программный код не копируется в окончательный исполняемый модуль. Вместо этого регистрируются точки входа, и когда исполняемый модуль загружается операционной системой, динамические библиотеки, от которых он зависит, также загружаются, и точки входа изменяются и указывают на реальный программный код внутри библио- теки, поскольку теперь он находится внутри адресного пространства нового процесса. В системах Win32 создание динамической библиотеки обычно достигается путем генерации небольшой статической библиотеки, известной как библиотека импортиро- вания (import library), содержащей программный код, который приложение будет использовать для установки адресов при загрузке динамической библиотеки. Испол- няемые модули компонуются вместе с такими библиотеками импортирования анало- гично тому, как компонуются обычные (статические) библиотеки. Расположенные в библиотеке функции, которые могут быть использованы для дина- мической компоновки, называются экспортными функциями. В зависимости от компиля- торов и/или операционной системы, экспортными могут быть все функции библиотеки или только те, которые вы явно пометили каким-то образом, например, указав их в файле ото- бражения (mapfile) системы Solaris или используя модификаторы_decl spec (dlleX port) для компиляторов платформы Win32. Преимущество динамической компоновки заключается в снижении требова к объему дискового пространства и рабочим наборам операционной системы, поскол^ ку отсутствует дублирование блоков программного кода в различных исполняв»^ файлах или в нескольких параллельно выполняющихся процессах [Rich также означает, что исправления ошибок и усовершенствования могут осушествл*^ без повторного построения независимых исполнительных модулей. В самом деле’^ где библиотеки являются частью операционной системы, такие обновления быть побочным эффектом установки или обновления другого программного обес ния или самой операционной системы, и поэтому об этом могут ничего не знать ставщики программ, ни даже пользователи
145 7 двоичный интерфейс приложения Глава _______________________________ 7,2. Совместимость динамической компоновки (программный код С, cdecl) на платформе Win32; ^Представляет совместимую комбинацию Компилятор, использующий библиотеку Компилятор. фоРмируюший Borland CodeWarrior Digital Mars GCC Intel Visual C++ Borland # # # # # # CodeWarrior #♦ # # # # # Digital Mars #♦ # # # # # GCC #♦ # # # # # Intel #♦ # # # Visual C++ #♦ # # # # # Естественно, применение совместно используемых библиотек имеет недостатки, связанные с так называемым «адом DLL» [Rich 2002], когда обновляемые версии дина- мических библиотек при наличии в них ошибок могут «сломать» работу (часто важ- ных) программ, до этого функционировавших нормально. Другой стороной ада DLL является очень распространенная проблема исправления ошибок библиотек, которые нарушают работу зависимых от них программ. Имен не указываю, но вы понимаете, о каких библиотеках идет речь. Несмотря на эти очень реальные проблемы, преимуще- ства перевешивают такую озабоченность, и трудно представить возможность возврата к чисто статической компоновке. Динамическая компоновка существенно влияет на нашу совместимость, как можно видеть из табл. 7.2. Каждая клиентская программа была построена данным компиля- тором с помощью импортированной библиотеки, скомпонованной соответствующим компилятором, и совместимость проверялась с переключением на библиотеки DLL, построенные другими компиляторами, и попыткой выполнения программы. Здесь существует почти идеальная совместимость, если не брать в расчет примене- ние библиотек других компиляторов для Borland (помечено звездочкой *). Это объясня- ли тем фактом, что компилятор Borland в динамических библиотеках перед символами ^Вит префиксы с ведущим подчеркиванием, используя соглашения С по вызову функ- на платформе Win32 для статической компоновки. Большинство других компиля- платформы Win32 не используют их, вероятно, потому что так делает Visual C++ лась ИИ Micros°fi- (Решить эту проблему для Borland не совсем просто, поэтому появи- Под ЗВездочка *• Я предоставляю возможность читателю исследовать этот вопрос. ка: воспользуйтесь опцией -и и утилитами IMPLIB и IMPDEF.) В системах не возникает проблем с префиксами имен. бц п п°ДУмать, то эта полная совместимость идеально подходит для нас. На какой Вцс ^Форме вы ни работали, - на платформе Win32 или на платформе Solaris, - и . 676 подключаться к динамическим системным библиотекам. Если бы это было **s
146 Часть 2. Выживание в условиях реального ми^ не так, то ваш компилятор не смог бы генерировать программный код для соответск вующей системы и было бы мало возможностей его генерации в данной среде. 7.3. Требования C++ для двоичного интерфейса приложения Несколько аспектов языка C++ делают задачу обеспечения унифицированного дВо. ичного интерфейса существенно более сложной, чем для С. Язык C++ имеет классы которые значительно сложнее структур (хотя они имеют много общего со структура, ми), поскольку конкретный класс может иметь один или несколько базовых классов. C++ обеспечивает полиморфизм на этапе выполнения через механизм реализации виртуальных функций. Хотя компиляторы используют общий механизм - таблицу виртуальных функций - многие из них применяют взаимно несовместимые схемы. Компиляторы C++ используют гораздо более сложные и в том числе запатентованные схемы назначения имен символам, делая почти невозможным вызов двоичных компо- нент C++, построенных одним компилятором, из клиентского программного кода C++, построенного другим компилятором. В добавление к проблемам именования символов в языке С компиляторы C++ ис- пользуют расширенные имена в двоичном коде для обеспечения механизма перегрузки функций и политики защиты типов при компоновке. Из-за того что C++ поддерживает статические объекты, этот язык должен обес- печивать создание и уничтожение глобальных и функционально-локальных статиче- ских объектов. Мы увидим в гл. 11, что различные компиляторы применяют различные схемы, что приводит к различному порядку инициализации глобальных объектов. Конечно, это является еще одним препятствием обеспечения двоичного интерфейса в C++. В языке C++ также предусматривается обработка исключений, получение ин- формации о типах на этапе выполнения (RTTI) и различные стандартные функции языка C++, и все это должно иметь одинаковые или совместимые форматы и согласо- ванно работать на этапе выполнения. 7.3.1. Расположение объектов в памяти В языке C++ проблемы расположения структур С в памяти по-прежнему остаются (см. раздел 7.2.1), но все значительно усложняется из-за размещения наследУемЫХ классов, шаблонов и виртуального наследования [Lipp 1996]. Проблемы размещения наследуемых классов имеют много общего с проблемами упаковки вложенных стрУк тур С; виртуальное наследование является еше одним зависимым от реализации аспеь том (стандарт С++-98: 9.2; 12), который дополнительно усложняет и без того эту сЛ°* ную картину. Способ реализации шаблонов также оказывает влияние. В разделах 12.3 и 12-4 РаС сматриваются возможности влияния шаблонов на расположение объектов посрел вом наследования.
147 7 Двоичный интерфейс приложения ^"^2?виртуальные функции В стандарте C++ описываются особенности механизма реализации виртуальных кциЙ на которых базируется осуществление полиморфизма C++ на этапе выполне- фУНКн0 он не предписывает какой-то определенный способ реализации этого механиз- НИЯдт0 тот случай, когда все современные коммерческие компиляторы используют ме- ханизм таблиц виртуальных функций [Stro 1994], где экземпляр класса, опреде- ляющий виртуальные функции (или который является наследником такого класса), содержит скрытый указатель - vptr- на таблицу указателей функций - viable - которые ссылаются на виртуальные функции. Такое единообразие дает некоторые основания для оптимизма, но различные поставщики применяют разные соглашения. Этот вопрос подробно рассматривается в гл. 8. Кроме формата таблицы viable компиляторы должны также прийти к единому мнению относительно того, когда объекты данного класса имеют таблицы viable, а когда они «повторно используют» эти таблицы базового класса. 7.3.3. Соглашения по вызову функций и расширение имен Одним из самых очевидных и досадных с точки зрения С недостатков C++ является расширение имен в двоичном коде, которое должно обеспечивать один из базовых механизмов языка C++: перегрузку. Рассмотрим программный код листинга 7.1. Листинг 7.1. class Mangle { public: void func(int i); void func(char const *); ); int main() { Mangle mangle; mangle.func(10); mangle.func("Hello"); return 0; пРослСОЗНЭТеЛЬНО Опускаю опРеделения методов класса Mangle1, чтобы мы могли -МОГ" ПРОИСХОДИТ Расширение имен. Если вы построите эту программу при н°комИ ^sual C++ 6.0, вы не получите сообщений об ошибках при компиляции, °новщик выдаст следующие сообщения: перевод названия класса - «искажение». - Примеч. пер.
148 Часть 2. Выживание в условиях реального error: unresolved "void Mangle::func(char const *)" (?func@Man- gle@@QAEXPBD@Z) error: unresolved "void Mangle::func(int)" (?func@Mangle@@QAEXH@Z) (ошибка: неразрешенная ссылка ...) Используемые здесь странные символы применяются для поддержки перегрузи В языке С может существовать только одно определение данной функции в исполняв мом модуле, и поэтому двоичное имя, называемое символическим именем, задается как простой вариант имени функции, зависящий от конкретной системы. Но в C++ функции (свободные функции, методы классов и методы экземпляров) могут перегру. жаться. Если допускается нескольким функциям иметь одинаковое имя, должен суще. ствовать способ обеспечения различимыми символическими именами всех перегру. жаемых функций. То же самое относится к одинаково поименованным методам раз- личных классов или одинаково поименованным классам из различных пространств имен. В результате все компиляторы выполняют так называемое «искаженное рас- ширение имен» (mangling), что является очень подходящим названием, поскольку сравнительно хорошо читаемые имена функций преобразуются в неподдающиеся ин- терпретации длинные цепочки символов, гарантирующие уникальность имен. Поскольку C++ как бы располагается поверх загружаемых символов программных интерфейсов операционной системы, которые написаны для работы с совместимыми с С программными интерфейсами, идентификаторы символов C++ необходимо преобразовывать в форму, обеспечивающую их уникальность. Без этого компоновщик не знал бы, какую перегруженную функцию следует подключать к вызовам в клиент- ском программный коде, и программа не могла бы функционировать. Предполагается, что в полностью интегрированной среде построения программ на C++ компоновщик мог бы понимать перегруженные функции и не потребовалось бы применять странные имена. Однако все же необходимо предусмотреть некоторую форму кодирования, и вам достаточно продвинуться всего лишь до родного интерфейса Java (JNI -Java Native Interface) [Lian 1999] для того, чтобы увидеть, как такие вещи, пусть «чистые” в реализации, все же оказываются достаточно плохо читаемыми В любом случае интегрированная рабочая среда C++ потребовала бы применения загрузчика С+ и поэтому мы оказываемся почти в порочном круге. Но поскольку мы используем для этого компоновщики, почему мы должны об это беспокоиться? Сами по себе схемы расширения имен весьма разумны, но тогда в проблема? Все очень просто. Различные поставщики компиляторов применяют Р личные схемы расширения имен, и поэтому на практике нельзя динамически з жать и вызывать библиотеки C++, построенные одним компилятором из программ^ кода, построенного другим компилятором. Мы более подробно рассмотрим вопрос в разделе 9.1.
149 двоичный интерфейс приложения Статическая компоновка Если мы теперь проанализируем совместимость статической компоновки простой иотеки C++ и простой клиентской программы, построенных с использованием ичных компиляторов платформы Win324, мы увидим (см. табл. 7.3), что картина one хуже, чем для С. 7.3.5. Динамическая компоновка jvfbi уже говорили, что проблемы несоответствия схем расширения имен при статической компоновке можно избежать, обеспечивая для каждого компилятора свой вариант наших библиотек, которые клиенты могут использовать для построения своих выполняемых программ. Но такое решение не подходит в случае динамической компо- новки. Динамические библиотеки, в принципе, могут совместно использоваться несколькими исполнительными модулями на этапе выполнения, которые потенциаль- но могут быть построены при помощи различных компиляторов. Если символические имена в динамической библиотеке имеют расширенную форму в соответствии с согла- шениями, не понятными компилятору, применяемому для построения другого процес- са, тот процесс не будет загружен. На практике вновь оказывается, что несколько поставщиков компиляторов плат- формы Win32 используют совместимые схемы, включая соглашения по расширению имен, как можно видеть из табл. 7.4L Несомненно, что динамические библиотеки обеспечивают более высокую степень совместимости, чем статическая компоновка. Однако должно быть столь же очевидным, что здесь остается существенная доля несо- вместимости. Это служит несомненным доказательством того, что C++ не имеет соот- ветствующего двоичного интерфейса на платформе Win32. Снова мы могли бы рассмотреть вариант с несколькими динамическими библио- теками, каждая из которых обеспечивает соответствующие расширенные имена. Мы могли бы предусмотреть поставку версий наших динамических библиотек для каж- (На° К0МПИЛЯТОРа: libmystuf f_gcc.so; mystuff_cw_dmc_intel_vc.dll. все же пришлось бы иметь больше библиотек импортирования согласно рас- шренной в предыдущем разделе совместимости статической компоновки, но это не так важно.) теки Г ПРИ пРименении этого подхода возникает ряд проблем. Во-первых, библио- д^ся не выполняют оба своих назначения, поскольку на диске и в памяти прихо- библиХРаНИТЬ больше библиотек, и необходимо согласованно обновлять все формы Родь " Кроме того, некоторые динамические библиотеки не только исполняют С°4еРЖатСТЫХ хранилищ программного кода. Динамические библиотеки могут СеЧдегелЬ СТатические данные (и в следующей главе мы коснемся дополнительных Ьств этого), которые могут обеспечивать логику программы, например, P Intel теперь позволяет осуществлять динамическую компоновку C++ совместно с модулями, 1 компилятором GCC на платформе Linux.
150 Часть 2. Выживание в условиях реального мирд менеджер пользовательской памяти или пул сокета. Если используется несколько версий (специфичных для конкретных компиляторов) одной программной логики все может очень запутаться. Таблица 7.3. Совместимость статической компоновки (программный код С) на платформе Win32; знак » представляет совместимую комбинацию Компилятор, формирующий библиотеку Borland Компилятор, использующий библиотеку CodeWarrior Digital Mars GCC Intel Visual С+Г Borland # Code Warrior Digital Mars GCC Intel # # # # # # Visual C++ # # # Таблица 7.4. Совместимость динамической компоновки (программный код С, cdecl) на платформе Win32, знак # представляет совместимую комбинацию Компилятор, формирующий Компилятор, использующий библиотеку библиотеку _____ Borland CodeWarrior Digital Mars GCC Intel Visual C++ Borland # CodeWarrior # # # # Digital Mars # # # # GCC Intel # # # # Visual C++ # # # # - Поэтому хотя вполне допустимо в отдельных случаях обходить проблему двоими0 го интерфейса динамических библиотек C++ путем предоставления несколь^ версий динамических библиотек, каждая из которых ориентированна на конкр°т компилятор, это достаточно обременительный подход, и мне не известно ни оди^ системы, где бы использовался именно такой подход. Кажется вполне очевидным, для обеспечения переносимости в рамках одной операционной системы нам пр1,де прибегнуть к компоновке в стиле С. Существенным недостатком является невоз»^ ность применения расширенных имен, и поэтому мы не можем экспортир0 импортировать перегруженные функции или любые методы класса.
^7 Двоичный интерфейс приложения ""Г~сих пор мы как будто соглашались нным моим другом. К счастью, помощь ^тся немного вернуться в прошлое. 151 с предначертанным судьбою путем, обрисо- можно найти совсем рядом; нам просто при- 7 4. Теперь мне ничто не мешает программировать в стиле С Если посмотреть на статическую и динамическую компоновку как С, так и C++, казывается вполне очевидным, что единственно разумным подходом по применению независимой от компилятора динамической компоновки является применение интерфейса С при создании программного кода. Теперь мы собираемся рассмотреть не зависящий от компилятора способ работы с объектами, но сначала нам следует быстро освежить в памяти возможности совмес- тимости С и С++- 7.4.1. extern “С” Т. к. C++ является почти супермножеством С, естественно существует способ его взаимодействия с С-функциями. Поскольку C++ расширяет имя каждой функции в случае ее перегрузки, необходимо иметь механизм предотвращения расширения имен для функций, реализованных на С. Это делается с помощью ключевых слов extern " С", например: void cppfunc(int); extern "C" void cfunc(int); Теперь вы можете использовать сfunc () в вашем исходном тексте на C++, а ком- пилятор и компоновщик гарантируют, что ссылки будут разрешены нерасширенным символом (например, _cf unc). Применение ссылок функции C++ cppfunc () приве- дет к формированию символов ?cppfunc@@YAXH@Z для компиляторов и компонов- щиков, совместимых с Visual C++. Естественно, после того, как было объявлено применение функцией правил компо- новки С, ее нельзя перегружать версией, которая также использует компоновку С: void cppfunc(int); extern "C" void cfunc(int); extern "C" void cfunc(char); // Ошибка: "несколько экземпляров "cfunc" // используют С-компоновку" ~ И ЭТ° Удивляет многих на собеседовании* - вы можете перегружать функ- °Дна ЯВЛеННую со специФикатоРом extern "С", любое количество раз, если ни очень не объявляется как extern " С". Сначала может показаться, что это ^Усть У вас имеется некоторый набор перегруженных функций C++, ДИрую ДЛЯ Удобства пользователя все кроме одной функции являются ретранс- нми, как показано в листинге 7.2. ** знают и многие люди, которые сами проводят интеррью!
152 Часть 2. Выживание в условиях реального мирд .— Листинг 7.2. // ConnApi.h struct corm_info_t *corm_handle_t; corm_handle_t CreateCormection( char const ‘host , char const ‘source , int flags, unsigned ‘pid); conn_handle_t CreateCormection(char const ‘host, int flags); conn_handle_t CreateCormection( char const ‘host, int flags , unsigned ‘pid); // ConnApi.cpp corm_handle_t CreateCormection( char const ‘host , char const ‘source , int flags, unsigned ‘pid) { } corm_handle_t CreateCormection(char const ‘host, int flags) { return CreateCormection(host, host, flags, NULL); } corm_handle_t CreateCormection(char const ‘host, int flags , unsigned ‘pid) { return CreateCormection(host, host, flags, pid); } Для того чтобы поместить этот программный интерфейс в независимую от компи- лятора динамическую библиотеку, вы могли бы просто объявить первый вариант функции как extern " С", а другие варианты - как перегрузки, работающие толы® в рамках единицы компиляции C++, как показано в листинге 7.3. Листинг 7.3. // ConnApi.h struct corm_info_t *corm_handle_t; tifdef ___cplusplus extern "C" { tendif /* cplusplus */ corm_handle_t CreateCormection( char const ‘host , char const ‘source , int flags, unsigned ‘pid); tifdef ___cplusplus } /* extern "C" */ inline conn_handle_t CreateCormection( char const ‘host , int flags) ( return CreateConnection(host, host, flags, NULL);
7 Двоичный интерфейс приложения 153 } inline conn_handle_t CreateConnection( char const ‘host , int flags , unsigned *pid) { return CreateConnection(host, host, flags, pid); } #endif /* _cplusplus */ // ConnApi.cpp conn_handle_t CreateConnection(char const ‘host, char const ‘source, int flags, unsigned ‘pid) { } Приятным побочным эффектом является применение этого подхода программистами С. Поскольку среди нас их по-прежнему много, неплохо иметь возможность увеличения поль- зовательской базы вашего программного кода. Очевидно, это работает только в том случае, когда ваша библиотека использует функции и все ее типы представлены в форме С, или, по крайней мере, имеют иден- тичное размещение при применении любых потенциальных компиляторов C++ данной операционной системы. Типы, представимые в форме С, по существу являются типами POD (см. «Введение»), которые определяются в C++ для обеспечения взаимозаменяемости С и C++. При исполь- зовании С-компоновки программист обычно стремится ограничить свои типы параметров типами POD. Однако extern -С* в действительности означает всего лишь «отсутствие расширения имен»; это не означает «только С». Поэтому вполне допустимо определить следующий класс: class CppSpecific { int CppSpecific::‘pm; }; extern "C" void func(CppSpecific const &); Даже несмотря на то, что возможно построение вашей разделяемой библиотеки применением С-связей в функциях, которые манипулируют классами C++, такой д опасен, потому что многие компиляторы имеют другие модели компоновки ктов [Lipp 1996], как мы увидим в гл. 12. На практике благоразумно придержи- ^Ъся типов POD. |3iatelHaK0 ЭТ° Не конец истории о переносимости C++, как мы увидим позже в данной
154 Часть 2. Выживание в условиях реального мира 7.4.2. Пространства имен Когда я говорил ранее о том, что у нас нет возможности свободно экспортировать перегруженные функции или методы класса, я не упоминал пространства имен. Это не было оплошностью. В [Stro 1994] Бьерн Страуструп обсуждает ограничение максимум одним экземп- ляром функций extern "С" для одного имени в единице компоновки независимо от контекста пространств имен. Он называет это «ловким, но опасным приемом, обес- печивающим совместимость» («compatibility hack»), и это действительно так. Однако именно этому приему мы можем быть благодарны, поскольку он наделяет нас неко- торой гибкостью при определении переносимых функций. В основном, если функция extern *С • определяется в рамках пространства имен, это пространство имен не указывается в символическом имени. Поэтому в следующем примере функция будет иметь символическое имя ns_f unc: namespace X { extern "C" void ns_func(); } Используя этот подход, стандартная библиотека может размещать функции стан- дартной С-библиотеки в пространстве имен std, не нарушая связей с ней в С++-про- граммах. Можно обратить это себе на пользу, поскольку мы можем определять наши перено- симые функции в рамках пространства имен, проявляя свою приверженность к C++, и все же использовать их в программном коде С. Однако это обоюдоострое решение, т. к. может быть только единственная двоичная форма функции с С-связью. Другими словами, если вы определяете переносимые функции и при компоновке вы применяете нечто, выполняющее то же самое, то при работе компоновщика возникнет конфликт, несмотря на то, что вы определяете свои функции в других пространствах имен C++- На практике я никогда не сталкивался с этой проблемой, но это не значит, что ею можно пренебречь. Лучше прибегнуть к устранению неоднозначности С-стиля в виде обозначения <программный интерфейс>_<название функнии>, как, например- Connection_Create(). 7.4.3. extern “C++” *С’ Любопытным и редко используемым противоположным аналогом extern является extern "C++". Этот спецификатор объявляет функцию или блок функи использующих С++-стиль компоновки. Поскольку это действует только при пРимеН*|| нии в рамках программного кода на C++, вы можете поинтересоваться, а наД° ' вообще когда-нибудь употреблять этот спецификатор.
155 Глава7 Дв°ичный интеРфейс приложения рассмотрим следующий заголовочный файл: // extern_c.h #ifdef __cplusplus extern "С" ( #endif /* —cplusplus */ int fund () ; int func2(int pl, int p2, int p3 /* = -1 */); #ifdef __cplusplus } /* extern "C" */ inline int func2(int pl, int p2) ( return func2(pl, p2, -1); } #endif /* __cplusplus */ func2 () имеет третий параметр, который, будучи не установленным на некоторое осмысленное значение, по умолчанию получит значение -1. Поскольку С не под- держивает установку значений параметров по умолчанию, подходящий метод заключается в обеспечении единственной перегрузки C++, что мы делаем, перегружая функцию в режиме компиляции C++(внутри второго блока #ifdef _cplusplus). Теперь рассмотрим еще один заголовочный файл, предназначенный только для применения в клиентском программном коде: // no_extern_c.h int fund () ; int func2(int pl, int p2, int p3); Если этот файл содержит объявления файлов, компилируемых в С, то они будут иметь С-связи, и имена не будут иметь расширенную форму в объектном файле или файле библиотеки. Применение их в рамках C++ приведет к ошибке компоновщика, поскольку откомпилированный программный код заставит компоновщик искать рас- ширенные имена. Чтобы предотвратить это, рекомендуется [Stro 1994] окружить Директивы #include спецификатором extern "С, как в следующем примере: II cpp_src.cpp extern "С" { Kindude "no_extern_c .h" J int main() { return func2(10, 20, -1) ; ) буд^°Жалению’ если вы т0 же самое сделаете с таким файлом, как extern_c. h, вы ццф пР°Информированы о том, что не можете иметь вторую перегруженную функ- Пс2 <) со связями в стиле языка С. Поэтому не достаточно просто объявлять единственные перегруженные С++-функции вне блока extern "С"; вам необ-
*156 Часть 2. Выживание в условиях реального мирд холимо их также заключить в блок extern "C++", который указывает компилятору на необходимость применения для них С++-компоновки (то есть использовать рас ширение имен), как показано в листинге 7.4. Листинг 7.4. // extern_c.h #ifdef ___cplusplus extern "С" ( #endif /* ___cplusplus */ int fund () ; int func2(int pl, int p2, int p3 /* = -1 */); Sifdef ___cplusplus extern "C++" ( inline int func2(int pl, int p2) { return func2(pl, p2, -1); } } /* extern "C++" */ } /* extern "C*" */ #endif /* ___cplusplus */ Этикет требует воздерживаться от заключения в блок любых «чистых» заголо- вочных файлов C++ (иногда использующих расширения .hpp и .hxx или совсем не ис- пользующих расширения, как заголовочные файлы стандартной библиотеки). Но заго- ловочным файлам смешанного типа почти всегда придают расширение.!!, и поэтому если у вас программный код на C++, вам следует его соответствующим образом защи- тить. Именно таким образом можно создавать заголовочные файлы, которые будут ус- тойчиво совместимы как с С, так и с C++. Существуют случаи, когда вам требуется объявлять и даже определять функции C++ в контекстах, автоматически окружаемых спецификатором extern "С". Хоро- ший пример этого - использование языка определения интерфейса (IDL - interface defin1' tion language) объектов COM. He является необычным определение внутри IDL неко- торых вспомогательных функций C++ или простых классов, поскольку в этом случае разделение между программным кодом и типами совместно с используемыми ими и« терфейсами будет минимальным. В данном случае оберните программный код Усл°в но определенными директивами extern "C++" так, как этого требует компилятор MIDL, который окружает все сгенерированные определения интерфейса директив extern "С". Мне следует сделать замечание, прежде чем мы завершим эту тему. Неско старых компиляторов имеют проблемы при инстанциировании шаблонных пар^ метров внутри функций, объявляемых как extern «с -, и выдают сбивающие с т сообщения об ошибках:
7 Двоичный интерфейс приложения 157 —" extern "С void CreateSomeObject(SomeObject *рр) { *рр new Concrete<Scra«Object>(); // Ошибка» шаблон Concrete // нельзя обмнлшь как extern "С" } Простое решение - обеспечить ретранслирующие функции: SomeObject *makeSomeObject () { return new Concrete<SomeObject>(); } extern "C" void CreateSomeObject(SomeObject *pp) { *pp nakeSomeObject () ; } 7.4.4. Получение дескриптора классов C++ До сих пор мы добивались неплохой переносимости, но при этом серьезно жертвуя выразительностью C++. Слава богу, это не конец истории. Немногие из нас собираются реализовывать свой клиентский программный код на С. Хорошо, если имеется возмож- ность выбора, поскольку существует много программистов С, желающих воспользовать- ся нашими библиотеками, даже если они написаны при помощи новомодных и неэффективных объектно-ориентированных методов. Но мы собираемся использовать C++ на стороне клиента хотя бы для того, чтобы воспользоваться RAII (см. раздел 3.5), и поэтому что мы можем сделать, чтобы наш программный код более дружески относил- ся к возможностям C++? Ну, аналогично тому, как мы можем деконструировагь открытый интерфейс класса, расположенный за программным интерфейсом языка С, мы можем реконструировать его на стороне клиента в форме класса-оболочки, который поможет нам удобно мани- пулировать ресурсами при помощи RAIL Конечно, это будет противоречить естествен- ному желанию обеспечить высокую эффективность, но такая жертва часто оказывает- ся оправданной. Класс-оболочка может также выполнять инициализацию и освобож- Дение библиотеки, поэтому он может быть полностью самодостаточным. При некоторых условиях вы можете усовершенствовать этот метод для реального °рта классов, хотя и добиваясь этого окружным путем. Давайте рассмотрим один По Их ЛК)бимых классов - Bufferstore из системы Synesis - который реализуется Писанной ниже методике. Логически его можно представить в следующей форме: class Bufferstore { PUbliC: Bufferstore(size_t cbBuffer, unsigned cBuffers),- -BufferStore(); Public- unsigned Allocate(void **ppBuffers, unsigned eBuffers);
158 Часть 2. Выживание в условиях реального ми^ unsigned Share( void const **ppSrcBuffers , void **ppDestBuffers, unsigned eBuffers); void Deallocate(void **ppBuffers, unsigned eBuffers); }; Это создает набор совместно используемых буферов, которые могут затем распре, деляться, освобождаться и совместно использоваться, причем очень эффективно, идеально подходит для реализации сетевых служб. Этот класс следующим образом можно вынести за рамки программного интерфейса С: // MLBfrStr.h ___SYNSOFT_GEN_OPAQUE(HBuffStr); // Генерирует уникальный дескриптор HBuffStr BufferStore_Create(Size siBuffer, UInt32 cBuffers),- void BufferStore_Destroy(HBuffStr hbs); UInt32 BufferStore_Allocate( HBuffStr hbs, PPVoid buffers , UInt32 cRequest); UInt32 BufferStore_Share( HBuffStr hbs, PPVoid srcBuffers . PPVoid destBuffers, UInt32 eShare); void BufferStore_Deallocate( HBuffStr hbs, PPVoid buffers , UInt32 cReturn); Этот программный интерфейс реализуется путем преобразования дескрипторов буфера HBuffStr в указатели внутреннего класса Bufferstore—1, как в сле- дующем примере: //В MLBfrStr.cpp UInt32 BufferStore_Allocate( HBuffStr hbs, PPVoid buffers , UInt32 cAllocate) { BufferStore_ *bs = BufferStore_::HandleToPointer(hbs); return bs->Allocate(buffers, cAllocate); } Это вполне заурядный прием, который к тому же немного снижает эффективность. Но это позволяет нам реализовать класс на C++ при поддержке переносимого С-и+ терфейса. Кроме того, мы можем использовать его в стиле C++, поскольку заголо вочный файл содержит также программный код, показанный в листинге 7.5. Листинг 7.5. // MLBfrStr.h #ifdef __cplusplus extern "C++" { #endif /* ___cplusplus */ class Bufferstore { 1 Лучше было бы использовать имя BufferStorelmpl, но мне очень нравится символ подчеркПва,,,1Я копируйте меня!
7 двоичный интерфейс приложения 159 void Deallocate(PPVoid buffers, UInt32 cReturn) { BufferStore_Deallocate(m_hbs, buffers, cReturn); } private: HbuffStr m_hbs; #ifdef __cplusplus } /* extern "C++" */ #endif /* __cplusplus */ Теперь мы имеем C++ на обеих сторонах, связь между которыми осуществляется при помощи переносимого С-интерфейса. В действительности это является шаблоном Bridge (мост) [Gamm 1995], и поэтому вы можете считать себя «ужасно» современ- ным, поскольку теперь никому не придется выполнять нудную и утомительную работу по его реализации. Расплачиваться за это приходится небольшим дополнительным расходом времени из-за того, что внешний класс содержит внутренний класс не напрямую, а через дескриптор. Однако в основном этот метод зарезервирован для содержательных клас- сов, и поэтому эти небольшие дополнительные затраты не существенны. (Допускаю, что, возможно, именно это накликало «беду»; я должен признаться, что в последнее десятилетие проблема двоичного интерфейса потребовала от меня очень многое тща- тельно обдумать в языке C++.) 7.4.5. Ловушки, определяемые реализацией Нарисованная до сих пор картина представляется достаточно радужной, но сущест- Ч'ют некоторые моменты, которые могут несколько испортить краски. Во-первых, в некоторых операционных системах могут применяться различные соглашения по вызову функций. Полное обсуждение этих соглашений по вызову им ЦИ” Выходит за Рамками данной книги, но должно быть ясно, что если функция зоват ДРУГ°е двоичное имя или по-другому использует стек, это не позволит исполь- •Зимо Ра3Раб°таннь,е нами методы двоичного интерфейса. Поэтому там, где необхо- Инт ’ соглашения по вызову функций должны быть частью спецификации двоичного Таким образом, вам следует ожидать нечто подобное, показанному 11,стцНге 7 6 Листинг 7.6. 11 extern_c.h * ifdef WIN32 * define MY-CALLCONV _cdecl # se /* ? операционная система */ define MY_CALLCONV
160 Часть 2. Выживание в условиях реального мира • tendif /* конец установки признака операционной системы */ #ifdef __cplusplus extern "С" { #endif /* __cplusplus */ int MY_CALLCONV fund () ; int MY_CALLCONV func2(int pl, int p2, int p3 /* = -1 */),- #ifdef __cplusplus } /* extern "C" */ tendif /* cplusplus */ Аналогичная ситуация возникает с размерами типов, используемых функциями вашего двоичного интерфейса. Вы не можете гарантировать, что размеры типов, соз- данные одним компилятором, будут совпадать с размерами типов, созданных другим компилятором. Если вы используете какой-нибудь тип, например, long, который ин- терпретируется неодинаково различными компиляторами одной и той же операцион- ной системы, у вас возникнут проблемы. И здесь очень пригодятся типы фиксирован- ного размера (см. гл. 13), и моя стратегия - применять только типы фиксированного размера в функциях двоичного интерфейса. На практике эта проблема редко возникает с интегральными типами, но достаточно часто компиляторы по-разному представляют числа с плавающей точкой и символь- ные типы. Например, существует значительное расхождение в том, какой размер зада- ется разными компиляторами для типа long double. Некоторые компиляторы (например, Borland, Digital Mars, GCC и Intel) делают это в соответствии со стандартом IEEE 754 [Kaha 1998] и ойределяют этот тип как 80-битовый в то время, как другие определяют его как 64-битовый (и такой же размер имеет тип double). При отсутст- вии гарантии вам настоятельно рекомендуется подстраховаться и выдать предупреж- дение. С этой проблемой связана другая, жертвой которой вы, очень вероятно, уже оказы- вались, - проблема упаковки структур. Поскольку различные компиляторы могут по- разному выполнять упаковку, мы должны явным образом обусловить размер упаков- килюбых структур, которые будут совместно использоваться в нашем двоичном ин терфейсе. Как и для соглашений по вызову функций, это может подразумевать боль шую «возню» на уровне препроцессора с использованием обычных, но нестандартНЬ1 директив упаковки ttpragma, как показано в листинге 7.7. Листинг 7.7. #if defined(ACMELIB_COMPILER_IS_ABC) # pragma packing 1 tel if defined(ACMELIB_COMPILER_IS_IJK) t pragma pack(push, 1) telif . . . struct abi_struer
двоичный интерфейс приложения 161 int i; short s; char ar[5]; ); # if defined (ACMELIB_COMPILER_IS__ABC) I pragma packing default # elif defined(ACMELIB_COMPILER_IS_IJK) « pragma pack(pop) # elif • Хороший способ обойти это, если все компиляторы поддерживают аналогичную семантику упаковки, заключается в том, чтобы определить установку и возврат режима упаковки директивами pragma в отдельных включаемых файлах, что может упростить сопровождение и улучшить читаемость: «include <acmelib_pack_push_l.h> struct abi_struct ( ); «include <acmelib_pack_pop_l.h> Несомненно, возникает много практических проблем, когда пытаешься получить наш двоичный интерфейс, но мы - неидеальные практики и мы не собираемся бездей- ствовать, столкнувшись с некоторыми трудностями. В следующей главе мы собираем- ся рассмотреть возможные способы обеспечения еще лучшей переносимости C++.
Глава 8 Переносимые через границы Мы видели, как можно получить работающий двоичный интерфейс, если огра, ничить себя С-функциями, и как его немного улучшить для использования перегру. женных функций в единице компиляции C++. Можно добиться многого на основе при- менения этого казалось бы ограниченного подхода, использующего программный интерфейс. Мы также видели, как можно эмулировать переносимость неполиморфных классов с помощью подхода, построенного на применении дескрипторов. Но мы по-прежнему не можем свободно передавать объекты из одной системы в другую, и оба подхода накладывают ограничения на типы, которыми можно манипу- лировать с помощью их функций. Бросается в глаза отсутствие одного из самых важных свойства C++: полиморфизма на этапе выполнения. Можно ли нам улучшить ситуацию? 8.1. Как сделать таблицы vtable максимально переносимыми? Каждый, кто занимался программированием COM-объектов, несомненно знает, что вполне осуществим независимый от компилятора полиморфизм. В самом деле, техно- логия СОМ не зависит от языка, и достаточно обычной практикой является создание компонентов СОМ на C++, D, VB1 и даже С. Делать это на С достаточно трудно и может принести вам дополнительные очки на собеседованиях, а также даст возмож- ность получить оценку «очень хорошо» за понимание работы технологии СОМ (и C++), но в реальных условиях вам лучше позволить вашему компилятору синте- зировать таблицы виртуальных функций (ytables) [Stro 1994]. Без каких-либо дополнительных предисловий я просто покажу вам, как просТ0 можно передавать объекты C++ с помощью двоичных интерфейсов С: #define OBJ_CALLCONV . . . // совместим с любой ОС struct lObject
r^8-Переносимые через границы 163 virtual void OBJ_CALLCONV SetName(char const *s) = 0; virtual char const *OBJ_CALLCONV GetNameO const = 0; extern "C" int make_Object(lObject **pp); Давайте рассмотрим некий клиентский программный код (листинг 8.1): Листинг 8.1. int main() ( lObject *pObject; if(make_Object(&pObject)) { pObject->SetName("Reginald Perrin"); std::cout « pObject->GetName() « std::endl; ) return 0; ) Динамические библиотеки, содержащие функцию фабрики классов [Gamm 1995, Sutt2000] make_Object (), и исполняемые модули, содержащие main (), создава- лись для каждого из наших шести компиляторов. В данном случае нет необходимости представлять таблицу сравнения компиляторов, поскольку все 36 перестановок рабо- тают отлично. А как это выглядит для какого-нибудь двоичного интерфейса C++? По-видимому, мы сможем поддерживать полиморфизм на этапе выполнения, обра- щаясь к объекту через полностью абстрактный класс: интерфейс. Расширением этого подхода является выбор различных функций make_Object () или обеспечение возврата ею экземпляров различного типа в зависимости от переданных аргументов или от других критериев. 8-1.1. Представление таблиц viable Анализируя приведенный программный код, вы, вероятно, заинтересуетесь, какая здесь подразумевается объектная модель C++, и вы будете совершенно правы, стандарте C++ не оговорено, как осуществляется доступ к виртуальной таблице се^СЙ данным экземпляром. Это не составляет проблему, пока мы ограничиваем п интерфейсами, которые не содержат данные-члены; не имеет значения, где рас- ен указатель на vtable, - в начале или в конце, - поскольку он будет единст- венным членом. таб^°Лее сУщественн0> что никак не оговаривается представление самой виртуальной или Ы класса- Фактически, стандарт даже не обязывает, чтобы виртуальные функ- примРеаЛИЗОВЬ1ВалИСЬ С помощью stable. Просто так оказалось, что все компиляторы н0 дпеНЯЮТ ИМенно их- Ничто не мешает какому-нибудь умельцу изобрести совершен- ие в реализацию- Этот вопрос прояснится, если мы рассмотрим ее представле- Какв ВИДе СТ₽УКТУРЫ С. Эквивалент на С интерфейса lObject мог бы выглядеть, листинге 8.2:
Часть 2. Выживание в условиях реального 164 Листинг 8.2. struct lObject; struct TObjectVTable ( void (*SetName)(struct lObject const *obj, char const *s); char const *(*GetName)(struct lObject const *obj); }; struct lObject ( struct TObjectVTable ‘const vtable; ); Если у вас нет опыта применения технологии СОМ, этот программный код может выглядеть для вас необычно, но на самом деле здесь выполняются достаточно очевид- ные действия. Он работает, поскольку по существу этот класс - просто структура, и если он определяет виртуальные функции (или наследуется от класса, содержащего такие функции), то он содержит скрытый член, называемый vptr. vptr - это указатель на таблицу (обычно совместно используемую всеми экземплярами класса), называе- мую vtable, которая содержит указатели на все (виртуальные) функции-члены класса. В данном случае vtable имеет тип struct IObj ectVTable, который содержит ука- затели на методы SetName () и GetName (). Она подобна любой таблице указателей на функции, но только первым параметром всех функций является указатель на струк- туру интерфейса - указатель this в C++. Структура интерфейса struct lObject имеет единственный член vtable, который ссылается на свою vtable - экземпляр struct TObjectVTable. Как я говорил, при таком представлении vtable существует полное согласие между шестью компиляторами платформы Win32. То что компиляторы платформы Win32 поддерживают такое представление, возможно, не просто совпадение, поскольку именно такое представление используется в технологии СОМ, а в наши дни ни один из компиляторов на платформе Win32 не будет очень популярен, если он не может под- держивать СОМ-технологию. Хотя мы можем считать, что такое представление модели объектов вполне очевид- но и эффективно, существуют компиляторы, которые поступают по-другому. Одним из таких компиляторов платформы Win32 является GCC 2.95 (в тестах использовалась версия 3.2). Из существующей очень путанной информации относительно недокумен- тированных методов можно сделать вывод, что в этом случае vtable располагается в памяти следующим образом: struct IObjectVTable ( uint32_t vl; /* Всегда 0 */ void *v2; /* Некая неизвестная функция */ uint32_t v3; /* Всегда 0 */ void (*SetName)(struct lObject *, char const *»); uint32_t v4; /* Всегда 0 */ char const *(*GetName)(struct lObject const *); };
165 Переносимые через границы '^д^ементы структуры vl, v3 и v4 имеют нулевые значения, и поэтому я предпола- чТо они используются для обеспечения надлежащего выравнивания элементов Га1° ктуРы- Элемент v2 оказывается какой-то функцией, чей адрес в действительности и близок к адресам функций SetName () и GetName (), но я не знаю ее точное назначение. Компилятор GCC 2.95 - не единственный с другим представлением вирту- ьноЙ таблицы. Компилятор C++ компании Sun2 использует следующее поэлемент- ное представление: struct lObjectVTable ( void *vl; /* Некая неизвестная функция */ void *v2; /* Некая неизвестная функция */ void (‘SetName)(struct lObject *, char const *s); char const ‘(‘GetName)(struct lObject const *); Таким образом, мы стоим перед неприятным выбором. Один из вариантов - остано- вить свой выбор на этом частичном решении, по крайней мере, для платформы Win32, поскольку, по-видимому, каждый современный компилятор поддерживает его. При работе на других платформах может выявиться аналогичное единообразие, и в этом случае мы могли бы поступить таким же образом. Все это ерунда! Мы - неидеальные практики и понимаем, что это не дает желае- мого результата. Нам необходимо найти полное решение. 8.1.2. Динамические операции с таблицами vtable Перед тем, как мы попытаемся разработать наше полностью переносимое решение, я хочу на минуту сыграть роль безответственного хозяина и показать вам некоторые рискованные, но информативные подходы к расположению в памяти объектов C++. Возможно, вы будете в ужасе смотреть на все это, с беспокойством думая о том, при- дется ли вам самому определять такие таблицы vtable на С. Скорее всего, вам не придет- ся это делать. Это одна из вещей, с которыми C++ справляется достаточно хорошо. Очень плохо-пытаться нарушить структуру таблиц vtable любого класса, сгенериро- Ванного компилятором, поскольку любые изменения, вероятно, будут отражаться на ^ех экземплярах этого класса, пока выполняется ваш процесс, но ничто не может за- бить вам работать со своими собственными таблицами vtable, реализованными в С. На может в очень редких случаях использоваться для изменения поведения объектов ^Дтапе выполнения программы. Это не значит, что я бы рекомендовал так поступать; с °Сновном полезно Для лучшего понимания реализаций C++, а не для систематиче- применения в промышленном программном обеспечении, но полезно знать, как g ется. основном вам необходимо уметь делать три вещи. Во-первых, вам придется раз- свою собственную таблицу vtable. Это выполняется средствами С, и вы просто **Ь1 выделить память, достаточную для хранения содержимого vtable. Во-вторых,
166 Часть 2. Выживание в условиях реального мира вам необходимо копировать существующую vtable достоверного объекта. Это делается средствами С, но для объекта, созданного в единице компиляции C++. Наконец, ВЬ) можете изменить член вашей новой таблицы vtable, устанавливая его на нужный вам объект. Это делается средствами С, но это также может быть сделано для объекта, соз- данного в единице компиляции C++. Это все может выполняться в одной функции: void AlterObject(Thing *thing) ( typedef struct ThingVTable vtable_t; vtable_t *vt = (vtable_t*)malloc(sizeof(vtable_t)); *vt = *thing->vtable; vt->Method = someOtherFunction; thing->vtable = vt; ); Я предоставляю вам возможность поупражняться в своем мастерстве и дополнить этот программный код обработкой ошибок и завершающими действиями после уничтожения vtable, а также принять собственное решение относительно потенциаль- ной пригодности для вас данного подхода. Я знаю одного поставщика программного обеспечения, который использует эти методы для эффективной поддержки динамиче- ских свойств различных типов путем переключения таблиц vtable и их элементов, но будем считать, что я вам этого не говорил! 8.2. Переносимые таблицы vtable Конечно, это достаточно сомнительный прием. Давайте вернемся и попытаемся найти подход, действительно обеспечивающий переносимость vtable. Проблема с под- ходом, который разрабатывался до этого, заключается в том, что он использует специ- альный прием, позволяющий обойти различия в определении членов таблицы vtable и в ее упаковке разными компиляторами. В целом такой подход в лучшем случае зави- сит от платформы и бесполезен в худшем случае, поскольку зависит от любого измене- ния схем представления компиляторами таблиц vtable. К счастью, в один их таких моментов, когда хочется воскликнуть «почему я не д°" думался до этого еще пять лет тому назад?», я нашел простой способ решения этой проблемы: вместо попыток вывести общий формат на основе таблиц vtable бачьш11" ства компиляторов и работать с ним, почему бы не определить свой собственн формат таблиц vtable и заставить все компиляторы работать с ним? Результат не силь^ отличается от того, что мы уже видели, но он работает для всех компиляторов ДаНН платформы. И все же я должен вас предупредить: хотя он концептуально пРивлека\ лен, его реализация выглядит не очень привлекательно. Рассмотрим полностью п и носимую версию нашего интерфейса lObject, показанного в листинге 8.3.
Глава8. Переносимые через границы Листинг 8.3. ♦include <poab/poab.h> ♦include <poab/pack_warning_push.h> * include <poab/pack_push_ambient.h> struct lObject; struct TObjectVTable { void (‘SetName)(struct lObject *obj, char const *s); char const *(*GetName)(struct lObject const *obj); }; struct lObject ( struct TObjectVTable ‘const vtable: ♦ifdef cplusplus protected: lObject(struct TObjectVTable *vt) : vtable(vt) (} -TObjectO () public: inline void SetName(char const *s) { assert(NULL != vtable); vtable->SetName(this, s); ) inline char const *GetName() const ( assert(NULL != vtable); return vtable->GetName(this); } private: lObject(lObject const &rhs); lObject boperator =(lObject const &rhs); #endif /* __cplusplus */ ); *include <poab/pack_pop_ambient.h> #include <poab/pack_warning_pop.h> Независимо от того, является ли единицей компиляции С или C++, интерфейс и его п^е определяются как структуры, совместимые с С. Именно это позволяет нам управ- Файл ^ПаК°ВК0Й Ф°Рматы eWKTyP упаковываются строго посредством включения Файл°В <роа1э/₽аск—push_ambient. h> и <poab/pack_pop_ambient. h>. Эти #PrЫ СОДеРжат специфичные для компилятора директивы упаковки, например, 1На а9та Рас^ (4) для Borland. Два других предупреждающих включения предна- ы Для подавления и переформулировки предупреждений, выдаваемых некоторы- ^пиляторами и относящихся к файлам, которые содержат директивы упаковки.
168 Часть 2. Выживание в условиях реального мира Эти предупреждения достаточно важны, и поэтому будет неразумно их просто от- ключить. Решение заключается в окружении таких включаемых файлов директивами, подавляющими предупреждения. Естественно, подавление или переформулировка пре- дупреждений не может выполняться внутри «файлов-нарушителей», поскольку они тогда не смогут сработать во включенном файле. Следует отметить три существенных момента. Во-первых, интерфейс имеет защи- щенный конструктор. Поскольку компилятор C++ не будет настраивать нашу таблицу vtable, нам необходимо это сделать самим. Естественно, это можно делать только в производном классе, и поэтому данный конструктор принуждает производный класс обеспечить настройку виртуальной таблицы. Следует отметить, что конструктор копирования и оператор копирующего присваивания являются закрытыми. Вы можете поинтересоваться, почему бы нам не обеспечить конструктор копирования следующе- го вида: lObject(lObject const &rhs) : vtable(rhs.vtable) (} Если копируемый экземпляр находится в другом модуле (то есть в динамической библиотеке), который может быть впоследствии выгружен во время выполнения копирования, вы могли бы завершить все корректно сконструированным объектом, который ссылался бы на программный код, который больше не существует в адресном пространстве процесса. Не очень удачное решение! Вопрос исчезновения программно- го кода более подробно рассматривается в гл. 9. Во-вторых, деструктор определяется в секции защищенных методов, чтобы в кли- ентском программном коде не допустить вызов оператора delete для экземпляра ин- терфейса. Кроме того, он не объявляется виртуальным. Оба этих аспекта более деталь- но обсуждаются в разделе 8.2.6. В-третьих, для удобства создания клиентских программ на C++ в 10b j ect опреде- лены два встроенных метода (inline methods). Это означает, что в клиентском программном коде на C++ можно использовать нормальный синтаксис, как, например: lObject *obj = . . . obj->SetName("Scott Tracy"); Поскольку обычно такие инфраструктуры встречаются чаще в клиентском про* граммном коде, чем при реализации серверов, это является важным фактором, способ- ствующим удобству и простоте использования. Это также важно по другой причине, которую мы рассмотрим в следующем разделе. 8.2.1. Упрощение программного кода с помощью макросов Недостаток данного подхода заключается в его чрезмерной многословное?11' Чересчур большой многословности. В некотором смысле вам просто не везет’ вы играете в карты и сами их раздаете. Тем не менее, на практике именно этот Д°в^ используется, чтобы не применять данный подход. Откровенно говоря, я думаю,
169 fngsa 8. Переносимые через границы такОе положение можно выразить словом «furphy»1. Данный подход не сложен и легко ялизуется генератором программного кода или встраиваемым мастером вашей любимой интегрированной среды разработки и отладки. Повсюду существует много еиМворков, которые требуют более сложных и «тайных» знаний, чем этот подход. Более того, все сложности находятся на стороне сервера. На стороне клиента про- граммный код выглядит в сущности точно так же, как при использовании обычного класса C++- Тем не менее, для того, чтобы сделать этот подход более приемлемым, вы можете при желании реализовать его в виде макросов2 * *. Я привел пример в программном коде на компакт-диске, который выглядит следующим образом: *include "poab_gen.h" STRUCT_BEGIN(IObj ect) STRUCT_METHOD_1_VOID(lObject, SetName, char const *) STRUCT_METHOD_0_CONST(lObject, GetName, char const *) STRUCT_END(IObj ect) 8.2.2. Совместимые компиляторы Мы знаем, что используемый нами формат таблиц vtable соответствует формату, который применяют некоторые компиляторы для реализации своих таблиц vtable. Для этих компиляторов можно уточнить определение интерфейса следующим образом: Листинг 8.4. #if defined(__cplusplus) && \ de f ined(POAB_COMPILER_HAS_COMPATIBLE_VTABLES) struct lObject ( virtual void SetName(char const *s) = 0; virtual char const ‘GetName() const = 0; ); *else /* ? ___cplusplus */ • • • // Предыдущее определение (листинг 8.3) #endif /* C++ и переносимые таблицы vtable */ С^При использовании такого компилятора интерфейс является виртуальным классом ние использовании Другого компилятора он использует переносимое определе- Ие‘ Э10 относится к стороне клиента и/или сервера. Он также может быть инкапсу- иР°ван в макросах. Я Всегда Слово «да стараюсь расширить познания языка страны, приютившей меня: «furphy» - это австралийское 2 ’ бающее «абсурдное или ложное сообщение, а также слухи». ИСпользУет достаточно необычную рекурсию директив включения, и потому вы можете проверить просто для того, чтобы удивиться скрытому смыслу всего этого.
170 Часть 2. Выживание в условиях реального Теперь мы можем понять, почему обеспечение удобных встроенных функций нашего интерфейса сделано не просто ради удобства. Это позволяет создавать одица. ковый клиентский программный код для совместимых и несовместимых компиля- торов. 8.2.3. Переносимые серверные объекты Мы познакомились с переносимым интерфейсом и неким простым клиентским программным кодом. Очевидно, что масса сложностей, связанная с применением этого метода, будет находиться на стороне сервера, и поэтому давайте рассмотрим, на- сколько все здесь плохо. В листинге 8.5 приводится первая половина реализации класса Obj ect, который в свою очередь реализует интерфейс lObject. Листинг 8.5. class Object : public lObject { public: virtual void SetName(char const *s) ( m_name = s; } virtual char const *GetName() const ( return m_name.c_str(); } # i fndef POAB_COMPJLER_HAS_COMPATIBLE_VTABLES . . . // Вся функциональность переносимого интерфейса #endif /* !POAB_COMPILER_HAS_COMPATIBLE_VTABLES */ private: std::string m_name; }; Для компиляторов, которые используют формат таблиц vtable, совместимый с нашим переносимым форматом vtable, это целиком вопрос реализации. Следует от- метить, что SetName () и GetName () определяются как виртуальные функции, и мы вскоре увидим почему. Для компиляторов, которые требуют переносимые таблицы vtable, нам необходим0 использовать программный код, заключенный между препроцессорными директива ми, как показано в листинге 8.6. Листинг 8.6. #i fndef POAB_COMPILER_HAS_COMPATIBLE_VTABLES public: Object() : lObj ect(GetVTable())
глава 8- Переносимые через границы 171 - Р Object(Object const &rhs) : IObj ect(GetVTable()) , m_name(rhs.m_name) <} orivate: static void SetName_(lObject *this_, char const *s) static_cast<Object*>(this_)->SetName(s); } static char const *GetName_(lObject const *this_) ( return static_cast<Object const*»(this_)->GetName(); } static vtable_t ‘GetVTable() { static vtable_t s_vt = MakeVTableO ; return &s_vt; ) static vtable_t MakeVTableO ( vtable_t vt = { SetName_, GetName_ ); return vt; ) #endif /* !POAB_COMPILER_HAS_COMPATIBLE_VTABLES */ Прежде всего следует отметить, что как конструктор по умолчанию, так и кон- структор копирования инициализируют член vtable путем вызова статического метода GetVTable (). Этот метод содержит локальный статический экземпляр члена типа vtable_t; для объекта Object это TObjectVTable. Инициализация статического экземпляра осуществляется путем копирования экземпляра vtable_t, возвращаемо- го другим статическим методом MakeVTable (). Как мы увидим в гл. 11, такие ло- кальные статические объекты представляют собой достаточно неудачное решение, особенно в многопоточных средах. Однако в данном случае не возникает проблем, поскольку возвращаемый методом GetVTable () указатель всегда будет одинаковым в любой единице компоновки (см. гл. 9). Из-за того, что SetName_ () и GetName_ () имеют фиксированные адреса в единице компоновки, возвращаемое методом MakeVTable () значение всегда будет содержать одни и те же значения. Следователь- но при любой реализации параллельной обработки статическая виртуальная таблица s_vt всегда будет содержать одинаковые значения; единственным побочным эффек- том любых условий состязаний может оказаться неоднократность инициализации, но такое будет происходить крайне редко. Сами виртуальные методы в действительности являются статическими методами Name_ () и GetName_ (). Каждый из них в качестве первого аргумента принимает Указатель this данного экземпляра, this_.* Важно отметить, что он затем приводится ее общему типу Obj ect. Если этого не сделать, мы окажемся в бесконечном цикле, льку они бы вызывали встроенные методы, определенные в lObject. ' Я?----------------------------- пРЧЧеп-НаЮ’ " знаю- Знак подчеркивания здесь выглядит совсем неуместно. Как и раньше. (Я не всегда иваюсь принципов, описанных в гл. 17.)
172 Часть 2. Выживание в условиях реального мира Давайте теперь рассмотрим, по какой причине методы доступа SetName () и Get Name () определяются в классе как виртуальные. При использовании совмести, мых компиляторов, которые в основном применяют наследование от настоящего абстрактного класса C++, эти методы были бы виртуальными, поскольку Obj ect являл, ся бы наследником lObject, где они определялись бы как виртуальные. Невозмож- ность представить их виртуальными во всех компиляторах могла бы стать серьезной проблемой несовместимости, если вы создаете подклассы как производные от типа ОЬ- j ect, которые затем передаются в клиентский программный код через интерфейс lOb- ject. Делая их виртуальными, мы можем реализовывать внешнее «виртуальное» пове- дение - то, что клиент видит, - исходя из внутреннего виртуального поведения. Недостаток заключается в том, что мы расплачиваемся двумя косвенными вызова- ми, а не одним, если компилятор не может определить допустимость оптимизации, позволяющей преобразовать виртуальный вызов метода доступа в статический метод. Я полагаю, что в большинстве случаев такая цена получения независимости от компи- лятора вполне оправдана. Что касается лично вас, то вы свободны в своем выборе и можете реализовывать ваши серверы, используя совместимый компилятор, или на основе своего собственного подхода можете сделать методы доступа не виртуальны- ми, и в этом случае эффективность вызова метода будет идентична эффективности вызова обычного виртуального метода. 8.2.4. Упрощение реализации переносимых интерфейсов Главная проблема описываемого до сих пор подхода, по крайней мере по моему мнению, заключается во внешнем виде инфраструктуры конкретных классов, реали- зующей переносимые интерфейсы. К счастью, это легко можно поправить, помещая ее в соответствующий класс, который затем используется в качестве основы реализации любых конкретных классов. Поэтому мы можем определять класс lObjectlmpl в том же самом заголовочном файле, где содержится lObject, который имеет функ- ции и таблицу vtable, помещенную нами в класс Object. Для совместимых компиля- торов тип lObjectlmpl будет просто объявляться оператором typedef, ссылающимся на IOb j ect. Теперь вся картина становится значительно более привле- кательной: Листинг 8.7. //В lObject.h struct lObject { }; #if defined(__cplusplus) && \ defined(POAB_COMPILER_HAS_COMPATIBLE_VTABLES) typedef lObject lObjectlmpl; #else /* ? __cplusplus */ . . . Сделанные ранее объявления (листинг 8.3)
Гл^а 8. Переносимые через границы 173 class lObjectlmpl : public lObject { ...II определения функций и таблицы vtable }; #endif /* C++ и переносимые таблицы vtable */ Теперь любой производный класс определяется просто и не загромождается инфра- структурой. как в следующем примере: class Object : public lObjectlmpl { public: virtual void SetName(char const *s) ; virtual char const *GetName() const; private: std::string m_name; }; Поскольку интерфейсы играют очень важную роль и особо тщательно проек- тируются и реализуются, что требует много времени, лично я не думаю, что сущест- вуют проблемы с умелой реализацией соответствующего класса или с усовершенство- ванной реализацией вашего генератора программного кода, и поэтому я считаю этот подход в высшей степени практичным. 8.2.5. Клиентский программный код на С Точно так же, как и в рассмотренном в предыдущей гл. подходе (программный ин- терфейс плюс оболочка), используя метод «объектов, переносимых через границы» (Objects across Borders - О А В), мы можем позволить клиентскому программному коду на С манипулировать нашими серверами. Повсюду существует очень много С-про- граммистов, и такой картина будет оставаться еще очень долгое время. Поэтому все, что расширяет применимость ваших библиотек, может только принести пользу. Если вы являетесь приверженцем C++1, довольно в редких случаях вам потребует- ся вызывать ваши интерфейсы из С, хотя такое может случаться. Вы можете улучшать некий существующий программный код на С и захотите использовать конкретную биб- лиотеку C++. Как бы там ни было, вызов вашего двоичного интерфейса C++ из С выполняется Достаточно просто, хотя и не совсем кратко: II Клиентский программный код на С lObject *obj; °bj->vtable->SetName(obj, "Archie Goodwin"); Ptincf("Name: %s\n", obj->vtable->GetName(obj)); полагаю, что вы именно такой человек; я не могу представить, что многие из тех, кто страдает «++- *’ сумеет пройти непростые предыдущие главы и при этом по-прежнему будет «бичевать» C++.
174 Часть 2. Выживание в условиях реального ми^ ------------- .— 8.2.6. Ограничения объектов, переносимых через границы Представленная до сих картина имеет только радужные краски, хотя это и стоило немалых усилий. Но прежде чем вы кинетесь с острым ножом на все свои проекты я должен признать несколько недостатков этого метода. Естественно, существует масса свойств C++, которые остались нераскрытыми Поскольку взаимодействующие единицы компоновки вашего исполнительного модуля могут быть построены различными компиляторами, очень вероятно, что любые аспек- ты C++, которые непосредственно не затрагиваются этим методом, будут иметь раз- личные свойства. Во-первых, один механизм идентификации типов во время выполне- ния (RTTI) [Lipp 1996], по-видимому, будет отличаться от другого. То же самое отно- сится к выбрасыванию и перехвату исключений, которые строятся на основе механиз- ма RTTI. Вы не можете использовать в качестве идентификатора типа (typeid) указатель, ссылающийся на переносимый интерфейс, и точно также вы не можете вы- бросить исключение из вызванного метода переносимого интерфейса. Здесь справедливы все не характерные для C++ ограничения, связанные с пере- дачей ресурсов между единицами компоновки (это подробно обсуждается в гл. 9). Например, если ваш интерфейс выделяет некоторую память для клиента из своей внутренней динамической памяти, скажем, с помощью оператора new, то тогда клиент не может ее удалить. Она должна быть возвращена посредством некоторого метода ее освобождения того же самого интерфейса или другого объекта, захваченного этим ин- терфейсом. Это стандартная схема использования ресурсов «добропорядочными граж- данами». Этот интерфейс не содержит виртуальный деструктор. Обеспечение метода вирту- ального деструктора само по себе не составляет проблемы. Его переносимость дости- гается в высшей степени просто: struct TObjectVTable { void («Destructor)(struct lObject *obj); }; Проблема возникает при его применении. Во-первых, нет способа, позволяющей сделать метод деструктора «встроенным», и поэтому мы можем вызывать его на стор° не клиента с помощью оператора delete только для совместимых компиляторов- которые рассматривают этот интерфейс как абстрактный класс C++. Но в действитель ности это не так уж плохо: мы вовсе не собираемся предоставлять возможность выз оператора delete в любых условиях, поскольку маловероятно, что клиентский ПР^ граммный код и программный код сервера будут совместно использовать одни и те реализации операторов new/delete, что привело бы к краху. Поэтому мы не со 1 емся предоставлять деструктор в качестве элемента интерфейса.
175 8 переносимые через границы —Следующим аспектом является то, что нам вообще не нужен виртуальный деструк- независимо от того, будет ли к нему обращаться клиентский программный код или Причина заключается в том, что мы можем захотеть получать производный класс из НеТного интерфейса, а наличие виртуального деструктора означало бы различное разме- щение в памяти для совместимых и несовместимых компиляторов. Нам пришлось бы выравнивания вставлять фиктивное поле (подобно полям void* в виртуальной таблице системы, описанной в разделе 8.1.1). Что касается наследования, то вам нельзя иметь множественное наследование при использовании этого метода. Однако это в редких случаях представляет собой проблему - с этим недостатком можно смириться, учитывая другие недостатки C++. В используемом мною конкретном примере возникают другие проблемы. Напри- мер, владение объектом никак не регламентируется, но это происходит почти ис- ключительно из-за простоты примера, а не из-за ущербности методики применения переносимых таблиц vtable. По своей сути этот подход имеет прочную основу, и вы можете использовать его для обеспечения любого требуемого уровня сложности. Обычно применяется два подхода, регламентирующих владение объектом. Оба используют функцию фабрики классов, как, например, make_Object (). В одном случае соответствующая функция destroy_Ob j ect () вызывается для возврата экзем- пляра, когда он больше не нужен. Это простая и работающая модель, но она лучше при- способлена для сценариев с одним пользователем и большими объектами, например, для встраиваемых модулей компилятора. Другой подход заключается в использовании под- счета ссылок. В целом, этот метод лучше всего обеспечивает переносимость таблиц vtable. 8-3. Двоичный интерфейс и объекты, переносимые через границы: заключение Мне бы хотелось вновь обратиться к дефекту из гл. 7 и переформулировать его. Боль- шая часть данной книги, я надеюсь, недвусмысленно показывает, что C++ - лучший во многих отношениях язык, и я почти всегда его первым выбираю, когда речь идет Реализации библиотек. Но для программирования интерфейсов между двоичными мо- ^чями и особенно между динамически связываемыми модулями он не совсем подходит. С++ не является подходящим языкам для программирования интерфей- °в модулей. п0пьеВОЗМОЖно здесь раскрыть весь спектр проблем, с которыми я столкнулся при Но я 6 использ°вать расширенные имена при передаче объектов другим системам, C++ вершенно убежден благодаря собственному опыту и опыту многих клиентов, что Mbiv 6 ЯВЛяется подходящим языком для программирования интерфейсов модулей. Дим дополнительные проявления этого дефекта в других главах данной части.
176 Часть 2. Выживание в условиях реального мира ------------------------------------------------------------------------------ Я не собираюсь убеждать вас, что описанные методы очень просто использовать Они требуют определенных усилий, и приходится считаться с серьезными ограниче. ниями. Я не стал все их здесь рассматривать, поскольку мы будем их обсуждать в бу. дущих главах, т. к. они характерны для других методов, применяемых в C++, и также проявляются при создании двоичного интерфейса. И как бы сильно ни разыгралось мое воображение, я также не могу утверждать, что они представляют собой полный двоичный интерфейс. Например, вы не можете использовать множественное или виртуальное наследование, не можете выбрасывать исключения за пределы двоичного интерфейса или непосредственно удалять объекты захваченные с их помощью. Если вы пишите программный код, который должен взаи- модействовать только с программным кодом, написанным поставщиком того же самого компилятора (и версии, которая чаще всего должна оставаться неизменной), то вам лучше не полагаться на эти методы. В этой сфере деятельности осуществляются усилия по поддержке полного спектра средств языка C++ [Itan-ABI], но эти методы не вполне развиты и не имеют широкого применения. Представленный мною метод пригоден и широко применим, и он может, хотя и с ограничениями, использоваться для широкого спектра существующих компи- ляторов и операционных систем. Но нельзя допускать, чтобы из-за ограничений и сложностей вы подумали, что это решение носит всего лишь теоретический характер и не применимо на практике; силь- нее исказить реальное положение вещей невозможно. После настройки инфраструк- туры использовать такие системы легко и просто1. На самом деле лучший пример, позволивший мне продемонстрировать пригод- ность этого метода, был связан с проектом, над которым я работал несколько лет назад. Я был введен как специалист2 * по C++ в состав группы, работающей над проектом, сроки разработки которого были несколько ограничены. Руководителю разработки и мне пришлось взять предназначенную для рынка США систему синтаксического анализа, которая разрабатывалась в течение нескольких лет, и перенести ее на рынок Австралии, причем работу необходимо было завершить за месяц. После однодневной работы существующей системы мы столкнулись с серьезным синдромом NIH (Not Invent- ed Here - «сюда нельзя вмешиваться») и начали полностью переделывать систему Три недели спустя мы имели расширенную архитектуру синтаксического анализатора’ а также несколько специфичных для Австралии модулей анализа, работающих на платформе Win32, Linux и UNIX (DEC Alpha). Эта система была впоследствии перене сена на несколько других операционных систем, включая Solaris и VMS. Она работал* быстрее и обеспечивала более высокий уровень распознавания входных данных, чеМ любая предыдущая система этой компании, и вскоре стала их мировым стандартом- 1 Я использовал это с хорошими результатами в мультиплексоре компиляторов Artunus (см. приложен»6 ожн°- знан’ 2 «Специалист» является эвфемизмом термина «эксперт», и это всегда опасно. В настоящее время, возм я знаю в два раза больше о C++, чем тогда, и чем больше я узнаю, тем меньше (я это сознаю) я И действительно, вам, по-видимому, следует отложить эту книгу в сторону и почитать Саттера (Sutter)!
177 r^8. Переносимые через границы -'''^^Твсего вышесказанного состоит не в том, чтобы порадовать вас моими айными успехами, а чтобы убедить вас в том, что метод объектов, переносимых СЛУ граниЧы является очень мощным и широко применимым; он подходит не только объектов СОМ платформы Win32. ПОСледнее: нет никаких сомнений, что нарисованная мною картина обусловлена моим собственным опытом и моими предпочтениями. Я не являюсь сторонником одного языка и получаю особое удовольствие, работая на границе двух языков1. За по- следнее десятилетие я много занимался COM-технологией, и мне нравятся многие, хотя и не все, аспекты этой технологии. Наконец, я не являюсь очень большим сторон- ником исключений2 и не могу вспомнить, когда последний раз я использовал механизм RTTI; и поэтому отсутствие этих механизмов при реализации двоичного интерфейса C++ не представляется для меня большой потерей. Вы, несомненно, располагаете другим опытом и вполне можете иметь другую точку зрения. Если это именно так, то я надеюсь, что вы сможете, по крайней мере, получить от этих двух глав в целом более глубокое понимание модели объекта в C++ и вопросов, связанных с двоичным ин- терфейсом, даже если вы не используете эти методы в своей работе. Integiatj^ К0ЛонкУ в журнале « C/C++ User’s Journal» (Журнал пользователей C/C++) под названием «Positive c4Dv ,ОП>> (Г1°ложительная интеграция»), где рассматриваются всевозможные вопросы интеграции С++ 2 ми языками. (Некоторые из этих статей включены в компакт-диск.) вс-^ иоймиге меня неправильно. Исключения - безусловно лучший механизм решения некоторых проблем ,lpИaняHanl>ИMel,’ ПРИ отказах в конструкторе, при нехватке памяти и для глубокой семантической обработки изе данных. Я просто считаю, что ими часто злоупотребляют в других случаях.
Глава 9 Динамические библиотеки В прошлой главе мы коснулись некоторых аспектов динамических библиотек но полная картина значительно сложнее. Существует несколько областей применения динамических библиотек, в которые как в ловушки может попасться неосторожный программист и которые дают особенно сильный эффект при использовании C++. Большинство рассматриваемых в данной главе вопросов не являются характерны- ми только для C++ или даже лучше сказать C/C++, а имеют действительно важное значение для повседневного опыта разработки и, кроме того, вскрывают специфиче- ские проблемы C++, которые обсуждаются в последующих главах. При работе с динамической компоновкой сталкиваешься с четырьмя основными про- блемами: идентичность объектов, продолжительность жизни, контроль версий и владение ресурсами. 9.1. Явный вызов функций Прежде чем мы станем разбирать эти четыре важных вопроса, я хочу немного пого- ворить о функциях C++ и динамической компоновке. Это не относится к тому, чем вам придется часто заниматься в реальных условиях, но это позволит выявить несколько проблем, которые мы собираемся рассмотреть, а также добавить некоторые дополни- тельные штрихи к картине, описывающей двоичный интерфейс. В операционных средах, поддерживающих динамическую компоновку, обычно обеспечиваются два разных, но связанных друг с другом механизма неявной и явной загрузки. Ваши компилятор, компоновщик и операционная среда обеспечивают неяв- ную загрузку, и в целом вы можете рассматривать все это как автоматический процесс Существенно то, что ваш исполняемый модуль имеет внутри себя записи, содержат”6 нужные ему символы и показывающие, с какими динамическими библиотеками он” связаны. Перед выполнением исполнительного модуля он загружается, и все его зав” симости разрешаются. Если отсутствует какая-нибудь библиотека или какая-нибудь функция из этих библиотек, выполнение программы прекращается1. 1 В действительности, некоторые программные интерфейсы позволяют разрешать символы по требо63” что имеет очевидные плюсы и минусы.
179 ^9 динамические библиотеки —;7^явноЙ загрузке в вашем программном коде осуществляется вызов системной функ- например, diopen (), которая запрашивает доступ к требуемой библиотеке. Опера- иИЙ’ система будет затем пытаться найти место расположения соответствующего а. используя свой конкретный набор правил. Например, в системе Linux файл ищется ^rrvnix указанных в переменной среды LD_LIBRARY_PATH, в кэше системной библиоте- В и в каталогах системных библиотек. Функция LoadLibary () платформы Win32 меег более изощренный алгоритм, включающий просмотр каталога приложения, текуще- го каталога, несколько системных путей и путей, указанных в переменной среды PATH; она даже затрагивает реестр! [Rich 1997] Если местоположение библиотеки найдено и опреде- лено что она имеет формат исполняемого файла, библиотека загружается в адресное про- странство вызвавшего ее процесса. После загрузки библиотеки операционной системой вам необходимо обращаться к ее функциям. Это делается путем вызова функции поиска, например, dlsymO с передачей дескриптора (handle) библиотеки и названия нужной вам функции. Такие функции поиска обычно возвращают неопределенный тип, например, void*, который вы приводите к требуемому типу функции. Конечно, вам необходимо знать сигнатуру функции, которую вы намерены вызвать. После завершения работы с функциями библиотеки вы можете указать операцион- ной системе на необходимость ее выгрузки, например, вызывая функцию die lose (). 9.1.1. Явный вызов функций C++ Использование явной загрузки динамических библиотек является той областью, где бросается в глаза то, что C++ «стоит на плечах» С. Когда мы хотим загрузить или использовать функцию С (из программного кода на С или на C++), то в действительно- сти это делается довольно просто (без учета обработки ошибок): Листинг 9.1. // Вызвать функцию // char const *find_filename(char const *fileName); II расположенную в libpathfns.so void print_filename(char const *fileName) { char const *(*ffn)(char const *); void *hLib = diopen("libpathfns.so", RTLD_NOW); (void*&)(ffn) = dlsym(hLib, "find_filename"); printf("%s => %s", path, find_filename(fileName)); diclose(hLib); Днако если бы использовался С++-стиль компоновки функции f ind_f ilename (), 3ован*>ИЦ1ЛОСЬ бЫ пеРедавать ее расширенное имя в вызове функции dlsym (). При исполь- Не л и компилятора GCC в моей системе Linux оно будет _Z13f ind_f ilenamePKc. егко запомнить, не так ли?
180 Часть 2. Выживание в условиях реального мирд Когда вы имеете дело с методами класса, картина оказывается немного сложнее Рассмотрим следующий класс: class Thing { public: void PublicMethod(char const *s); private: void PrivateMethod(char const *s); }; Для загрузки и выполнения открытого метода PublicMethodf) вам пришлось бы делать примерно следующее: Thing thing; void *lib = dlopen(. . .); void *pv “ dlsym(lib, "_ZN5Thingl2PublicMethodEPKc"); void (Thing::*pubm)(char const *); (void*&)pubm = pv; (thing.*pubm)("Ugly ..."); Хотелось бы, чтобы это делалось гораздо проще и удобнее. Поскольку различные компиляторы применяют разные схемы расширения имен, нам пришлось бы иметь много операторов условной компиляции, даже если бы мы использовали один компи- лятор для всех единиц компоновки нашего приложения1. Все это слишком сложные варианты для решения обычных задач, они могут подойти разве только что в исключи- тельных ситуациях. 9.1.2. Нарушение правил C++ управления доступом к методам Хотя с вами такое едва ли когда-нибудь случится, давайте на минуту предположим, что у вас имеется веская причина вызывать закрытый метод Pri vat eMethod О класса Thing. Вам очень не повезло, если вы ограничены обычными средствами C++- Вы не можете сделать это с помощью производного класса, поскольку этот метод закрытый, а не защищенный. Привычные «хитрости», связанные с получением его адреса, также не помогут. void (Thing::*pm)(char const*) = &Thing::PrivateMethod; // Ошибка Однако если реализация Thing: : PrivateMethod помешается в динамическую библиотеку, вы можете вызывать его с помощью явной загрузки, как в следующем пр° граммный коде, скомпилированном Visual C++: 1 Я знаю одного парня, который потратил уйму времени (своего работодателя) и (собственных) работая над многоплатформенной библиотекой, которая выдавала расширенные имена различных расширения имен, и которая для заданной платформы пыталась бы действовать, используя несь известных схем, чтобы постараться найти символ. Как и все подобные «хорошие идеи», это имело обы в таких случаях конец: никто не заказывал это, никому это не было нужно, и никто не был готов использ0 нечто настолько сложное.
Гл^а 9- Динамические библиотеки 181 Thing thing; HINSTANCE lib = LoadLibrary(. . void *pv dleym(lib, " _ZN5Thingl3PrivateMethodEPKc"); void (Thing::*prim)(char const *); (void*&)prim = pv; (thing.*prim)("Evil ..."); Это один из тех методов, за применение которого вы можете получить взбучку от кого-нибудь, кто выше вас по рангу, но этот метод может оказаться спасением, если вы работаете с некой действительно агрессивной технологией, и у вас нет возможности перекомпилировать ее исходный код или недостаточно мазохистских наклонностей, чтобы поступать таким образом. 9.2. Идентичность объектов: единицы компоновки и пространство компоновки 9.2.1. Единицы компоновки Статические библиотеки по определению неполные. В противном случае они были бы исполняемыми модулями. Это означает, что в статической библиотеке существуют какие-то ссылки на неразрешенные символы, или она не содержит точки входа в ис- полняемый модуль, которая соответствовала бы операционной системе или виртуаль- ной машине, где она находится, а может быть и то, и другое. То же самое относится к объектам (с внешними ссылками): их необходимо только объявлять. Напротив, динамическая библиотека, как и исполняемый модуль, обладает полнотой. В ней нет неразрешенных символов, за исключением ссылок на системные библиотеки и/ или другие динамические библиотеки. А любые объекты, на которые делаются ссылки, должны быть определены в единице компоновки. Детали могут немного отличаться - например, динамическая библиотека не .всегда может содержать точку входа - но концептуально это не меняет дело. 9.2.2. Пространство компоновки Значимость единицы компоновки лучше бросается в глаза, когда мы рассматриваем пространство компоновки. Для классической модели автономного исполняемого м°Дуля пространство компоновки - это комбинация всех объектных файлов и статиче- ских библиотек. В рамках пространства компоновки может находиться только один эк- p. пляр каждого символа и каждого объекта (переменной с внешней ссылкой). °®°рим ли мы о простых старых С-объектах, таких как массивы, или о первоклассных ектах C++, любые статические объекты в единице компоновки разрешаются в ходе Доения единицы компоновки. То же самое относится ко всем единицам компонов- »включая динамические библиотеки. В каждой динамической библиотеке существу- ДНа копия всех функций и объектов, из которых она состоит.
182 Часть 2. Выживание в условиях реального мирд (Следует отметить, что я не останавливаюсь здесь на тонких деталях зависимостей одних единиц компоновки от других единиц компоновки. Например, исполняемый модуль может зависеть от динамической библиотеки, и, следовательно, не будет содержать внутри себя тех функций, от которых он действительно зависит. Однако он будет содержать информацию, позволяющую загрузчику операционной системы разрешать эти зависимости, и поэтому его можно считать номинально полным. Более того, динамические библиотеки могут экспортировать объекты, как и функции и поэтому с помощью этого механизма одна единица компоновки может использовать какой-нибудь объект совместно с другой. И вновь, это детали, о которых вы должны знать, но которые не должны отвлекать от главного свойства пространств компоновок.) Эта концепция многократных копий имеет решающее значение для успешного при- менения динамической компоновки, и она особенно важна для C++, а также при использовании статических объектов. Всякий раз, когда программный код в заданном пространстве компоновки ссылается на объект по имени, он ссылается на одно и то же имя в том же самом пространстве компоновки. 9.2.3. Многократные копии имен объектов Давайте для иллюстрации разберем простой случай. Классический пример исполь- зования статических объектов - счетчик экземпляров, когда каждый конструктор класса увеличивает счетчик экземпляров. Это позволяет вам контролировать общее количество экземпляров вашего класса, используемых в приложении. Но если вы опре- делили статический член в каждой единице компоновки, вы будете получать лишь значение количества экземпляров, созданных в рамках единицы компоновки, из ко- торой вы осуществляете вызов. Если вы совместно используете такие классы всеми единицами компоновки, проблема не возникнет, но если не так, вам необходимо это учитывать. Другой областью, где вам не удастся их избежать, являются ваши участки со встроен- ным программным кодом (как методов класса, так и свободных функций), который содержит функционально-локальные статические объекты. В этом случае почти наверня- ка все закончится тем, что у вас будут различные экземпляры для различных единиц ком- поновки, независимо от совместного использования любого программного кода. Мы видели пример этого в прошлой главе, когда синтезировали независимые от компиля тора таблицы vtable. В данном случае в действительности необходимо было иметь отдельные экземпляры для каждой единицы компоновки, но в большинстве примене ний функционально-локальных статических объектов в этом нет необходимости- и кто-то может подумать, что так должно быть всегда. Мы увидим в оставшихся глава* дополнительные примеры того, как осторожно следует подходить к применению фУнК ционально-локальных статических объектов.
183 , 9. динамические библиотеки Глава’ продолжительность ЖИЗНИ Проблема продолжительности жизни связана с программным кодом и объектами, ко- Могут исчезать несмотря на то, что они по-прежнему нужны. (См. гл. 31, гдерассмагРивается подо®ная проблема, связанная с продолжительностью жизни возвра- щаемых значений.) При использовании неявной загрузки это обычно не вызывает проблем. Загрузчик операционной системы сохраняет все библиотеки, от которых зависит единица компо- новки, в памяти процесса вплоть до завершения процесса. Поэтому редко возникает ситуация, когда программный код единицы компоновки исчезает до того, как он стано- вится ненужным, и на моем опыте такое случилось только однажды, когда динамиче- ская библиотека была неявно загружена в результате явной загрузки зависимой биб- лиотеки. После выгрузки последней ссылки на программный код первой библиотеки оставались в исполнительном модуле, и когда к ним обращались, процесс терпел не- удачу. Однако здесь речь по-прежнему идет о явной загрузке, хотя и косвенно. Однако при явной загрузке возникает другая проблема, связанная с продолжитель- ностью жизни объектов C++. Поскольку объекты конструируются перед тем, как воз- никнуть, и уничтожаются, чтобы обеспечить их исчезновение, если некоторый про- граммный код ссылается на такой объект до его «рождения» или после его «смерти», то результат будет непредсказуемым. Эта классическая проблема упорядочения статиче- ских объектов, и она сама по себе не является характерной для динамической компо- новки; просто динамическая компоновка может ее обострить. Этот вопрос настолько важен, что достоин отдельного рассмотрения (см. раздел 11.2), и поэтому мы не будем продолжать его обсуждение здесь. При использовании явной загрузки удивительно просто сделать так, что загружен- ный программный код станет как бы приведением. Если вы используете явную загруз- ку для извлечения символов из динамической библиотеки, вы должны гарантировать, что адрес функции не кэшируется и не используется после того, как вы выгрузили биб- лиотеку. Канонический пример выглядит следующим образом: void *lib = dlopen(. . void *pfn = dlsym(lib, ". . dlcloee(lib); ((void (*)())pfn)() ; в ЕСтественно, это не должно пройти мимо вашего партнера-программиста, и не- ние °’ ЧТ° ВаШ менеджеР заблаговременно запланировал всестороннее рецензирова- Ви^пР0гРамМног° кода в сроки, утвержденные для проекта внимательным старшим ^пРезиденТОм по маркетингу. Однако могут встречаться тонкие проявления Всякий раз, когда вы сохраняете указатель на функцию, находя- тся Я В ЯВН0 загРУженн°й библиотеке, вам необходимо гарантировать, что библио- это|Ле будет выгружена до того, пока остается потенциальная возможность вызова Чи^НКЦИИ’ В Реапьных условиях это не так уж легко сделать, как может показать- Цц Поэтому этот вопрос будет дополнительно рассмотрен в следующей главе, когда деМ говорить о специальной памяти потока (Thread-Specific Storage).
184 Часть 2. Выживание в условиях реального ми^ Между прочим, несколько лет назад я был свидетелем проявления одного неожи. данного свойства системы UNIX - это не была система Linux - в которой не делался подсчет ссылок на ее функции diopen () и diclose (). Наша программа исполь- зовала динамические библиотеки, в которых может находиться один или несколько подключаемых к приложению модулей. Программа делала независимые вызовы diopen () для каждого зарегистрированного подключаемого модуля и как добро, порядочный клиент ресурса выполняла эквивалентное количество раз вызов функции die lose (). Увы, первый же ее вызов выгружал библиотеку, и последующие вызовы этих функций приводили к непредсказуемому поведению. К счастью, такие ситуации достаточно легко распознаются, и их не так уж сложно исправлять. Эту проблему вполне можно разрешить, учитывая то, что при загрузке динамической библиотеки, по-видимому, имеет смысл абстрагироваться от средств конкретной платформы и осу- ществлять ее с помощью платформо-независимого программного интерфейса, - и мы использовали именно такое решение. 9.4. Контроль версий Второй главный источник проблем динамической компоновки возникает в резуль- тате изменений версий. Некоторые из этих проблем очевидны, другие - не очень, но если вам по-настоящему не повезет, все они могут, в конце концов, привести к нару- шению работы ваших и других исполняемых модулей. В тяжелых случаях сделанные вами изменения могут привести к ситуации, при которой вам потребуется иметь старую версию данной динамической библиотеки для некоторых приложений системы и новую версию для каких-то других приложений. Это называется «адом DLL» [Rich 2002]. 9.4.1. Потерянные функции Первая и, возможно, самая очевидная проблема контроля версий возникает, когда развернутая новая версия динамической библиотеки не имеет каких-то функций, при- сутствующих в старой версии. В данном случае не будет загружаться никакая зависи- мая единица компоновки, и ваш исполняемый модуль также не будет загружаться Простое решение заключается в том, чтобы вообще не удалять никакие функции из ваших динамических библиотек, а операционным системам запретить это делать посредством своих двоичных интерфейсов. Некоторые поставщики программного обеспечения подходят к этому более обстоя тельно и назначают программным интерфейсам уровни стабильности, которые пока зывают разработчикам, в какой степени и будет ли вообще эволюционироваТЬ программный интерфейс в будущих версиях. Разработчики используют эти сведен1’* для информирования о своем применении программных интерфейсов и для выраб°т стратегии развития своего программного обеспечения в соответствии с изменен» поставщиков.
185 Глава 9- Динамические библиотеки ^9^2. Измененные сигнатуры При использовании С-компоновки находяшиеся в динамических библиотеках сим- олы не содержат информацию об аргументах функций. Это относится как к про- ммному коду на С, так и к программному коду на C++, использующему директиву xtern “С" - Опасно то, что при изменении сигнатуры функции клиентский про- граммный код, находящийся в любой зависимой единице компоновки, по-прежнему будет связываться во время загрузки с новой версией функции, и это будет соответст- вующим образом отражаться на устойчивости вашего исполняемого модуля и на пре- вратном истолковании вашего резюме. При расширении имен это не происходит, по- скольку другая сигнатура функции приводит к формированию другого расширенного имени. Естественно, один из главных принципов хорошего разработчика программного обеспечения - избегать изменений сигнатуры функций, которые оказались в «выпущен- ной» версии, то есть если может существовать затрагиваемый изменениями клиентский программный код, не контролируемый программистами разработчика. На практике после завершения цикла разработки и выпуска программного обеспечения вам следует воздерживаться от изменения функций, даже если вы «уверены», поскольку в вычисли- тельной технике понятие уверенности носит эфемерный характер. Если вам необходимо обеспечить измененную семантику функции, добавьте новую функцию и отметьте, что не рекомендуется использовать старую версию, но не удаляйте ее. Имеет смысл выделить еще один аспект изменения сигнатуры. В большинстве систем выполняемое во время загрузки импортирование и экспортирование объектов динамической библиотеки основано на применении имен. Однако на платформе Win32 экспортируемые символы могут представляться в экспортной таблице либо своим именем, либо порядковым номером. Применение порядковых номеров может умень- шить размер ваших динамических библиотек, и это может также использоваться для сокрытия имен функций от тех, кто так и горит желанием произвести реконструирова- ние программного кода системы. Недостатком является то, что клиентские единицы компоновки рассчитывают на неизменность заданной функции. Удаление порядкового номера из DLL-платформы Win32 почти гарантирует, что клиентские исполняемые МодУли не будут загружаться. Использование прежнего порядкового номера для новой Функции будет просто означать, что вызывающий и вызываемый объекты будут кидать получения разных данных, и в результате вы получите неприятный крах при- ложения. Как сообщается в [Rich 1997], компания Microsoft благосклонно относится ^экспорту имен функций, и библиотеки DLL системы Win32 в основном придержи- “Л'отся этого подхода. Существует одно симпатичное, но нестандартное применение порядковых номеров, Зв°ляющее вам переименовывать программные интерфейсы, не нарушая клиентский с ОгРаммный код до тех пор, пока сигнатуры функций остаются прежними. Однако ?*елать это трудно, не совершив оплошность, и поэтому я бы не рекомендовал поступать образом.
186 Часть 2. Выживание в условиях реального мира 9.4.3. Изменения режимов работы Наиболее существенной частью любой разработки программного обеспечения яв- ляется сопровождение [Gias 2003], и вам неизбежно придется модифицировать режим работы существующих функций. Эти изменения могут быть связаны с исправлением ошибок или с улучшением семантики. Осуществляя усовершенствования, вы должны обеспечивать обратную совместимость. Часто это достигается только в том случае когда совместимость вами запланирована непосредственно в первоначальном проекте. Например, вы можете предусмотреть флажки для одного из параметров, который обу- словливает необходимый вам режим работы функции. В этом случае вы можете улучшать функцию путем добавления новых значений флажков. В принципе, вы свободны в своем выборе изменения любого режима работы вашего программного обеспечения, который не описан в опубликованной документа- ции интерфейса. Однако на практике вам все же надо быть осторожным. Если вы даже исправляете ошибку, вам иногда необходимо сознавать, что какой-нибудь клиентский программный код может зависеть от ошибочного режима работы. Например, вы можете иметь функцию, которая записывает некоторые данные в буфер, предоставлен- ный вызывающей программой Первая версия всегда заполняет неиспользованную часть буфера нулями. Это не является частью первоначального проекта, и сначала ни у одного клиентского программного кода здесь не возникало проблем. В конце концов, вы столкнулись с тем, что неиспользованную часть буфера необходимо оставить не- тронутой. К сожалению, теперь уже существуют клиентские приложения, написанные с учетом применения нулевого заполнителя. В таких случаях ваши действия будут зависеть от факторов реальной внешней среды, причем без сомнения придется учитывать количество клиентов, поддерживае- мые вами ресурсы и т. д. и т. п. Например, поставщики операционных систем, имеющие огромные пользовательские базы, обычно предпочитают сохранять такие недокументированные «возможности», поскольку от этого сильно зависит успех их бизнеса. В данном случае единственная возможность состоит во вводе новой функции с исправленной семантикой и в сохранении старой версии для старых клиентских единиц компоновки. 9.4.4. Константы Если для функции динамической библиотеки вы используете перечисление enu^ или набор флажков, то, очевидно, вы не должны изменять никакие значения членов enum или флажков - в противном случае вы рискуете нарушить работу приложении существующих клиентов. Однако там, где речь идет о константе, - объявленной с помо- щью директивы #def ine, спецификатора const или о константе-члене класса, - ДеЛ° обстоит немного сложнее. Любые изменения констант будут отражаться только в том программном коде, который компилировался и разворачивался после того, как эта кон- станта изменилась.
187 Глава 9. Динамические библиотеки Один из способов решения проблемы - определить константу в некоторой функции невоЙ библиотеки (core library) и сделать так, чтобы все зависимые библиотеки ызывали эту функцию для извлечения константы на этапе выполнения программы. Очевидно, это будет работать только для тех значений, которые не устанавливаются на этапе компиляции. В С++-версии необходимо объявлять константу класса, определяя ее в одной биб- лиотеке. Для того чтобы повлиять на режим работы всех зависимых библиотек, необ- ходимо поставлять лишь обновленную версию корневой библиотеки. Естественно, зависимые библиотеки должны быть написаны так, чтобы можно было работать с раз- личными значениями константы, и ваша инфраструктура тестирования должна позво- лять вам контролировать эту возможность. 9,5. Владение ресурсами «Добропорядочные» пользователи ресурсов должны всегда возвращать ресурс туда, откуда они его получили. В целом, если функция f nl (), находящаяся в единице компоновки А, распределяет ресурс и возвращает его вызвавшей функции f п2 (), находящейся в единице компоновки В, то f п2 () должна возвращать этот ресурс в функцию, которая содержится в единице компоновки А. Если это не произойдет, то вполне вероятно, что программа закончится аварийно. Это особенно важно при работе с динамическими библиотеками, поскольку любые объекты менеджера статических ресурсов будут локальны по отношению к простран- ству компоновки динамической библиотеки. Таким образом, вызывать оператор delete для освобождения некоторой памяти в функции одной библиотеки, которая распределялась в другой, может быть столь же абсурдно, как передача вашему ближай- шему соседу подарка, преподнесенного тещей на день вашего рождения, и который вам не понравился1. Эта проблема имеет два решения. 9.5.1. Совместно используемые пулы Первое решение предусматривает обеспечение всех динамических библиотек, вторые используются исполняемым модулем, совместно используемого пула в одной динамической библиотеке. Это можно сделать двумя способами, но в обоих случаях сводится к одной веши. Давайте рассмотрим проблему памяти. Один способ обеспечения безопасного распределения и освобождения памяти ^*Ду динамическими библиотеками предусматривает зависимость всех этих библиотек одной динамической библиотеки, которая поддерживает операторы new/delete зу^И ФУНКЦИИ malloc () /free (). Библиотека этапа выполнения Visual C++ исполь- ЭтУ модель, предоставляя библиотеку MSVCRT. DLL, и другие поставщики пред- *0°т аналогичные возможности. Иц Так’ если ваша теща не является вашим ближайшим соседом. Но это рассказ о динамической новке, а не фильм ужасов!
188 Часть 2. Выживание в условиях реального мира Вариант этого подхода предусматривает обеспечение реализации этих функций ддя каждой единицы компоновки, но реализуются они с помощью совместно используе. мой функции распределения памяти. Этот подход я использую в разделяемых библио- теках системы Synesis на платформе Win32, где имеются локальные операторы new/ delete, зависящие от функций (Меш_А11ос (), Mem_Free () и т. д.), находящихся в корневой динамической библиотеке. Это наиболее простой способ совместно используемых реализаций на C++ ваших динамических библиотек, управляющих ресурсами. 9.5.2. Возврат в вызывающую программу Лучше всего всегда возвращать ваши ресурсы вызывающей программе, которая предоставила их вам, но это может вызвать трудности при использовании классов С++ Если ваша реализация класса находится в одной разделяемой библиотеке, а все другие совместно ее используют, то все это работает хорошо. Но если какая-нибудь реализа- ция встраивается в каждое пространство компоновки - например, с помощью опреде- лений встроенной функции - то нечто, сконструированное в одной динамической биб- лиотеке и уничтоженное в другой, будет передаваться через другой программный код, хотя и применяется при этом один и тот же класс. На практике применение классов обязывает использовать разделяемые пулы для большинства ресурсов, особенно для широко распространенных и имеющих мелко- зернистую структуру: если речь идет о памяти, вы, вероятно, уже применяете разделяе- мые пулы благодаря поставщику вашего компилятора. Однако пулы других ресурсов необязательно должны быть совместно используемыми. В этом случае у вас могут возникнуть трудности, если вы поддерживаете свои пулы в отдельных динамических библиотеках. 9.6. Динамические библиотеки: заключение Если вы хотите, чтобы единицы компоновки «общались» на C++, вы должны при- держиваться одного компилятора и одних и тех же библиотек программ C/C++ этапа выполнения, или ваши компиляторы должны быть взаимно совместимыми в этом отношении, например, Intel и Visual C++. С учетом того, что мы узнали в двух преДЫ' дущих главах, это кажется похожим на некую тавтологию. Но важно то, что если мы теперь вернемся к вопросу создания двоичного интерфейса для C++, мы сможем понять, что при использовании компилятором совместимых схем расширения имен- но несовместимого механизма RTTI, режима управления объектами и так далее, д°ста точно просто можно будет получать программы, в корректности которых невозможн° будет убедиться. Это было бы не очень хорошо. На практике многие (возможно, большинство?) программы создаются с пример нием базовых единиц компоновки C++. Однако подавляющая их часть создается с» пользованием одного компилятора или группы компиляторов с совместно использу^ мым двоичным интерфейсом C++. Напротив, интерфейс с операционной систем и единицы компоновки независимых поставщиков выполняются на С.
Глава 10 Поточная организация вычислений Тема многопоточной организации вычислений очень обширна и сама по себе достойна нескольких книг [Bute 1997, Rich 1997]. Подходя к этому вопросу упрощен- но я склонен считать, что все сложные проблемы многопоточного программирования относятся к синхронизации доступа к ресурсам. В качестве ресурсов может выступать отдельная переменная, класс или даже какой-нибудь продукт одного потока, который используется другим потоком. Дело в том, что если два или более потоков должны осу- ществлять доступ к одному и тому же ресурсу, необходимо обеспечить безопасность этого доступа. Увы, как С, так и C++ были разработаны еще до того, как многопо- точность приобрела нынешнюю популярность. Поэтому: Дефект: в языках С и C++ ничего не говорится о поточной организации вычислений. Что это значит? Ни в какой части стандарта вы не найдете ни одной ссылки на по- точную организацию вычислений1. Означает ли это, что вы не можете писать многопо- точные программы на C++? Разумеется нет. Но это означает, что C++ не обеспечивает поддержки многопоточного программирования. На практике это имеет существенные последствия. При написании многозадачных систем необходимо остерегаться возникновения Wx классических ситуаций [Bute 1997, Rich 1997]: условий гонок (состояния состяза- ния, или race conditions) и взаимных блокировок. Условия гонок возникают, когда два независимых потока вычислений одновременно осуществляют доступ к одному и тому ресурсу. Следует отметить, что я использую здесь термин «поток вычислений» для процессов одной системы и потоков одного процесса или различных процессов в Рамках одной базовой системы. Для защиты от условий гонок в многозадачных системах используются такие меха- [ButЫ СИНХРонизаиии’ как мьютексы (mutexes), переменные состояний и семафоры Но е ^997. Rich 1997], позволяющие предотвратить одновременный доступ к совмест- Используемым ресурсам. Когда один поток вычислений захватывает ресурс УПра^?80 попгок {thread) лишь однажды встречается в стандарте (С++-98: 15.1; 2), когда обсуждаются потоки ения °бработкой исключений.
190 Часть 2. Выживание в условиях реального мИра _ _— (это также называют блокировкой ресурса), другие потоки блокируются и находяТся в состоянии ожидания до тех пор, пока этот ресурс не освободится (разблокируется) Естественно, взаимодействие двух или нескольких независимых потоков вычисле ний, в принципе, носит очень сложный характер, и возможна ситуация, когда каждый из двух потоков блокирует один из ресурсов, ожидая освобождения другого. Это назц. вается взаимной блокировкой (deadlock). Менее распространенным, но столь же серь. езным является активный тупик (livelock), когда два или более процесса постоянно изменяют состояние в ответ на изменения состояния другими процессами, и ни один процесс не может продвинуться дальше. Как условия гонок, так и взаимные блокировки трудно предсказать или тестиро- вать, что является одной из самых сложных практических задач многопоточного про- граммирования. Хотя взаимные блокировки очень легко обнаруживаются - ваш испол- няемый модуль останавливается - их все же сложно диагностировать, поскольку ваш процесс (или поток внутри него) перестает выполняться. 10.1. Синхронизация доступа к целым числам Поскольку содержимое регистров процессора сохраняется в контексте потока каждый раз, когда происходит переключение потоков процесса, самой основной формой синхронизации является та, которая гарантирует сериализацию доступа по любому адресу памяти. Если размер блока считываемой памяти равен одному байту, то доступ к нему процессором осуществляется как неделимая операция. В действитель- ности то же самое может происходить при чтении блоков большего размера в соответ- ствии с правилами работы данной архитектуры. Например, 32-битовый процессор может обеспечивать сериализацию доступа к 32-битовым значениям, если они выров- нены на границу 32 битов. Сериализация доступа невыровненных данных может как допускаться, так и не допускаться любым конкретным процессором. Трудно предста- вить работоспособную архитектуру, где не обеспечивалась бы неделимость таких операций. Предоставляемые процессором гарантии хороши в том случае, если вы собирае- тесь считывать и записывать определяемые платформой целые числа как неделимые операции, но существует много других операций, которые хотелось бы сделать недели мыми и которые не являются таковыми, поскольку в действительности состоят из не скольких операций. Классическим примером является операция увеличения ил» уменьшения на единицу значения переменных. Оператор в действительности является сокращенной записью оператора
191 -о Ю Поточная организация вычислений Глав» ___________________________________ Операция инкремента i подразумевает извлечение значения этой переменной из памяти, добавление 1 к этому значению и затем сохранение нового значения по тому же аДресУ памяти переменной i. Данную операцию называют также операцией чтения-модификации-записи (Read-Modify-Write - RMW) [Gerb 2002]. Поскольку это трехэтапный процесс, любой другой поток, который пытается одновременно изменить значение переменной i, может сделать результат одного или обоих потоков недосто- верным. Если оба потока пытаются выполнить операцию инкремента i, возможен про- пуск этапа, как в следующем примере: Поток 1 Поток 2 считать i из памяти считать i из памяти увеличить значение на единицу считать i из памяти сохранить в памяти новое значение i сохранить в памяти новое значение i Оба потока считывают из памяти одинаковое значение, и когда Поток 1 сохраняет увеличенное значение, он записывает результат Потока 2. В итоге одна операция ин- кремента оказывается потерянной. На практике различные процессоры будут использовать различные операции для выполнения этих этапов. Например, на процессоре Intel это может быть реализовано следующим образом: mov eax,dword ptr [i] inc eax mov dword ptr [i] , eax или в более краткой форме: add dword ptr [i],1 Но даже во втором случае, когда используется одиночная инструкция, нет гарантии того, что данная операция будет неделимой в многопроцессорных системах, поскольку логически она эквивалента первой реализации, и поток другого процессора может «вклиниваться» описанным выше образом. 10.1.1. функции операционной системы Поскольку операции неделимого инкремента и декремента являются краеугольным с м многих важных механизмов, включая подсчет ссылок, очень важно иметь а, позволяющие выполнять эти операции в потокозащищенном режиме. Как мы позже, неделимых целочисленных операций часто оказывается достаточно для ватьздЧеНИЯ потокозащищенности нетривиальных компонентов1, что позволяет доби- сУЩественного улучшения производительности. Вк1с°’®П)1,б^ТЬ1Я Мною в гл- 7 компонент BufferStore системы Synesis является одним из примеров получения стРодействия за счет того, что это осуществляется без синхронизации каких-либо объектов ядра.
192 Часть 2. Выживание в условиях реального мира Платформа Win32 обеспечивает системные функции Interlockedlncre^ ment () и InterlockedDecrement (), сигнатура которых имеет следующий вид: LONG Interlockedlncrement(LONG *р); LONG InterlockedDecrement(LONG *p); Они реализуют семантику «почти» операций инкремента и декремента. Другими слова- ми, возвращаемое ими значение скорее относится к новому значению, чем к старому. Сис- тема Linux обеспечивает аналогичные функции [Rubi 2001]: atomic_inc_and_tes t () и atomic_dec_and_test (). Подобные функции могут быть на любой платформе. Применяя такие функции, мы можем теперь переписать наш первоначальный оператор инкремента в совершенно потокозащищенном режиме: atomic_inc_and_test(&i); // ++i Реализация такой функции для процессора Intel могла бы просто содержать перед инструкцией префикс LOCK, как в следующем примере:1 lock add dword ptr [i], 1 Префикс LOCK приводит к возбуждению сигнала LOCK# на шине и предотвращает обращение по этому адресу памяти любых других потоков в ходе выполнения инструк- ции ADD. (Конечно, все происходит значительно сложнее, включая линии кэша и всю прочую «таинственную» деятельность, но логически это обеспечивает выполнение ин- струкции без прерывания любыми другими потоками и процессорами.)2 Недостатком применения семантики блокировок является снижение быстродейст- вия. Реальное снижение отличается для различных архитектур: на платформе Win32 длительность такой операции может составлять приблизительно 200-500% от небло- кируемого варианта (как мы позже увидим в данной главе). Поэтому недостаточно просто сделать каждую операцию потокозащищенной; на самом деле общая цель мно- гопоточной организации вычислений - обеспечить параллельное выполнение незави- симых процессов. Поэтому на практике необходимость применения неделимых операций обычно задается в параметрах построения исполняемого модуля. На платформе UNIX, как правило, определяется символ препроцессора —REENTRANT, указывающий про- граммному коду, написанному на С или на C++, на построение единицы компоновки, предназначенной для работы в многопоточной среде. На платформе Win32 различны ми компиляторами применяются символы _МТ и____МТ__или им подобные. Естес? венно, такой подход может быть обобщен вплоть до использования символа, независи мого от платформы и компилятора, например, ACMELIB_MULTI—THREADED, которь затем применяется для выбора соответствующих операций на этапе компиляции. 1 На самом деле такой инструкции нет - я просто пытаюсь быть кратким! 2 На многопроцессорных машинах или на машинах с многоядерными процессорами (multiple-core & обеспечивающими гиперпотоковые вычисления, потоки могут фактически выполняться параллельно. время как при использовании одного процессора только создается видимость их параллельной Раб° однопроцессорных машинах инструкции могут прерываться (но это не относится к неделимым операии •
М Поточная организация вычислений 193 Глава _____________________________________________________________________ ~~#i fdef ACMELIB_MULTI_THREADED atomic_increment(&i); #else /* ? ACMELIB_MULTI_THREADED */ #endif /* ACMELIB_MULTI_THREADED */ Поскольку при повсеместном использовании этого приема программный код будет выглядеть очень непривлекательно, распространена также практика оформления такой операции в виде некой общей функции, в рамках которой осуществляется разграниче- ние действий процессора. Множество примеров такого подхода можно найти в распро- страненных библиотеках, таких как Boost и Active Template Library (ATL) компании Microsoft. Я должен отметить, что не все операционные системы поддерживают неделимые операции над целыми числами, и в этом случае вам, возможно, придется прибегнуть к применению объекта синхронизации операционной системы, например, мьютекса, для блокировки доступа к программному интерфейсу неделимых целочисленных операций, и этот подход мы рассмотрим позднее в данной главе. 10.1.2. Неделимые типы Мы видели, как просто можно использовать операции — и ++ над целыми типами в однопоточной среде, но при многопоточной организации вычислений нам необходи- мо применять примитивы операционной системы и архитектуры. Недостаток данного подхода заключается в том, что даже когда мы абстрагируемся от различий путем ис- пользования общей функции, например, integer_increment, она целиком полага- ется на неделимость выполнения операций над целыми типами. Не так уж сложно забыть об этом, и в таком случае вы можете столкнуться в вашем приложении с усло- вием гонки, которое очень трудно диагностировать. Язык C++ нацелен на обеспечение синтаксического единообразия, разрешая типам, которые определяются пользователем, иметь вид встроенных типов (built-in types). Поэтому, почему бы не сделать так, что неделимые типы будут выглядеть как встроен- Ные типы> но при этом во всех случаях будут реализовываться как неделимые опера- ции? Нет причин, которые запрещали бы это, и в действительности это очень просто сделать'. ,*KaTOPbl v°latile способствуют их использованию при объявлении любых таких переменных, Не позво, СЯ Достаточно распространенной практикой при многопоточном программировании, поскольку они самы*, Компилятору манипулировать переменными, помещая их в свои внутренние регистры и. тем naM«ni 1м ШаЯ согласованность их значений со значениями переменных, расположенных в реальных ячейках 13^22$ Ь1 ₽ассмотРим это более подробно в разделе 18.5.
194 Часть 2. Выживание в условиях реального мира Листинг 10.1. class atomic_integer { public: atomic_integer(int value) : in_value (value) () // Операции public: class_type volatile &cperator ++() volatile { atomic_increment (&rn_value) ; return ‘this; ) const class_type volatile operator ++(int) volatile { return cl ass_type (a tomic_post increment (&rri_value)) ; } class_type volatile &operator -() volatile; const class_type volatile operator -(int) volatile; class_type volatile &cperator +=(value_type const &value) volatile ( atomic_postadd(&m_value, value); return ‘this; ) class_type volatile &operator -= (value_type const bvalue) volatile; private: volatile int rn_value; ); Несомненно, здесь C++ упрощает многопоточную обработку. Однако остается под вопросом, насколько такой подход может обеспечить естественную семантику целых типов. Приводимый выше программный код показывает, как легко при наличии данной библиотеки реализовывать операции инкремента и декремента, а также сложения и вычитания. Однако умножение, деление, логические операции, сдвиг и другие операции значительно сложнее, и большинство библиотек неделимых целочисленных операций не обеспечивают их. Если вы хотите иметь их в своем про* граммном коде, автор дает вам полную свободу действий: вы можете сделать это в качестве упражнения. Именно такой подход используется компонентами неделимых операций библиотеки Boost, в которой предусматриваются специфичные для платформы версии типа atomic_count, который обеспечивает только операции ++ и — (atomic_increment' atomic_decrement) вместе с неявным преобразованием (atomic_read), и поэтому если вы выберите что-нибудь гораздо более сложное, вы окажетесь в хорошей компании-
Глава 10. Поточная организация вычислений 195 ^^Синхронизация доступа к блокам кода: критические области В большинстве случаев для обеспечения требований синхронизации недоста- точно ограничиться одиночной неделимой операцией. В этих случаях требуется мо- нопольный доступ к тому, что называется критической областью [Bulk 1999]. Напри- мер если вам необходимо осуществлять обновление значений двух переменных в виде неделимой операции, вы должны использовать объект синхронизации для гарантирования монопольного доступа к критической области каждым потоком, как в следующем примере: // Совместно используемые объекты SYNC_TYPE sync_obj; SomeClass shared_instance; //В каком-то месте программного кода делается вызов со стороны // нескольких потоков lock(sync_obj); int i “ shared_instance.Methodi(. . .); shared_instance.Methods(i + 1, . . .); unlock(sync_obj); Последовательность из двух операций Methodi () и Method2 () должна выпол- няться без прерывания. Поэтому программный код, где они вызываются, обрамлен вы- зовами по захвату и освобождению объекта синхронизации. В целом, применение всех таких объектов синхронизации обходится очень дорого, и поэтому желательно сводить к минимуму или вообще избегать связанных с ними затрат. Существует два случая, когда обеспечение безопасных критических областей сопрово- ждается применением высокозатратных объектов синхронизации. Во-первых, само по себе использование объектов синхронизации может быть связано с большими затратами. Например, рассмотрим, как распределяются по времени (в миллисекундах) показанные втабл. 10.1 десять миллионов циклов захвата-освобождения четырех объектов синхрони- звиии на платформе Win32 в сценарии управления вызовами двух пустых функций. Резуль- Тап>| прямо свидетельствуют о существенных затратах, связанных с применением объек- Т0В СИНХРонизации, которые до 150 раз превышают затраты на вызов обычных функций. Во-вторых, дополнительные затраты, связанные с объектами синхронизации, обес- ”ечивающими защиту критических областей, обусловлены блокировкой доступа ^критическим областям любых других потоков. Чем больше критическая область, тем вероятна такая блокировка, и, следовательно, следует делать критические облас- в Как можно короче или разбивать их на субкритические секции, что обсуждалось си 6'2' Однако поскольку затраты по захвату и/или освобождению объектов Ронизации могут быть достаточно высокими, очень трудно обеспечить баланс количеством участков, на которые разбиваются критические области, и време- ожидания потоков. Только составление профиля работы для каждого конкретного ** может дать вам исчерпывающие ответы.
196 Часть 2. Выживание в условиях реального _______ 10.2.1. Межпроцессные и внутрипроцессные мьютексы Мьютексы являются самой распространенной формой объекта синхронизации охраняющего критические области, и в зависимости от используемой операционной системы могут быть двух видов: межпроцессные и внутрипроцессные. Межпроцесс. ный мьютекс может использоваться в более чем одном процессе и, следовательно может обеспечивать межпроцессную синхронизацию. На платформе Win32 такой мьютекс обычно создается путем вызова функции CreateMutex() с указанием имени соответствующего объекта. Другие процессы могут после этого обращаться к тому же самому мьютексу, задавая это же имя в функции CreateMutex() или OpenMutexO *. При использовании PTHREADS [Bute 1997], библиотеки потоков стандарта POSIX системы UNIX, мьютекс может передаваться дочернему процессу при порождении нового процесса или может совместно использоваться через отобра- жаемую память. Таблица 10.1. Объект синхронизации Однопроцессорная машина Мультипроцессорная машина Отсутствует 117 172 CRITICAL_SECTION 1740 831 Неделимая операция 1722 914 Мьютекс 17891 22187 Семафор 18235 22271 Событие 17847 22130 Внутрипроцессные мьютексы, напротив, доступны только внутрипроцессным потокам. Поскольку нет необходимости их передавать за границы процесса, можно полностью или частично избежать затратных обращений к функциям ядра операцион- ной системы, потому что их состояние может поддерживаться в памяти процесса. Win32 имеет такую конструкцию, как CRITICAL_SECTION (критическая секция), которая представляет собой облегченный механизм, позволяющий выполнять обра- ботку операций без участия ядра и осуществлять вызов функций ядра только тогда, когда требуется передать право владения другому потоку. В тех случаях, когда удобно использовать внутрипроцессные мьютексы, достигается значительный выигрыш в производительности, как можно видеть из табл. 10.1, результаты которой был*’ получены при работе исполнительного модуля в одном потоке. Мы увидим позже, каЬ работает CRITICAL_SECTION при выполнении многопоточного приложения. 1 Дочернему процессу можно также передавать дескриптор непоименованного мьютекса, используя ДР'Г механизмы межпроцессного взаимодействия, но передача имени является самым простым механизмом.
Ю Поточная организация вычислений Глава *•____—------------------ ^^2^2. Сппк-мьютвксы 197 Существует специальный тип внутрипроцессного мьютекса, который основан на обычно неудачной практике опроса. Попросту говоря, опрос означает ожидание воз- ожности изменения состояния путем периодической проверки этого состояния, как показано в следующем примере: int g_flag; // ожидающий поток while(0 == g_flag) О ...II Теперь выполняются действия, которые были отложены Подобного рода программный код «съедает» процессорное время, поскольку опра- шивающий поток часто1 имеет такой же приоритет, как и поток, которому предстоит изменить флажок для того, чтобы опрашивающий поток стал выполняться дальше. Опрос - это одна из тех неудачных идей, характерных для новичка в области мультипо- токовой обработки, которому еще предстоит многому научиться или неожиданно получить выходное пособие. Однако существуют обстоятельства, при которых подобный подход дает очень хорошие результаты. Давайте сначала рассмотрим реализацию спин-мьютекса spin_mutex (воображаемого класса в UNIXSTL8), показанного в листинге 10.2. Листинг 10.2. class spin_mutex ( public: explicit spin_mutex(sint32_t *p = NULL) : m_spinCount((NULL != p) ? p : &m_internalCount) , m_internalCount(0) {} void lock() { for(; 0 != atomic_write(m_spinCount, 1); sched_yield()) {} } void unlock() { atomic_set(m_spinCount, 0); } !! Члены Private: sint32_t *m_spinCount; ----- sint32_t m_internalCount; 1’0,Уго^ИТ °T приоРитетов соответствующих потоков и от любых внешних событий, возникновение которых °*НДать другие потоки.
198 Часть 2. Выживание в условиях реального мира // Реализация не требуется private: spin_mutex(class_type const &rhs); spin_jnutex &operator =(class_type const &rhs); }; Механизм работы спин-мьютекса очень прост. При вызове метода lock () выпол- няется неделимая операция записи для установки переменной спина *m_spinCount (целый тип) на значение 1. Если ее предыдущее значение - 0, то вызывающий поток оказался первым, кто установил его и «захватил» мьютекс, а после этого данный метод возвращает управление. Если ее предыдущее значение равнялось 1, то это означает, что другой поток опередил вызывающий поток, и поэтому он не может захватить в данный момент этот мьютекс. Он затем вызывает функцию sched_yield () биб- лиотеки PTHREADS для передачи управления другому потоку; затем он вновь «пробу- ждается», и все начинается сначала. И так происходит до тех пор, пока очередная по- пытка не окажется удачной. Таким образом он блокирует мьютекс и становится его владельцем. Когда захвативший мьютекс поток вызывает метод unlock (), переменная спина вновь принимает значение 0, и другой поток может затем захватить этот мьютекс. При небольшом усложнении конструктора и использовании внутреннего счетчика m_internalCount можно конструировать этот класс с внешней переменной спина, что очень полезно при определенных обстоятельствах (как мы увидим в гл. 11 и 31). Спин-мьютексы не очень хорошо подходят при высокой конкуренции, но там, где невысока вероятность конкуренции и/или небольшие затраты по захвату и освобожде- нию объекта синхронизации, они могут представлять собой эффективное решение. Учитывая их потенциально высокую стоимость, я, как правило, использую их только для инициализации, где конкуренция очень редка, но теоретически возможна и должна приниматься в расчет. 10.3. Эффективность неделимых целочисленных операций Перед тем как перейти к дополнительным возможностям многопоточной обработки (раздел 10.4) и к специальной памяти потока (раздел 10.5), я хочу рассмотреть вопро- сы эффективности различных стратегий обеспечения неделимости операций над целыми числами. 10.3.1. Обеспечение неделимости целочисленных операций с помощью мьютекса Когда ваши неделимые операции над целыми числами не обеспечиваются среДсТ' вами вашей операционной системой, вам может потребоваться помощь мьютекса ДлЯ блокировки доступа к программному интерфейсу неделимых целочисленных опера ций, как показано в листинге 10.3.
Глава Ю- Поточная организация вычислении Листинг 10.3. 199 namespace { Mutex s_mx; } int atomic_postincrement(int volatile *p) { lock_scope<Mutex> lock(s_mx); return *p++; } int atomic_predecrement(int volatile *p) { lock_scope<Mutex> lock(s_mx); return — *p; } Проблема здесь связана с эффективностью. Вам не только приходится иногда идти на существенные затраты по вызову системных функций для захвата и освобождения объекта мьютекса, но вам приходится испытывать конкуренцию со стороны других по- токов, стремящихся в одно и тоже время выполнять свои неделимые операции. Каждая отдельная неделимая операция вашего процесса затрагивает один объект мьютекса, что естественно приводит к возникновению узкого места. Однажды я был свидетелем неудачной попытки снизить эти затраты за счет приме- нения отдельного мьютекса для каждой неделимой функции. К сожалению, оказалось так, что это приводит к заметному снижению времени ожидания. Несомненно, вы до- гадались, что это тщательно было протестировано на машине с одним процессором Intel. Как только приложение стало выполняться на многопроцессорной машине, оно сразу же «вырубилось»1. Поскольку каждый мьютекс защищал функцию, а не данные, возможной оказывалась ситуация, когда некоторые потоки увеличивают значение переменной, а другие потоки уменьшают эту переменную. Достигалась только невоз- можность одновременного выполнения двумя потоками одинаковой операции с одной Целочисленной переменной. Многопоточная обработка имеет столько особенностей, что нельзя быть уверенным в правильности своего программного кода до тех пор, пока вы не проверите его работу на многопроцессорной машине. Несмотря на столь неприятную неудачу, существует возможность распределения конкуренции по нескольким мьютексам для ослабления узкого места. Для этого требу- ется всего лишь выбирать мьютекс на основе какого-нибудь свойства манипулируемой ”еРеменн°й. Ну, существует только один известный нам атрибут мьютекса - его адрес. ы не можем очень точно знать его значение, поскольку оно будет изменяться.) 1 "Ькпй----------------------------- 14,11 же результат будет наблюдаться при использовании гиперпотоковых однопроцессорных машин.
Часть 2. Выживание в условиях реального мира 200 Это выглядит примерно так: namespace { Mutex s_mxs[NUM_MUTEXES]; int __stdcall Atomic_PreIncrement_By(int volatile *v) { size_t index = index_from_ptr(v, NUM_MUTEXES); lock_scope<Mutex> lock(s_mxs[index]); return -i-+*(v); } Функция index_from_ptr () обеспечивает детерминированное отображение адреса на целое число в диапазоне [0, NUM_MUTEXES-1). В этом случае нельзя просто выполнить деление адреса по модулю из-за того, что в большинстве систем данные выравниваются на границы 4, 8, 16 или 32 байтов. Подходящим будет про- граммный код примерно следующего вида: inline size_t index_from_ptr(void volatile *v, size_t range) { return (((unsigned)v) » 7) % range; } При тестировании на своей собственной машине Win32 я обнаружил, что число 7 дает более высокую производительность, чем другие значения, но маловероятно, что такая ситуация будет и на других платформах, поэтому вы должны сами найти опти- мальное значение для своей платформы. 10.3.2. Диспетчеризация операций на этапе выполнения Мне бы хотелось показать вам, как можно, используя одну маленькую хитрость, повы- сить эффективность выполнения неделимых операций над целыми числами для плат- формы Intel. Как мы узнали, процессор Intel будет выполнять без прерывания операцию типа RMW, представленную единственной инструкций (например, ADD, XADD), и поэтому на однопроцессорных машинах не требуется блокировать шину. Напротив, в многопроцессорных системах шина должна быть заблокирована. Поскольку значительно легче создавать и поставлять только одну версию программного кода, было бы неплохо Дпя нашего программного кода выполнять блокировку шины только по необходимости. Поскольку в обоих случаях количество инструкций очень небольшое, нам необходимо сделать это очень эффективно, в противном случае вызванная проверкой задержка не бу’Д^ оправдана. Упрощенная форма* решения представлена в листинге 10.4, и это решение совместимо с большинством современных компиляторов платформы Win32. Полная реализация этих функций включена в компакт-диск
Слава Ю. Поточная организация вычислений Листинг 10.4. 201 namespace ( static bool s_uniprocessor = is_hosc_up(); inline ___declspec(naked) void __stdcall atomic_increment(sint32_t volatile * /* pl */) { if(s_uniprocessor) { _asm { mov ecx, dword ptr [esp + 4] add dword ptr [ecx], 1 ret 4 ) ) else ( _asm { mov ecx, dword ptr [esp + 4] lock add dword ptr [ecx], 1 ret 4 ) ) ) Если вы даже не знакомы с ассемблером Intel, вы сможете понять простоту этого механизма. Переменная s_uniprocessor имеет значение «истина» для однопроцес- сорных машин и «ложь» для многопроцессорных машин. В первом случае операция инкремента выполняется без блокировки. Блокировка используется только во втором случае. Любые возможные условия гонок, возникающие при инстанциировании, не играют никакой роли, поскольку по умолчанию применяется блокировка, которая дает нужный результат. В приводимых ниже тестах производительности именно этот механизм использует- Ся в программном интерфейсе неделимых операций Atomic API системы Synesis и в неделимых функциях библиотеки WinSTL. 10.3.3. Сравнение эффективности Мною уже много рассказано о различных механизмах обеспечения неделимости Рации над целыми числами, и поэтому давайте теперь рассмотрим некоторый фак- материал. Перед тем, как это сделать, мне бы хотелось отметить, что приводи- в данном разделе числа отражают работу операционных систем Win32, исполь- Щих только (одно- или много-) процессорную архитектуру Intel. Параметры работы й архитектуры и/или других операционных систем могут иметь иные значения.
202 Часть 2. Выживание в условиях реального мира Я исследовал семь стратегий. В каждом случае имеется некая общая глобальная переменная, которая в потоке либо увеличивается, либо уменьшается на единицу Первая стратегия - неохраняемая (Unguarded) - подразумевает отсутствие блокировки и просто осуществляет операции инкремента или декремента при помощи операторов ++ или —. Следующие две используют особенности архитектуры: библиотечные функ- ции Atomic_* системы Synesis и встроенные функции WinSTL. Четвертая вызывает системные функции Win32 Interlocked—*. Последние три стратегии используют объект синхронизации - CRITICAL_SECTION системы Win32, spin_mutex библио- теки WinSTL и мьютекс ядра Win32 - для контроля доступа в критическую область в которой осуществляется модификация переменной с помощью оператора ++ или Результаты показаны в табл. 10.2 и включают в себя для каждой стратегии общее время выполнения 10 миллионов операций, выполняемых 31 конкурирующим пото- ком. Поскольку измерения проводились на машинах разного типа, получены также данные, позволяющие сравнить их производительность. Сознательно предусматривалось, что программа тестирования, применяющая раз- личные механизмы блокировок, таким образом порождала нечетное количество пото- ков, что после завершения всех потоков обрабатываемые переменные имели большое ненулевое значение, равное количеству итераций. Это являлось наглядным под- тверждением того, что операции действительно были неделимыми. Во всех приведен- ных случаях, включая неохраняемую обработку на однопроцессорной машине, обес- печивался правильный режим работы. Подтверждая наши знания о том, что некоторые отдельные инструкции многопроцессорных машин не являются неделимыми, случай неохраняемой обработки на многопроцессорной машине давал при каждом прогоне существенно различные значения, свидетельствуя о прерывании циклов RMW одного потока другим потоком. Таблица 10.2. Схема синхронизации Однопроцессорная машина Многоп роцессорная машина Общее время (мс) % относи- тельно неохраняемого режима Общее время (мс) % относительно неохраняемого режима Неохраняемые операции ++ и - 362 100% 525 (неверно) 100% Программный интерфейс Atomic ’ системы Synesis 509 141% 2464 469% Встроенные функции atomic * библиотеки WinSTL 510 141% 2464 469% Программный интерфейс Win32 Inter- locked* 2324 642% 2491 474% CR1TICAL_SECTION системы Win32 5568 1538% 188235 35854% Спин-мьютекс библиотеки WinSTL 5837 1612% 3871 737% MUTEX системы Win32 57977 16016% 192870 36737%
Глава Ю. Поточная организация вычислений 203 Что касается производительности, то в глаза бросаются несколько моментов. Во- первых, относительный расход процессорного времени выше на многопроцессорной машине, свидетельствуя, по-видимому, о том, что кэши мультипроцессора не были потревожены. Во-вторых, что касается механизма архитектурной диспетчеризации - программ- ного интерфейса Atomic_ API системы Synesis и встроенных функций atomic * билио- теки WinSTL - надо отметить, что он показал очень хорошие результаты в однопроцес- сорной системе, составляя только 22% от затрат применения функции системной биб- лиотеки Win32 Interlocked—* и всего лишь 141% от затрат применения неохра- няемых операторов ++/- На многопроцессорной машине дополнительные затраты, не связанные с блокировками в тесте процессора, вполне приемлемые, составляя всего лишь дополнительный 1%. Я бы сказал, что если вы пишите приложения, которые должны работать в системах как с однопоточной, так и с многопоточной архитектурой, и собираетесь поставлять единственную версию, то, по-видимому, вы получите боль- шую выгоду при использовании этого метода диспетчеризации. Результаты подтверждают хорошо известный факт, что мьютексы как объекты ядра представляют собой очень дорогой способ реализации неделимых операций, и только сумасшедший может использовать их для этой цели в системах Win32. Результаты применения критической секции CRITICAL_SECTION несколько отличаются от подхода, использующего мьютекс. Не знаю, что показывает ваш опыт, но мой опыт многопоточного программирования на платформе Win32 говорит в пользу того, что применение критической секции является значительно более эффективной альтернативой по сравнению с мьютексом. И действительно, этот подход оказывается приблизительно в 10 производительнее на однопроцессорной машине. Однако он обеспечивает примерно такую же производительность на многопроцессорной машине. И снова вам необходимо протестировать свой программный код на многопроцес- сорных системах, чтобы в данном случае проверить предположения об эффективности применяемых вами механизмов. Я бы сказал, что механизм, использующий CRITICAL—SECTION, не является тем механизмом, который следует использовать Для осуществления неделимых операций; в отличие от мьютексов я действительно часто видел, как этот подход используется в базах клиентского программного кода. Вы можете удивиться тому, что кто-то может использовать спин-мьютекс для реа- лизации неделимых операций. Ну, неделимые операции из заголовочного файла <asm/atomic.h> системы Linux <asm/atomic.h> обеспечивают только диапа- зон в 24 бита. Более того, в некоторых версиях Linux отсутствуют функции с коррект- ной семантикой (увеличить значение на единицу и вернуть предыдущее значение). Ис- пользуя концептуально более простые функции записи/установки значений, все же м°жно обеспечить полномасштабную неделимость операций без существенного сни- жения эффективности.
204 Часть 2. Выживание в условиях реального мира Я надеюсь, что эти результаты заставят вас немного задуматься при выборе способа реализации. Как вы помните, представленные результаты относятся к платформе Win32- для других архитектур параметры производительности могут значительно отличаться Но основной урок заключается в необходимости профилирования и проверки собствен- ных предположений. 10.3.4. Неделимые операции над целыми числами: заключение Основное внимание в этой главе я уделил неделимым операциям, и я никак не объяс- нил это. Существует три причины. Во-первых, по моему мнению, они могли бы быть непосредственно встроены в язык C++, что, по-видимому, нельзя сказать о других конструкциях, обеспечивающих многопоточность и синхронизацию из-за слишком больших отличий и отсутствия широкого распространения1. Во-вторых, эта очень полезная конструкция, которая столь же проста, сколь и эффек- тивна. Только путем применения неделимых операций над целыми числами можно дос- тигнуть необходимого (или почти необходимого) уровня потокозащищенности для за- метного количества классов C++, как будет показано в последующих главах. И последняя причина заключается в том, что в литературе неделимые операции обсуждаются в лучшем случае недостаточно. Надо надеяться, теперь вы будете думать о них чаще. Неделимые операции предусматриваются на многих платформах либо как функции системной библиотеки или нестандартных библиотек, либо предоставляется возмож- ность написания на ассемблере собственных версий. (Я сознаю иронию в предложе- нии использовать ассемблер в книге, которая в основном посвящена передовым мето- дам C++; мы живем в странном мире.) Даже когда мы останавливаем свой выбор на мьютексе (обычно библиотек PTHREADS) для реализации нашей неделимой операции, существуют меры, позво- ляющие повысить эффективность. Спин-мьютекс - это одна из тех вещей, которых сле- дует остерегаться в таких случаях. Вы будете использовать несколько экземпляров класса мьютекса, реализуемых при помощи программного интерфейса неделимых целочисленных операций с применением одного или нескольких глобальных мьютексов. В таком случае вам следует использовать некоторое разграничение действий процес- сора, чтобы сделать ваш мьютекс «чистым» мьютексом (на базе библиотек PTHREADS), в противном случае ваш подход отрицательно скажется на производительности и даст не тот результат, который вы ожидаете. Это действительно хороший пример повышения доверия к многопоточной разра- ботке. На практике нам в действительности необходимо детально рассмотреть наши требования к синхронизации вычислений и средства базовой системы (одной или не скольких), в которых будут выполняться наши приложения. Было бы очень хорошо- если бы C++ действительно предусматривал неделимые операции, но по моему лично 1 Следует отметить некоторые библиотеки, например, великолепные библиотеки Pthreads для платфор^ь Win32 (см. приложение А), в которых делается попытка обобщения опыта поточной организации вычислен’
Глава W. Поточная организация вычислении 205 му мнению значительно труднее обеспечить стандартные высокоуровневые примити- вы синхронизации1 и поддерживать максимальную эффективность для различных архитектур. Часто оказывается, что в данном случае лучше всего иметь в своем рас- поряжения все средства, которые позволяют поддерживать многопоточные вычисле- ния ([Bute 1997, Rich 1997]). 10.4. Многопоточные расширения Теперь, после того, как мы рассмотрели несколько характерных для многопоточной обработки вопросов, вы, возможно, посчитаете, что было бы полезно предусмотреть в языке встроенные средства поддержки операций, выполняемых в многопоточной среде. И действительно, в некоторых языках предусматриваются конструкции для мно- гопоточных вычислений. По традиции в C++ чаще добавляются новые библиотеки, чем новые элементы языка. Мы рассмотрим две потенциальные области такого рас- ширения языка, увидим, как они могут быть обеспечены средствами языка и как мы можем реализовать их с помощью библиотек (и с помощью небольших препроцес- сорных трюков). 10.4.1. synchronized В языках D и Java предусматривается ключевое слово synchronized, которое может использоваться для охраны критической области, как в следующем примере: Object obj = new Object О; synchronized(obj) ( . . . // критический программный код } Один из способов включения в язык ключевого слова synchronized мог бы пре- дусматривать следующее автоматическое преобразование указанного выше программ- ного КОДЯ' < —lock_scope__<Object> __lock_(obj); ( . . . // критический программный код } ) Шаблон ___lock_scope______ был бы во всех отношениях аналогичен шаблону cK_scope, описанному в разделе 6.2. Его было бы несложно сделать и, используя Связанный с ним шаблон свойств блокировки std: : lock_traits, можно было бы 'j——________________________ пРизнаки> показывающие, что это может случиться в будущей версии стандарта, и тогда все казанное, я надеюсь, будет неуместно. И все же я сомневаюсь, что такое произойдет.
206 Часть 2. Выживание в условиях реального мира синхронизировать экземпляр любого типа, допустимого в шаблоне свойств, причем при этом необязательно пришлось бы прибегать к блокировке объекта синхронизации. Это ключевое слово не является действительно серьезным претендентом на рас- ширение языка, поскольку применяя несколько макросов, мы можем добиться того же самого. В основном требуются всего лишь следующие два макроса: «define SYNCHRONIZED_BEGIN(T, v) \ С \ lock_scope<T> __lock___(v); «define SYNCHRONIZED_END() \ } Единственным небольшим неудобством для нас является то, что не определяется автоматически тип объекта, а также то, что программный код выглядит не очень при- влекательно:1 SYNCHRONIZED_BEGIN(Object, obj) { . . .11 критический программный код ) SYNCHRONIZED_END() Если вам не нравится макрос SYNCHRONIZED_END (), вы можете всегда немного усложнить ваш макрос SYNCHRONIZED (), определяя его следующим образом: «define SYNCHRONIZED(Т, v) \ for(synchronized_lock<lock_scope<T> > __lock__(v); \ __lock__; __lock___. end_loop () ) Шаблонный класс synchronized_lock<> используется здесь только для опреде- ления состояния2 и завершения цикла, поскольку мы не можем в операторе for объяв- лять переменную для проверки второго условия (см. раздел 17.3). Этот класс является «прикрепляемым» (bolt-in class, см. гл. 22), и он выглядит следующим образом: Листинг 10.5. template <typename Т> struct synchronized_lock : public T ( public: template <typename U> synchronized_lock(U &u) : T(u) * Можно возразить, что ухудшение внешнего вида в действительности является достоинством, поскольку это лишний раз подчеркивает синхронизируемый статус критической области, а это очень важно, т. к. эту особенность легче заметить любому, кто читает программный код. 2 На самом деле здесь не определяется оператор operator bool(). Мы увидим в гл. 24, почему они не используются и как правильно с ними работать.
_ 10 поточная организация вычислений 207 Глава |и'_______________________________________________________________________ , m_bEnded(false) {) operator bool () const { return !m_bEnded; ) void end_loop() { m_bEnded = true; ) private: bool m_bEnded; ); Существует другая сложность (разве может быть иначе!). Как говорилось в разделе 17.3, компиляторы по-разному реагируют на объявления внутри оператора цикла for, и если бы нам пришлось иметь две синхронизируемые области в одной области види- мости, некоторые старые компиляторы были бы недовольны. SYNCHRONIZED(Obj ect. obj) { ...II критический программный код ) . - . // некритический программный код SYNCHRONIZED(Object, obj) // Ошибка: "переопределение _____lock__" { . // другой критический программный код } Поэтому в переносимом решении необходимо обеспечить различные объекты —lock____, так что нам приходится воспользоваться препроцессором:1 #define concat___(х, у) х ## у #define concat_(x, у) concat___(х, у) #define SYNCHRONIZED(Т, v) \ for(synchronized_lock<lock_scope<T> > \ concat_(______lock_, ____LINE__) (v) ; \ concat_(__lock_, ____LINE___); \ concat_(__lock_, ____LINE___) .end_loop()) Это выглядит непривлекательно, но работает со всеми проверенными компиляторами, и вас не беспокоит анахронизм поведения оператора for, то просто используйте упро- НУЮ версию. Полные версии этих макросов и классов включены в компакт-диск. 83X1 возможность провести маленькое исследование, чтобы убедиться в необходимости определения конкатенации.
208 Часть Выживание в условиях реального мира 10.4.2. «Анонимная синхронизация» С управляемой объектом критической областью может возникнуть проблема, которая выражается в том, что иногда у вас нет объекта, который вы хотите использовать дця блокировки. В таком случае вы можете просто объявить статический объект в локальной области или, что предпочтительнее, в (анонимном) пространстве имен того файла, где находится критическая область. Вы могли бы также воспользоваться подходом, приме- няемым в макросе SYNCHRONIZED (), И создать макрос SYNCHRONIZED_ANON(), который вводит локальный статический объект, но тогда потенциально вы можете столк- нуться с условием гонки, когда два или более потоков могут пытаться одновременно вы- полнить конструирование статического объекта. Существуют способы обойти эту про- блему, что мы увидим при обсуждении статических объектов в следующей главе, но лучше всего избегать этой ситуации. В таких случаях наилучшее решение - объект, действующий на всем пространстве имен. 10.4.3. atomic Возвращаясь к моей любимой теме, связанной с синхронизацией операций, а именно, неделимых операции над целыми числами, можно указать одно расшире- ние языка - ключевое слово atomic для поддержки программного кода, подобного следующему: atomic j = ++i; // Эквивалентно j = atomic_preincrement(&i) или для применения трюка с обменом операторами XOR,1 atomic j л= i л= j л= i; // Эквивалентно j = atomic_write(&i, j); Компилятор должен обеспечивать преобразование этого программного кода в соот- ветствующую неделимую операцию машины требуемой архитектуры.2 К сожалению, при наличии различий наборов инструкций нам пришлось бы либо ограничиваться не- переносимым программным кодом, либо допускать применение ключевого слова atomic только для нескольких операций. Несомненно, нам не хотелось бы, чтобы компилятор предпринимал там, где он мог, облегченные меры, и молчаливо реализо- вывал другие операции, блокируя и разблокируя совместно используемый мьютекс, лучше, когда подобные действия представлены в программном коде в явном виде, как это мы делаем сейчас. Было бы очень хорошо применять ключевое слово atomic для С и C++ в рамках ограниченного подмножества неделимых операций над целыми числами, которые пре- 1 Это старый хакерский прием [Dewh 2003], и он часто встречается в вопросах на собеседованиях. Проверь16 и убедитесь, что он работает, хотя, я полагаю, он не гарантирует переносимость! 2 Следует отметить, что предлагаемое мною ключевое слово относится к операции, а не к переменной- Ес^ определить переменную с таким ключевым словом и затем в последующих 50 строках полагаться неделимость операций с нею, то это едва ли облегчит сопровождение такого программного кода. Горазд0 - предпочтительнее использовать функции atomic_*, учитывая очевидный смысл этих функций и возмоги их быстрого поиска.
Глава Ю. Поточная организация вычислений 209 дусматриваются во всех архитектурах. Однако в действительности применение функ- ций atomic_* не составляет никакого труда, а читаемость программного кода, несо- мненно, останется на прежнем уровне (а возможно, и улучшится). Единственным ре- альным недостатком является необязательность их существования на всех плат- формах; будем надеяться, что в новой версии стандарта C/C++ это требование будет учтено. 10.5. Специальная память потока До сих пор в ходе всего проводимого в данной главе обсуждения основное внима- ние уделялось вопросам синхронизации доступа к общим ресурсам со стороны многих потоков. Существует другой аспект поточной организации вычислений, связанный с обеспечением специальных потоковых ресурсов, более известных под названием специальной памяти потока (Thread-Specific Storage - TSS) [Schm 1997]. 10.5.1. Повторное использование В однопоточных программах применение локального статического объекта внутри функции - разумный способ облегчения использования функции. В стандартной биб- лиотеке С этот метод применяется в нескольких функциях, включая strtokO, которая выделяет лексические единицы из строки, построенной на основе некоторого набора разделителей: char *strtok(char *str, const char *delimiterSet); Эта функция таким образом поддерживает внутренние статические переменные, отра- жающие текущую позицию лексического анализа, что последующие вызовы (при значении NULL аргумента str) последовательно возвращают извлеченные из строки лексические единицы, К сожалению, при использовании таких функций в многопоточных процессах возни- кает классическое условие гонок. Какой-нибудь поток может инициировать новый лек- сический разбор в то время, когда другой поток находится в середине этого процесса. В отличие от других условий гонок, решение в данном случае не связано с сериали- зацией доступа с помощью объекта синхронизации. Это лишь не позволило бы одному потоку модифицировать применяемые в лексическом анализе внутренние структуры, когда это делает другой поток. Это не препятствовало бы прерыванию одного потока Другим при выполнении ими лексического анализа. Здесь необходимо не сериализовать доступ к глобальным потоковым переменным, а обеспечить применение локальных потоковых переменных1. В этом назначение намяти TSS. 'ч; ------------------ беременные библиотеки программ С и C++ этапа выполнения реализуют strtok() и подобные функции, бльзуя память TSS. 14-225
210 Часть 2. Выживание в условиях реального мира 10.5.2. Специальные данные потока и локальная память потока Инфраструктура многопоточной обработки как в PTHREADS, так и в Win32 обес- печивает в той или иной степени TSS. Чтобы их не перепутать, версия PTHREADS [Bute 1997] имеет название специальных данных потока (Thread-Specific Data - TSD) а версия Win32 [Rich 1997] - локальная память потока (Thread-Local Storage - TLS), но все это означает одно и то же. Каждая из них имеет средства создания переменной, которая способна содержать различные значения для каждого потока процесса. В PTHREADS эта переменная назы- вается ключом, а в Win32 - индексом. В Win32 место расположения значения ключа каждого потока называется слотом. Мне больше нравятся ключи, слоты и значения. TSD в PTHREADS строит свою работу на базе следующих четырех библиотечных функций: int pthread_key_create( pthread_key_t *key , void (‘destructor)(void *)); int pthread_key_delete( pthread_key_t key); void *pthread_getspecific( pthread_key_t key); int pthread_setspecific( pthread_key_t key , const void ‘value); Функция pthread_key_create () создает ключ (неопределенного типа) pthread_key_t. Вызывающая программа может также передавать функцию за- вершения, которую мы вскоре обсудим. Значения могут устанавливаться и считывать- ся отдельно для каждого потока путем вызова функций pthread_setspecif ic () и pthread_getspecif ic (). Функция pthread_key_delete () вызывается для уничтожения ключа, когда он больше не нужен. Программный интерфейс памяти TLS в Win32 имеет аналогичный квартет функций: DWORD TlsAlloc(void); LPVOID TlsGetValue(DWORD dwTlsIndex); BOOL TlsSetValue(DWORD dwTlsIndex, LPVOID IpTlsValue); BOOL TlsFree(DWORD dwTlsIndex); Обычно программные интерфейсы этих TSS используются следующим образом: в главном потоке создается ключ до активации любых других потоков, и этот ключ сохраняется в общей области (в глобальной переменной или в виде возвращаемого функцией значения). После этого все потоки работают со своими копиями данных TSS, сохраняя и считывая их из своих слотов. К сожалению, существует несколько дефектов в этих моделях, особенно, в версии Win32.
Глава 10. Поточная организация вычислений 211 Во-первых, программные интерфейсы обеспечивают ограниченное количество ключей. PTHREADS гарантирует, как минимум, 128 ключей; Win32 - 64.1 В реальных ловиях едва ли возникнет потребность в большем их количестве, но учитывая уве- личение многокомпонентное™ программного обеспечения, нельзя утверждать, что такое вообще не может случиться. Вторая проблема связана с тем, что программный интерфейс Win32 не обеспечива- ет очистку слота при завершении работы потока. Это означает, что вам необходимо каким-то образом перехватить управление при завершении потока и освободить ре- сурсы, связанные со значениями, находящимися в слоте потока. Естественно, для С++- программистов это означает очень неприятную потерю возможности автоматического уничтожения ресурсов, предоставляемую нам языком, что может сделать почти невоз- можной работу некоторых сценариев. Несмотря на то, что PTHREADS обеспечивает средства по освобождению ресурсов при завершении потока, предусмотренный здесь механизм является недостаточно полным и не позволяет просто и корректно обрабатывать ресурсы. По существу PTHREADS обеспечивает нас механизмом RAII неизменяемого типа (см. раздел 3.5.1). Хотя это является значительным улучшением по сравнению с отсутствием вообще какого-либо RAII на платформе Win32, существуют случаи, когда желательно иметь возможность изменения находящегося в слоте значения данного ключа. Можно самим очистить его от прежних значений, но было бы значительно лучше, если бы это дела- лось автоматически. Четвертая проблема связана с тем, что PTHREADS предполагает возможность вызова функции очистки во время освобождения ресурсов. Если на момент заверше- ния работы какого-нибудь потока программный интерфейс оказался неинициализиро- ванным, то это может привести к тому, что теперь нельзя будет вызывать функцию очистки, которая может обращаться, прямо или косвенно, к этому программному интерфейсу. Аналогично - и на практике это даже более вероятно - если функция очи- стки содержится в динамической библиотеке, она может теперь не существовать в памяти процесса, и ее вызов приведет к аварийному завершению. Ю.5.3. __declspec(thread) и TLS Перед тем, как мы рассмотрим способы решения этих сложных проблем, мне бы хотелось описать один механизм TSS, который поддерживается большинством компи- ^ТоР°в на платформе Win32 для того, чтобы применение функций TLS на платформе 2 сопровождалось не очень многословными комментариями. Компиляторы ляют вам использовать спецификатор_decl spec (thread) при определении еменных, как в следующем примере: ——declspec(thread) int х; 5(>j|i,iI.n^0Ws и NT 4 обеспечивают 64 ключа. Боле современные операционные системы обеспечивают К°ЛНчество (Windows 98/МЕ - 80, Windows 2000/ХР - 1088), но программный код, который должен Ться в любой системе Win32, должен исходить из числа 64.
212 Часть 2. Выживание в условиях реального мира Теперь х будет иметь отдельное значение для каждого потока, то есть каждые поток будет иметь собственную копию этой переменной. Компилятор помещает любые такие переменные в секцию . tls, а компоновщик объединяет их всех в одну секцию. Когда операционная система загружает процесс, она находит секцию . tis и создает блок специальной памяти потока для ее хранения. При создании любого нового потока для него также создается соответствующий блок. К сожалению, несмотря на очень высокую эффективность этого подхода [Wils 2003d], существует громадный недостаток - он применим только для исполняе- мых модулей и не может использоваться для динамических библиотек. Он может при- меняться в неявно подключаемых динамических библиотеках, которые загружаются во время загрузки процесса, поскольку операционная система может выделять блок специальной памяти потока для всех единиц компоновки, загружаемых во время загрузки приложения. Проблема возникает в том случае, когда позже будет явно загру- жаться динамическая библиотека, содержащая секцию .tls; операционная система не может вернуться назад и увеличить блоки всех существующих потоков, и поэтому загрузка вашей библиотеки закончится неудачей. Я думаю, что лучше всего не использовать спецификатор______decl spec (thread) в любых библиотеках DLL, даже в тех случаях, когда вы уверены, что библиотека будет всегда вызываться неявно. В современном многокомпонентном мире всегда может оказаться так, что библиотека DLL может неявно компоноваться с компонентом, ко- торый явно загружается исполняемым модулем, полученным другим компилятором или написанным на другом языке, и этот компонент еще не загружал свою DLL. Ваша DLL не может быть загружена и, следовательно, не может быть загружен компонент, который зависит от нее. 10.5.4. Библиотека Tss Мне так часто приходилось сталкиваться с этими четырьмя проблемами, возни- кающими при использовании механизмов TSS в PTHREADS и Win32, что я взял на себя смелость и написал библиотеку, которая обеспечивает необходимую мне фУнк‘ циональность. Она состоит из восьми функций и двух вспомогательных классов. Глав- ные функции, которые совместимы с С и C++, приводятся в листинге 10.6: Листинг 10.6. // MLTssStr.h - функции объявляются со спецификатором extern "С" int Tss_Init (void); /* Завершается неудачей, если возвращает значение < 0- void Tss_Uninit(void); void Tss_ThreadAttach(void); void Tss_ThreadDetach(void); HTssKey Tss_CreateKey( void (*pfnClose)() , void (*pfnClientConnect)() , void (*pfnClientDisconnect)() , Boolean bCloseOnAssign); void Tss_CloseKey( HTssKey hEntry);
r 10 Поточная организация вычислений 213 Глава '«• void Tss_SetSlotValue( HTssKey hEntry , void ‘value , void “pPrevValue /* = NULL */); void *Tss_GetSlot Value(HTssKey hEntry); Как и все хорошие программные интерфейсы, эта библиотека имеет методы Init и Uninit20, чтобы обеспечить готовность применения программного интерфейса любыми клиентами, которым он нужен. Он также содержит две функции для под- ключения и отключения потоков, о которых мы вскоре поговорим. для манипулирования ключами предназначены четыре функции. Однако их функ- циональные возможности шире. Для обеспечения возможности освобождения ресурсов при завершении потока в функции Tss_ CreateKey () предусматривается необязательная функция обратного вызова pfnClose; если эта функция вам ненужна, укажите NULL. Если вы хотите, чтобы функция очистки применялась к значениям слота при их перезаписи, то задайте значение true параметру bCloseO- nAssign. Предусмотрены два параметра с необязательными функциями обратного вызова pfnClientConnect и pfnClientDisconnect, которые позволяют предотвра- тить окончательное исчезновение программного кода. Они могут задаваться в любых случаях, когда требуется гарантировать существование в памяти функции, заданной параметром pfnClose, и ее вызов в нужный момент. Когда я сам использовал этот программный интерфейс, были случаи применения функций Init/Uninit для других программных интерфейсов или для блокировки и разблокировки находящейся в памяти динамической библиотеки, а при необходимости и их комбинации. Семантика функций Tss_CloseKey () и Tss_GetSlotValue () не содержит ничего необычного. Однако функция Tss_ SetSlotValue () по сравнению с ее эк- вивалентами в PTHREADS/Win32 имеет дополнительный параметр, pPrevValue. Если этот параметр имеет значение NULL, то предыдущее значение перезаписывается и подвергается очистке с помощью функции, заданной при создании ключа. Однако если этот параметр имеет другое значение, то действия по очистке значения не выпол- няются и предыдущее значение возвращается вызвавшей программе. Это позволяет Реализовывать расширенные возможности управления значениями, предоставляя по Умолчанию мощную семантику для обеспечения очистки. Естественный следующий шаг для этих функций, представляющих собой программ- интерфейс С, - инкапсулирование их в классах, управляющих диапазоном действия Депа С°В’И ПРедУсматРивается два таких класса. Первым является класс TssKey. Он не • ничего особенного - только упрощает интерфейс и применяет механизм RAII для 1Тия ключа - и поэтому я приведу лишь открытый интерфейс: Листинг 10.7. template ctypename Т> class TssKey {
214 Часть 2. Выживание в условиях реального мира public: TssKey( void (‘pfnClose)(Т ) , void (‘pfnClientConnect)() , void (*pfnClientDisconnect)() , Boolean bCloseOnAssign = true); -TssKey(); public: void SetSlotValue(T value, T *pPrevValue = NULL); T GetSlotValue() const; private: . . . Члены; скрыть конструктор копирования и оператор присваивания }; Реализация содержит статические утверждения (см. раздел 1.4.7) для того, чтобы обеспечить sizeof (Т) == sizeof (void*), препятствуя попыткам хранения в слоте значений больших объектов. Тип значения приводится к типу параметра, чтобы клиент- ский программный код был проще. Следующий класс немного интереснее. Если вы используете находящееся в слоте значение для создания единственной сущности с целью ее повторного применения, вам следует действовать по образцу, показанному в листинге 10.8: Листинг 10.8. Tss key_func(. . .); OneThing const &func(Another ‘another) { OneThing ‘thing = (OneThing*)key_func.GetSlotValue(); if(NULL == value) { thing = new OneThing(another); key_func.SetSlotValue(thing); } else { thing->Method(another); } return ‘thing; Однако, если функция имеет более сложный вид - а большинство именно такие то может существовать несколько мест, где может изменяться находящееся в с значение. Каждое из них означает возможность утечки ресурсов из-за преждевре ного возврата до вызова функции SetSlotValue (). По этой причине предусМ° Ро класс, управляющий диапазоном действия ресурсов TssSlotScope, который дится в листинге 10.9. Я признаюсь, что испытываю особую привязанность к классу, т. к. он обеспечивает в некотором смысле «перевернутый» (inside-out) м низм RAIL
Глава 10. поточная организация вычислений 215 ^Листинг 10.9. template <typename Т> class TssSlotScope { public: TssSlotScope(HTssKey hKey, T &value) : m_hKey(hKey) , m_valueRef(value) , m_prevValue((value_type)Tss_GetSlotValue(m_hKey)) { m_valueRef - m_prevValue; ) TssSlotScope(TssKey<T> key, T &value); -TssSlotScope() { if(m_valueRef != m_prevValue) { Tss_SetSlotValue(m_hKey, m_valueRef, NULL); ) } private: TssKey m_key; T &m_valueRef; T const m_prevValue; // Реализация не требуется private: . . . Скрыть конструктор копирования и оператор присваивания }; Экземпляр класса создается из ключа TSS (представленного в виде TssKey<T> или HTssKey) и ссылки на значение внешней переменной. Конструкторы затем уста- навливают внешнюю переменную на находящееся в слоте значение с помощью вызова Tss-GetSlotValue (). В деструкторе значение внешней переменной сравнивается с первоначальным значением в слоте, и находящееся в слоте значение обновляется методом Ss-SetSlotValue () , если оно изменилось. Теперь нам проще писать клиентский граммный код, и мы можем полагаться на механизм RAII для обновления при необ- °сти находящегося в слоте значения, относящегося к конкретному потоку. Листинг 10.10. OneThing const &func(Another ‘another) { OneThing ‘thing; TssSlotScope<OneThing*> scope(key_func, thing); thing = new OneThing(another);
216 Часть 2. Выживание в условиях реального else if( . . . ) thing = . . .; else return *thing; } 11 деструктор объекта scope обеспечивает вызов Tls_SetSlotValue() Итак, мы увидели, как используется библиотека Tss, но как она работает? Ну, я Со бираюсь предоставить вам право самим реализовать эти функции1, но нам необходимо рассмотреть, как обрабатываются уведомления потоков. Это делается с помощью дВух функций, которые до сих пор не были описаны мною: Tss_Thread Attach () иTss_ThreadDetach(). Эти две функции следует вызывать, когда выполнение потока, соответственно, начинается и завершается. При необходимости вы можете для этого воспользоваться инфраструктурой вашей операционной системы или библиоте- ки программ этапа выполнения. В противном случае вам придется сделать это самому На платформе Win32 все библиотеки DLL имеют внешнюю точку входа DllMain () [Rich 1997], которая получает уведомления при загрузке/разгрузке процесса и при начале/завершении выполнения потоков. В библиотеках системы Synesis для платформы Win32 базовая DLL (MMCMNBAS.DLL) вызывает Tss_ThreadAttach(), когда ее функция DllMain () получает уведомление DLL_THREAD_ATTACH, и вызывает Tss_Thread Detach (), когда получает DLL_THREAD_DETACH. Поскольку эта биб- лиотека общего назначения, любые другие члены любого исполняемого модуля могут также использовать библиотеку Tss без предварительной настройки, и это работает. Листинг 10.11. BOOL WINAPI DllMain(HINSTANCE, DWORD reason, void *) { switch(reason) { case DLL_PROCESS_ATTACH: Tss_Init(); break; case DLL_THREAD_ATTACH: Tss_ThreadAttach(); break; case DLL_THREAD_DETACH: Tss_ThreadDetach(); break; case DLL_PROCESS_DETACH: Tss_Uninit(); break; 1 Или воспользоваться компакт-диском, т. к. я включил исходный код библиотеки. Однако будьте остоР0*^., поскольку он был написан давно и не так уж хорошо! Кроме того, вероятно, он не оптимален, и поэтом) следует воспринимать лишь в качестве примера применения данного метода, а не как эталон PeaJ1’ библиотеки TSS.
217 io Поточная организация вычислений ^^^датформе UNIX эта библиотека вызывает pthread_key_create () из иии Tss_Init () для создания закрытого, неиспользуемого ключа, который назначается лишь для обеспечения гарантии получения библиотекой обратного г ва при завершении выполнения каждого потока, после чего выполняется вызов киии Tss_ThreadDetach (). Поскольку в PTHREADS не существует механизма иииализапии данных отдельного потока, написана библиотека Tss для того, чтобы действовать надлежащим образом при запросе данных из несуществующего слота создавать слот, когда он не существует во время запроса на сохранение значения в слоте. Таким образом, Tss_ThreadAttach() можно рассматривать как механизм эффективного расширения всех активных ключей в ответ на запуск потока вместо постепенного выполнения этих действий в ходе обработки данных потока. Если вы не пользуетесь программным интерфейсом PTHREADS или Win32 или вам не нравится помещать свою библиотеку в DLL системы Win32, вы должны гарантиро- вать вызов всеми потоками функций подключения и отключения. Однако эта библио- тека так написана, что даже если вы не сможете или не захотите это сделать, то при по- лучении завершающего вызова Tss_Uninit () выполняется зарегистрированная функция очистки всех ключей, расположенных во всех доступных слотах. Это представляет собой мощный механизм перехвата всех уведомлений, и вам придет- ся считаться с единственной проблемой (если не брать в расчет запоздалость очистки), которая возникает в том случае, если для освобождения ресурса ваша функция очистки должна вызываться из того же потока, который его выделял. Если это именно так, и вы не можете своевременно обеспечить исчерпывающее уведомление относительно подключе- ния и отключения потоков, то вам не повезло. Что вы хотите - мы имеем дело всего лишь с протоплазмой и кремнием! 10.5.5. Эффективность TSS До сих пор я не касался вопросов эффективности. Естественно, сложность библио- теки и существование мьютексов, обеспечивающих сериализацию доступа к памяти, обусловливает нетривиальные затраты по сравнению, скажем, с реализацией TLS на платформе Win32, которая действительно работает очень быстро [Wils 2003f]. В опре- деленном смысле это не составляет проблему, поскольку если вам необходима такая Функциональность, то вы готовы в какой-то мере платить за это. Во-вторых, пере- ключение потоков стоит очень дорого, потенциально на это могут пойти тысячи UMiQ0B [Ви]^ 1999], и поэтому некоторые дополнительные затраты на TSS, вероятно, ЧУдут серьезно беспокоить. Однако мы не можем вообще игнорировать этот аспект. я минимизации затрат при использовании функций библиотеки Tss или любого про- граммного интерфейса TSS вы можете передавать данные TSS вниз по цепочке вызо- бы’ Э Не осУществлять их поиск собственно на каждом уровне, рискуя слишком О^СТР° переключать контексты из-за конкуренции внутри инфраструктуры TSS. евидно, это нельзя обеспечить при помощи функций системной библиотеки или Ре Их часто используемых функций общего назначения, но возможно в рамках ваших "Пизаций собственных приложений.
218 Часть 2. Выживание в условиях реального Мира .— Более того, для памяти Tss удобно пользоваться шаблоном TssSlotScope поскольку он будет пытаться обновлять слот только в том случае, когда действительно требуется изменить значение. Как обычно выбор стратегий зависит от ваших предпочтений и связан с поиском компромисса между эффективностью, устойчивостью, простотой программирования и требуемыми функциональными возможностями.
Глава 11 Статические объекты Статические объекты отличаются от переменных стека и переменных динамиче- ской памяти (переменных «кучи») тем, что они занимают фиксированную область па- мяти, выделенную компоновщиком, и продолжительность их жизни (в целом) не зави- сит от логики выполнения процесса. Существует три категории статических объектов. 1. функционально-локальными статическими переменными называются те, которые определяются в области видимости функции. 2. Глобальные статические переменные и переменные пространства имен - их также называют нелокальными статическими объектами - это те, которые определяются в глобальном пространстве имен, а также в поименованном или анонимном про- странстве имен. 3. Статические переменные-члены - это члены, совместно используемые в экземп- лярах класса, в которых они определены. В данной главе рассматриваются проблемы, характерные для статических объек- тов, и предлагаются некоторые меры по их решению. Важно понимать, что инициализация статических объектов является двухэтапной операцией. Во-первых, «реализация» обеспечивает инициализацию нулями области памяти, которую они занимают. Стандарт (С++-98: 3.6.2; 1) устанавливает, что «ини- циализация нулями всех локальных объектов в статической памяти всегда выполняет- Ся ^Рел любой другой их инициализацией». На практике это обычно выполняет меха- низм загрузки процесса операционной системой, поскольку эти переменные разме- СЯ в сегменте .data (или .bss) единицы компоновки (исполняемого модуля Динамической библиотеки), который просто копируется в сегмент памяти для я-записи [Lind 1994]. В листинге 11.1 переменные il и 12 будут инициализиро- ваны нулями. Листинг 11.1. extern int SomeOtherModulesFunc(); namespace StaticsAbound
220 Часть 2. Выживание в условиях реального ^ира inline int LocalFunc() { return 10; } struct Thing { Thing() : i(SomeOtherModulesFunc()) {} int i; } thing; int il = 0; int i2; int i3 = 1; int i4 = LocalFunc(); int i 5 = ::SomeOtherModulesFunc(); Когда статические объекты имеют тип POD (см. «Введение») и инициализируются константными выражениями, их значения могут быть записаны в исполняемый модуль компилятором, и поэтому на этапе выполнения вообще не расходуется время на их ини- циализацию, то есть i3 будет присвоено значение 1 во время компиляции/компоновки. Более того, при реализации вполне допускается (стандарт С++-98:3.6.2; 2) осуществлять оптимизацию и применять статическую инициализацию в тех случаях, когда результат динамической инициализации будет идентичен статической. Следовательно, компиля- тор мог бы на вполне законных основаниях выполнить статическую инициализацию 14 значением 10. Нулевую и константную инициализацию совместно называют статической инициа- лизацией: это первый этап. Все другие выполняемые в ходе инициализация действия известны как динамическая инициализация: это второй этап. Динамическая инициализа- ция требуется всегда, когда на этапе компиляции нельзя полностью осуществить инициа- лизацию, и она включает в себя инициализацию типов POD динамическими значениями, а также конструирование объектов нелокальных статических типов классов: i5 и thins в листинге 11.1. Вся статическая инициализация выполняется до любой динамической инициал”33 ции. Более того, обычно динамическая инициализация всех нелокальных статичес & объектов осуществляется до выполнения функции main (), хотя это не оговарива стандартом. Как сказано в стандарте (С++-98: 3.6.2; 3), нелокальный статический о должен быть инициализирован до «первого применения любой функции или о ъ определенных в той же самой единице трансляции, в которой должен инициализир^|Л ся данный объект». На практике такая поздняя инициализация происходит в Р случаях, и в известных мне компиляторах это делается до входа в main О *• ^аК °°° аМ- ванно утверждает стандарт (С++-98: 3.6.1; 1), в автономной среде «при запуске пр ------------------------------ in др>с1'' 1 Вероятно, это определяется тем, как реализация принимает во внимание встроенную 10 нетипичную среду.
221 статические объекты Глава _________________ '^^полняются конструкторы объектов, находящихся в области видимости простран- МЬ1 имен, имеющих статический срок хранения», откуда можно сделать вывод, что 0183 предполагает завершение динамической инициализации нелокальных статиче- ^их объектов до вызова main (). 11.1. Нелокальные статические объекты: глобальные объекты Несмотря на то, что язык ясно определяет взаимоотношения между этапами ини- циализации и главным направлением вычислений, применение нелокальных статиче- ских объектов имеет несколько недостатков (см. раздел 15.5) и в целом не рекоменду- ется их использовать [Sutt 2000, Dewh 2003]. Главная проблема, которую мы сейчас рассмотрим, связана с их упорядочиванием. Проблема упорядочивания состоит из двух тесно связанных проблем. Первая про- блема заключается в том, что между двумя или более статическими переменными могут быть циклические взаимозависимости. Это фундаментальная проблема проек- тирования, и она не имеет решения, но существуют способы, позволяющее повысить вероятность ее обнаружения. Вторая проблема связана с возможностью ссылки на нелокальную статическую переменную до ее инициализации или после ее де-инициализации. Возможность воз- никновения такой ситуации нередко пугает разработчиков и является постоянной темой обсуждения в сетевых конференциях, и, можно считать, представляет собой дефект C++. Дефект: C++ не обеспечивает механизм управления упорядоченностью глобальных объектов. *1 <1. Упорядоченность внутри единицы компиляции °бьектоМКаХ Заданной единичЬ1 компиляции продолжительность жизни глобальных конст В Подчиняется тем же правилам, которые существуют для объектов стека: они ДаРтС++РУЮТСЯ В порядке их опРеделений и уничтожаются в обратном порядке (стан- Лений ' Следует отметить’что здесь речь идет именно о порядке опреде- ’а не объявлений, и это важно. ° ЛИСТИНГА Ип аПопяп~ 11,2 констРУирование производится в последовательности ol, о2, оЗ, о4, кУничтожения - о4, оЗ, о2, ol. Листинг 11.2. Class Object; e*tern Object о2;
222 Часть 2. Выживание в условиях реального Object olfol*); Object о2("о2*); int mainО ( Object оЗ(*оЗ"); Object o4("o4">; return 0; } Когда статические объекты находятся в единице компиляции процесса, они кон- струируются до входа в main () и уничтожаются после того, как main () возвращает управление. Вполне законно и уместно делать зависимым один глобальный объект от другого, когда последний уже определен в той же самой единице компоновки. Поэтому о2 можно было бы определить как копию ol. extern Object о2; Object ol(*ol*); Object o2(ol); // Это допустимо Однако не допускается иметь зависимость от объекта, который еше не определен, даже если он и был объявлен, хотя компилятор и позволит вам это сделать. Следующий пример приводит к непредсказуемому результату: extern Object о2; Object ol(o2); // Результат не определен! Object о2("о2’); Поскольку пространство под глобальные объекты распределяется заранее и ини- циализируется нулями, объекту ol передается правильный адрес о2, но все значения членов объекта о2 имеют нули. Это может стать причиной краха или просто привести к возникновению скрытой ошибки, что зависит от того, как определен объект Obj ect. При некоторых обстоятельствах программа может отработать корректно, но все же было бы серьезной ошибкой рассчитывать на это. 11.1.2. Упорядоченность для разных единиц компиляции Когда дело доходит до упорядочивания глобальных объектов, находящихся в несколь- ких единицах компиляции, мы просто попадаем на территорию, зависимую от реализации На практике все зависит от компоновщика. В большинстве компоновщиках глобальны6 объекты размещаются в последовательности, соответствующей порядку компоновки единиц компиляции. Рассмотрим листинг 11.3.
Глава 11. Статические объекты Листинг 11.3. 223 // object, h class Object { . . extern Object ol; extern Object o2; extern Object o3; // main.cpp «include "object.h" Object oOCoO"); Object ol("ol"l; int main() { . - - } // object2.cpp «include "object.h" Object o2("o2"); // object3.cpp «include "object.h" Object o3("o3"); Если предоставить компоновщику объектные файлы objectl.cpp, object2. срр и objects . срр именно в этом порядке, то в результате для несколь- ких компляторов мы получим упорядочение, показанное в табл. 11.1: Таблица 11.1. Компилятор/компоновщик Порядок Borland C/C++ 5.6 o0.ol.o2, o3 CodeWarrior 8 oO, ol,o2, o3 Digital Mars 8.38 o3,o2.o0, ol GCC 3.2 o3, o2, oO, ol htel C/C++ 7.0 oO, ol,o2, o3 Visual C++6.0 oO. ol,o2. o3 Watcom C/C++ 12 o3, o2, o0,ol Очевидно, что эти компиляторы четко придерживаются двух противоположных m дТеГИ^‘ ®orbnd. CodeWarrior, Intel и Visual C++ обеспечивают конструирование и ^аЛЬНЫХ объе™в в порядке компоновки ими объектных файлов. Digital Mars, GCC com обеспечивают обратную последовательность. Ое Несогласование вызывает проблему. Если бы все компиляторы/компоновщи- М0)|(”0ДДеРЖивали стандартный механизм упорядочивания глобальных объектов, но было бы полагаться на предсказуемый порядок глобальных объектов в вашем ₽Иложении.
224 Часть 2. Выживание в условиях реального мира Несомненно, такое решение будет достаточно хрупким, поскольку корректно^ вашего программного кода зависит от некоего внешнего фактора: последовательности объектных файлов в файле проекта или в make-файле. Для нарушения таких зависимо стей обычно достаточно совсем небольшого «усилия», и эти нарушения очень сложно будет диагностировать или даже обнаружить. Тем не менее, в принципе можно использовать управляемое компоновщиком упорядочивание объектных файлов как средство, обеспечивающее требуемую после довательность статических объектов. Если вы достаточно доверяете своей команде разработчиков и стабильности своих инструментальных средств, вы можете использо- вать этот подход. Однако будет сложно на протяжении всего жизненного цикла вашего программного продукта убеждаться в том, что построенные вами проекты по-прежне- му сохраняют требуемую упорядоченность. Один из практических подходов заключается во включении отладочного программ- ного кода в каждую единицу компиляции, по крайней мере, в отладочные версии для трассировки порядка выполнения инициализации. Поскольку мы знаем, что этот поря- док в рамках заданной единицы компиляции фиксирован, и что все объекты либо ини- циализируются до выполнения функции main (), либо до первого использования любого из них, нам необходимо всего лишь вставить для трассировки нелокальный статический объект в начале или в конце каждой единицы компиляции, и тогда мы сможем однозначно определить, какую упорядоченность обеспечивает компоновщик. Давайте рассмотрим, как это можно осуществить на практике. Файл CUTrace. h содержит объявление функции CUTraceHelper (), которая выводит на печать имя инициализируемого файла, сообщение и имеет несколько необязательных аргументов. Это может выглядеть следующим образом «. . .cu_ordering_test .срр: Ini- tialising». Другая функция, CUTraceO, просто принимает сообщение и аргу- менты, а затем передает их вместе с файлом функции CUTraceHelper (): // CUTrace.h extern void CUTraceHelper(char const ‘file, char const *msg, va_list args)- namespace { void CUTrace(char const ‘message, ___) { va_list args; va_start(args, message); CUTraceHelper( BASE FILE message, args); va_end(args); } } // namespace Этот программный код обладает двумя важными особенностями Во-перв^ CUTrace () определяется в анонимном пространстве имен, что означает полу4 копии функции каждой единицей компиляции (см. раздел 6.4). Однако это не доЛ*т0 беспокоить, поскольку компиляторы при оптимизации легко могут свесТИяТцо. к вызову CUTraceHelper (). В любом случае это будет происходить, вер0
225 Глава11 • Статические объекты при использовании отладочных или тестовых версий, а не итоговой версии, ключевого слова static компоновщик стал бы жаловаться на наличие множе- еНных определений, а применение ключевого слова inline привело бы просто аННудированию компоновщиком всех версий кроме одной. К Вторая особенность связана с применением нестандартного символа BASE FILE_ Компиляторы Digital Mars и GCC определяют этот символ в качестве имени первичного Лайла реализации, для которого выполняется компиляция; в качестве его обычно ис- нодьзуется файл, имя которого задается в командной строке. Таким образом, даже если CUTraceO определяется в заголовочном файле CUTrace.h, имя первичного файла реализации будет передано функции CUTraceHelper (). Естественно, поскольку данный символ не является стандартным, этот метод в таком виде не сработает для других компиляторов. Решение состоит в использовании другими компиляторами символа BASE FILE_______. Следует признать, что данный метод многословен, но работает, и его легко внедрить в сценарии Perl, Python или Ruby2. И давайте будем честными: если вы готовы пойти на крайние меры для обес- печения при компоновке требуемой упорядоченности, небольшое количество этого дополнительного программного кода в каждом исходном файле не будет являться той проблемой, которая беспокоит вас больше всего. // SomelmplFile.cpp #ifndef __BASE_FILE__ static const char __BASE_FILE__[] = ___FILE_; #endif /* __BASE_FILE__ */ #include "CUTrace.h" В конце вновь обеспечивается внутреннее связывание, чтобы на этот раз гарантиро- вать получение единственной копии класса CUTracer. // CUTrace.h namespace { static CUTracer s_tracer; ) // namespace и Следует отметить, что это будет работать также при определении CUTrace () -tracer static (см. раздел 6.4), но лучше использовать анонимное пространст- имен, если вам не приходится поддерживать очень старые компиляторы. гагыаЖе еСЛИ ВЫ ®лагоРазУмно не собираетесь при разработке вашей программы пола- Писат НЭ УПОРялоченность компоновки - и давайте будем откровенны: кто захочет ctefi ПРогРаммнь,й код, корректность которого зависит от наблюдаемых особенно- доведения? - этот метод все-таки может быть очень полезен в качестве диагно- ’ •> НеСК0Г° СРедства’ и ПОЭТОМУ я советовал бы включать его в большие проекты, хотя °бес Рекомендовал бы вам полагаться в своей работе на упорядоченность, которую is _?ечивает компоновка. '22s
226 Часть 2. Выживание в условиях реального мира Рекомендация: не следует полагаться на упорядоченность инициализации глобальн объектов. Тем не менее, следует использовать механизмы трассировки для последовательности инициализации глобальных объектов. 11.1.3. Избегайте глобальных объектов в main() Очевидно, всех этих проблем можно достаточно просто избежать, если вообще не иметь никаких глобальных переменных, но иногда возникают ситуации, когда вы не можете обойтись без них. Это можно сделать простым, но неэлегантным способом - путем замены ваших глобальных объектов на объекты стека в функции main(), откуда вы можете непосредственно управлять продолжительностью их жизни и выда- вать указатели на них другому программному коду вашего исполняемого модуля. Листинг 11.4 показывает, как это можно обеспечить. Листинг 11.4. // globall.h class Globall { }; extern Globall 'g_pGloball; // global2.h class Global2 { }; extern Global2 *g_pGlobal2; // main.cpp #include "globall.h’ #include *global2.h" int main(. . .) ( Globall globall; g_pGloball = bgloball; Global2 global2; g_pGlobal2 = &global2; return g_pGlobal2->Run(. . .)
227 Глава 11 • Статические объекты Очевидным недостатком данного подхода является необходимость использования азателя для обращения ко всем глобальным переменным, что может привести к не- значительной потере эффективности и что также создает небольшие синтаксические неудобства- Более неприятный недостаток заключается в том, что в случае если какой- нибудь клиентский программный код объектов каким-то образом сам использует гло- бальные переменные и использует псевдо-глобальные переменные функции main () за ее пределами, ваша программа завершится аварийно при обращении к динамиче- ской памяти. Но там, где возможен полный контроль продолжительности жизни и упорядоченности ваших статических объектов, ваши усилия могут быть оправданы. 11.1.4. Упорядоченность глобальных объектов: заключение В общем случае очень трудно (а может быть, вообще невозможно) предсказуемо управлять упорядоченностью глобальных объектов, обеспечивая при этом переноси- мость программного кода. Однако в следующем разделе мы увидим, что ситуация не столь безнадежна при использовании синглетонов (см. раздел 11.2). Можно адаптиро- вать одно из решений проблемы упорядочивания синглетонов - связанное с программ- ным интерфейсом, использующим счетчики, - к задаче упорядочивания глобальных объектов, если каждый глобальный объект данного типа можно связать с уникальным идентификатором на этапе компиляции, но это выходит за рамки данной главы. И в самом деле, для решения этой проблемы потребуется проделать столько «цирко- вых номеров», что лучше вернуться немного назад и проверить, правильные ли задачи были поставлены на этапе проектирования. 11.2. Синглетоны Понятие шаблона синглетона [Gamm 1995] применимо для некоего заданного типа в том случае, если существует только единственный его экземпляр. В C++ реализация синглетона обычно основана на применении статического объекта. Если некий тип Рассчитан на применение семантики синглетона, то, поскольку C++ позволяет вам «оторвать себе ногу»1, автор этого типа обязательно должен гарантировать возмож- ность создания только единственного экземпляра. Существует несколько механизмов, Кот°РЫе в той или иной форме рассчитаны на применение ключевого слова static. И -2.1. Синглетон Майерса Май ^еуе *998] Скотт Майерс (Scott Meyers) описывает так называемый синглетон еРса. В целом, он выглядит следующим образом: 1 '---------------------------------- ^0^™°’ БьеРн Страуструп сказал примерно следующее: «С позволяет легко подстрелить свою ногу; ++ Это сделать сложнее, но когда это удается, вы отрываете всю ногу» [Stro-Web], Я полагаю, что Демонстрирует мое согласие с этим утверждением.
228 Часть 2. Выживание в условиях реального мира ' '— Листинг 11.5. class Thing { public: Thing &GetInstance() { static Thing s_instance; return s_instance; } private: Thing(Thing const &}; Thing boperator =(Thing const &) ; }; Здесь используется отложенное вычисление [lazy-evaluation; Meye 1996] для созда- ния по требованию единственного экземпляра с помощью метода Getlnstance(). Сокрытие конструктора копирования и оператора копирующего присваивания гаран- тирует невозможность создания других экземпляров. При этом возникает несколько проблем. Во-первых, здесь используются локальные статические объекты, которые мы подробно рассмотрим в разделе 11.3, и из-за этого возникает (при нынешних способах реализации этих объектов) условие гонки, когда программа выполняется в многопоточной среде. Во-вторых, ваши возможности управления продолжительностью жизни экземп- ляра ограничиваются всего лишь его созданием при первом его использовании и его уничтожением механизмом завершения процесса вместе со всеми другими статиче- скими объектами (локальными и нелокальными). Если кто-либо, например, деструк- тор другого статического объекта, вызывает метод Thing: .-GetInstance () после уничтожения экземпляра синглетона s_instance, он обратится к неинициализиро- ванному объекту, и произойдут нехорошие вещи. Это называется проблемой «мерт- вых» ссылок [Dead Reference problem; Alex 2001]. 11.2.2. Синглетон Александреску Изощренное решение проблемы мертвых ссылок обеспечивается синглетоном Александреску [Alex 2001], который подключается к механизмам очистки объектов- синглетонов и гарантирует, что они уничтожаются в последовательности, соответст- вующей относительной оценке их долговечности. Единственный экземпляр каждого типа создается в динамической памяти, и -п0 гически тип имеет приблизительно следующий вид: Листинг 11.6. class Thing ( public: Thing bGetlnstance()
Глава 11 Статические объекты 229 if(NULL == sm_instance) // Реальная операция будет потокозащищенной { sm_instance = new Thing(); Singletoninfrastructure::Register(sm_instance, . . } return *srn_instance; } private: static Thing *sn\_instance; b Уничтожение объекта Thing планируется в соответствии с предоставляемой програм- мистом оценкой его «долговечности». Хотя фактическая реализация достаточно сложна, с моей стороны было бы очень неучтиво избегать сложности, если она помогает справить- ся с дефектом, и я уверен, что вы, уважаемый читатель, первым бы это отметили. Применение этого метода фактически привело бы к наличию в вашей программе примерно следующего кода: class Thinglmpl { . . .}; class AnotherImpl { . .}; inline unsigned GetLongevity(Thinglmpl *) { return 3; } inline unsigned GetLongevity(AnotherImpl *) { return 1; } typedef SingletonHoldercThinglmpl, . . .> Thing; typedef SingletonHolder<AnotherImpl, ...» Another; Функции GetLongevity () обеспечивают значения относительной долговечно- сти, которые затем используются инфраструктурой для арбитража упорядоченности Уничтожения, гарантируя большую продолжительность жизни объекта Thing по Членению с объектом Another4. С этой реализацией связаны некоторые действительно интересные и информа- ™вные концепции, и я настоятельно рекомендую вам познакомиться с нею, но про- блема в моем случае заключается в том, что решение основано на предоставлении программистом оценочных значений всем глобальным объектам, находящимся в системе. Создается впечатление, что очень легко можно ошибиться, и эту ошибку ^НЦИПе трудно Диагностировать, т. к. нет способа, который позволял бы выяв- Тог °Шиб°чность оценивания в произвольной тестовой последовательности. Более ^П°Требу-тся очень большие усилия, если необходимо будет добавить новые Изо Синглет°ны. Мое решение, с которым мы вскоре познакомимся, гораздо менее ^^Ренное, но оно также требует меньшей предусмотрительности от программиста.1 “ОЗМОЖил ^*Мсцга Н°* ЭТ° Дает вам повод подумать об ограниченности моих способностей; по-видимому, здесь мои "^Ии будут излишни.
230 Часть 2. Выживание в условиях реального мира 11.2.3. Счетчики Шварца, обеспечивающие своевременную инициализацию: остроумное решение Существует действительно простое решение проблемы упорядочивания гпобаль ных объектов, известное под названием счетчика Шварца (Schwarz Counter) - также называемое «остроумным счетчиком» (Nifty Counter) - которое было описано Джери Шварцом (Jerry Schwarz) [Lipp 1998] в виде механизма обеспечения доступности статических объектов внутри библиотеки lOStreams до начала основных вычислений идо их использования в каких-либо других статических объектах1. Пример такого решения мы уже видели в разделе 6.4, где обеспечивалась своевременная установка нового обработчика. Данный метод заключается в определении инициализирующего класса и внедре- нии его экземпляра в каждую единицу компиляции, которая включает заголовок ини- циализируемого класса или программного интерфейса. В применении к классу Thing это будет выглядеть следующим образом: Листинг 11.7. class Thing < public: Thing bGetlnstance(); }; struct Thinglnit { Thinglnit() { Thing::GetInstance(); } }; static Thinglnit s_thinglnit; Теперь статический2 экземпляр s_thinglnit будет захватывать экземпляр Thing в каждой единице компиляции. Первый из них получает его, и все рассчитано на то, что это будет первый и последний раз, когда это нужно делать. Счетчик Шварна работает действительно хорошо для независимых статических объектов, которь,е просто обслуживают объекты других «клиентов» или основной программный код- Однако с этим подходом все же связаны две проблемы. 1 Этот метод является одним из самых «изобретаемых» в C++. Я в большей степени практик, чем те0',е^ке и мне также пришлось изобрести нечто подобное, и только впоследствии я понял, что более умный человек. опередил меня. бо.пее Как я говорил ранее (см. разделы 6.4 и 11.1.2), для обеспечения внутреннего связывания предпочтительны анонимные пространства имен. Я использую здесь ключевое слово static. т применяется в программном коде, показанном в следующем разделе. В любом случае, я не боюсь похожим на грубого старого торговца подделками.
Слава 11 • Статические объекты 231 Во-первых, если вы достаточно смелы и позволяете себе запускать потоки во время инициализации глобальных объектов, тогда можно получить условие гонки в методе Get instance (). В целом по разным причинам нежелательно запускать потоки до входа в main (), и не в последнюю очередь из-за возможности блокировки процесса, поскольку некоторые библиотеки программ этапа выполнения используют функции инициализации динамической библиотеки, которые внутренне сериализованы, для активации конструкторов глобальных объектов и обеспечения уведомлений подсоеди- нения потоков. Рекомендация: не создавайте потоки во время инициализация глобальных объектов. Вторая проблема связана с тем, что все же могут возникнуть трудности с обеспече- нием упорядоченности. Если порядок включения неодинаков в различных единицах компиляции, все же можно получить неправильную последовательность создания и уничтожения объектов. Пусть вы имеете два класса, А и В, в файлах headerA. h HheaderB.h, которые также содержат объекты инициализации initA и initB соответственно. Теперь у нас будет два файла реализации X. срр и Y. срр, имеющие следующий вид: // Х.срр # include <headerA.h> // привносит initA # include <headerB.h> // привносит initB // Y.cpp # include <headerB.h> // привносит initB # include <headerA.h> Il привносит initA Проектировщики Айв хорошо воспользовались опережающими объявлениями, и поэтому если А зависит от в, его заголовок просто объявляет class В, а не включа- ет headerB. h, и наоборот. Но вновь результат зависит от того, в какой последовательности производится ком- поновка. Если объекты в X. срр компонуются до объектов в Y. срр, мы получим сле- дующую последовательность инициализации: initA, initB, initB, initA, и в ре- ^льтаге синглетон А будет создан до создания синглетона В. Если эти файлы компо- цСЯ в обратном порядке, синглетоны тоже будут созданы в обратном порядке. практике подобная «хрупкость» упорядоченности хотя и редко, но все-таки ается> и поэтому такое решение нельзя назвать удачным. 11 *2.4. Подсчет ссылок в программных интерфейсах диницей более высокого уровня, чем функция, являет- собой набор функций или программный интерфейс, Некоторые библиотеки - это просто набор родствен- Ся б Языке С функциональной е, o6gc Лиотека, представляющая Печивающий доступ к ним.
232 Часть 2. Выживание в условиях реального мира — ных, но несвязанных функций. Хорошим примером такого набора функций могут слу жить строковые функции стандартной библиотеки С.1 Мы можем называть такие биб лиотеки не сохраняющими состояния (stateless libraries). Однако другие библиотеки поддерживают внутреннее состояние некоторых или всех своих функций. Они называются библиотеками, сохраняющими состояние (state ful libraries). В разделе 10.5 мы видели пример такой библиотеки - Tss. Сама библиотека может не сохранять состояние, но при этом она может использовать другие сохраняющие состояния библиотеки для реализации своей функциональности Поскольку она напрямую зависит от состояния базовых библиотек, мы в ходе нашего обсуждения будем рассматривать такую библиотеку как сохраняющую состояние. Сохраняющие состояния библиотеки обязательно должны инициализироваться до их использования и де-инициализироваться после того, как они стали не нужны. Эта инициализация может осуществляться в нескольких формах. Может потребоваться, чтобы функция инициализации вызывалась только один раз. Обычно это выполняется внутри процесса main (). В другом случае функция инициализации может игнориро- вать все, кроме первого вызова. В обоих случаях может потребоваться функция де- иницианилизации, но не всегда. Обе формы инициализации приводят к проблемам, когда они используются в при- ложениях, состоящих из нескольких единиц компоновки. Улучшенная форма позволя- ет делать многократные вызовы функции инициализации и соответствующее им ко- личество вызовов функции де-иницианилизации - это так называемые программные интерфейсы с подсчетом ссылок. Хотя это не часто обсуждается, все библиотеки общего назначения и большинство специальных библиотек должны обеспечивать многократную инициализацию и деини- циализацию для того, чтобы каждый компонент клиентского программного кода был максимально независимым в рамках более крупного компонента. Мало привлекательная альтернатива - полагаться на то, что автор приложений, в которых они находятся, обес- печит их инициализацию. Пока клиенты таких программных интерфейсов правильно выполняют их инициа- лизацию и де-инициализацию, и пока подсчитываются ссылки используемых ими про- граммных интерфейсов, все будет работать без сбоев. Например, функции инициализации и де-инициализации библиотеки Tss (см. раздел Ю-5) выглядят следующим образом (обработка ошибок пропущена):2 Листинг 11.8. int Tss_Init(void) { CoreSync_Init(); // Инициализировать программный интерфейс объекте- // синхронизации 1 С одним или двумя несущественными исключениями, например, strtok(). 2 Я не могу показать вам здесь плохо читаемое содержимое функции _sg_tsslib(), но она наход»,тсЯ компакт-диске, и вы можете посмотреть, как она выглядит, если вам необходимо поиздеваться над собои-
Глава ц_ Статические объекты 233 // Инициализировать программный интерфейс управления // памятью { Scope_CoreSync lock(sg_cs); _sg_tsslib(l); // Создать синглетон tss } return 0; } void Tss_Uninit(void) { { Scope_CoreSync lock(sg_cs); _sg_tsslib(-l); // Закрыть синглетон tss ) Mem_Uninit(); CoreSync_Uninit(); Таким образом, библиотека Tss, по-видимому, не может быть использована, когда не инициализированы библиотеки, от которых она зависит - CoreSync и Мет. Поэтому проблемы упорядочивания здесь не возникает. Данный подход обладает существенным побочным эффектом, который заключается в том, что любые циклические зависимости обнаруживаются немедленно, поскольку ини- циализация оказывается в бесконечном цикле. Хотя мы не любим бесконечные циклы, они очень полезны, если их возникновение детерминировано, как в данном случае. Если ваша функция инициализации возвращает управление, у вас нет циклических взаимозависимо- стей; в противном случае ваш программный код еще не готов д ля поставки. И последнее небольшое замечание. Некоторые библиотеки требуют, чтобы первый вызов функции инициализации был (вручную) сериализован, то есть пользователь биб- лиотеки должен гарантировать выполнение первого вызова из главного потока до того, как появится шанс сделать это у любых других потоков. Обычно это объясняется тем, что используемые для обеспечения потокозащищенного доступа элементы синхрониза- ции обычно сами создаются при первом вызове. Хотя в большинстве случаев это не вы- зывает трудностей, само собой разумеется, что библиотеки, которые могут обслуживать многопоточную инициализацию, более устойчивы и их легче использовать. И -2.5. Программные интерфейсы со счетчиками, оболочки, Р°кси и наконец упорядоченные синглетоны! То ^Ся ЭТа проблема преследовала меня в течение нескольких лет, пока я не понял: ’ что другими может восприниматься как шаг назад, в действительности представля- в °и (небольшой) шаг вперед. Я сознаю, что программисты С могут обвинить меня Ытке научить бабушку разбивать яйца, а программисты C++ - в том, что я предла- луЧиаНахронизмь,> но боюсь, что нужное мне решение именно в этом, и его мы уже по- Ли- Проще говоря, сохраняющая состояние многократно инициализируемая биб-
234 Часть 2. Выживание в условиях реального мира лиотека логически эквивалентна синглетону. Учитывая то, что мы можем - как это МЬ1 уже видели - обеспечивать корректную упорядоченность для таких библиотек, остает- ся сделать только небольшое усилие - и мы сможем представить упорядоченные синг- летоны как тонкие оболочки вокруг них. Достаточно только организовать небольшой счетчик Шварца и включить в общий программный код директиву #ifdef - и мы имеем то, что нам нужно. Если использовать в качестве примера библиотеку Tss, то этот синглетон будет иметь форму, представленную в листинге 11.9. Листинг 11.9. class Tss { public: TSS () { if(Tss_Init() < 0) { . . . // Выбросить соответствующее исключение } } -Tss() { Tss_Uninit(); } HTssKey CreateKeyf void (*pfnClose)() , void (*pfnClientConnect)() , void (*pfnClientDisconnect)() , Boolean bCloseOnAssign = false); . // другие функции программного интерфейса как методы // Tss private: . Скрыть конструктор копирования и оператор присваивания }; #ifndef ACMELIB_BUILDING_TSS_API static Tss tss; // Счетчик Шварца #endif /* ACMELIB_BUILDING_TSS_API ♦/ Во-первых, каждая единица компиляции, которая включает этот заголовок, получает счетчик Шварца, и поэтому каждая единица компиляции гарантированно имеет «сингле- тон» Tss. Во-вторых, упорядоченность «синглетонов» обеспечивается функциями init/ Unit программных интерфейсов - любая библиотека, которая сама зависит от библиотеки Tss, будет вызывать функции Tss_Init/Unit внутри своей собственной функции ини циализации; таким образом решается проблема «мертвых» ссылок. Наконец, счетчик Шварца исключается из единиц компиляций, в которых размешается сама библиотека Tss, поскольку мы не собирается устанавливать ее зависимость от самой себя.
Глава 11 • Статические объекты 235 Этот метод устраняет, по крайней мере в простых случаях, отложенное вычисление кз большинства методов, использующих синглетоны C++. Однако довольно просто от- делить инициализацию этого программного интерфейса от создания базового состоя- ния и по требованию создавать состояние в отложенном режиме. Поскольку это про- граммный интерфейс, а не экземпляр класса, и в нем производится подсчет ссылок, создание состояния можно отложить до нужного момента, но все же детерминировано уничтожить, когда счетчик ссылок программного интерфейса дойдет до 0. Здесь везде неявно подразумевается возможность восстановления состояния биб- лиотеки, если после де-инициализации она может быть повторно инициализирована. На практике я никогда не сталкивался ни с какими проблемами, связанными с возмож- ностью восстановления или самой ее реализацией, но справедливо будет упомянуть об этом, поскольку вы можете попасть в ситуацию, когда восстановление окажется не- возможным и/или нежелательным. Единственная связанная с данным подходом реальная неприятность заключается в том, что при применении синглетона приходится расплачиваться снижением эффек- тивности. Очень вероятно, что всегда один вызов функции будет приходится на один вызов метода синглетона, который не будет встроенным (исключая случаи, когда используется оптимизация в рамках всей программы). Однако поскольку синглетон обычно представляет собой нечто большое, предназначенное для больших и важных вешей, вероятно, потери не будут заметны. В любом случае корректность более важна, чем эффективность. Вам решать, что использовать: простые оболочки вокруг программного интерфейса библиотеки или метод переносимых виртуальных таблиц vtable для работы с истин- ными объектами C++; в обоих случаях вы работаете с синглетоном! 11.3. Функционально-локальные статические объекты В предыдущих двух разделах мы рассматривали нелокальные статические объек- ты. В данном разделе мы обсудим локальные статические объекты, область видимости которых ограничена функцией, например: Local &GetLocal() { static Local local; return local; Решающее отличие нелокальных от локальных статических объектов заключается т°м, что локальные статические объекты создаются по необходимости, то есть при "®рвом вызове функции. Последующие вызовы просто используют уже сконструиро- ный экземпляр. Естественно, должен существовать механизм, позволяющий реги- Р Ровать создание экземпляра, и поэтому при его реализации будет использоваться Идимый флажок инициализации.
236 Часть 2. Выживание в условиях реального мира Хотя применение флажков инициализации не оговорено в стандарте, реализация такого механизма очевидна. Раздел стандарта (С++-98:6.7; 4) устанавливает, что «объект инициализируется, когда логика управления впервые проходит через его объявление Если инициализация оканчивается выбрасыванием исключения, она не завершается и поэтому может быть повторена, когда следующий раз управление дойдет до объявле- ния объекта. Если управление повторно доходит до объявления (рекурсивно) во время инициализации объекта, режим работы не регламентируется». Из этого следует, что пре- дыдущая функция в действительности будет иметь следующий вид, если показать скры- тые операторы: Листинг 11.10. Local &GetLocal() { static bool ______bLocallnitialized___ = false; static byte ___localBytes__[sizeof(Local)]; if(!___bLocallnitialized__) { new(______localBytes__) Local(); ______bLocallnitialized___ = true; } return *reinterpret_cast<Local*>(___localBytes__); } Проблема в том, что в условиях многопоточности здесь может возникнуть условие гонки. Два или более потоков могут войти сюда и увидеть, что bLocallnitialized_имеет значение «ложь», и они одновременно будут пытаться построить объект local. Это может привести к утечке памяти или может вызвать крах процесса, но любой из этих результатов нежелателен. Можно наивно1 предположить, что здесь сможет помочь ключевое слово volatile в объявлении статического объекта. В конце концов, стандарт С утверждает (С99-5.1.2.3; 2), что «при обращении к объекту с ключевым словом volatile модификация объекта, модификация файла или вызов функции, которая выполняет любую из этих операций, вызываются побочными эффектами, которые зависят от условий выполнения программы. В определенных заданных точках последовательности выполнения, называемых точками следования (sequence points), влияние всех побочных эффектов предыдущих вычислении будут исчерпаны, и последующие вычисления больше не вызовут никаких побочных эффектов. (Краткий перечень точек следования приводится в приложении Б.) Стандарт C++ говорит в основном то же самое в (С++-98:1.9; 7). Увы, тот факт, что в стандартах ничего не говорится о поточной организации вычислений, означает, что при реализации нельзя полагаться на наши предположения- На практике применение ключевого слова volatile никак не гарантирует потокоза щищенность объекта. Таким образом, volatile по существу является механизмом» Здесь я имею в виду себя. О, до боли приятные воспоминания.
237 _ пиа11 статические объекты главе • ____________ ^читанным на использование специальных возможностей аппаратуры, хотя иногда это ключевое слово может быть полезно для других вещей (см. раздел 12.5). дефект: функционально-локальные статические экземпляры классов с нетриви- альными конструкторами не являются потокозащищенными. 11.3.1 - Принесение в жертву отложенных вычислений Один способ исключения этого риска заключается в использовании счетчика Шварца, чтобы гарантировать инициализацию всех локальных статических экземп- ляров до входа в режим многопоточной обработки (имея в виду недопустимость ини- циирования потоков в любых конструкторах глобальных объектов, о чем упоминалось в разделе 11.1). Такой подход эффективен, но он сводит на нет большинство свойств, обусловленных локальностью объекта. Более того, вполне возможна ситуация, при ко- торой некоторые функции, содержащие локальные статические объекты, будут рабо- тать ненадлежащим образом при слишком раннем вызове; мы можем снова столкнуть- ся с проблемами глобальных объектов. 11.3.2 . Помощь со стороны спин-мьютексов Условие гонки, присущее инициализации локальных по отношению к потоку объек- там, имеет очень важное значение, и поэтому не может быть проигнорировано. Однако оно все же возникает очень редко. Мы можем сыграть на этой особенности и предложить удивительно элегантное решение1, основанное на применении спин-мьютексов, которые в свою очередь зависят от неделимых операций, - и то, и другое подробно обсуждалось в гл. 10. Local &GetLocal() { static int guard; // Будет обнуляться во время загрузки spin_mutex smx(&guard); li Спин-мьютекс для "guard" lock_scope<spin_inutex> lock(smx); // Блокировка диапазона действия "smx" static Local local; return local; ) Все это работает, т. к. статическая переменная guard принимает нулевое значение на этапе обнуления значений во время инициализации процесса. Нестатический спин- Мь,°текс smx создается для guard и сам блокируется, когда задается в качестве пара- МетРа шаблона lock_scope при создании также нестатического экземпляра. Поэто- У единственный способ обеспечить проверку невидимого флажка инициализации кта local и самого этого объекта - использовать для охраны эффективный ме- спин-мьютекса. читателю: всякий раз, когда я называю решение удивительно элегантным, вы можете не сомневаться, ’ как я считаю, придумано мною.
238 Часть 2- Выживание в условиях реального мира Несомненно, все это связано с дополнительными затратами, но, как мы видели в разделе 10.3, для спин-мьютексов характерны очень незначительные затраты, если не считать случаи высокой конкуренции за доступ к охраняемой секции и/или когда охра- няемая секция имеет большой размер. Поскольку всегда, кроме первого случая, охраняе- мая секция будет состоять из одного сравнения (невидимого флажка инициализации) иодного перемещения (возврата адреса local), собственные затраты секции очень небольшие. И трудно представить какой-нибудь клиентский программный код, где не- сколько потоков будут конкурировать за синглетон Local с такой частотой, что доста- точно ощутимой окажется вероятность потери циклов спина. Поэтому это решение очень хорошо подходит для охраны локальных статических объектов от гонок. 11.4. Статические члены Было бы некорректно закончить главу, посвященную статическим объектам, не рас- смотрев статические члены, и поэтому давайте это сделаем сейчас. Часть рассматри- ваемых в данном разделе вопросов - новые, другие обсуждались в предыдущих главах. 11.4.1. Устранение зависимости от порядка компоновки Иногда вам приходится кодировать библиотечные классы или функции, которые никогда не будут меняться либо на протяжении всего периода существования процес- са, либо на протяжении всего сеанса текущего пользователя или сеанса системы, либо даже на протяжении всего существования системы. В таких случаях нежелательно вы- зывать эти компоненты каждый раз, когда требуется осуществить доступ к постоянной информации, особенно когда сама информация потенциально может быть достаточно дорогой. Хороший пример - программный интерфейс высокоточных счетчиков производи- тельности Win32, состоящий из двух функций: BOOL QueryPerformanceCounter(LARGE_INTEGER *); BOOL QueryPerformanceFrequency(LARGE_INTEGER *); Каждая функция принимает указатель, ссылающийся на 64-битовое целое число. Первая функция возвращает текущее значение системного высокоточного аппаратного счетчика производительности. Вторая возвращает частоту счетчика, которая не может меняться на протяжении всего времени жизни сеанса системы и на практике является постоянной величиной для процесса. Частота используется для преобразования машинного времени, возвращаемого Query Performancecounter (), в интервалы реального времени. Естественно, как последователь C++, я инстинктивно ощетинился против подобно го «голого» программного интерфейса, и поэтому при следующем его использовании был написан класс-оболочка*. Класс high_performance_counter поддерЖиваеТ два целых числа, которые представляют измеренный интервал (отмеченный вызовам*’
239 статические объекты Гт»» ‘________________ - Тфв start () и stop ()), и обеспечивает методы для представления интервала секундах, миллисекундах или микросекундах. Проблема в том, что вызов обеих в кцИй ЭТого программного интерфейса связан с большими затратами [Wils 2003а], ^поэтому благоразумно избегать необязательных повторных вызовов QueryPer- formanceFrequency () путем кэширования значения. Это классическая ситуация вменения статического члена. Однако поскольку одним из ведущих принципов биб- лиотеки STLSoft является стопроцентная реализация с помошью только заголовочных файлов1, это является трудной задачей. Это можно обеспечить путем применения функционально-локального статического экземпляра в статическом методе, как пока- зано в следующем примере: class high_performance_counter; private: static LARGE_INTEGER const &frequency() { static LARGE_INTEGER s_frequency; return s_frequency; } Но это только полдела; он все еще не инициализирован. Решение заключается в инициализации s_f requency путем вызова другого статического метода, получающего значение частоты, как показано в листинге 11.11. Листинг 11.11. class high_performance_counter; { static LARGE_INTEGER const query_frequency() { LARGE_INTEGER freq; if(!QueryPerformanceFrequency(&freq)) { freq = std::numeric_traits<sint64_t>:;шах(); °б°бщещТ^ 1'ласс (Robert L. Glass) [Gias 2003] приводит убедительный пример устранения преждевременного *4011. ,W П^тем отсРочки преобразования программного кода из специализированного в повторно подходит”ЫИ ВПЛ0ТЬ до того момента» когда он не потребуется второй раз, и я склонен согласиться с этим файлов СЛеД^ет честн° признаться, что у меня есть личное и, вероятно, догматическое предубеждение против Размыщл ализации> которые, по-моему, нужны только для определения статических членов. При спокойном ^^HHTbc ИИ У МеНЯ возникаютсомнения в рациональности такого подхода, но, по-вцдимому, я просто не могу •Ч но он „а Достоинствах нестесненной «неделимой» директивы #include. Рационален такой подход или МНого матеРиала для этой главы, который (я надеюсь) вполне подходит для темы статических
240 Часть 2. Выживание в условиях реального мира return freq; } static LARGE_INTEGER const &frequency() { static LARGE_INTEGER s_frequency = query_frequency (); Следует отметить, что если система не поддерживает высокоточный аппаратный счетчик и Query Performancecounter () возвращает FALSE, то значение уста- навливается на максимум, чтобы последующее деление интервала не привело к деле- нию на ноль, а просто вернуло 0. Я выбираю данный подход, поскольку эти классы из- мерения производительности предназначены для получения профилей программного кода, а не для обеспечения какой-то части функциональности приложения; вы, воз- можно, предпочтете выбросить исключение. Но решение пока еще не полное, поскольку мы используем функционально-локаль- ный статический объект, а такие объекты в целом подвержены гонкам. В данном случае самое плохое, что может случиться - это многократный вызов query_f requency () при чрезвычайно редких обстоятельствах. Поскольку всегда возвращается одно и то же значение, и нам нужно, чтобы сам класс Счетчика производительности работал с мини- мальными затратами, я предпочитаю рискнуть и выбираю очень редкую, но плодо- творную многократную инициализацию. 11.4.2. Адаптивный программный код Прежде чем мы завершим тему статических членов, мне бы просто хотелось пока- зать вам финальный хитрый трюк1, который позволяет писать классы, которые могут адаптировать собственное поведение к условиям, в которых они оказываются. С классом счетчика производительности, описанного в предыдущем разделе, связа- на одна проблема: если аппаратный счетчик недоступен, пользователь класса всегда будет считывать одинаковые бесполезные значения, равные нулю. На самом деле Win32 поддерживает другие функции синхронизации, одна из которых, GetTick- Counter (), имеется на всех платформах, но обладает значительно меньшей точно- стью [Wils 2003а]. Поэтому другой класс, performance_counter, пытается там, где возможно, обеспечивать высокоточные измерения, а низкоточные измерения вы- полнять в других случаях. Для этого избегают прямых вызовов QueryPerformanceCounter () в польз} статического метода measure (), определение которого имеет вид, показанный в лис тинге 11.12. 1 Я показываю все это не для того, чтобы поощрить самонадеянную, беспричинно-бунтарскую п031>^чее которой гордятся очень многие проектировщики программного обеспечения. Но я не верю также в то, что продуктивным является сокрытие от людей сложных и опасных вещей; посмотрите на Java! Я показываЮ^^ все это для того, чтобы вы были информированы, и потому что знание неправильного подх°ла оказывается столь же ценным, как и знание правильного подхода.
рлава 11 • Статические объекты 241 Листинг 11.12. class performance—counter { private: typedef void (*measure_fn_type)(epoch_type&); static void performance—counter::qpc(epoch—type &epoch) { QueryPerformanceCounter(bepoch); } static void performance_counter::gtc(epoch—type bepoch) { epoch = GetTickCount(); } static measure-fn_type get_measure_fn() { measure_fn_type fn; epoch-type freq; fn = QueryPerformanceFrequency(bfreq) ? qpc : gtc; return fn; ) static void measure(epoch-type bepoch) { static measure_fn_type fn = get_measure_fn(); fn(epoch); ) II Операции public: inline void performance—counter::start() { measure(m_start); ) inline void performance_counter::stop(); Методы frequency () и query-frequency () остались прежними за исключе- Нием того, что аварийное завершение QueryPerformanceFrequency () приводит к Установке частоты на значение 1000, чтобы учесть тот факт, что GetTickCounter () в°звращает значение в миллисекундах и дает правильные интервалы. И вновь условия гонок имеют «мягкую» форму, и поэтому они игнорируются для Ранения затрат максимально низкими для нормальных вызовов класса счетчика. 16-225
242 Часть 2. Выживание в условиях реального мира 11.5. Статические объекты: заключение В конце концов, проблемы статических объектов сводятся к упорядочиванию идентичности и синхронизации. Нетрудно представить, насколько язык оказался уязвим в этих трех областях. Упорядочивание в действительности вызывает трудности только в том случае, когда вы имеете несколько глобальных объектов, которые «увяз- ли» во взаимозависимостях: это не могло вызвать проблем в первое время, поскольку тогда их просто было совсем немного, лишь только cout и с in. Идентичность вызы- вает реальные трудности только в том случае, когда вы имеете несколько единиц ком- поновки в одном выполняемом модуле; динамическая компоновка, хотя и не совсем новая технология, но еще не настолько зрелая, чтобы иметь первостепенное значение в первоначальном проекте C++. Проблемы синхронизации возникают только в много- поточных сценариях; как и динамические библиотеки, многопоточность получила широкое распространение после выхода первоначальных проектов C++. Нельзя сказать, что удалось бы избежать всех этих проблем, если бы разработчики первоначальных проектов языка обладали достаточным предвидением и машиной вре- мени. В более поздних языках по-прежнему не удавалось убедительно разрешить эти проблемы: иногда это делалось путем запрета применения проблематичной конструк- ции, что требовало построения всех объектов на базе динамической памяти; иногда обеспечивая не намного лучшее (а часто худшее) обращение с ними по сравнению с C++. Разве является новый язык эффективным и одновременно потокозащищенным? Итак, вот мой банальный совет. Единственно правильное решение - это сознавать, что данные проблемы существуют, и использовать методы, позволяющие по возмож- ности их избегать или, по крайней мере, ослаблять. Большинство этих методов концеп- туально очень простые, даже если их реализация достаточно утомительна (например, синглетоны, построенные на базе программных интерфейсов). Прежде чем мы закончим главу, скажите, разве вам не доставляет некоторое удов- летворение то, что в большинстве случаев решение проблем разнообразного использо- вания статического объекта рассчитано на применение ключевого слова static. И потом, конечно, существует старый спин-мьютекс...
Глава 12 Оптимизация «Компиляторы что-то дают, а что-то отнимают; в конечном итоге мы просто перемалы- ваем биты в челюстях виртуальной машины в ожидании исполнения маленьких самородков нашей логики на всегда слишком медленных метапроцессорах» Джордж Фрейзер (George Frazier), уважаемый проектировщик программного обеспечения и по совместительству космонавт-стажер, 2003 В ел. 2 мы рассматривали разнообразные элементы функциональности, неявно добавляемые компилятором C++ в тех случаях, когда вы сами их не указываете, напри- мер, конструкторы, операторы копирования, операторы new и delete и тому подоб- ное. В данной главе я хочу посмотреть с другой стороны и обсудить некоторые другие элементы программного кода, устраняемые компилятором. Естественно, само по себе данное обсуждение оптимизации не является полным - для этого вам необходимо познакомиться с несколькими хорошими книгами, приводимыми в библиографии [Bulk 1999, Gerb2002, Meye 1998]. В них основное внимание уделяется вопросам опти- мизации, которые могут повлиять на свойства C++, а не только лишь на эффективность работы вашего исполняемого модуля. Следует отметить, что мы не рассматриваем оптимизацию библиотек - например, оптимизацию небольших строковых данных [Меуе 2001], изменяющихся автоматиче- ских буферов (см. гл. 32) или быструю конкатенацию (см. гл. 25) - здесь рассматрива- ется только оптимизация на уровне языка. 12.1. Встроенные функции В некотором смысле данный раздел не столько посвящен оптимизации, сколько применению связанного с оптимизацией ключевого слова для других целей. Ключевое СЛ0Во inline было введено для того, чтобы «помогать программисту» [Stro 1994] управлять действия компилятора, связанные с оптимизацией. Хотя многие современ- компиляторы уделяют столько же много внимания опциям командной строки, ко и спецификатору inline, он по прежнему играет свою роль в оптимизации. е*ду прочим, ключевое слово ini ine включено также в язык С в стандарте С99. многих компиляторах имеется также его расширение: _inline_для GCC, line для большинства других компиляторов платформы Win32.
244 Часть 2. Выживание в условиях реального мира 12.1.1. Остерегайтесь необдуманной оптимизации Нас часто предостерегали против необдуманного использования оптимизации [Stro 1997, Dewh 2003, Sutt 2002, Meye 1996, Meye 1998], и это предостережение отно- сится также к ключевому слову inline. Существует несколько причин нежелательно- сти необдуманного применения этого спецификатора. Во-первых, время, которое вы рас- ходуете на оптимизацию до выявления проблем производительности вашего приложе- ния, не используется вами на обеспечение устойчивости, гибкости, простоты сопровож- дения и всех других важных качеств, отличающих хорошее программирование. Во-вторых, если вы делаете встроенным, например, метод класса, то клиентский про- граммный код этого метода вы привязываете к реализации класса сильнее по сравнению с тем случаем, когда реализация этого метода содержалась бы в файле реализации, ко- торый невидим для клиентского программного кода. Всякие сделанные вами изменения метода потребуют повторения компиляции клиентского программного кода, а для сред- них и больших проектов это может оказаться дорогим удовольствием [Lako 1996]. Этим вы можете, кроме того, подвергнуть опасности свою популярность в команде. В-третьих, вы не можете устанавливать контрольные точки во встраиваемом про- граммном коде. Компиляторы и системы IDDE здесь помогают вам, запрещая встраи- вание при построении отладочных версий, и поэтому в большинстве случаев вам не приходится неожиданно сталкиваться с этой проблемой. Но не забывайте, что в этом случае вы будете отлаживать пути программного кода, которые не будут точно соот- ветствовать тому, что происходит в рабочих версиях. Наконец, бесконтрольное использование ключевого слова ini ine может в дейст- вительности увеличить размер вашего программного кода, поскольку компилятор может - не забывайте, что inline носит рекомендательный характер - вставить тело функции в каждое место вызова. Программный код большого размера, привязанный к современной конвейерной архитектуре, при которой инструкции кэшируются блока- ми, может в действительности стать причиной снижения скорости. Лучше всего прислушиваться к советам гуру, составить профиль работы вашего программного кода и сделать соответствующие настройки. 12.1.2. Библиотеки, составленные только из заголовочных файлов Несмотря на сделанные нами пояснения относительно использования ключевого слова inline как средства оптимизации, оно имеет также другое применение. Возмо* но, это не входило в первоначальные намерения его включения в язык, но нам не след)еТ это игнорировать. При написании библиотек гораздо проще распространять и использовать их, есл1 удается представить их в виде одних лишь заголовочных файлов. Даже когда функи”я’ определенная как inline, не может быть встроенной, это ключевое слово обязыв компилятор/компоновщик обрабатывать любые копии (стандарт С++-98: 3.2; 5),не давая сообщения об ошибке, и это, как правило, означает существование в ра* единицы компоновки единственного определения функции.
[лава12°птимизация 245 Поэтому, применяя inline, вы освобождаетесь от необходимости определения единственной версии функции и ее представления в файле реализации. Это очень выгодно при написании библиотек. Более того, поступая так, вы попадете в хорошую компанию, поскольку такой подход применяется также повсюду в стандартной библио- теке Конечно, большая часть стандартной библиотеки представляет собой программ- ный код шаблонов, который должен быть встраиваемым и, кроме того, ожидается, что он хорошо работает, и поэтому это не единственная причина его «встраиваемости». Но все же этот фактор имеет значение, причем существенное. Следует отметить, что при использовании шаблонных функций вам нет необходи- мости применять ключевое слово inline, чтобы гарантировать слияние дубликатов, если только вы не собираетесь сделать ваш программный код переносимым на очень старые компиляторы (например, Visual C++ 4.2). 12.2. Оптимизация возвращаемых значений Оптимизация возвращаемых значений (Return Value Optimization - RVO) широко освящается в литературе [Dewh 2003, Meyel 996], и поэтому я не собираюсь подробно останавливаться на этом вопросе. В основном, если возвращающая значение функция вызывает конструктор возвращаемого типа, компилятор способен сэкономить на во- ображаемом промежуточном экземпляре и непосредственно сконструировать экземп- ляр, который получит возвращаемое значение в клиентском программном коде. В сле- дующем примере не создается промежуточная внутренняя переменная функции Сге- ateString (), а непосредственно осуществляется конструирование в памяти, зани- маемой переменной si: String CreateString(char const *s) { return String((NULL == s) ? "" : s); String si = CreateString("Initialization via Assignment syntax"); До тех пор пока вы будете явно использовать конструктор в программном коде, вы можете вполне рассчитывать на его оптимизацию любым современным компиля- ТоР°м- Результаты компилирования следующего клиентского программного кода не- сколькими современными компиляторами можно увидеть в табл. 12.1, которая показы- вает создаваемое ими количество объектов для каждого оператора. 1 CreateString("1"); 2 String s2 = CreateString("2"); 3 String s3(CreateString("3")); Есл^ДНаК° ДЛЯ некотоРых реализаций характерны два странных маленьких нюанса. ВтаК Вы пользуетесь синтаксисом оператора присваивания, который представлен примером 2, то конструктор копирования должен находиться в контексте вызо- если он не используется. Стандарт (С++-98: 3.2; 2) говорит, что «конструктор
246 Часть 2. Выживание в условиях реального МИра копирования [принимается во внимание], даже если он не создается при реализации» Итак, если ваш класс содержит недоступный конструктор копирования (см. раздел 2.2.3), или этот конструктор не может быть сгенерирован компилятором (см. раздел 2.2.1), вы не можете использовать оптимизацию RVO. Ну, все-таки это только теория. Если в конструкторе копирования String исполь- зуется спецификатор explicit, то лишь компиляторы CodeWarrior, Comeau, GCC и Visual C++ (7.1) обеспечат его реализацию; другие компиляторы его исключат в ре. зультате оптимизации. Если он объявляется как закрытый, все компиляторы (за ис- ключением Digital Mars) его реализуют. В обоих случаях применение оптимизации в отсутствие доступного конструктора копирования противоречит стандарту, и на это не следует полагаться. Если в своем программном коде вы используете синтаксис с вызовом функции, вам также необходимо иметь доступный конструктор копирования, даже если он опять не используется. String s2(CreateStringl"Init via function call syntax")); В этом случае компиляторы также могут действовать не по правилам. При исполь- зовании оператора присваивания можно наблюдать такой же эффект при объявлении конструктора копирования с ключевым словом private или explicit, который характерен для оператора присваивания. Но странно то, что в этом случае способность оптимизировать некоторыми компиляторами становится меньше, чем при использова- нии синтаксиса оператора присваивания. Из табл. 12.1 видно, что компиляторы Bor- land 5.6, Comeau 4.3.0.1 и Intel 7.0 не могут обеспечить оптимизацию RVO в случае синтаксиса вызова функции в то время, как все рассматриваемые компиляторы приме- няли RVO в случае синтаксиса оператора присваивания. Я могу только высказать пред- положение, что при их тестировании основное внимание уделялось синтаксису опера- тора присваивания. Таблица 12.1. Количество экземпляров, создаваемое в каждом случае Компилятор Пример 1 Пример 2 Пример 3 Borland (5.6) 1 1 2 CodeWarrior 8 1 1 1 Comeau 4.3.0.1 1 1 2 Digital Mars 8.38 1 1 1 GCC 3.2 1 1 1 Intel 7.0 1 i 2 Visual C++ 7.1 1 1 1 Watcom 12.0 1 1 ।
Глава 12-0птимизация 247 12.2.1 - Оптимизация именованного возвращаемого значения Оптимизация именованного возвращаемого значения (Named Return Value Optimi- "atfon - NRVO) представляет собой слегка модифицированную оптимизацию RVO, вторая почти столь же широко поддерживается, и понимание которой также не вы- зывает трудностей. Иногда перед возвратом переменной нам может потребоваться ее обработка, которая выходит за пределы разумного интерфейса класса. Стандартным примером является реализация дополнительных операторов. Последнее, что нам может потребоваться, - это обеспечение в нашем классе конструктора, выполняюще- го конкатенацию, как в следующем примере: String operator +(String const &lhs, String const &rhs) ( return String(Ihs, rhs); } Возможно, мы могли бы смириться с этим1 для строк, поскольку для них не сущест- вует другой бинарной операции. Но что будет, если эту стратегию мы применим для чи- словых типов? Что тогда делать с вычитанием, умножением, делением? Только не пред- лагайте ввести третий параметр в конструктор, определяющий тип операции, иначе я скажу издателю оставить пустой вторую половину книги и взять с вас двойную цену! Так или иначе, каноническая реализация конкатенации строк представляется в виде свободной функции, реализованной с помощью операции += над копией первого аргумента: String operator +(String const &lhs, String const &rhs) ( String result(Ihs); result += rhs; return result; } Но поскольку мы не возвращаем уже сконструированный экземпляр данного типа, мы теряем возможность оптимизации RVO. К счастью, этот случай подходит для опти- мизации NRVO. Она в целом означает осуществление оптимизации RVO для имено- ваиных экземпляров. Если компилятор сможет сделать вывод, что при возврате значе- ний все возможные пути программного кода ссылаются на одну и ту же переменную, может применить конструирование и затем манипулировать поименованным воз- вРащаемым значением перед его возвратом. Как и для оптимизации RVO, различные компиляторы по-разному обеспечивают под- ку оптимизации NRVO. Если мы воспользуемся клиентским программным кодом МыПРИмеРа оптимизации RVO и всего лишь немного изменим CreateString(), м°жем протестировать наши компиляторы на предмет оптимизации NRVO. МогУ смириться с этим. Этот подход бессмыслен, опасен и просто совершенно неверен.
248 Часть 2. Выживание в условиях реального мира String CreateString(char const * si, char const * s2) { String result(sl); result += s2; return result; } Результаты довольно интересны. Табл. 12.2 показывает количество объектов, скон- струированных в каждом из трех случаев. Таблица 12.2. Количество экземпляров, создаваемое в каждом случае Компилятор Пример 1 Пример 2 Пример 3 Borland (5.6) 2 2 3 ~ CodeWarrior 8 2 2 2 Comeau 4.3.0.1 1 1 2 Digital Mars 8.38 1 1 1 GCC 3.2 1 1 1 Intel 7.0 2 2 2 Visual C++7.1 2 2 2 Watcom 12.0 1 1 1 Конечно, при выполнении оптимизации обоих типов мы можем искажать результа- ты, помещая оператор printf () в программный код для его трассировки. Однако даже если это так, нас это не волнует по двум причинам. Во-первых, устранение программного кода в результате этой оптимизации не обязано осуществляться без побочных эффектов. В реальных условиях в большинстве случаев программный код, который мы хотим оптимизировать подобным образом, будет характеризоваться такими ненулевыми затратами процессорного времени. В этом все дело. В противном случае оптимизация была бы не очень-то полезна, не так ли? Во-вторых, несколько компиляторов действительно применяют оптимизацию во всех тестируемых нами случаях, но слухи об ошибочных установках не наполнили мир разработок C++- Несмотря на проблемы законности, семантика вашего программного обеспечения может трактоваться по-разному, а не только его эффективность может быть различной. Если вы разрабатываете программное обеспечение с применением компилятора- который успешно применяет эти оптимизации в любых случаях, а ваше приложение использует какой-нибудь механизм трассировки экземпляров для того, чтобы убедить ся в жизнеспособности процесса, при переходе на другой компилятор вам, возможно, придется испытать несколько неприятных моментов, пока вы не поймете в чем Дел0-
Глава 12. Оптимизация 249 12.3. Оптимизация пустой базы Оптимизация пустой базы (Empty Base Optimisation - ЕВО) - это оптимизация, при второй компиляторы могут позволить классу, полученному на основе пустого базового класса, например, class EmptyBase {}; - не выделять дополнительного простран- ства под базовый класс, что логично, поскольку он пустой. Другими словами, как произ- водная, так и базовая часть данного экземпляра имеют один и тот же адрес. Это может быть полезно, когда базовые классы используются для обеспечения типов-членов, как это делается в шаблонных классах стандартной библиотеки unary_function Mbinary_function2, или также при обеспечении повторного использования реали- зации, построенной на основе применения классов стратегий. Каноническая форма оптимизации ЕВО представлена ниже: class EmptyBase О; class Child : EmptyBase О; II Дочерний класс будет иметь размер, совпадающий с размером любого II пустого класса, например, EmptyBase STATIC_ASSERT(sizeof(Child) == sizeof(EmptyBase)); // форма 1 Но эта форма (которую мы назовем ЕВО-1) не отражает все ситуации возможного применения данной оптимизации. Я могу представить семь форм. Четыре из них, связанные с единичным наследованием, показаны на рис. 12.1, где кружки обозначают пустые классы, а квадраты представляют классы с членами. Буква Р обозначает роди- тельский класс, Д - дочерний класс, а В - внучатый класс. Вторая форма (ЕВО-2) идентична первой, но здесь дочерний класс имеет один или несколько переменных-членов, и поэтому сам он имеет ненулевой размер. Третья (ЕВО- 3) и четвертая (ЕВО-4) формы определяют возможность распространения оптимизации на дочерний класс оптимизированного производного класса. Честно говоря, трудно представить, что какая-нибудь реализация не позволяла бы распространять оптимиза- цию вниз по иерархии наследования, но мы постоянно сталкиваемся с сюрпризами, имея Дело с режимами работы, зависимыми от реализации. Последние три формы, показанные на рис. 12.2, относятся к оптимизации в услови- ях множественного наследования. Они играют важную роль, поскольку многие совре- менные методы построения шаблонов рассчитывают на способность наследования какого-нибудь нетривиального первичного типа и также наследования дополнитель- на «классов-примесей» (mixin3), задающих просто тип или стратегию.
250 Часть 2. Выживание в условиях реального мира Рис. 12.2. Формы оптимизации ЕВО в условиях множественного наследования Форма пять (ЕВО-5) представляет ситуацию, когда пустой дочерний класс строится на базе двух пустых базовых классов. Форма шесть (ЕВО-6) аналогична пятой, только дочерний класс не является пустым. Форма семь (ЕВО-7) отражает получение дочернего класса от одного пустого базового класса и одного непустого класса; это сильно напоми- нает ситуацию с реализациями некоторых контейнеров, которые являются наследниками (закрытыми или защищенными) класса реализации и также дополнительного пустого класса-примеси, выделяющего память. Табл. 12.3 показывает, как поддерживается оптимизация компиляторами плат формы Win32. Из результатов видно, что большинство компиляторов поддерживаЮТ все формы, которые полезно знать. Однако всеми компиляторами полностью под держиваются только упрощенные формы ЕВО-1 и ЕВО-3. От этого мало пользы, п° скольку очевидно, что большинство классов, для которых нам бы хотелось воспользо ваться преимуществами оптимизации ЕВО, будут иметь ненулевой размер.
Глава 12. Оптимизация 251 "^gorland 5.6 - это единственный компилятор, у которого имеются проблемы с формами единичного наследования, и надлежащий режим его работы можно задать с помошью флажков -Ve и -VI, которые по умолчанию не устанавливаются; они ого- варивают применение, соответственно, базовых классов нулевой длины и «старое раз- мещение классов Borland». Я не уверен в полезности их использования в промышлен- ном программном продукте; остается надеяться, что следующая версия Borland дос- тигнет уровня остальных компиляторов. Во всяком случае, я считаю, что хватит уже ругать компилятор Borland. Единствен- ная форма, представляющая какие-либо проблемы для других компиляторов, - это случай с двумя пустыми базовыми классами, производным от которых является непус- той класс. Хотя это не самая распространенная схема наследования, все увеличи- вающееся применение классов стратегий при параметризации шаблонов совместно с наследованием говорит о реальной возможности такой схемы наследования. Таблица 12.3. Поддержка форм оптимизации ЕВО. Знак # указывает на наличие поддержки Компилятор EBO-1 EBO-2 EBO-3 EBO-4 EBO-5 EBO-6 EBO-7 Borland (5.6) # -Ve-Vl # -Ve-Vl -Ve-Vl CodeWarrior # # # # # # CodePlay # # # # # # # Comeau # # # # # # # Digital Mars # # # # # # # GCC # # # # # # # Intel # # # # # # Visual C++ # # # # # # Watcom # # # # # # Дефект: поддержка форм оптимизации ЕВО обеспечивается не всегда и сильно зависит от компилятора. Конечно, это не есть дефект самого языка C++, поскольку это является результатом отличий в реализациях необязательной оптимизации. Но это влияет на производитель- н°сть вашего программного кода, иногда достаточно сильно (см. раздел 32.2.5). Итак, же нам делать с этим? В определенном смысле в нашем распоряжении имеется очень мало вариантов. Очевидно, важной мерой является правильный выбор вами ком- во Т°РЭ большинство современных компиляторов поддерживают оптимизацию Чт^ВСеХ 11011 почти во всех ее формах. Что касается Borland, то возможно, к моменту л* н* вами этого фрагмента текста они выпустят свой новый компилятор (в составе н°вого продукта C++BuilderX), и в нем будет реализована эта оптимизация.
252 Часть 2. Выживание в условиях реального мира Во-вторых, если вы должны обеспечить широкую совместимость компиляци постарайтесь использовать методы, которые не будут полагаться на 0птимизацИ{0 ЕВО, и научитесь обходиться компилятором, который не поддерживает ее. Существует не совсем удачный трюк, который вы можете использовать, когда вам требуется применять ЕВО для распределителей памяти стандартной библиотеки; ег0 мы рассмотрим в разделе 32.2.5. 12.4. Оптимизация пустых производных классов Итак, ЕВО имеет дело с пустыми базовыми классами. А как насчет пустых дочерних классов? Рассмотрим следующий пример: class X { int i; }; template ctypename T> class Y : T {}; std::cout « sizeof(Y<X>); // Каков размер Y<X>? Поскольку класс Y теперь не вводит в дополнение к параметризованному типу ни- каких новых переменных-членов и никаких виртуальных членов, в идеальном случае мы может рассчитывать на то, что для его размещения не потребуется дополнительной памяти. Поэтому sizeof (Y<X>) должно равняться sizeof (X). Мы могли бы назвать это оптимизацией пустых производных классов {Empty Derived Optimization - EDOJ по аналогии с ее более известной старшей «сестрой». Итак, остается ответить на вопрос о том, как справляются с EDO наши компиляторы. Как и в случае ЕВО, существует несколько ситуаций, при которых может появиться пустой дочерний класс, как показано на рис. 12.3 и 12.4. Вновь буковой Р обозначается ро- дительский класс, а Д - дочерний. Однако обратите внимание на то, что осуществляется также подстановка шаблонных дочерних классов, что отмечено угловыми скобками. Рис. 12.3. Формы оптимизации EDO в условиях единичного наследования
253 Глава 12-Оптимизация —^почему мы должны об этом беспокоиться? Ну, как мы увидим в нескольких при- содержащихся в части 4, применение шаблонных классов, производных М своих параметризованных типов, служит основой некоторых мощных методов. и и использовании таких методов никогда не хочется занимать лишнее пространство, и если размер занимаемого пространства случайно оказывается другим, это может сде- лать метод непригодным (см. гл. 21). Итак, как наши компиляторы справляются с этой оптимизацией? Табл. 12.4 показы- вает, что они делают это немного лучше. Фактически, только Borland имеет некоторые проблемы с данной оптимизацией1. Он не поддерживает формы 5-8. Однако при одно- временном использовании флажков -Ve и -VI он все же поддерживает формы 7 и 8. Таблица 12.4. Компилятор EDO-1 EDO-2 EDO-3 EDO-4 EDO-5 EDO-6 EDO-7 EDO-8 Borland (5.6) # # # # -Ve-VI -Ve -VI CodeWarrior # # # # # # # Comeau # # # # # # # Digital Mars # # # # # # # # GCC # # # # # # # Intel # # # # # # # # Visual C++ # # # # # # # # Watcom # # # # # # # # Рнс-12.4. Формы оптимизации EDO в условиях множественного наследования Таким образом, еще раз отметим, что если вы хотите эффективно пользоваться *Им средством, то вам следует подобрать себе компилятор 'к--------------------------------- ®огЬ ^°Менту прочтения вами этого фрагмента может оказаться доступным новый компилятор C++ фирмы П(* Для системы C++BuilderX, и мы можем надеяться, что он пойдет в ногу с остальными.
254 Часть 2. Выживание в условиях реального 12.5. Предотвращение оптимизации Будучи программистами мы столько времени и усилий тратим на осуществление опти мизации, что кажется странным желание ее не допустить. Зачем нам это нужно делать? Существует две причины. Во-первых, установленные для построения вашей смете мы глобальные оптимизационные настройки могут не подходить для конкретной еди- ницы компиляции или даже для конкретного блока программного кода в какой-нибудь единице компиляции. Хороший пример - это когда в целом вы оптимизируете занимае- мое пространство, но вам требуется оптимизировать быстродействие конкретного блока программного кода. Другая причина отключения оптимизации заключается в том, что при повышении эффективности вашего программного кода вам иногда приходится отключать какие-то возможности оптимизации для того, чтобы можно было реально измерить эффект опти- мизации. Конечно, это звучит странно, но некоторые современные компиляторы на- столько хорошо оптимизируют, что они могут свести на нет вашу работу по увеличению эффективности вашего программного кода. При использовании другого режима оптимизации для всей единицы компиляции вы можете с легкостью по-другому настроить ваш компилятор в make-файлах и в файлах проектов. Однако когда особые настройки требуются на уровне меньшем, чем единица компиляции, вам придется прибегнуть к специальным средствам опти- мизации компилятора. Например, следующий программный код не позволяет опти- мизировать быстродействие функции slow() при применении компиляторов Intel и Visual C++, независимо от оптимизационных настроек, определенных для едини- цы компиляции. // functions.срр # pragma optimize("gt", off) void slow() { forfint i = 0; i < std::numeric_limits<int>::max(); ++i) {} } # pragma optimize C", on) Как это ни странно, некоторые компиляторы настолько хорошо оптимизируют, что становятся в некотором смысле слишком хорошими. Программа, подобная представ- ленной ниже, в результате оптимизации некоторыми компиляторами не будет выпол нять вообще никаких действий. int main() { for(int i = 0; i < std::numeric_limits<int>::max(); ++i) {} return 0;
Глава 12. Оптимизация 255 Ничего особо удивительного в этом нет, если не брать в расчет тот факт, что ряд компиляторов не станут в действительности ее оптимизировать. Конечно, на практике невероятно, что вы будете заинтересованы в получении профиля работы такого детого цикла. Однако, возможно, вы попытаетесь измерить производительность некоторых встраиваемых функций и определить разницу между вашими функциями и эквивалентными, которые по существу не реализуются, как, например, функции в листинге 12.1. Листинг 12.1. template <typename Т> inline Т const &funcl(T const &t) { . // Реальная обработка t } template <typename T> inline T const &func2(T const &t) { return t; // Функция-заглушка. Просто возвращает t } int main() { performance_counter counter; counter.start(); for(int i = 0; . . .) { fund (i) ; } counter.stop(); cout « "fund: " « counter.get_millisecond() « endl; counter.start(); for(int i = 0; . . . ) ( func2(i); } counter.stop(); cout « "fund: " « counter.get_millisecond() « endl; Если вы выполняете циклы для сравнения производительности этих двух функций, и л РЫе компилят°ры могут сделать вывод, что вторая функция ничего не делает, Cod \ ИЧески Удалят весь цикл с функцией f unc2 (). Это могут сделать компиляторы Warrior 8 и Visual C++ 7.1. об к Мы Уже видели, спецификатор volatile бесполезен при многопоточной Вцч ке’ ^скольку стандарт вообще ничего не говорит о поточной организации ений, и реализацией определяется, в какой мере ключевое слово volatile
256 Часть 2. Выживание в условиях реального мира учитывается при многопоточности. Однако ключевое слово volatile может быть полезно, когда требуется предотвратить нежелательную оптимизацию. На самом деле стандарт (С++-98: 7.1.5.1; 8) говорит, что «volatile является подсказкой, позво- ляющей при реализации избежать агрессивной оптимизации соответствующего объек- та, т. к. значение объекта может меняться неизвестным для реализации способом». Тогда где нам его применять? Ну, вы могли бы подумать, что можно изменить опре- деление func2 О, передавая ей тип Т и возвращая Т const volatile &. Это дей- ствительно сработает для CodeWarrior 8, но Visual C++ 7.1 настолько современен, что его не так-то просто обмануть. Решение заключается в применении этого ключевого слова к индексу цикла i; в этом случае все проверенные мною компиляторы старались уважительно отнестись к такому программному коду. for(int volatile i = 0; - . .) {} Несмотря на это, я считаю, что здесь мы попадаем на территорию, существенно за- висимую от реализации. Если бы реализация не стала учитывать указанные в стандарте свойства volatile, я полагаю, это вызвало бы удивление, но такое всегда возможно. В современной операционной системе, которая использует виртуальную память, един- ственная возможность модификации переменной с ключевым словом volatile, об- ласть видимости которой столь сильно ограничена, как область видимости наших ин- дексов циклов, может быть связана с каким-то невероятно хитрым вмешательством программного кода другого процесса. В принципе, конечно, может случиться, что какая-нибудь реализация станет оптимизировать переменную с ключевым словом volatile, когда она будет совершенно уверена в допустимости этого: в конце концов данное ключевое слово всего лишь подсказка. Если вы параноик, то для вас имеется очень надежный способ, состоящий в вызове внешней системной функции из вашего цикла. Поскольку компилятор в принципе не может знать о внутреннем содержимом системной функции на этапе компиляции/ком- поновки, он не сможет ее оптимизировать. Можно просто вызвать функцию time () • Недостаток этого подхода в том, что вы не можете быть уверены в постоянстве допол- нительных затрат, связанных с такими вызовами, и поэтому вы можете исказить по- лученные результаты. Обойти это можно путем применения локального статического объекта, как показано в листинге 18.2, и поэтому системный вызов будет выполняться лишь один раз, но ваш компилятор все же не сможет его устранить путем оптимизации. Листинг 12.2. time_t inhibit_optimization() { static time_t t = time(NULL); return t; } int main))
Глава 12- Оптимизация 257 { int ret; performance_;-ounter counter; counter. starr. () ; for(int i = C; . . .) { ret static_cast<int>(inhibit_optiinization()); } return ret; } Так или иначе, я уверен, что идея вам понятна. С помощью ключевого слова volatile, по-видимому, можно подавить оптимизацию, но если вы хотите действительно быть уверены в этом, вам необходимо заставить компилятор думать, что некий объект изме- няется, даже если вы знаете, что это не так. -225
Часть 3 Языковые проблемы В новом альманахе для авторов, посвященном принципам хорошего сочинительства, рекомендуется, чтобы первые разделы книги представляли собой легкое введение ко всей теме, раскрывающее основные концепции и закладывающее прочную основу для изложе- ния последующего материала. Они должны предоставить читателю возможность почувст- вовать стиль письма автора и образ его мышления и обеспечить удобство восприятия ма- териала. Последующие главы должны строиться на этом, мягко подводя читателя к посте- пенно возрастающей сложности материала. Ну, и я в основном следую этим принципам. В материале шести глав этой части рас- крываются несколько фундаментальных вопросов, и хотя они более сложны, чем про- блемы, обсуждаемые в предыдущих главах, сложность материала не столь высока, чтобы отпугнуть читателя1. Тем не менее, я не обещаю, что вы будете чувствовать себя достаточно комфортно: некоторые из описанных в данной части дефектов лучше всего воспринимаются лежа с плиткой шоколада. Велогонка начинается участком со множеством коротких и крутых спусков и подъ- емов. Ничего не остается, как улыбнуться и двигаться вперед, не отставая от товари- щей по команде и убеждая себя, что боль в ногах необходима, чтобы их хорошо разо- греть на всю остальную часть гонки. Многие из описанных в данной части дефектов являются «недостатками в малом». Другими словами, они проявляются лишь в отдельных случаях и относительно несу- щественны по своей сути и по сложности связанных с ними проблем, а поэтому реше- ния достаточно простые и не требуют серьезных усилий. Но почти все они выделяют важные вопросы, к которым мы возвратимся позже в данной книге, когда перейдем к некоторым «недостаткам в большом» в частях 4 и 5 и рассмотрим расширения C++ в части 6. Некоторых проблем мы будем касаться постоянно. Мы обсудим достоинства схем размещения в памяти выражений, недостатки типа bool и обработки булевых значе- ний, проблемы работы с литералами и проблемы использования режима работы, «определяемого реализацией». Я полагаю, что не хватает некоторых ключевых слов, нарушается (в лучшем случае) область видимости нового оператора for, и рекомен дую применение в новом качестве символа NULL. Я даже имею смелость предложить Эту возможность я сохраняю для части 4.
259 ^3, языковые проблемы я с++ совершенно новый спецификатор cv! Я надеюсь, вы достаточно терпеливо ^несетесь к рассмотрению нами вопросов, которые могут вызвать у вас удивление или стать причиной повышения вашего кровяного давления; во всяком случае чтение книги под названием «C++: практический подход к решению проблем программирования» должно означать вашу готовность встретиться с некоторыми не- ожиданностями, разве не так? В этой части шесть глав: глава 13, «Фундаментальные типы»: глава 14, «Массивы и указатели»; глава 15, «Значения»; глава 16, «Ключевые слова»; глава 17, «Синтаксис» и глава 18, «Имена, вводимые typedef». Я надеюсь, вы узнаете несколько новых идей, иногда не бесспорных, и по новому оцените тонкости, изъяны и достоинства базовых принципов C++. Во всех случаях речь идет о том, как лучше создавать программный код - корректный, эффективный и устойчивый с точки зрения сопровождения. Когда вы пройдете через это, я полагаю, вам захочется увидеть, как отмеченные здесь недостатки и решения повлияют на остальную часть нашего путешествия в темные уголки C++.
Глава 13 Фундаментальные типы Концептуально тип определяется набором значений и операциями, которые можно выполнять над этими значениями. Такое понятие типа широко распространено и пред- ставляет собой наименее строгое определение. Однако на практике корректность работы с типами зависит не только от баланса между их пригодностью (к выполнению своего назначения) и экспрессивностью, но также от их безопасности и даже от такого «человеческого» фактора, как их имя. Важно, что тип обеспечивает вас достаточно мощными и гибкими средствами для ясного и эффективного представления и манипулирования необходимым вам поняти- ем, но в равной степени важно, что область действия операций надежно и предсказуе- мо ограничена. Фундаментальные типы (см. «Введение»), которые обеспечивает C++, пришли из С [Кет 1988]. Если не брать в расчет тип bool, который был добавлен в C++ (стандарт С++-98) и в С (согласно стандарту С99), этот список остался в основном без изменений с самых ранних дней существования этого языка [Stro 1994]. И поэтому существует несколько тонкостей, связанных с применением этих типов. Более того, этот список немно- го устарел, и существует ряд типов, которые могут быть с успехом добавлены в него. В данной главе рассматриваются некоторые новые типы, которые могут представ- лять собой полезные усовершенствования языка, и способы их реализации при огра- ниченных возможностях текущей версии языка. Мы также рассмотрим несколько про- блем, с которыми можно встретиться при использовании фундаментальных типов, включая bool, с которым часто возникает больше трудностей, чем он этого заслужи- вает (что мы обсуждаем в разделах 13.4.2 и 15.3). 13.1. Могу ли я получить байт? Компьютеры используют байт как базовую единицу хранения данных в памяти • и поэтому представляется естественным иметь тип, который позволяет осуществлять доступ к байтам и манипулировать ими. В большинстве случаев в C++, как и вС мь1 заинтересованы работать с конкретными типами, например, char const*, Perso 1 Современные архитектуры позволяют обращаться к отдельным байтам (хотя и с определенна1^ оговорками; см. раздел 10.1), но в некоторых архитектурах для адресации различных типов использ) указатели разного размера (например, 16-битовые указатели для адресации 16-битовых слов и 24-о< указатели для байтов).
Глава 13. Фундаментальные типы 261 т д. Однако возникают ситуации, когда нам необходимо манипулировать целыми блоками памяти, например при сжатии данных. В таких случаях мы работаем с груп- пами байтов, когда содержимое отдельных байтов не существенно. Дефект: вСи C++ отсутствует тип byte. К сожалению, тип byte отсутствует в C/C++, и поэтому в таких случаях, как правило, используется тип char. Это имеет смысл, поскольку размер char всегда равен одному байту. В стандарте (С++-98) это непосредственно не утверждается, но там говорится (стан- дарт С++-98: 53.3), что «оператор sizeof выдает количество байтов, которые занимает объект его операнда» и «sizeof (char), sizeof (signed char) и sizeof (un- signed char) равны 1». Тогда, очевидно, всегда должно выполняться соотношение sizeof (char) == sizeof (byte). (Следует отметить, что байт не обязан состоять из 8 битов; он просто должен иметь «размер, достаточный для размещения основного набора символов» [стандарт С++-98:3.9.1.1]. Однако независимо от количества в нем битов всегда sizeof (byte) == 1.) Все же это вызывает ряд проблем. 13.1.1. Поиск знака Первая проблема связана со «знакостью» типа char - в тех случаях, когда он не имеет спецификатора signed или unsigned, наличие знака зависит от реализации. Это приводит к трудностям, когда переменная char (используемая как byte) при- меняется в арифметических преобразованиях, в которых мы должны ее интерпретиро- вать как число. Если переменная char имеет знак, то ее присваивание типу большего размера приведет к переносу знакового бита на дополнительные биты значения [Stro 1997]. Если 8-битовое значение типа char равно Oxf f, то в операциях преобра- зования в целые числа, например, в 32-битовое целое число (со знаком или без него), результатом будет Oxffffffff, то есть-1 для целого типа со знаком или 4294967295 для целого без знака. Если char не имеет знака, то в результате того же самого присваивания получится значение 0x0 00000ff,TO есть 255 вне зависимости от знака целого числа. Решить эту проблему можно очень просто: всегда указывать спецификатор знака. 0 моему мнению, лучше всего использовать unsigned, чтобы предотвратить рас- ШиРение знака, поскольку это соответствует моему пониманию байта как совокупно- ™ бнтов, хотя я признаю, что такой подход не является безоговорочно лучшим, чем выбор спецификатора signed; некоторые утверждают, что signed более предпочти- ен при обнаружении ошибок недогрузки (underrun bugs). Важно сделать свой выбор тРанить двусмысленность, задавая спецификатор в явной форме.
262 Часть 3. Языковые проблем 13.1.2. Все содержится в имени Вторая проблема заключается в том, что имена типов несут смысловую нагруЗКу. они предполагают соответствующее их применение. Конечно, имена типов не играют никакой роли для компьютера, но они чрезвычайно важны для человека, который пишет и читает программный код. Применение (un)signed char для представления байта вводит в заблуждение. Применение типа char показывает читателю, что переменная представляет сим- вол; если это байт, то его следует называть byte или byte_t или что-то наподобие что воспримется однозначно. Учитывая утверждение Роберта Л. Гласса (Robert L. Glass) [Gias 2003] о том, что 60 процентов разработок по программному обеспечению связано с сопровождением, представляется разумным сделать ваш программный код максимально информативным для того, кто его будет сопровождать - через 18 месяцев им можете стать вы сами. Решение заключается в применении typedef для определения типа байта, например: typedef unsigned char byte_t; Рассмотрим следующие объявления двух переменных: unsigned char vl; byte_t v2; Переменная v2 однозначно представляет собой байт со всей вытекающей смысло- вой нагрузкой. Переменная vl может или нет ассоциироваться с «байтом», а не с сим- волом, что зависит от опыта и интуиции разработчика, читающего этот программный код. К сожалению, программные интерфейсы для схем кодирования многобайтовых наборов символов (MBCS - MultiByte Character Set) в широко распространенных биб- лиотеках Visual C++ применяют unsigned char (const) * для строк символов MBCS, которые во многом обесценивают интуицию разработчиков, использующих эти библиотеки. Даже в том случае, когда unsigned char всегда означает «байт» для всех читателей такого программного кода, очень легко не поставить спецификатор знака в вашем программном коде или мысленно пропустить его. Другим преимуществом непосредственного применения спецификатора знака является возможность отклонения компилятором операторов следующего вида: signed byte_t х; unsigned byte_t у; Это лишний раз подчеркивает (логическую) независимость байтов от знака. 13.1.3. Вникая в тип void При применении типа byte важным является представление его указателей- На практике распространено использование указателя void*, который несомненно настраивает читателя думать в терминах «чистой памяти». Применение void* имеет пре" имущество, связанное с тем, что компилятор отмечает любую попытку использовать ариФ
Слава 13. Фундаментальные типы 263 меТику указателей, которая для других типов указателей выполняется им автоматически. Однако здесь возникают другие трудности, поскольку арифметика указателей байтовых ^пов включает приведение типов, что потенциально может быть причиной ошибок при выполнении последующих операций. В некоторых программных интерфейсах указатели на байты представляются в виде unsigned char (const) *. Проблема в том, что с точки зрения человека параметр типа char* заставляет читателя думать о получении заполняемого буфера строк или одного символа, а параметр типа char const* - о строке символов, завершаемой нулем. Конечно, явное указание спецификатора (un)signed здесь помогает, но стоит всего лишь пару раз ошибиться со спецификатором знака и такая ошибка распростра- нится по всей базе программного кода, сопровождение которого быстро станет невоз- можным. Во всяком случае, мы видели, что некоторые библиотеки применяют тип un- signed char для представления символа. Немного лучшее решение заключается в использовании введенного стандартом С99 типа uint8_t (const) * (см. следующий раздел), но он (все еще) не входит в стандарт С-н- и скорее обозначает «целый тип» (число), а не «байт» (просто набор битов)1. Применение типов byte_t (const)* означает, что мы можем представить указа- тели, ссылающиеся на «непрозрачные» байты, в простой, легко воспринимаемой форме, и нам не приходится прибегать ни к какому приведению типов при выполнении арифметических операций с указателями, и поэтому программный код не подвержен ошибкам, возникающих из-за несоответствия типов указателей. 13.1.4. Повышение типобезопасности Существует еще один способ, позволяющий немного повысить типобезопасность. Я использовал в нескольких библиотеках подход, показанный в листинге 13.1. Листинг 13.1. #if defined(ACMELIB_COMPILER_IS_INTEL) || \ de f ined(ACMELIB_COMPILER_IS_MSVC) typedef unsigned __int8 byte_t; #else typedef unsigned char byte_t; #endif /* тип компилятора */ Тип —int8 представляет собой 8-битовый целый тип, определенный в компиля- Pax Intel и Visual C++ (и в некоторых других). Компилятор Visual C++ 6.0 имеет т юность (возможно, это ошибка?), которая эмулируется компилятором Intel3 и ко- выражается в том, что int 8 и char не рассматриваются как простые синонимы Друга (см. раздел 18.3). Напротив, они представляют различные типы. 7—___________________________ 310 сильно вводит в заблуждение при работе в тех очень редких архитектурах, где байт состоит не из 8 битов.
264 Часть 3. Языковые проблемы Мы можем обратить это себе на пользу, определяя byte_t как uns igned__int8 при использовании этих компиляторов, что позволяет нам писать функции, обеспечи- вая более высокую типобезопасность. (Естественно, тот факт, что мы специально опре. делили байт как 8-битовый целый тип, вызовет непреодолимые трудности на плат- форме с 9-битовыми байтами, но на такой платформе нам следует соответствующим образом установить другое определение типа byte_t.) Рассмотрим следующий класс, который предназначен для работы с памятью, а не с символами. class NoCharsPlease { public: NoCharsPlease(byte_t *); // Реализация не требуется private: #i fdef ACMELIB_CF_DISTINCT_BYTE_SUPPORT NoCharsPlease(char *); NoCharsPlease(signed char *); NoCharsPlease(unsigned char *); #endif /* ACMELIB_CF_DISTINCT_BYTE_SUPPORT */ }; Теперь, если вы попытаетесь передать указатель на блок памяти типа char, компи- лятор отвергнет его. byte_t *рс = new byte_t[10]; unsigned char *puc = new char[10]; NoCharsPlease ncpl(pc); // Компилируется нормально NoCharsPlease ncp2(puc); // Ошибка компиляции Поскольку это работает только на небольшом подмножестве компиляторов (имеющих приличный возраст) и к тому же рассчитано на использование нестандарт- ных возможностей компиляторов, нельзя сказать, что вы обязательно захотите вос- пользоваться этим подходом. Однако это может помочь вам получать максимум сведе- ний в тех случаях, когда вы используете несколько компиляторов (см. приложение В) Поскольку я, как правило, нахожусь именно в такой ситуации, я применяю этот метод- Рассчитанный на будущее переносимый вариант, относящийся только к компиля- торам C++, связан с использованием метода «настоящего» typedef (True Typedef, см. гл. 18), который делает недопустимым преобразование между типами байта и дрУги' ми интегральными типами. 13.2. Целые типы фиксированного размера В большинстве программ, написанных на С и C++, используется целый тип int’ Такая ситуация объясняется вескими причинами. Во-первых, в самые первые Дни существования языка С был единственный тип int. Не только по историческим
Глава 13. Фундаментальные типы 265 причинам int обычно самый эффективный целый тип, поскольку он определен как имеюший «естественный размер архитектуры» (стандарт С++-98: 3.9.1.2). Было бы глупо применять 32-битовый тип на современных 32-битовых машинах, если при его переносе на 64-битовые архитектуры он выполнялся бы не оптимально. Используя int, вы защищаете свой программный код на будущее от таких проблем. Однако возможность варьирования размера при переходе от одной архитектуры к другой также может вызвать трудности, если ваш программный код рассчитан на конкретный размер. Если переменная этого типа, предназначенная для хранения уни- кальных ключей, обеспечивает значения в диапазоне 0-4294967295 в одной среде, ее применение может вызвать некоторые трудности в другой среде, обеспечивающей только диапазон 0-65535. Это особенно важно учитывать во встроенном программ- ном обеспечении: электронщикам нужны типы фиксированного размера. Решение со- стоит в обеспечении определений типов фиксированного размера, которые отвечают соответствующему фундаментальному типу данной целевой среды и используют ме- тоды, аналогичные применяемым нами для байтов. Дефект: в языках С и C++ необходимо предусмотреть целые типы фиксированного размера. Стандарт С99 обеспечивает набор именно таких типов, обозначаемых int8_t, intl6_t, uint32_t и т. д., хотя они и вводятся с помощью спецификатора type- def, а не являются встроенными типами. (Он также обеспечивает целые типы мини- мального размера, например int_least64_t, и очень «быстрые» целые типы мини- мального размера, например sint_ fasttl6_t.) Многие библиотеки, которые должны быть переносимыми, применяют аналогичные методы: в ZLib определяются типы uLong, Byte и т. д.; STLSoft имеет типы uintl6_t, sint8_t и т. д.; Boost им- портирует типы С99 в пространство имен boost при его доступности, а в противном случае сама их определяет. Можно подумать, что теперь представлена полная картина, но, к сожалению, притя- гательность в языке C++ типа int - привилегированного самого первого типа языка - может стать причиной проблем. Проблема связана с перегрузкой и разрешением типа перегружаемой функции для некоторых, но не всех интегральных типов. (Строго говоря, Это не всегда относится к типу int: это может случиться с любым фундаментальным интегральным типом, имеющим такой же размер в конкретной среде. Но чаще всего эти неприятные проблемы проявляются с типом int. 13.2.1. Независимость от платформы ДЛяДавайте представим, что мы пишем компонент, обеспечивающий сериализацию но СИСтемы пеРеДачи данных в межплатформенной среде. Мы хотим иметь возмож- н ТЬ ПеРедавать значения переменных с одного конца и считывать их в пункте ачения в том же виде, в каком они записывались. Кроме незначительных сложно-
266 Часть 3. Языковые проблемы стей, связанных с порядком байтов, - которые, как мы полагаем, решаются внутри ком- понента при помоши ntohl (), htonl () или подобных функций - это достаточно просто и разумно делать для типов, размер которых задается явно, поскольку они всегда имеют одинаковый размер, на какой бы машине они не были получены. Однако совершенно не разумно использовать для такого потока данных тип int, поскольку он может иметь 64 бита на исходной машине и 32 бита на машине получателя, что может привести к потере информации. Рассмотрим класс Serializer и некий клиентский программный код, показан- ный в листинге 13.2. Листинг 13.2. class Serializer { // Операции public: void Write(int8_t i) ; void Write(uint8_t i); void Write(intl6_t i); void Write(uintl6_t i); void Write(int32_t i); void Write(uint32_t i); void Write(int64_t i); void Write(uint64_t i); void fn() Serializer s = . . . ; int8_t i8 = 0; int64_t ui64 = 0; int i = 0; s.Write(si8); s.Write(ui64); s.Write(i); // ОШИБКА: неоднозначный вызов s.Write(O); // ОШИБКА: неоднозначный вызов В средах, где long содержит 32 бита, мы можем определять 32-битовые типы на базе типа long. В тех средах, где int также содержит 32 бита, по-прежнему следУеТ использовать long. (Как мы увидим, это решение обоснованное.) В такой среде при- веденные выше перегружаемые методы Write () класса Serializer не содержаТ варианта для переменной i или литеральной константы 0, которые имеют тип inC- Одинаково возможно преобразование int в любую из восьми конкретных Ф°РМ (отличных от int) интегральных типов (см. раздел 15.4.1), и поэтому из-за неодно- значности компилятор не будет его выполнять.
Глава 13. Фундаментальные типы 267 Это неприятная ситуация. В другой среде может случиться так, что тип int сОдержит 32 бита, a long - 64 бита, и тогда не возникнет двусмысленности, и приве- денный выше программный код будет скомпилирован корректно. Один из способов борьбы с этим противоречием заключается в ограничении себя применением типов, имеющих однозначный размер, даже если вы раньше использовали тип int (либо short, либо unsigned long и т. д.). Это может создать (большие) неудобства и оз- начает, что вам придется приводить тип литералов (которые могут иметь тип int или long; см. раздел 15.4.1), и вы не можете быть уверены, что применение типов short, int, long (, long long) в таких перегруженных функциях обеспечит переноси- мость. Дефект (повторение): язык C++ нуждается в типах фиксированного размера, которые отличаются от встроенных интегральных типов и не могут неявно преобразовываться туда и обратно. Как вскоре мы увидим, эта проблема имеет несколько решений. 13.2.2. Специфическое поведение типов Рассмотрим другой случай, когда нам может потребоваться преобразование целых чисел в строки (в стиле С; см. гл. 31), причем обеспечивая все фундаментальные чи- словые интегральные типы, включая такие нестандартные, как long, 1опд4/ —int64. Из-за того, что целые типы signed и unsigned по понятной причине будут преобразовываться по-разному (то есть при преобразовании типов со знаком будет устанавливаться префикс ' - ', если значение меньше 0), нам необходимо обес- печить две разные функции для учета спецификатора знака. Нам также необходимо иметь 64-битовые версии для типа (unsigned) long long в дополнение к версиям Для типа (unsigned) int (это подразумевает 32-битовую архитектуру, в которой тип int содержит 32 бита). Итак, теперь мы в явной форме удовлетворили требования по преобразованию типов int, unsigned int, long long и unsigned long long. char const *integer_to_string(int ); char const *integer_to_string(unsigned int ); char const *integer_to_string(long long ); char const *integer_to_string(unsigned long long ); Но существует четыре других числовых интегральных типа: signed char, un- тИпьПеЙ char’ short и unsigned short. Ну, с ними все в порядке, не так ли? Эти (иЛ подвеРгнУТЬ| неявному, «мягкому» переводу (стандарт С++98: 4.5) в тип фун^9Г1еа) int’ и поэтому они могут также использовать соответствующие две кции. к сожалению, здесь возникает несколько проблем. Полагаясь на перевод и тип, мы также позволяем осуществлять преобразования с типами bool, char ar-t5- А что если мы хотим преобразовывать bool (при английской локализа-
268 Часть 3. Языковые пробны ции) в значения " true" или * false", а не в " 1" или " 0" ? Что если бессмысленно или неправильно преобразовывать ' А' (типа char или wchar_t) в значение "65"*? Решение состоит в явном определении только тех функций преобразования которые нам нужны, как показано ниже (используя типы С99): char const *integer_to_string(int32_t ); char const *integer_to_string(uint32_t ); char const *integer_to_string(int64_t ); char const *integer_to_string(uint64_t ); char const *integer_to_string(bool ); // возврат значения "true" или "false* и этих встраиваемых функций: inline char const *integer_to_string(int8_t i) { return integer_to_string(static_cast<int32_t>(i))l } char const *integer_to_string(uint8_t i) { return integer_to_string(static_cast<uint3 2_t >(i))i } char const *integer_to_string(intl6_t i) { return integer_to_string(static_cast<int32_t>(i)); } char const *integer_to_string(uintl6_t i) { return integer_to_string(static_cast<uint3 2_t>(i)); } К сожалению, это будет работать только для компиляторов, в которых int32_t определяется как тип, отличный от int, и который фактически рассматривается как другой тип; большинство компиляторов, которые используют, скажем,___int8/16/ 32/64 в своих определениях типов С99 фиксированного размера, рассматривают их совпадающими с соответствующими стандартными типами, а не как равноправный с ними целый тип одинакового размера (то есть на 32-битовой платформе). Если типы не различаются, нежелательные для вас преобразования типов char / wchar_t будут беспрепятственно выполняться. Существует два решения этой проблемы: не очень хорошее и многословное. Первое решение заключается в объявлении функций для char / wchar_t, но при этом они возвращают недопустимый тип, как в следующем примере: void integer_to_string(char ); char ch = 1A'; puts(integer_to_scring(ch)); // Ошибка компиляции Это сработает, но представляете, каким будет сообщение об ошибке, когда бедн „ ~ Д. - *. с {) ТИП старый компилятор попытается удовлетворить используемый функцией puts (>
269 Гл8ва 13. Фундаментальные типы const*, имея void? Едва ли это принесет большую пользу. Мы можем слегка шить этот вариант, заимствуя у Андрея Александреску [Alex 2001] прием, когда У имени дается описание ошибки, как в следующем примере: Struct wchar_t_cannot_convert_as_integer {}; wchar_t_cannot_convert_as_integer integer_to_string(wchar_t ); wchar_t ch = L' A1 ; puts(integer_to_string(ch)); 11 Ошибка с подсказкой посреди «шума» Компилятор Digital Mars выдает сообщение об ошибке «Error: need ex- plicit cast for function parameter 1 to get from: wchar_t_cannot_convert_as_integer to: char const *» (Ошибка: необходимо явное приведение типа параметра 1 из типа wchar_t_нe_мoжeт_пpe- образовываться как целый тип в тип char const *.) Это все же не есть нечто, чем можно было бы гордиться, не так ли? Многословное решение заключается в применении класса, в котором запрещаются нежелательные преобразования с помощью спецификаторов управления доступом, как в следующем примере: class integer_convert { // Преобразования public: static char const *to_string(int8_t ); static char const *to_string(uint64_t ); static char const *to_string(bool ); #if Idefined(ACMELIB_INT_USED_FOR_FIXED_SIZED_TYPES) static char const *to_string(int ); static char const *to_string(unsigned int ); #endif /* IACMELIB_INT_USED_FOR_FIXED_SIZED_TYPES */ 11 Запрещенные преобразования private: static char const *to_string(char ); static char const *to_string(wchar_t ); }; int32_t i = 0; char ch = 'A'; Puts(integer_convert::to_string(i)); II Компилируется нормально Puts(integer_convert::to_string(ch)); // Ошибка компиляции fu & ЭТ?Т P33 со°бщения более понятны, как, например (Intel C/C++): «error #308: ction "integer_convert::to_string(char)" is inaccessible» q 1(3#308: функция "integer_convert: : to_string(char) " недоступна). дУет отметить, что поскольку мы намеренно скрыли перегруженные версии для
270 Часть 3. Языковые проблеМы char / wchar_t, теперь можно спокойно удовлетворить тип (unsigned) int с помо щью условных директив препроцессора в том случае, когда они не используются в определении типов С99 фиксированного размера в данном компиляторе Третья возможность заключается в использовании «настоящих» typedef, которые мы подробно рассмотрим в гл. 18, где мы также переработаем компонент Seriali z- ег, описанный в разделе 13.2.1. Хотя им не совсем удобно пользоваться, этот вариант представляет собой полное решение таких проблем. 13.2.3. Целые типы фиксированного размера: заключение Надо надеяться, теперь вы понимаете, почему нам по возможности везде следует определять интегральные типы фиксированного размера, исходя из типов, отличных от int. Интересно отметить, что все компиляторы (кроме одного - GCC), к которым я имею доступ и которые используют заголовочные файлы cstdint / stdint.h, либо определяют типы С99 фиксированного размера на основе собственных типов фиксированного размера, либо они используют типы short и long, избегая int. Оба подхода терпят неудачу, если у вас нет другого типа, размер которого совпадает с размером (unsigned) int и которым можно воспользоваться взамен последнего, или если поставщик вашего компилятора использует их в своем определении типов С99. В этом случае вам либо приходится мириться с наличием потенциальных тонких ошибок, либо вы должны использовать настоящий typedef (см. раздел 18.4). Лично я выбираю либо запрет типов (unsigned) int, либо решение с настоящи- ми typedef, поскольку я предпочитаю «смиренный» подход возможности неявных преобразований: теперь набирать текст программы легче, и это значит, что в будущем мне придется это делать меньше. Но все же ни одно из решений не является особо удачным, не так ли? Очевидно, что существуют, по крайней мере, две проблемы с фундаментальными целыми типами: изменение размера при переходе из одной среды в другую, что достаточно очевидно, и предпочтение, отдаваемое преобразованию литералов в целые типы, которое осуще- ствляется более тонко, неожиданно и, возможно, более трудно. Также очевидно, что (по крайней мере, первая) проблема широко признана, о чем свидетельствует введение в стандарт С99 типов фиксированного размера. Мы видели, что лучше избегать типа int в определении таких типов, как и то, что невозможно достигнуть этого во всех средах. Итак, для решения этих проблем: • всегда применяйте типы фиксированного размера, если вы в каком-то смысле мак- симально используете его возможности; • при перегрузке функций для поддержки нескольких интегральных типов выпол- няйте перегрузку всех необходимых вам типов и только их;
Глава 13- Фундаментальные типы 271 пользуйтесь литералами экономно и будьте готовы к приведению их типа при работе с перегруженными функциями. Это создает хотя и реальные, но небольшие трудности; следует иметь в виду, что указанные меры не решают все вопросы, и поэтому вы не должны забывать об этих проблемах; компилируйте свои компоненты с использованием практически максимально воз- можного количества компиляторов и прислушивайтесь ко всем полученным преду- преждениям; • когда вам необходимо абсолютно полностью владеть ситуацией, используйте «на- стоящие» typedef (раздел 18.4). (Здесь подчеркнуто слово «необходимо», по- скольку такая ситуация возникает редко и вызывает дискомфорт.) 13.3. Целые типы большого размера Имеющихся в C/C++ встроенных целых типов часто оказывается вполне доста- точно для удовлетворения программистских нужд. Однако по мере усложнения архи- тектуры машин и увеличения размеров дисков возросла потребность в целых типах большого размера. 32-битовые целые числа со знаком обеспечивают диапазон значений -2,147,483,648 =>2,147,483,647, а они же без знака-0 => 4,294,967,295. Поэтому 32-битовое число может представлять, скажем, смещение файла только в том случае, если максимальный размер файла примерно равен 4Гб. В 64-битовых операци- онных системах очень важно иметь 64-битовые типы для представления полного слова, а также для представления указателей, способных работать с 64-битовым адрес- ным пространством. Современные компиляторы C/C++ обеспечивают 64-битовые целые числа, и хотя в настоящее время отсутствует их стандартизация, вызывая опре- деленные неудобства, - некоторые компиляторы обеспечивают предусмотренный в стандарте С99 тип long long, другие-_int64, - достаточно просто с помощью препроцессора, используя typedef, создать независимые от компилятора типы, например, int64_t. Но иногда нам требуются типы большего размера, чем те, которые мы можем получить от наших компиляторов. Если необходимо иметь произвольно большие целые типы, то это следует обес- печивать с помощью библиотеки [Hans 1997], как это сделано в некоторых других язы- КЭХ’ НапРимер, в Python и Ruby. Такие типы очень гибкие, но для работы с ними ис- пользуются сложные и относительно неэффективные методы. некоторых приложениях необходимо использовать целые типы большого раз- бит * НапРимеР’ в криптографическом анализе. Если нам необходимо иметь более 64 или мы работаем с компиляторами, которые не имеют 64-битовые целые числа1, язь,ке мы не найдем соответствующие средства поддержки. ------------------------- Игм°РИпл ВеСТНЬ1е мне современные (32-битовые) компиляторы обеспечивают 64-биговые целые числа, но нельзя ^’Овать возможность отсутствия таких чисел.
272 ЧастьЗ. Языковые проб^ Дефект: в языках С и C++ не предусмотрены большие целые типы фиксированного размера. Сам по себе это не такой уж серьезный изъян, поскольку потребность в таких типах возникает достаточно редко, а минимально допустимый диапазон типов фиксирован ного размера, по видимому, всегда будет предметом субъективных дискуссий. Можно представить, какими могут быть перебранки по этому поводу! Более того, без прямой аппаратной поддержки целочисленной арифметики возникают достаточно нетриви- альные трудности. Уолтер Брайт (Walter Bright), автор компиляторов Digital Mars для языков C/C++/D, утверждает [WBE mail], что «сделать это неэффективно довольно просто: всего лишь перейдите к двоично-десятичной арифметике, но эффективная реа- лизация требует работы с флажком переноса, который недоступен [из C/C++]. Поэтому это потребует применения ассемблера». Так же как и для типов произвольного размера, решение для C++ (и С) состоит в создании пользовательских типов, которые будут представлять такие типы. И в самом деле, одним из самых мощных средств языка C++ является поддержка в нем типов, опре- деляемых пользователем. Естественным подходом является определение структуры, которая содержит под- ходящие фундаментальные целые типы, например, uinteger64, который мы рас- сматривали в разделе 4.4, и его эквивалент со знаком: struct integer64 ( uint32_t lowerVal; int32_t upperVal; }; struct uinteger64 ( uint32_t lowerVal; uint32_t upperVal; }; Как мы видели в разделе 4.4, пользоваться такими типами не так уж просто. Здесь отсутствует инкапсуляция (см. раздел 4.5), поскольку в клиентском программном коде можно независимо манипулировать элементами lowerVal и upperVal. Они не являю1 ся типами значений (см. раздел 4.6), т. к. не работает следующий программный код: integer64 il; integer64 i2; if(il — i2) // Ошибка - в integer64 не определен данный оператор
273 Глава 13 фундаментальные типы поддерживается естественный синтаксис и семантика (см. раздел 4.7) целыми числами: Ими не операций с integer64 i2 = il + i2; // Ошибка. В integer64 не определен данный // оператор |Иы собираемся подробно исследовать тип uinteger64 и возможность поддержки синтаксиса и семантики арифметических типов значений (см. раздел 4.7) в гл. 29. Как мы увидим, C++ позволяет нам преобразовать его в почти совершенный 64-битовый пользовательский тип. Но именно это «почти» представляет собой реальный дефект и подтверждает описанный ранее дефект. 13.4. Опасные типы Мы уже говорили о некоторых опасностях, связанных с усечением целых чисел и расширением знака. Теперь настало время рассмотреть, какие другие опасности можно ожидать от C/C++. 13.4.1. Ссылки и временные переменные В книге Стива Дьюхерста (Steve Dewhurst) «C++ Gotchas» (Неожиданные свойства C++) [Dewh 2003] в свойстве #44 - «ссылки и временные переменные» - подчеркивается опасность совместного использования встроенных и вводимых typedef целых типов, когда речь идет о ссылках. (На самом деле typedef здесь не причем, просто при его использовании программисту легче потерять бдительность.) Рассмотрим следующий программный код: int main() { long 1 = 2222; short const &s = 1; 1 = 0; printf("%ld, %d\n", 1, s); return 0; Men K °™ечает Стив, из-за того, что типы отличаются друг от друга (возможно раз- ), компилятор будет синтезировать временные переменные, которые будут суше- ниро^’ КЭК МИНИМуМ’ Так же долго» как ссылки (стандарт С++-98: 8.5.3) и будут ко- вПосле ЗНачение соответствующего rvalue (1 в данном случае). Таким образом, когда 1 ПечатьДСТВИИ Устанавливается на 0. временная переменная не затрагивается. Поэтому на ИзМе|) выводится "0, 2222", а не "0, 0". Стив утверждает, что «смысл операции прогр^еТСя’ и это Делается молчаливо». Табл. 13.1 показывает, как реагируют на этот код некоторые популярные компиляторы. Только Borland выдает преду- C°dety Ие °б ошибке в стандартном режиме компиляции, а компиляторы Comeau, arn°r, Intel, Visual C++ и Watcom выдают сообщение о потенциальной опасности
274 ЧаотьЗ. Языковые Пробле^ только при более высоком уровне предупреждений. Особо следует выделить Digital М и GCC, поскольку они в действительности обеспечивают требуемый режим рабств Однако они поступают неверно, поскольку стандарт устанавливает (С++98: 8.5.3) Чт* «временная переменная... создается и инициализируется, а ссылка связывается с вреМен° ной переменной». Это хорошо демонстрирует, что никакой отдельный компилятор Не может рассматриваться как пример абсолютно корректной интерпретации языка Таблица 13.1. Предупреждения компилятора при использовании ссылок на разные типы Компилятор Предупреждение на стандартном урове? Требуемый уровень предупреждений Результат Borland (5.6) Да - "0,2222" Code Warrior 8 Нет -warn implicit "0, 2222" Comeau 4.3.0.1 Нет —remarks "0. 2222" Digital Mars C/C++8.34 Нет - "0,0" GCC 3.2 Нет - "0.0" HP 11.00 aCC 3.39 Нет - "0, 2222" Intel C/C++7.0 Нет -W4 "0, 2222" Sun Solaris 2.7 Forte 6.0 Нет - "0, 2222" Visual C++ 6.0 Нет -W3 "0, 2222" Visual C++ 7.0 Нет -W3 "0, 2222" Visual C++7.1 Нет -W3 "0, 2222" Watcom 12.0 Нет -wx -0, 2222" Решение в данном случае достаточно простое: не следует так делать. Для того чтобы это ограничивающее решение заработало, необходимо придерживаться следующих правил: (1) установить для компилятора высокий уровень предупрежде- ний и (2) использовать несколько компиляторов. Эти два совета вы будете слышать от меня много раз на протяжении всей этой книги. 13.4.2. bool Возможно, это единственный очень непопулярный среди ревностных сторонников C++ тип, который встречается во всей книге. Моя позиция заключается в том, что boo является крайне желательным типом; он полезен при внутренней реализации классов и функций, но - как это определяется в стандарте и реализуется поставщиками компиля торов - этот тип бесполезен при спецификации функций и открытых интерфейсов сов. Размер типа bool «определяется реализацией» (см. стандарт С++-98: 5.3.3),х все наши компиляторы (см. приложение А) его реализуют с использованием од байта (см. табл. 13.2).
Гла013. Фундаментальные типы 275 Хотя условные выражения теоретически преобразуются в значение типа bool, оверка сгенерированного программного кода наших компиляторов показала, что они е осуществляют предварительное преобразование в свой (однобайтовый) тип bool; они просто осуществляют проверку «истинности» самого выражения. Например, выражении if (р), где р является типом указателя, выполняется проверка р на нера- венство нулю. В 32-битовой архитектуре с 32-битовыми указателями и целыми числа- мИ очевидно будет недостаточно преобразовать в булево значение, используя, напри- мер, выражение ((р & OxFFFFFFFF) != 0) ? true : false или нечто подоб- ное. К сожалению, когда нужно присвоить логическое выражение переменной типа bool, должно выполняться именно такое преобразование. Пользователи CodeWarrior или Visual C++ привыкают получать предупреждения (т. к. все вы устанавливаете мак- симальный уровень предупреждений, ведь так?) относительно неэффективности этого преобразования и, вероятно, стараются избегать их по мере возможности. Что огорча- ет, так это отсутствие при работе любых других компиляторов предупреждений о сни- жении эффективности при любом уровне вывода предупреждений (см. табл. 13.2). Можно предположить, что стандарт C++ оставил возможность принимать решения разработчикам компиляторов, поскольку они склонны поступать именно так, что явля- ется в основном результатом хорошо обоснованной стратегии. Можно высказать еще одно предположение о том, что разработчики компиляторов реализуют тип bool как однобайтовый тип по соображениям экономии памяти. Таблица 13.1. Предупреждения компилятора об усечении значений типа bool Компилятор sizeof(bool) Предупреждение на стандартном урове? Требуемый уровень предупреждений Borland (5.6) 1 Нет CodeWarrior 8 1 Нет -warn implicit Digital Mars C/C++8.34 1 Нет GCC 3.2 1 Нет Intel C/C++7.0 1 Нет Visual C++ 6.0 1 Нет -W3 Visual C++ 7.0 1 Нет -W3 Visual C++7.1 1 Нет -W3 .Vfateoin 12.0 1 Нет исло экземпляров типа bool в контейнерах обычно ниже по сравнению с их ом в качестве возвращаемых значений или аргументов функций, и если вас волну- ми ь°НОмия памяти на уровне булевых значений, вам следует воспользоваться класса- n itset и bitstring Чака Аллисона (Chuck Allison) [Alli 1993, Alli 1994] и их Годными классами1. ц^Могли бы использовать std::vector<bool>, но он именуется «нечестно» и пренебрегает стандартным Нств°м имен, и поэтому я полагаю, что вы спокойно обойдетесь без него.
276 Часть 3. Языковые проблемы Но преобразование никак не связано с точностью и влияет только на эффектив- ность, и оно все же не так уж часто встречается, хотя и зависит от используемого вами стиля программирования. Реальная проблема с типом bool заключается в непредска- зуемости его размера, особенно когда речь идет о взаимодействии с другими языками Даже если вы никогда не будете писать свою программу на С, вы обязательно будете использовать С в качестве языка, обеспечивающего взаимодействие с дина- мическими библиотеками; в противном случае для вас в части 2 имеется несколько хороших доводов в пользу такого подхода. Нравится вам это или нет, но совместимость с С должна оставаться характерной чертой C++, и именно это является источником проблем с типом bool. Трудно представить, чтобы компилятор C/C++ обеспечивал различные размеры в С и C++ для типов, определенных в обоих языках, и поэтому можно смешивать С и C++, не очень заботясь об этих типах. Но некоторые компиля- торы (C/C++) обеспечивали bool более продолжительное время для C++, чем для С (где bool определяется с помощью директивы #define, переопределяющей тип _Воо1 стандарта С99), а некоторые по-прежнему не обеспечивают его для С, и поэто- му его приходится синтезировать при помощи typedef. В реальности существует изобилие его форм: BOOL, BOOLEAN, Boolean, boolean_t, Bool и т. д. Часто они определяют булев тип как int или как enum, и во многих случаях это было сделано за- долго до появления bool в словаре C++, когда выбор int был в высшей степени оправдан. Проблема в том, что при определении структуры данных или функций программ- ного интерфейса, использующих булевы типы для операций как в С, так и в C++, очень легко можно написать примерно следующее: #if defined!__cplusplus) || \ defined(bool) /* для компилятора С, использующего тип bool стандарта С99 (macro) */ typedef bool bool_t; «else typedef BOOL bool_t; #endif /* __cplusplus */ Следовало ожидать, что когда-то произойдет этот «несчастный случай», и я встречал- ся с ним в программном обеспечении нескольких клиентов. Однажды я сам так посту- пил. Приличная доля программных интерфейсов в библиотеках общего назначения Syn- esis реализована на хорошем старом С, и поэтому тип Bool широко использовался в Synesis. Проблема заключалась в том, что одна функция, реализованная на С, возвра- щала тип Bool, логично получаемый путем вызова функции, возвращающей 32-битовое целое число без знака, содержащее длину пути. Пока длина пути находилась в диапазоне от 1 до 255, все работало гладко. Однако этой функции постоянно приходилось работать с адресами URL, и когда однажды длина пути превысила число 255 и составила е точное кратное, все закончилось крахом. Поскольку причиной неприятностей являлось возвращаемое значение этой фУ1”^ ции, а схемы назначения имен символам обычно игнорируют возвращаемые т (если они вообще включают сигнатуры функций; см. гл. 7), это несоответствие обнаруживалось на этапах компиляции и компоновки.
Глава 13- Фундаментальные типы 277 Конечно, я признаю, что для того, чтобы заметить такую ошибку, не надо быть гением, и я был очень обескуражен из-за того, что сам ее совершил. Однако мне прихо- дится много размышлять о подобных вешах, и эта ошибка «дремала» в программном коде, который регулярно использовался на протяжении нескольких лет до того, как ошибка проявилась! После того, как это произошло, мне все-таки пришлось потратить на отладку два дня, пока не стало ясно, в чем дело. Вероятно, здесь имеются две конфликтующие точки зрения. С одной стороны, я ругаю отсутствие фиксированного размера типа bool, а с другой, я выступаю за при- менение эффективного по скорости размера, что для меня означает размер типа int. Я не согласен, что если bool был бы определен в стандарте как имеющий одинаковый с int размер - «естественный размер для данной архитектуры», то сразу же оба требо- вания были бы удовлетворены. Тогда программный код, использующий bool, мог бы рассчитывать на использование предсказуемого размера для любой данной архитектуры (в действительности мы должны говорить об операционной среде, поскольку некоторые операционные системы могут виртуально реализовываться на машинах других архитек- тур, иногда имеющих другой размер слова), что лишь ограничивало бы выполнение этого программного кода в системах с различными архитектурами. Во всяком случае все другие фундаментальные типы не допускают такой межплатформенный перенос, и поэтому не в этом проблема. Дефект: тип bool должен иметь такой же размер, как и int. Решение этой проблемы, как это часто бывает, состоит в ограничении самого себя. Я никогда не использую тип bool в тех случаях, когда возможен доступ к нескольким единицам компоновки - динамическим/статическим библиотекам, дополнительным объектным файлам, что обычно означает его отсутствие в функциях и классах, появ- ляющихся вне заголовочных файлов. На практике это означает применение псевдобу- лева типа, который имеет размер типа int. В открытых библиотечных заголовочных файлах системы Synesis определяется именно такой тип Boolean, а применение типа Ь°о1 ограничивается автоматическими переменными и исключительно единицами компиляции C++.
Глава 14 Массивы и указатели Массив - это «фиксированный набор однотипных данных... логически занимающих непрерывный участок памяти и... доступных при помощи индекса» [Sedg 1998а], и он является одной из фундаментальных структур данных [Knut 1997]. В то время как боль- шинство языков имеют массивы, многие современные языки не имеют указателей потому что их считают слишком опасными. Указатели позволяют осуществлять прямой доступ к ячейкам памяти экземпляров, на которые они ссылаются, но они могут привес- ти (и часто приводят) к искажению памяти. Тем не менее, в соответствии с «духом С» (см. «Введение») языки С и C++ поддерживают указатели, потому что они обладают большими возможностями и позволяют создавать эффективный программный код. В этой главе мы рассматриваем некоторые проблемы, где возможности языка остав- ляют желать лучшего, включая проблемы размеров массива, дуализм массива и указа- теля, а также уникальную для C++ проблему передачи указателей на массивы унасле- дованных типов. 14.1. Не повторяйте себя В книге «The Pragmatic Programmer» (Прагматичный программист) Эндрю Хант (Andrew Hunt) и Дэвид Томас (David Thomas) описывают свой принцип DRY или Don’t Repeat Yourself (не повторяйте себя). По существу это означает, что все должно опре- деляться лишь однажды, поскольку в противном случае неизбежно возникнут несогла- сованности, когда одно определение обновляется, а другие - нет. Это - основной пока- затель качества программного кода, что легко заметить при определении и манипу- лировании массивами: char аг[23]; strnset(ar, ' 23); Если мы изменяем размерность аг, соответственно не изменяя точно таким же образом третий аргумент в вызове функции strnset (), то программа будет не в состоянии заполнить весь массив или, еще хуже, может затронуть объекты, расподо женные вне массива. Обычно в таких ситуациях советуют объявлять константу (чеРе3 #def ine в С или const в C++) и всегда использовать ее имя, и тогда потребуете” делать только одно изменение1, как в следующем примере: ~~------------------------ с и е>+ч Фактически, изменений два, если вы, как в приведенном примере, обеспечиваете работающую в оГО версию, что часто имеет место в библиотечных заголовочных файлах. Несмотря на это, принцип изменения вполне обоснован.
Глава 14. Массивы и указатели 279 —' #ifdef cplusplus const size_t DIM_A = 23; #else # define DIM_A (23) #endif /* __cplusplus */ char ar[DIM_AJ; strnset(ar, ', DIM_A); Теперь размер ar изменяется в результате изменения DIM_A, который также зада- ется в аргументе функции strnset () и указывается во всех других местах, где ис- пользуется эта константа. Это хорошее решение, но для него все же характерны оши- бочные изменения программного кода. Возможно, тот, кто сопровождает этот про- граммный код, захочет добавить позицию для нулевого признака конца и ошибочно из- менит «неправильную» строку: char ar[DXM_A ♦ 1]/ strnset(ar, ' , DIM_A); // А как насчет ar[DIM_A] ? Естественно, визуальный контроль программного кода поможет обнаружить это, но, как все мы знаем, он проводится намного реже, чем следовало бы*. Было бы дейст- вительно хорошо, если бы массивы в C++ имели, подобно другим языкам, свойство length (длина), как в следующем примере: char ar[DIM_A]; strnset(ar, ' ar.length); С и C++ имеют оператор sizeof (), но он возвращает только размер в байтах. Вэтом случае мы могли бы использовать sizeof (), потому что sizeof (char) == sizeof (байт) (см. раздел 13.1). Однако если бы в примере использовались wchar__t и wcsnset (), то функцией wcsnset () в результате применения sizeof () обрабатывалась бы только половина или четверть аг (в зависимости от ко- личества байтов, содержащихся в типе wchar_t). Нам же нужен оператор, который выдает количество элементов массива, а не количество байтов. Дефект: С и C++ не обеспечивает оператор dimensioned () (для типов массивов). ом случае, если вы позволите десяти рецензентам проанализировать одну и ту же часть программного НоП10крОНи пРИДут с десятью различными списками проблем. Таким образом, дело не только в частоте, и в самой возможности обнаружения подобных проблем.
280 ЧастьЗ. Языковые проб^ Это - проторенный путь, и классическое1 решение (которое работает для С и C++) имеет вид макрокоманды, подобной NUM ELEMENTS ()2, которая определяется как #define NUM_ELEMENTS(х) (sizeof((х)) / sizeof((х)[0])) Количество элементов вычисляется путем деления общего количества байтов на ко личество байтов в одном элементе. Наш пример принимает вид: char ar[DIM_A]; strnset(ar, ' NUM_ELEMENTS(ar)); Это решение работает во всех случаях, но оно все-таки имеет изъян, который мы рассмотрим в разделе 14.3 после краткого отступления. 14.2. Вырождение массивов в указатели Ради эффективности, удобства и, возможно, по исторической случайности [Lind 1994] в С и C++ различие между указателями и массивами несколько размыто. Указатель представляет собой одну ячейку памяти, значение которой ссылается на не- которую точку в адресуемом пространстве памяти. Массив - непрерывный блок одного или более экземпляров конкретного типа. Указатель на тип, совпадающий с типом элемента массива, может устанавливаться на любой элемент в массиве, и ра- зыменование указателя позволяет получать такое же значение, какое получается при индексировании массива. (Фактически существует немного излишняя гибкость во взаимозаменяемости указателей и массивов, которая приводит нас к другой пробле- ме. См. раздел 14.6.) Итак: int аг[5]; int *р = аг; аг - массив int, который имеет пять элементов, р - указатель на int. Т. к. массив конвертируем в указатель (С ++-98: 4.2), совершенно законно присваивать массив аг указателю р, но фактически это означает, что р ссылается на адрес первого элемента аг. int *q = bar[0]; assert(р == q); Также законно и весьма обычно применять оператор индексации к р, и поэтому следующие два оператора семантически эквивалентны. int vl = ar [ 3 ] ; int v2 = p [ 3 ] ; assert(vl == v2); 1 До недавнего времени многие компиляторы были неспособны обеспечивать более новую форму.с мы познакомимся в разделе 14.3. 1 я в качесТВС 2 Это единственный фрагмент программного кода, который «живет» еще с тех дней, когда я ц]еНо^а аспиранта проводил исследования в начале девяностых. Все остальное давно с сожалением было выброо;1хпЛ борт. До меня дошли сведения, что при разработке ядра системы Solaris использовался очень похожи*
рива 14. Массивы и указатели 281 14.2.1. Коммутативность оператора индексации Причина того, что указатель подобно массиву может индексироваться, объясняется способом индексации выражений в С и C++. На этапе компиляции выражение аг [п] интерпретируется компилятором в *(ar + n) [Lind 1994]. Поскольку указатели могут участвовать в арифметических операциях, р может быть подставлено вместо аг, то есть *(р + п), и в результате получаем р[п]. Интересной особенностью [Dewh 2003, Lind 1994] является коммутативность встроенного оператора индексации, и поэтому выражения индексации массивов и указателей могут иметь обратную форму, то есть п [аг] и п [р]. Иногда отмечается [Lind 1994], что это не более, чем причудливая странность языка, которой можно удивить новичка или выиграть конкурс на самый неожиданный код на С, однако эта особенность приносит практическую пользу при применении одного из наиболее современных подходов к программирова- нию на C++ - обобщенного программирования. И в самом деле, как указано в [Dewh 2003], это допускается только для встроенного оператора индексации и поэто- му может использоваться для ограничения какого-нибудь участка программного кода возможностью работы только с типами массивов и указателей, отклоняя типы классов с перегруженным оператором индексации, как в следующем примере: template <typename Т> void reject_subscript_operator(T const &t) ( sizeof(t[0]); // Компилятор отвергнет, если тип Т не является индексируемым sizeof(0[t]); И Кошилягрр отвергает, если тип Т имеет только определяемый И ткяаахжютем. оператор индексации } void reject_subscript_operator(void const * const) {) void reject_subscript_operator(void * ) {} Здесь используются перегрузки для void, т. к. не допускается разыменовывать Указатель void (const). Эти функции используются для отклонения определенно- 1X5 ПОльзователем типа с оператором индексации. Рассмотрим программный код лис- тинга 14.1. Листинг 14.1. Struct Pointer { operator short *() const; ); struct Subscript
282 ЧастьЗ. Языковые проблем int operator [](size_t offset) const; }; void *pv = &pv; void const *pcv = pv; int ai[100]; int "pi = ai; Pointer ptr; Subscript subscr; reject_subscript_operator(pv); reject_subscript_operator(pcv); reject_subscript_operator(ai) ; reject_subscript_operator(pi); reject_subscript_operator(ptr); reject_subscript_operator(subscr); // Компиляция этого оператора // завершается неудачей! Вы, вероятно, задаетесь вопросом, зачем нам нужно обнаруживать и отклонять определенный пользователем тип с оператором индексации. Ну, всегда не плохо найти новые способы обнаружения каких-то свойств и принуждения к их использованию (см. главу 12); теперь настало время обобщенного программирования и похоже, что это надолго. Возвращаясь назад к нашему определению NUM_ELEMENTS (), мы видим, что если мы используем обратную форму оператора индексации, мы можем не допус- тить его применение к определяемым пользователем типам, что не делало предыдущее определение. «define NUM_ELEMENTS(x) (sizeof((x)) / sizeof(0[(х)])) template <typename T> struct vect ( T ^operator [](size_t index); }; vect<int> vi; int ai[NUM_ELEMENTS(vi )] ; // Отвергается компилятором при использовании нового // определения NUM_ELIMENTS 14.2.2. Предотвращение вырождения Я не хочу утверждать, что такое вырождение массивов в указатели - полновесный недостаток, т. к. без этой возможности краткость программного кода и его гибкость стали бы намного меньше. Тем не менее, оно может вызывать раздражение, как нами повсеместно отмечается в данной главе и в гл. 27 и 33.
Глава 14. МаеснБь1 и укэзатели 283 Для встроенных массивов не предусматривается никаких мер по предотвращению лсваивания массива указателю. Передавая их функциям, можно объявлять функции, Снимающие указатели или вместо указателя ссылки на массивы, но ничто не препят- ^ует их преобразованию в указатель в программном коде функции. Вы, вероятно, задаетесь вопросом, почему необходимо об этом заботиться. Ну, сам факт того, что массивы действительно вырождаются в указатели, привел к широкому использованию вырожденной формы р = аг вместо более точной р = &аг[0]. Несмотря на такое удобство, это может иметь отрицательный эффект, когда целью про- граммирования является универсальность. Если вы определяете тип класса, который действует, в некотором смысле, подобно массиву, вы обычно позаботитесь о том, чтобы он имел операторы индексации, как в следующем примере: class IntArray { int const ^operator [](size_t offset) const; int ^operator [](size_t offset); Это, в целом, предпочтительнее обеспечения неявного оператора преобразования. (Мы поговорим об этом более подробно в гл. 32 и 33.) Хотя этот класс теперь под- держивает синтаксис индексации, аналогичный применяемому для массивов, он не обеспечивает неявного преобразования в указатель. Если вы пытаетесь использовать такой тип с программным кодом, в котором применяется вырожденная форма, это при- ведет к неудаче. Подобная проблема характерна для итераторов произвольного доступа [Aust 1999, Muss 2001], предусматриваемых контейнерами последовательностей. Т. к. допустимо реализовывать итераторы как типы классов, вы можете попасть в затруднительное по- ложение, если станете полагаться на синтаксис вырожденной формы. Например, кон- тейнер pod_vector (см. раздел 32.2.8) реализует операции вставки с помощью Функции menunove (), как в следующем примере: menunove (&first [0] , blast [0], . Если вы склонны, - как и я сам, когда писал это, - рассматривать итераторы как Указатели, а не как типы, действующие аналогично указателям только в соответствии с концепцией итератора в STL [Aust 1999, Muss 2001], то вы могли бы написать следующее: menunove (first, last, ... II Итераторы не преобразуются в void * Это не будет компилироваться при использовании тех стандартных библиотек, в ко- Р определяются итераторы типа класса. Таким образом, вам стоит пока воздержи- я от использования некорректного представления массива в виде указателя. Даже м такие вещи вас не сбивают с толку, все-таки не плохо напоминать себе, к чему ет пРивести применение такого синтаксиса.
284 ЧастьЗ. Языковые проблемы 14.3. dimensionof() Определение dimensionof () (в форме NUM_ELEMENTS () ), данное в разделе 14.1, рассчитано на буквальную подстановку, выполняемую препроцессором, и поэто- му оно содержит серьезный изъян. Если мы это делаем для указателя, к которому, Как мы знаем, можно применять индексирование, программный код после подстановки будет неверным. int аг[10]; int *р = аг; // или = &аг[0] size_t dim - NUM_ELEMENTS(p); // sizeof(int*) / sizeof(int) 1 Результат будет получаться путем деления размера указателя, ссылающегося на тип int, на размер типа int, который равен 1 на большинстве платформ. Это неверно (исключая случай, когда размерность массива, на который указывает р, оказывается равной 1) и сильно вводит в заблуждение. Результат будет столь же плохим при применении этого оператора к определенно- му пользователем типу, имеющему оператор индексации (operator []). В таком случае полученный размер может быть любым числом от 0, когда размер экземпляра больше, чем возвращаемое значение для оператора индексации, до большого числа, когда соотношение между ними обратное. Может даже оказаться так, что во время про- ведения сеанса интерактивной отладки полученное значение будет правильным, вызы- вая у автора программного кода совершенно ложное ощущение надежности кода! Было бы гораздо лучше, если бы компилятор ругался на такое применение (псевдо) оператора и отказался бы компилировать это выражение. К счастью, если мы восполь- зуемся советом из раздела 14.2.1 и используем обратную форму оператора индексации в макросе NUM_ELEMENTS (), эта проблема исчезнет. Но даже если соответствующим образом написанный вами макрос будет это делать - а большинство этого не делают - у вас все же возникнут трудности с указателями, поскольку они могут точно так же, как имена массивов, использоваться в обеих формах оператора индексации. Дефект: неспособность отличить массивы от указателей может привести к тому, что операция статического определения размера массива будет применена к указателям или типам классов, что даст неверный результат. Решение в данном случае заключается в обеспечении возможности различать масси вы и указатели. До недавнего времени это нельзя было сделать, но большинство совре менных компиляторов пользуются для этого одним методом. Он основывается на том. что при разрешении аргументов шаблона ссылочного типа (стандарт С++-98: 14.3- [Vand 2003, р58]) не происходит превращение (вырождение) массива в указатель. Поэто му мы можем определить макрос - назовем его dimensionof () - для старых комп»’ ляторов точно так же, как NUM_ELEMENTS (), а для современных использовать сле дующую комбинацию макроса, структуры и функции:
Глава14- Массивы и указатели 285 template <int N> struct array_size_struct ( byte_t c[NJ; }; template <class T, int N> array_size_struct<N> static_array_size_fn(T (&) [N]); #define dimensionof(x) sizeof(static_array_size_fn(x).c) В целом, здесь объявляется, но не определяется шаблонная функция static_array_ size_fn (), которая принимает два параметра шаблона: ссылку на массив типа Т и размерность N. Это не позволяет применять указатели и определен- ные пользователем типы, но пока этого еще недостаточно для нашего оператора dimensionof (). Функция возвращает экземпляр шаблонной структуры array_size_struct, которая параметризуется размерностью, передаваемой функ- ции static_array_size_fn(), и содержит массив байтов этой размерности. Макрос dimensionof () просто применяет оператор sizeof () к массиву возвра- щаемого экземпляра структуры, тем самым получая размерность массива1. Выражение dimensionof () (либо NUM_ELEMENTS (), либо наш новый макрос dimensionof () + функция) вычисляется на этапе компиляции и поэтому может использоваться там, где допускается применение константного значения, например, в параметрах шаблона, в размерностях массивов, в значениях enum и т. д. Поскольку стандарт (С++-98:5.3.3) устанавливает, что операнд оператора sizeof () не вычисляется, нет необходимости определять static_array_size_f п (), и поэто- му все это обеспечивается совершенно бесплатно. Не тратится процессорное время на этапе выполнения, и не раздувается программный код; фактически вообще не генериру- ется никакого программного кода! Попытка применить dimensionof () к любому типу, отличному от массива, приводит к ошибке компиляции. int ai[23]; int *pi = ai; vector<int> vi(23); size_t cai = NUM_ELEMENTS(ai); // Компилируется нормально 8ize_t cpi - NUM_ELEMENTS(pi); 11 Компилируется, но это неправильно! eize_t cvi NUM_ELEMENTS(vi); // Компилируется, но это неправильно: cai = dimensionof(ai); // Компилируется нормально cpi dimensionof(pi); // Ошибка - правильно! cvi dimensionof(vi); // Ошибка - правильно! внимание на то> 410 компиляторы совершенно свободно могут размешать в памяти члены используя любые критерии, и поэтому полная реализация должна окружить array_size_struct р^*тсгвующими директивами упаковки pragma, например, #pragma pack(l), для обеспечения равенства И структуры значению N.
(а потому Менее 286 ЧастьЗ. Языковые проб^ Мне следует отметить, что существует возможность более кратко понятно) реализовать dimensionof <), а именно: templatectypename Т, int N> byte_t (&byte_array_of_same_dimension_as(Т (&)[N]))[N]; #define dimensionof(x) sizeof(byte_array_of_same_dimension_as((x))). К сожалению, этот способ правильно воспринимается меньшим количеством ком- пиляторов1, и поэтому я рекомендую пользоваться первым вариантом. / 14.4. Нельзя передавать массивы функциям Как вы полагаете, что будет выдавать программный код, представленный в лис- тинге 14.2? Листинг 14.2. void process_array(int ar[10J) { printf("["); for(size_t i = 0; i < dimensionof(ar); ++i) ( printf("%d ", ar[i]); ) printf ("Bn") ; } int main() { int arl[10] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 process_array(arl) ; return 0; } Если вы скажете «[0123456789]», вероятно, вы очень удивитесь, узнав действительный результат. Фактически, эта программа выдает «[0]»- Такой результат, как для С, так и для C++, может показаться очень неожиданным, когда вы впервые о нем узнаете. Мы объявили массив из 10 целых чисел, задавая ему после довательность значений и передавая их функции, в объявлении которой указан массив из 10 целых чисел. Так в чем же дело? Visual C++ 7.0 допускает первый вариант, но не второй.
Глава 14- Массивы и указатели 287 Ну, Дел0 в том»что и С и C++ не могут передавать массивы функциям! Но насколько Вь1 помните, функциям можно передавать массивы, не так ли? Увы, это большое заблу- ждение. Массивы всегда преобразуются в указатели при передаче их функциям в С, легко игнорируя любые попытки указания вами размерности массива, а C++ действует (почти полностью) также для обеспечения совместимости. В ранее приведенном при- мере мы могли бы объявить process_array любым из следующих способов, а результат был бы одинаков: void process_array(int ar[20]); void process_array(int ar[J); void process_array(int *ar) ; Я затронул этот вопрос сейчас, вероятно, для того, чтобы вы вспомнили объявление main () с аргументами char * *argv и char *argv [ ]. Я не собираюсь вникать в историю этой ситуации, поскольку она подробно рассматривается в гл. 9 великолеп- ной книги «Deep С Secrets» (Тайные секреты С) Питера ван дер Линдена (Peter van der Linden) [Lind 1994], но я собираюсь предложить следующее: Дефект: в языках С и C++ массивы вырождаются в указатели при их передачи функциям1. Такая гибкость очень полезна во многих случаях, но она может стать источником реальных проблем. C++ (и С, соответственно) обеспечивают компактную систему типов, и поэтому мы не можем передавать указатель на double (или их массив) чему- либо, что ожидает получить указатель на float. Тем не менее, мы можем передавать массив любой длины функции, которая ожидает (в форме указателя) получить массив. Итак, как же быть? Реальная проблема заключается в потере размера массива, и поэтому, если мы сможем найти механизм передачи размера массива функции, а также сам массив (то есть указатель на массив), мы будем вполне удовлетворены. Неудивительно, что это осуществляется с помощью шаблона, для которого я придумал название аггаУ_ргоху (прокси массива), который показан в листинге 14.3. Листинг 14.3. template <typename Т> class array_proxy ( I/ Конструирование Public: template <size_t N> ______explicit array_proxy(T (&t)[N]) Действительности, существует несколько случаев, когда это утверждение неверно, как мы увидим в ДНем Разделе этой главы (раздел 14.7).
288 ЧастьЗ Языковые проблемы : m_base(t) , m_size(N) О explicit array_proxy(T *base, size_t size) : m_base(t) , m_size(size) О // Состояние public: T *base() ( return ni_base; } size_t size() const ( return m_size; ) // Члены private: T *const m_base;. size_t const m_size; //He требует реализации private: array_proxy ^operator =(array_proxy const &); ); Существуют также ретранслирующие функции, определенные таким образом (как мы увидим в разделе 14.6.5), что допускается создание экземпляра аггау_ргоху без указания типа. template <typename Т, size_t N> inline array_proxy<T> make_array_proxy(T (&t)[NJ) { return array_proxy<T>(t); } template <typename T > inline array_proxy<T> make_array_proxy(T *base, size_t size) { return array_proxy<T>(base, size); ) Этот шаблон встречается по всей данной главе по мере его уточнений, позво ляющих решать другие проблемы массивов/указателей, и поэтому в данный момент не стоит слишком беспокоиться о его несовершенстве. Мы можем теперь передавать наш массив действительно как массив: void process_array(array_proxy<int> const bar) { printf("[•); for(size_t 1 0» i < ar.slzeO; ++i)
Глава 14. Массивы и указатели 289 " printf("%d ", i[ar.bass()]); // Just massing with yal } printf("]\n"); } Теперь мы получаем нужный нам результат. Очевидно, нам нужен более удобный интерфейс для обращения к прокси, чем указание base () на месте индекса. Запомни- те это: дальше вы встретитесь с другими аггау_ргоху. 14.5. Массивы всегда передаются с помощью адреса Как подробно описано в [Lind 1994], переменные всех типов в С кроме массивов передаются в аргументах функции по значению, а массивы передаются по ссылке. В C++ по умолчанию действуют такие же правила, и, кроме того, все остальные типы также могут передаваться по ссылке, если перед аргументом поставить знак &. В обоих языках не предусмотрен механизм передачи массивов по значению. int i; int ar[10]; void f(int x, int у[10]) { x = x + 1; // i не изменяется у[0] =y[0] +1; //ar изменяется } f(i, ar); Хотя потребность в этом возникает редко, - и поэтому нельзя считать дефектом отсутствие такой возможности, - иногда передача массива по значению может быть же- лательна. К счастью, это обеспечивается неожиданно просто. Содержимое типов struct (и union, а также class в C++) копируется по значению, и поэтому вам дос- рочно просто определить структуру, содержащую ваш массив, и передать ее функции, int i; Struct int10 ( int values[10]; } ar; void (int x, intlO y) { x=x+l; // i не изменяется У-values[0] = у. values [0] +1; Il ar не изменяется ) f<i. ar);
290 Часть 3. Языковые проблеМЬ| Естественно, в C++ вы получите обобщенную версию, задавая в параметрах шабл0 на и тип и размерность. template <typename Т, size_t N> struct array_copy { T values[N]; }; Вот так. Компилятор C++ будет генерировать конструктор по умолчанию (раздел 2.2.2), деструктор (раздел 2.2.8), конструктор копирования (раздел 2.2.3) и оператор копирующего присваивания (раздел 2.2.4), если тип Т может поддерживать эти методы для конкретного экземпляра. Естественно, размер любого такого копируемого массива устанавливается на этапе компиляции. Если вы хотите иметь копируемый массив переменного размера, вам лучше воспользоваться вектором std: : vector (см. раздел 14.6.4). 14.6. Массивы унаследованных типов Эта хорошо описанная в литературе ловушка [Меуе 1996, Stro 1997, Dewh 2003] - один из наиболее проблемных недостатков C++. Если вы имеете родительский класс Base и производный класс Derived, и они отличаются размером (то есть экземп- ляры Derived больше, чем Base), то передача указателя на массив Derived функ- ции, которая по определению принимает указатель на массив Base, приведет к не- приятным последствиям, поскольку все, кроме одного элемента со смещением индекса 0, будут неправильно выровнено. Лучшее, на что можно рассчитывать при таких обстоятельствах, - быстрый и очевидный крах программы. Рассмотрим программный код листинга 14.4. Листинг 14.4. struct Base ( Base() • m_i0(0) (} int m_i0; ); void print_Base(Base &b) ( printf("%d ", b.m_i0); ) class Derived : public Base {
Слава М- Массивы и указатели Derived() {) int m_il; 291 void print_array(Base ab[], size_t cb) ( for(Base *end = ab + cb; ab != end; ++ab) ( print_Base(*ab); // Обработать каждый элемент ) } int mainO ( Base ab[10]; Derived ad[10J; print_array(ab, 10); // Нормально print_array(ad, 10); // Компилируется и выполняется, но неприятно- сти возникнут // впереди! В нашем примере первый вызов print_array () правильно выведет «0 0 0 0 0 0 0 0 0 0», но второй приведет к получению «010101010 1». В дей- ствительности, это хуже краха, поскольку ошибка может оказаться незамеченной, когда симптомы неправильной работы столь «мягкие», как в данном случае. К счастью, в большинстве реальных случаев это приводит к краху. Можно утверждать, что это вовсе и не дефект1, а просто артефакт модели объекта C++ [Lipp1996]. Но это так часто не замечается и/или неверно понимается, что становится очень опасным, а компиляторы не способны обеспечить хотя бы какую-то защиту против этого, и поэтому, на мой взгляд, данная проблема перерастает в серьезный дефект. Дефект: дуализм массивов и указателей в C++ в сочетании с поддержкой поли- морфной обработки унаследованных типов представляет собой опасность, и здесь компилятор нам ничем не может помочь. В остальной части данного раздела мы рассмотрим несколько частичных решений и методов, позволяющих избегать возникновения этой проблемы, а также эффективное альтернативное представление массивов в качестве параметров функций. Как Утверждали некоторые из моих рецензентов!
292 ЧастьЗ. Языковые проб^^ 14.6.1. Храните полиморфные типы в виде указателя Поскольку указатель производного класса представляет собой указатель базового класса, стандартный рекомендуемый подход [Меуе 1996, Dewh 2003, Sutt 2000] к ре шению нашей проблемы заключается в хранении указателей экземпляров в массиве (или в std: : vector) и в обработке их в таком виде. Это позволяет полностью изба виться от данной проблемы, и такое решение во многих случаях оказывается более предпочтительным. Однако это охватывает только те случаи, где вы собираетесь манипулировать типами с помощью (виртуальных) функций. Это не всегда удобно, поскольку часто желательно обеспечивать простые классы-оболочки для структур программного интерфейса С (см. разделы 3.2 и 4.4). Кроме того, иногда может оказаться желательным (хотя такое встречается редко; см. гл. 21) наследовать полиморфные типы без применения таблиц vtable. Последний недостаток заключается в том, что этот подход налагает дополнитель- ные затраты как на реализацию алгоритма из-за дополнительных косвенных ссылок при обращении к каждому элементу, так и на вызывающую программу из-за независи- мого распределения памяти и инстанциирования массива и его элементов-ссылок, как показано в листинге 14.5. Листинг 14.5. void process_array(Base *ab[], size_t cb) ( for (Base **end = ab + cb; ab != end; ++ab) { print_Base(**ab); // Обработать каждый элемент. Возможно потребуется // проверка ненулевого значения *ab! } } int main() { Derived ad[10]; Base *apb[dimensionof (ad) ]; for(int i = 0; i < dimensionof(ad); ++i) { apb[i] = &ad[i]; } process_array(apb, 10); // Хорошо, но оправданы ли усилия? Поскольку мы хотим исследовать все имеющиеся в нашем распоряжении возмож ности, перейдем теперь к рассмотрению альтернативных подходов.
Глава К Массивы и указатели 293 14.6.2. Обеспечение конструкторов, используемых не по умолчанию Первое, что можно сделать, - это не допустить создание любых массивов. Масси- ву составленные из типов классов, могут объявляться только в том случае, когда тип класса обеспечивает доступный конструктор по умолчанию, то есть значения его аргу- ментов принимаются по умолчанию, либо этот конструктор вообще не обеспечивается. Если можно сделать так, что производные классы не будут содержать конструкторы по умолчанию, то удастся избежать проблемы. Однако поскольку не существует способа определения базового класса, который не позволял бы его производным классам со- держать конструкторы по умолчанию, этого можно добиться лишь в том случае, когда все классы пишутся либо одним автором, либо одной командой, либо в рамках органи- зации разработчиков, которая применяет доскональный визуальный контроль про- граммного кода. В других случаях не стоит надеяться на это. 14.6.3. Скрывайте операторы new и delete для вектора Можно повлиять на природу массива производных типов путем сокрытия в базовом классе операторов создания и уничтожения векторов - operator new[] () и operator delete[] (): class Base { Std::String s; private: void *operator new [](size_t); void operator delete [](void *); int main() ( Base *pbs = new Base[5]; // Derived *pds = new Derived[5]; // Base ab[5]; // Derived ad[5]; / / He допускается - неудобно He допускается - хорошо По-прежнему допускается - хорошо По-прежнему допускается - плохо! Сокрытие для векторов операторов new и delete всего лишь запрещает распреде- Ление массива в динамической памяти. Это не препятствует определению распреде- ляемых в стеке массивов производных классов и затем их передаче функции, рассчи- °й на работу с массивом экземпляров родительских классов. лее того, ничто не может запретить авторам производных классов предусматри- но °ТкРЫТЬ,й доступ к операторам создания и уничтожения векторов. Поэтому ос- Масси ПР°ИЗВОЛИМЫЙ этим методом эффект заключается в запрете создавать нам Нам в Base в динамической памяти; поскольку это не позволяет установить нужный 3апРет, этот подход бесполезен.
294 ЧастьЗ. Языковые проблем 14.6.4. Используйте std:-.vector Как я говорил в предыдущем разделе, когда речь заходит о хранении и манипулир0. вании массивами переменного размера, на мой взгляд, существует очень немного причин (см. раздел 32.2.8, где приводится одна такая причина) не использовать вектор std: :vector, и, конечно, эксперты рекомендуют применять именно его [Sutt 2000 Stro 1997, Meye 1996, Dewh 2003]. Большинство горячих сторонников C++, как правило, думают, что применение std: : vector является решением нашей проблемы с массивами: void process_array(std::vector<Base> &ab) { std: :for_each(ab. begin () ,ab.end(), . . // Обработать все элементы } int main() { std::vector<Base> ab(10); std::vector<Derived> ad(10); process_array(ab); // Нормально process_array(ad); // Ошибка компиляции Однако то, что вы можете использовать в своем клиентском программный коде, отличается от того, что должна делать библиотечная функция. Это, несомненно, типо- безопасное решение, поскольку std: :vector<Base> совершенно отличается от std: :vector<Derived>, и эти две формы вектора не могут взаимодействовать (без некоего тяжеловесного приведения типов). Но несмотря на это, я полагаю, что данный совет ошибочен. Во-первых, существуют обстоятельства, когда требуются массивы - например, когда нужна статическая память - и std: : vector (или любой другой аналогичный массиву контейнер) просто не подойдет. Во-вторых, элементы могут уже храниться в каком-то другом месте, возможно, как часть какого-то большего набора, размещенного в другом векторе. Если мы хотим пере- давать подмножество содержимого этого вектора нашей функции, мы должны копиро- вать элементы, передать их и (при допустимости неконстантных манипуляций) копиро- вать обратно*. Это объясняется тем, что vector, как и все другие контейнеры стандарт* ной библиотеки, хранит и манипулирует непосредственными значениями элементов. Даже если мы каким-то образом могли бы гарантировать совместимость в ходе выполне- ния реального процесса, представьте, как это повлияет на эффективность работы (И даже не пытайтесь начать поиск решений, защищенных от исключений!) 1 Здесь вы, возможно, правильно подумаете, что такое копирование может также быть необходим0 встроенных массивов. Не стоит опасаться - в решении это будет учтено
295 Qiasa 14- Массивы и указатели j 4.6.5. Обеспечьте одинаковый размер типов Ни один из предложенных ранее механизмов не является оптимальным или доста- ^чным для того, чтобы предотвратить ненадлежашее применение производных мас- сивов унаследованных типов. Прежде чем рассмотреть решение, мы должны обсудить случаи, когда при всех опасностях все-таки желательно применять алгоритмы над мас- сивами унаследованных типов. Что если бы мы смогли установить ограничения на производные типы классов, используемых в функциях обработки массивов, позво- ляющие им иметь такое же представление в памяти, какое имеет их базовый тип, и следовательно, сделать безопасным их применение в данном контексте? Если они имеют одинаковый размер, то встает вопрос их расслоения (см. главу 21), и поскольку производный тип совпадает с базовым типом, то вполне допустимо его рассматривать именно таковым в функции обработки. Итак, мы допускаем применение массивов унаследованных типов, когда их размеры совпадают. Тогда возникает вопрос: каким образом мы можем гарантировать совпадение их размера? Эксперты-рецензенты могут обладать навыком, позволяющим это делать в ограниченных случаях при выполнении ими визуального контроля программного кода, но разнообразие факторов, способных повлиять на это - трансформация шаблона (см. главы 21 и 22), упаковка структуры (см. главы 13 и 14), накладные расходы органи- зации производного класса (см. раздел 12.4) - делают это практически нереальным. Даже если проводится визуальный контроль программного кода - что случается доволь- но редко - он не гарантирует обнаружение всех ошибок и является всего лишь одним из инструментов, используемых для подтверждения правильности программного кода [Gias 2003]. Мы могли бы использовать утверждения (см. раздел 1.4) в программном коде, но нельзя в полной мере обеспечить подтверждение утверждений на этапе выполнения (то есть охват не всех путей программного кода, неполное тестирование рабочей версии). На этапе ком- пиляции утверждения значительно полезнее, но смысл выводимых сообщений об ошибках не очевиден, и рецензент может не заметить их отсутствие в конкретном производном клас- се. Лучше воспользоваться ограничением (см. раздел 1.2). Ограничение - это специальный блок программного кода, обычно шаблонный класс, который принудительно заставляет выполнять проектное требование. Это принуждение принимает форму ошибки на этапе компиляции, например, когда оказывается, что нельзя преобразовать один тип в другой, скольку мы хотим, чтобы наши типы имели одинаковый размер, мы используем огра- ничение с объясняющим требование именем must_be_same_size (то есть «размер Должен быть одинаков»; см. раздел 1.2.5). Ме ^епеРь мы имеем инструмент обнаружения массивов производных типов, но в каком Испо НЭМ СЛедУет его исп°льзовать? Фактически, ответ находится в самом решении, н ЬЗУЮЩим st<^: : vector,-параметризация шаблонов типами, связанными отноше- ние Наследования» приводит к получению несвязанных типов. Наше окончательное реше- раСс^РИНИМает форму усовершенствованной версии шаблона аггау_ргоху, который мы огрели в разделе 14.4. Листинг 14.6 показывает полную версию этого решения, ающего ограничение и некоторые дополнительные конструкторы шаблона.
296 ЧастьЗ.Языковыепроб^ Листинг 14.6. template ctypename T> class array_proxy { public: typedef T value_type; typedef array_proxy<T> class_type; typedef value_type ♦pointer; typedef value_type *const_pointer; // Спецификатор const // .не используется! typedef value_type ^reference; typedef value_type &cons t_re f erence; // Спецификатор const //не используется! typedef size_t size_type; // Конструирование public: template <size_t N> explicit array_proxy(T (&t)(Nj) // Массив T : m_begin(&t[0]) , m_end(&t[N]) {} template ctypename D, eize_t N> explicit array_proxy(D (&d)[N]) // Массив типов, совместимых с Т : m_begin(&d[0]) , m_end(&d[N]) { // Обеспечивает одинаковый размер типов D и Т. conetraint_muet_be_eame_eize(Т, D); } template ctypename D> array_proxy(array_proxy<D> &d) : m_begin(d.begin()) , m_end(d.end()) { // Обеспечивает одинаковый размер типов D и Т. conBtraint_nust_be_same_size(T, D); } // Состояние public: pointer base(); const_pointer baseO const; size_type sizeO const; bool empty() const; static size_type max_size(); // Индексация public: reference operator [](size_t index); const_reference operator [](size_t index) const;
14. массивы и указатели 297 // обеспечение итераторов public: pointer begin(); pointer end() ;' const_pointer begin() const; const_pointer end() const; // Члены private: pointer const m_begin; • pointer const m_end; ll Реализация не требуется private: array_proxy &operator =(array_proxy const &); }; Первый конструктор устанавливает указатели членов m_begin и m_end на начало и конец (точнее сразу за него) массива, к которому они применяются Используя аггау_ргоху, мы можем переписать process_array (): void process_array(array_proxy<Base> ab) { std: :for_each(ab. begin (), ab.endO, . . .); // Обработать все элементы } В данном случае process_array () написан таким образом, что он принимает значение не константного аггау_ргоху, т. к. при обработке элементов, возможно, потребуется их изменять. Если необходимо, чтобы ваша функция могла только считы- вать массив, в принципе, чуть эффективнее объявить в ней передачу аргумента типа array_ proxy<T> const &, хотя едва ли различие в производительности будет заметно на каком-нибудь доступном вам профайлере, учитывая вероятные относитель- ные затраты на выполнение самой функции process_array (). Мы можем расширить пример' путем включения типа Derived_SameSize, который является производным от Base, но не изменяет отображение в памяти (см. раздел 12.4). Теперь можно передавать массивы Derived_SameSize функции Process_array (), а новый аггау_ргоху способствует этому благодаря двум Другим своим конструкторам шаблона. DerivedjSameSize : public Base (}; ’ void main () Base ab[10]; Derived ad[10]; Derived_SameSize ads[10]; process_array(make_iarray_proxy(ab)); // Компилируется нормально process_array(make_array_proxy(ad)); // Ошибка компиляции. Хорошо! process_array(make_array_proxy(ads)); // Компилируется нормально - // очень хорошо
298 ЧастьЗ. Языковые проб^ Это - полное решение проблемы. Оно эффективно (без дополнительных затрат при использовании любого приличного компилятора), типобезопасно и обеспечивает про ектировщика функциями, позволяющими полностью защитить себя (или точнее свой программный код) от возможного ненадлежащего применения производных от своих типов. Более того, оно хорошо тем, что способствует использованию унаследованных типов, размер которых совпадает с родительским типом, и позволяет им стать прокси’ Последнее достоинство заключается в невозможности теперь передавать функции process_array неправильные расширения массива, что, несомненно, было возмож- но в первоначальной версии с двумя параметрами, позволяя нам теперь твердо при- держиваться принципа однократности определений («принципа DRY»). Этот метод не защищает иерархии типов, и поэтому можно было бы утверждать, что ему не удается должным образом противодействовать передаче массивов типов, связанных отношением наследования. Но я считаю, что такой взгляд ошибочен. Это сами функции - свободные функции, шаблонные алгоритмы, функции-члены - необ- ходимо защищать от передачи им массивов недопустимых типов. Поскольку примене- ние аггау_ргоху<Т> вместо Т* находится в компетенции автора любых таких функций, он может иметь любую необходимую ему защиту. 14.7. Нельзя иметь многомерные массивы Этот недостаток языка очевиден, и поэтому я не буду пытаться вас как-то подгото- вить к следующему утверждению: Дефект: C++ не поддерживает многомерные массивы1 Да, согласен, я поступил с вами как бульварная пресса, скрывая часть правды ради получения хорошего заголовка. Если быть точным, то как С, так и C++ в действитель- ности поддерживают многомерные массивы. Стандарт (С++-98 8.3.4.3) устанавливает, «когда несколько массивов... при объявлении располагаются рядом, создается много- мерный массив». Однако в нем также говорится, что «константные выражения, задающие границы... могут пропускаться только для первой размерности», и поэтому все, кроме самой левой размерности, должны иметь (на этапе компиляции) константу Следовательно: void process3dArray(int ar[][10][20]); // Нормально void process3dArray(int ar[][][]); // Ошибка! Компилятор озадачен 1 Стандарт С99 ввел в язык С массивы переменной длины (Variable Length Arrays - VLAs), поддерживаю многомерные массивы, размерность которых определяется на этапе выполнения программы. В разделе 3 • подробно рассматриваем причины, по которым их применение в C++ не так уж полезно, из-за чего, в последнюю очередь, они не входят в стандарт C++.
рева М. Массивы и указатели 299 С моей точки зрения, это настоящий дефект, т. к. не думаю, что мне когда-нибудь заХочется применять многомерный массив в серьезном приложении, в котором мне будеТ достаточно гибко управлять только основной размерностью. В таких случаях приходится либо находить обходные пути (другими словами, вручную рассчитывать значения указателя), либо максимально увеличивать фиксированные размерности, внутри которых будет располагаться реальный массив с динамическими размерностя- ми, либо использовать классы многомерных массивов. Встроенные массивы в С и C++ в памяти занимают непрерывный участок, когда па- мять, выделяемая для каждого элемента конкретной размерности, содержит элементы следующей старшей размерности в возрастающем порядке. Массив аЗ [ 2 ] [ 3 ] [ 4 ] фактически располагается в памяти, как показано на рис. 14.1. Рис. 14.1. Индексы аЗ [0] [0] [0] аЗ [0] [0][1] аЗ[0] [0][2] аЗ[0] [0] 3] аЗ + 3 аЗ[0] [1][0] аЗ[0] [1][1] аЗ[0] [1][3] аЗ[0] [2] [0] аЗ[0] [2] [1] аЗ[0] [2] [2] аЗ[0][2][3] аЗ[1][0][0] аЗ[1][0][1] аЗ [1] [0][2] аЗ[1][0] [3] аЗ [1] [1] [0] аЗ [1] [1] [1] аЗ [1] [1][2] аЗ [1] [1][3] аЗ [1 ] [2] [0] аЗ[1] [2] [1] аЗ[1][2] [2] аЗ[1][2][3] Адрес аЗ + 0 аЗ + 1 аЗ * 2 аЗ + 4 аЗ + 5 аЗ + 7 аЗ + 8 аЗ + 9 аЗ + 10 аЗ + 11 аЗ + 12 аЗ + 13 аЗ + 14 аЗ + 15 аЗ + 16 аЗ + 17 аЗ + 18 аЗ + 19 аЗ + 20 аЗ + 21 аЗ + 22 аЗ + 23
300 Часть 3. Языковые проблем Такое последовательное расположение элементов1 очень удобно, т. к. позволяет передавать «срезы» массива (подмассивы) просто путем указания адреса соответст вующей части массива. Например, какой-нибудь программный код, обрабатывающий двумерный массив с размерностями [3] [4], может также работать с массивами аЗ [ 0 ] или аЗ [ 1 ], как показано в листинге 14.7. Листинг 14.7. void print_array(int (*ра2)[3][4]); int аЗ[2][3][4]; // Трехмерный массив с размерностями (2,3,4) int а2[3][4]; // Двумерный массив с размерностями (3,4) int (*ра2) [3] [4]; // Указатель на двумерный массив с размерностями (3,4) ра2 = &а2; // Указатель на двумерный массив ра2 = &аЗ[0]; // Указатель на часть трехмерного массива print_array(pa2); // передача указателя на массив print_array(&а2); // а2 print_array(&a3[0]); // Срез массива аЗ print_array (&аЗ [1]),- // Другой срез массива аЗ Этот пример показывает один случай, когда массив не превращается в указатель при его передаче функции (см. раздел 14.4), но это объясняется тем, что параметр функции определяется в виде указателя на массив, а не является самим массивом, который, как мы знаем, может рассматриваться как указатель. Уже запутались? Схема размещения в памяти массивов объясняет, почему все кроме самой старшей размерности многомерных массивов должны быть фиксированы: компилятору необхо- димо знать значение всех других размерностей, чтобы он мог правильно сгенерировать смещения при трансляции индексной формы (см. раздел 14.2.1) для расчета фактиче- ского адреса элемента массива. Например, адрес элемента аЗ [ 1 ] [ 0 ] [ 3 ] в действи- тельности разлагается на следующие составные части (где символические константы D1 и D2 представляют значения двух младших размерностей: аЗ[1][0] + 3 (аЗ[1] + 0 ‘ D2) ♦ 3 ( (аЗ + 1 * DI * D2) + 0 * 4) + 3 Не зная эти два значения, компилятор не сможет вычислить реальное смешение, которое в данном случае будет равно: аЗ + 15 Мы видели в разделе 14.2, что массивы вырождаются в указатели. Нам необходим0 каким-то образом уточнить это правило. Старшая размерность массива почти всегда интерпретируется компилятором как указатель. Поверьте мне, установить причин? этого почти так же сложно, как решить задачу первичности курицы или яйца, но такие причуды встречаются в наших языках. 1 Следует отметить, что такой порядок не совместим с упорядоченностью элементов массива в языке Fortra когда максимальный шаг имеет самая правая размерность (см. раздел 33.2.3).
„ м 14 Массивы и указатели 301 Глава ______________________________________________________________________________ Итак, поскольку старшая размерность вырождается в указатель, мы можем напи- сать первый вариант в следующем виде: void process3dArray(int (*ar)(10][20]); Он показывает, что process3dArray () принимает указатель на двумерный массив с размерностями 10 и 20. Компилятор, очевидно, по-прежнему знает значения второй и третьей размерностей и спокойно может использовать этот массив. Однако вторая, недопустимая, версия принимает следующую форму: void process3dArray(int (*ar)[][]); Такая форма, очевидно, не содержит никакой информации о значениях двух млад- ших размерностей. Компилятор должен был бы предвидеть, как ясновидящий, раз- мерности массива вызывающей программы и соответствующим образом обрабатывать массив аг. Применять трехмерный массив можно единственным способом - обес- печив в функции process3dArray () три размерности (или хотя бы две). Это можно сделать на этапе компиляции с помощью констант, но тогда можно использовать только первый вариант объявления функции с явным заданием размерностей. Можно поступить по-другому и применить глобальные переменные, но этот вариант очень плохой, и поэтому нам придется использовать дополнительные параметры, как в сле- дующем примере: void process3dArray( int *ar, size_t extentO, size_t extentl , size_t extent2); He очень лаконично, не так ли? И не обеспечивается инкапсуляция, и такую форму не особенно легко сопровождать. К счастью, достаточно просто предложить очень привлекательное решение. Не скажу, что оно простое, поскольку в таких случаях баланс между выразительностью, гибкостью и эффективностью достигается больши- ми усилиями. Естественно, мы определим шаблонный класс. Подробное описание можно найти в главе 33-я теперь должен быть начеку, иначе вы никогда не дойдете до последней страницы - но здесь стоит отметить, что это решение исходит из возмож- ности получения N-мерных срезов из N+1-мерных массивов, как было показано ранее.
Глава 15 Значения 15.1. NULL - ключевое слово, которого не было В языке программирования С макрос NULL, находящийся в заголовочном файле stddef .h, применяется для представления нулевого указателя. При использовании этого конкретного символа для обозначения нулевого указателя его смысл очевиден читателю. Более того, т. к. его тип определяется как void*, это помогает избегать потенциальных проблем. Рассмотрим следующий программный интерфейс языка С, предназначенный для генерации и обработки лексем: struct Token *lookup(char const *tokenld); void fn() { struct Token *token = lookup(NULL); /* здесь использовать token */ Функция lookup () возвратит соответствующую существующую лексему или создаст новую, если ее идентификатор tokenld имеет нулевой указатель. Из-за того, что параметр tokenld имеет тип char const* (в который допускается преобразо- вывать тип void* в С), автор клиентского программного кода (который может как быть, так и не быть автором программного интерфейса Token) передает NULL для соз- дания нового объекта Token. Предположим, после того, как этот программный ин- терфейс достиг определенного уровня развития, его автор решил ускорить работу сис- темы путем изменения механизма поиска и применения целочисленной индексации • Естественно, индексация обычно начинается с нуля, а специальное значение -1 используется для запроса новой лексемы. Теперь при использовании NULL мы по- лучим ошибку компиляции (или, по крайней мере, предупреждение): * 1 В идеальном мире такие серьезные изменения не осуществляются, а порождается новая функция или новь11 программный интерфейс. Однако реальный мир редко ведет себя идеально.
Глава 15. Значения 303 struct Token ‘lookup(int tokenld); void fn() { struct Token «token lookup(NULL); // ошибка /* здесь обработать token */ } Это отличная новость. Если бы автор клиентского программного кода использовал О вместо NULL, мы могли бы столкнуться с самыми разными трудностями. Значение О успешно преобразовалось бы в тип int, поскольку он заменил char const*, и кто знает, что случилось бы в функции f п при получении token недействительного или нулевого указателя вместо создания нового экземпляра Token. В C++ картина совсем другая. Ирония в том, что ужесточение в C++ системы типов по сравнению с С в действительности приводит в таких случаях к утрате типами гибко- сти. Из-за того, что в С тип void* может быть неявно преобразован в любой другой тип указателя, вполне приемлемо определить NULL как ( (void*) 0) и обеспечить возмож- ность преобразования в указатель любого типа. Однако, поскольку в C++ по веским причинам не разрешается осуществлять неявное преобразование из void* в любой другой тип указателя без приведения типов (обычно используя static_cast), это означает, что NULL теперь нельзя определить столь же удобно, как в С. В C++ 0 может быть преобразован в любой тип указателя, и поэтому стандарт C++ (С++-98: 18.1; 4) оговаривает, что «макрос NULL определяется реализацией константы нулевого указателя C++... Таким определением может быть 0 и 0L, но не (void*) О». Но 0 может, конечно, в свою очередь преобразовываться в любой интегральный тип (включая wchar_t и bool, а также в типы чисел с плавающей точкой), что означает отсутствие проверки, которую мы видели в С, при компиляции этого программного кода в C++, и мы встали бы на сомнительный путь, не получив никакого предупрежде- ния о неминуемых проблемах. Хотя смысл символа NULL хорошо понятен тем, кто сопровождает программный код , очевидно, применять его в C++ вместо 0 - это все равно, что прикрывать лицо листом бумаги, спасаясь от монстров; отсюда почти везде рекомендуется использовать О вместо «загадочного» NULL. Но если не поддаться ложному чувству безопасности (для указателей), понимаешь, что в действительности это не намного лучше, и для вас Все может закончиться аналогичной путаницей, т. к. при рассмотрении литерала О всегда предпочтение отдается типу int. Представьте, что вы имеете класс строк следующего вида: class String ( explicit String(char const *s); }; ------------------------- исключением ошибочного применения для целых чисел - такое его применение приносит только вред.
304 ' ЧастьЗ. Языковые проблемы В отличие от строки стандартной библиотеки вы разрешаете передачу в конструкт нулевого указателя, и поэтому вы допускаете такое выражение, как Р String s(0); Пока все нормально. Однако потом вы решаете добавить конструктор, обеспечи- вающий инициализацию занимаемой строкой памяти на основе передаваемого количе- ства символов, содержащихся в строке. class String { explicit String(char const *s); explicit String(int cch, char chinit = '\0*); }; Если не изменить ваш клиентский программный код или функцию, которая вызы- валась в первоначальной версии, то повторная компиляция приведет к вызову совершенно другого конструктора. Эту ситуацию нельзя назвать нормальной, вы согласны? Как это ни странно, если вы измените тип с int на size_t (либо short, либо long, либо какой-нибудь другой тип, отличный от int), компилятор не отдаст предпочтение ни одному из возможных вариантов преобразования, и вы получите ошибку, связанную с неоднозначностью преобразования. Создается впечатление, что C++ специально сделали уязвимым по отношению к нулевым указателям для того, чтобы он был менее надежным, чем С. Дефект: в C++ необходимо добавить ключевое слово null, которое можно приравнивать и с которым можно сравнивать любой тип указателя, но нельзя это делать для любых других типов. Итак, что же делать? В [Dewh 2003] Стив Дьюхерст утверждает, что «в C++ не суще- ствует способа непосредственного представления нулевого указателя». Он также говорит, что применение NULL является «безнадежно старомодным». Ну, я всегда люблю принимать вызов. Поскольку данная книга посвящена тому, как получить от языка то, что вам нужно, и сделать компилятор вашим лучшим другом, у меня, конечно, имеется лекарство. Мы хотим иметь полновесное ключевое слово для нулевого указателя, семантика которого описывается указанным выше дефектом. Решение, что не удивительно, осно вано на применении шаблонов. Увы, основная часть решения уже была предложена целых пять лет тому назад в кн Скотта Майерса «Effective C++» (Эффективный C++), второе издание [Меуе ’ которая есть у меня, но я ее не читал1. Я натолкнулся на нее только во время реШ других вопросов, возникших при написании данной книги. --------------------------------- е я i,e Я прочитал первое издание, взяв его у моего друга, и под его впечатлением приобрел второе изда меНЯ помню, чтобы в первом издании поднимался вопрос о NULL, но конечно это может быть, что делает нечаянным плагиатором и, кроме того, некомпетентным исследователем.
Слава 15. Значения 305 Как только способ решения стал мне ясен - применение в составе структуры шаб- лонного оператора преобразования - я получил его почти с первой попытки: struct NULL_v { // Преобразование public: template <typename T> operator T *() const { return 0; } }; Теперь мы можем написать: String s(NULL_v()); Из-за того что NULL_v не является шаблонным классом и имеет оператор преобра- зования в качестве шаблона члена, он может применяться в любом месте без квалифи- катора и без ограничений. Теперь только от компилятора зависит, насколько коррект- ной будет нужная нам форма его применения. (Следует отметить, что оператор объяв- ляется как operator Т * () const, чтобы обеспечить преобразование только типов указателей. Если бы он объявлялся просто operator Т () const, он мог бы также преобразовывать числовые типы, и мы имели бы очень элегантный, но бесполезный механизм.) Итак, мы имеем свой «способ представления нулевого указателя... в C++». Картина все же далека от совершенства. Существует две проблемы. Во-первых, несмотря на то, что допустимо писать такие выражения, как: double *dp; if(dp == NULL_v()) (} не пройдет запись зеркального выражения, подобного следующему: if(NULL_v() == dp) {) Но В ЭТ°М начинает проявляться небольшая особенность моего решения. Оно не пол- в ’ Поск°льку работает в операторах и только в правой части выражений. Мы увидим с Р^еле 17.2, что в условных выражениях нам следует предпочесть значения rvalue Ои стороны выражений, чтобы присваивание не было ошибочным. На ак™чески наблюдается удивительная несогласованность между компиляторами, оС°РЫХ ЭТ° тестиРовалось- (Таким образом, само по себе это свидетельствует цИям’ Что еще предстоит много работы.) Некоторые хорошо справляются с выраже- 2,^ и об°их видов, а некоторые - ни с одним.
306 Часть 3. Языковые проблемы Для полной поддержки таких сравнений широким спектром компиляторов мы должны расширить определение NULL_v, включая метол equals () и четыре свободные функ- ции. Это также включает оператор преобразования указателя в член. Листинг 15.1. struct NULL_v { // Конструирование public: NULL_v() {} // Преобразование public: template <typename T> operator T *() const { return 0; } template <typename T2, typename C> operator T2 С и *() const { return 0; } template <typename T> bool equals(T const &rhs) const { return rhs == 0; } //He требуют реализации private: void operator &() const; NULL_v (NULL_v const &); NULL—V &operator =(NULL_v const &); template <typename T> inline bool operator (NULL_v const &lhs, T const &rhs) { return Ihs.equals(rhs); } template <typename T> inline bool operator «(T const &lhs, NULL_v const &rhs) { return rhs.equals(Ihs); } template <typename T> inline bool operator !-(NULL_v const &lhs, T const &rhs)
Глава 15. Значения 307 return !Ihs.equals(rhs); } template <typename T> inline bool operator !»(T const &lhs, NULL_v const &rhs) { return !rhs.equals(Ihs); } Метод equals () сравнивает rhs c 0, и он вызывается в двух свободных шаблон- ных функциях перегруженных операторов operator == (). Эти две функции спо- собствуют применению двух выражений, с которыми у нас были проблемы. Для пол- ноты обеспечены также две функции соответствующих операторов operator ! = (). NULL_v предназначен для представления значения нулевого указателя, и поэтому скрыты конструктор копирования и оператор копирующего присваивания: поскольку мы обычно не присваиваем NULL значение NULL, не имеет смысла разрешать при- сваивание NULL_v самому себе. Теперь также потребуется конструктор по умолча- нию, потому что мы объявляем, но не определяем конструктор копирования. (Следует отметить, что я заимствовал у Скотта оператор void operator & () const, по- скольку бессмысленно адресовать то, что является чистым значением. Отличная идея!) Итак, мы имеем наше решение в виде NULL_v с операторами operator Т * () const и operator Т2 С: : * () const, а также с четырьмя свободными функциями, обеспечивающими сравнение на равенство и неравенство, operator Т2 С: : * () const обеспечивает работу с указателями членов. class D { public: void dfO() {} }; void (D::*p£n0)() - NULL_v(); Здесь нет операторов <, <=, и им подобных, поскольку они не имеют смысла для нулевого указателя. Теперь нам просто нужно использовать NULL_v () везде, где мы Раньше применяли NULL. На самом деле это все-таки не ключевое слово, и, к сожале- нию, человеческая инерция и забывчивость таковы, что это, вероятно, станет не более, Чем Х0Рошей теорией, отложенной на полку. И благодаря всего лишь косметическим изъянам мы никогда не добьемся принятия этого подхода сообществом C++: слишком го символов, и все, что напоминает вызов функции, всегда будет казаться неэффек- ь,м, даже если это не так. Несомненно, я сам бы не использовал это! ° мы - неидеальные практики и поклялись никогда не сдаваться. Сначала мне захо- ЧЮго1*ВВеСТИ новый символ препроцессора, возможно, null. Но определение неболь- . символа препроцессора для нечто такого, что будет располагаться в заголовочных содержит в себе опасность. Не имея поддержки со стороны очень крупной
308 Часть 3. Языковые проблемы корпорации, занимающейся разработкой программного обеспечения, нельзя надеяться на успешность добавления чего-нибудь столь незаметного, как null4, в глобальное пространство имен препроцессора. Итак, что же делать? Мне не нравится идея иметь еще один уникальный макрос, например, null и по-прежнему нет гарантии, что кто-то другой не определит его где-нибудь. Нам нужен символ препроцессора, который никто не сможет переопределить, но как мы можем это гарантировать? Возможное решение само напрашивается. Вероятно, вы уже сами догадались, и поэтому я просто его изложу. Заголовочный файл stlsoft_nulldef .h библиотеки STLSoft содержит следующий программ- ный код:1 Листинг 15.2. «include "stlsoft_null.h" // отсюда stlsoft::NULL_v «include <stddef.h> «ifndef NULL « pragma message("NULL not defined. This is potentially dangerous. You are advised to include its defining header before stlsoft_jiulldef.h") «endif /* 'NULL */ «ifdef cplusplus # ifdef NULL « undef NULL « endif /* NULL */ # define NULL stlsoft_ns_qual(NULL_v)() «endif /* __cplusplus */ Теперь все кажется очевидным, не так ли? Мы захватили NULL! Мы можем спокойно это делать, поскольку вряд ли кто-нибудь станет переопределять этот символ. Теперь мы имеем автоматически настраиваемый типобезопасный нулевой указатель, который мы можем активировать всего лишь включением одной директивы #include. Этот файл никогда не включается ни в какие другие заголовочные файлы STLSoft, т. к. это было бы слишком самонадеянно. Это также предохраняет от наивной веры в непоколебимость NULL. Но если вы собираетесь использовать этот символ, вам просто достаточно включить его определение где-нибудь на более высоком уровне иерархии заголовочных файлов вашего приложения, и вы получите все, что необходи- мо для надежной обработки этого символа. Я, конечно, обычно его не использую, но он входит в мой список средств, применяемых при тестировании предварительных версий - для «построения системы в режиме NULL++». Теперь можно дать противопо- ложный совет, рекомендуя вам предпочесть NULL использованию 0 в качестве нуле- вых указателей. Это покажет, что вы очень современны. 1 В действительности, здесь содержится немного больше операторов, обеспечивающих обход сообщен”’’ «pragma компиляторами, которые его не понимают. К тем, которые его понимают, относятся Borland. Dig’,a Mars, Intel и Visual C++.
Глава 15- Значения 309 Но возможно, вы все же скептически отнесетесь к этому, и представленный мною пример не переубедит вас. Вы можете оказаться счастливцем, который работает в среде разработки, гарантирующей невозможность описанных мною изменений программных интерфейсов. Существуют еще две причины применения NULL. Первая, очень прозаическая, заключается в том, что это помогает поиску и замене данного сим- вола (на null) при переносе алгоритмов с C++ на один из родственных (и менее объ- ектно-ориентированных) ему языков. Вторая состоит в том, что он выполняет роль ишейки, хорошо натренированной на обнаружение «халтурных» реализаций. Если вы не верите мне, попытайтесь включить stlsoft_nulldef .h в начало иерархии включаемых файлов вашего приложения и посмотрите, каким впечатляющим будет результат. Я проверял это для нескольких популярных библиотек - не будет имен, не будет виноватых - и могу утверждать, что вокруг много сомнительного программного кода. Если кто-то применяет NULL для интегральных типов, то само собою возникает вопрос о том, какие еще небольшие оплошности могут оказаться в таком программном коде. Прежде чем завершить данную тему, я должен привести точку зрения Скотта Майерса на этот счет. Он утверждает, что применение такого NULL носит ограниченный характер, поскольку защищает программиста при написании вызывающей программы, но не вызы- ваемой. Но я считаю, что это предназначено именно д ля вызываемого программного кода, поскольку в данном случае необходимо не допускать именно изменений в библиотеках. Я бы предложил в том случае, когда возникает необходимость таким же образом за- щитить программный код библиотеки, просто объявлять версии для short, long, char const * и закрытую версию для int. Это хорошо согласуется с тем, что мы предпочитаем (см. раздел 13.1) избегать тип int и вместо него используем типы фик- сированных размеров. Так или иначе, я не думаю, что существует проблема зашиты программного кода библиотек от клиентского программного кода, а не наоборот. Поль- зователи библиотек могут теперь защитить себя от изменений в открытых интерфейсах библиотек. 15.2. Перейдем к ZERO Мы видели в предыдущем разделе, как защитить свой клиентский программный код от изменений (указателя на целый тип) в программном коде библиотек. А как быть с противоположной ситуацией? Предположим, что мы имеем клиентский программный •ЮД» использующий новую функцию lookup (int). // library.h struct Token *lookup(int tokenld); // client.cpp struct Token *token = lookup(O);
310 ЧастьЗ. Языковые проблемы — Теперь представим, что требования изменились вновь, и в этом программном интерфейсе снова вернулись к использованию типа char const*. Теперь режим работы нашего клиентского программного кода будет изменен без предупреждения что потенциально еще хуже. Теперь нам нужен эквивалент NULL_v для литералов о Учитывая уроки, полученные при работе с NULL, мы можем непосредственно перейти к решению, показанному в листинге 15.3. Листинг 15.3. struct ZERO—V { // Преобразование public: operator sint8_t () const { return 0; } operator uint8_t () const; operator sintl6_t () const; operator uintl6_t () const; operator sint32_t () const; operator uint32_t () const; #ifdef NATIVE_64BIT_INTEGER_SUPPORT operator sint64_t () const; operator uint64_t () const; # endif /* NATIVE_64BIT_INTEGER_SUPPORT */ # i fnde f INT_USED_IN_STDINT_TYPES operator signed int () const; operator unsigned int () const; # endif /* ! INT_USED_IN_STDINT_TYPES */ operator float () const; operator double () const; operator long double () const; // Реализация не требуется private: void operator &() const; ZERO_v(ZERO_v const &) ; ZERO_v const boperator =(ZERO_v const &); /// оператор == для ZERO_v и интегральных типов bool operator ==(ZERO_v const Бе, sint8_t i) { return i == 0; } bool operator ==(ZERO_v const uint8_t i) { return i == 0; } bool operator ==( ZERO_v const & , long double const &i) { return i == 0; } /// оператор == для любого типа и ZERO_v
Г/0ва 15. Значения 311 bool operator ==(sint8_t i, ZERO_v const &) { return i == 0; } III оператор != для ZERO_v и интегральных типов bool operator !=(ZERO_v const &, sint8_t i) { return i != 0; } III оператор != для любого типа и ZERO_v bool operator !=(sint8_t i, ZER6_v const &) { return i != 0; } Следует отметить, что здесь нет оператора operator Т () const, поскольку он кроме целых чисел преобразовывал бы также указатели. Это означает, что мы должны обеспечить отдельные операторы преобразования для всех числовых фундаменталь- ных типов. Более того, здесь специально пропущены операторы преобразования для типов char, wchar_t и bool. Аналогично тому, как NULL определяется с помощью NULL_v() в stlsoft_nulldef .h, символ ZERO определяется с помощью ZERO-VO в stlsoft_zerodef.h. Однако с моей точки зрения это слишком смелый шаг. Хотя я реализовал его в биб- лиотеках STLSoft, он в отличие от NULL_v не входит в набор моих регулярно исполь- зуемых инструментальных средств по двум причинам: 1. Я чувствую себя не совсем уверенно из-за потенциальной возможности конфликта с макросами, определенными в программном коде независимых разработчиков или в клиентском программном коде. Как я говорил, предположение о возможности не- стандартного переопределения символа NULL (конечно, отличного от нашего) обос- новано, но я все-таки не встречался с таким переопределением. Однако то же самое нельзя сказать о символе ZERO. В прошлом я видел несколько определений этого символа, и поэтому вероятность конфликта здесь значительно выше. 2. Его определение имеет очень непривлекательный вид. Естественно, это просто артефакт - мы привыкли видеть символ NULL и не привыкли видеть ZERO - но программисты очень плохо относятся к попыткам «подсунуть» им что-нибудь, имеющее уродливый вид. В данном случае я выбираю лучший вариант. Тем не менее, о нем стоит знать. Если вы осуществляете «чистую» разработку при- ложения, когда вы можете быть достаточно уверены, что ZERO еще не использован, Ивы хотите получить максимально возможную в C++ типобезопасность, то его ис- пользование будет в высшей степени оправдано. Более того, непосредственное применение ZERO_v () подходит в качестве прямо- способа ограничения использования в шаблоне только числовых типов, как в сле- ДУЮЩем примере: template ctypename Т> bool is_zero(T const &t) { return ZERO_y() t; // He будет компилироваться, если T не числовой тип!
312 Часть 3. Языковые проблемы 15.3. Изгибы «истины» Булева логика основана на понятии двух состояний, 0 и 1. Соответственно определяет- ся тип bool в C++ (стандарт С++-98: 3.9.1.6) как тип со значениями true (истина) или false (ложь). Что может быть проще или лучше? Те из нас, кому пришлось программиро- вать на C++ до введения типа bool, несомненно, нуждались в таком типе, и было создано много булевых псевдотипов. Они строились на базе перечислений, константных типов и оператора #def ine, но ни один подобный тип не обладал всеми необходимыми свойст- вами булева типа, и поэтому тип bool был введен в стандарт С++-98. Увы, даже в стандарте (С++-98: 3.9.1.6; примечание 42) отмечается, что «примене- ние значения bool... «непредусмотренным» образом... может привести к тому, что его поведение не будет соответствовать ни значению true, ни значению false». С вами такое никогда не произойдет, не так ли? Ну, очень легко можно придумать пример: reinterpret_cast<char&>(b) = 128; if(b != false) { printfCb != false\n"); // Это выводится на печать, ... ) if(b != true) { printfCb != true\n"); //и это, ... ) if (b) { printfCb is implictly true\n"),- // и это. ) Конечно, это искусственный и тщательно подобранный пример неадекватного по- ведения системы типов, от которого в 99 процентов случаев защитит практика хороше- го программирования, но все же существуют обстоятельства, когда это может случить- ся. Обычно это происходит при использовании типов union и при взаимодействии с другими языками, например, с С, как мы видели в разделе 13.4.2. В книге «The Gods Themselves» (Сами боги) Айзек Азимов (Isaac Asimov) сказал, что нет значимых чисел кроме 0,1 и бесконечности [Asim 1972]1. Тип bool подразу мевает поддержку двух значений, нарушая это правило, и на практике это означает поддержку теоретически бесконечного (хотя и практически ограниченного размером битового представления) количества значений. 1 Можно не сомневаться в том, что он никогда не имел дело с устройством готовки риса. применЯ1° нечеткую логику.
Глава 15. Значения 313 Дефект: применение булевым типом более одного дискретного значения является опасным. Возможны три решения. Во-первых, язык мог бы предписать или поставщики ком- пиляторов могли бы посчитать, что любой условный оператор, включающий литерал true, допускает перезапись с применением его отрицания - литерала false. Други- ми словами, следующее выражение: if( i && bl == true && < □ < 3 || Ь2 != true)) может интерпретироваться как: if( i && bl I- false ьь ( j < 3 II b2 -- false)) Поскольку на данный момент ни язык, ни компиляторы этого не делают - и, веро- ятно, никогда не будут это делать - второй, практической, мерой является гарантия отсутствия в вашем программном коде сравнений с литералом true. В-третьих, мы увидим в разделе 17.2, что существуют хорошие основания для того, чтобы все условные подвыражения были булевы. Поскольку типы bool булевы по определению, правильно будет избегать сравнения также с литералами и использо- вать саму переменную при проверке на истину, а ее отрицание при проверке на ложь, как в следующем примере: if( i && Ы && < □ < 3 | | !Ь2)) Этот вполне приемлемый способ программирования с использованием булевых типов, несомненно, более привлекателен, и многие программисты применяют его бес- ^знательно. Если вы к ним не относитесь, то вам следует подумать об этом: вы можете избежать проблем, просто отказавшись от применения символа true (и, возможно, f alse) во всех условных выражениях в своем собственном программном коде. 15.4. Литералы ы видели в предыдущем примере, как мы можем попасть в затруднительное по- р НИе’ полагаясь на переменные, в точности равные конкретным литералам. В этом Си^я рассматривается с другой стороны. Другими словами, здесь Ип атРиваются собственно сами литералы, отмечаются различные противоречия *0Тся предостережения.
314 Часть 3. Языковые проблемы 15.4.1. Целые числа «Тип целочисленного литерала зависит от его формы, значения и суффикса» (стандарт С++-98: 2.13.1.2). Здесь все вроде бы понятно. Этот раздел стандарта дальше поясняет, как суффикс влияет на тип целочисленного литерала. В основном, при отсут- ствии суффикса он будет иметь тип int или long int, если его значение не помеща- ется в int. При суффиксе 1 или L, он будет иметь тип long. При суффиксе и или U то он будет иметь тип unsigned int или unsigned long int, если unsigned int имеет недостаточный размер. Если суффикс представляет собой любую комбина- цию и (или и) и L (или 1), то он имеет тип unsigned long int. Как нами упоминалось в разделе, посвященному символу NULL, идею интерпрета- ции символа компилятором при помощи вызовов перегруженных функций нельзя считать вполне удачной. Пусть теперь мы напишем следующий набор перегруженных функций/методов и клиентский программный код: void f(short i); void fflong i); int main() { f(65536); Если бы вы написали этот программный код для компилятора, который рассматри- вает тип int в виде 16-битового числа, то компиляция прошла бы нормально, т. к. 65536 превышает максимальное 16-битовое число со знаком (или без знака, как в данном случае), и поэтому оно будет интерпретировано как long. Вторая перегру- женная функция принимает аргумент типа long, так что при вызове функции не воз- никает никаких трудностей. Однако если вы теперь попытаетесь откомпилировать этот программный код на современном 32-битовом компиляторе, где размер типа int равен 32 бита, вы обнаружите, что компилятор интерпретирует 65536 как тип int и, следовательно, не сможет сделать выбор в условиях неоднозначности преобразова- ния, когда требуется использовать либо перегруженную версию для типа short, либо для типа long. Дефект: зависимость от реализации размера целых типов в C/C++ ухудшает переносимость целочисленных литералов. Откровенно говоря, я не совсем уверен, что это можно рассматривать как реальный дефект. Я понимаю и в целом согласен с необходимостью зависимости размеров от реализации, по крайней мере, для некоторых фундаментальных типов. С и C++,явЛЯ' ясь универсальными языками, должны работать в различных архитектурах’ и единственный реальный способ ограничения занимаемого константами пространства - воспользоваться наименьшим общим знаменателем, если мы хотим выполнять
Глава 15. Значения 315 проверку на этапе компиляции. Учитывая это, нельзя не понять причину проблемы. Тем не менее, проблема существует, и мы должны это иметь в виду. Возможны два вари- анта решений. Первое решение заключается в том, чтобы воздерживаться от применения литераль- ных целых чисел в вашем программном коде и использовать константы. Поскольку реко- мендуется, чтобы в C++ явно задавался тип констант (а в наши дни должно быть только так), то проблема в основном исчезает; по крайней мере, это относится к определениям констант, где она значительно заметнее и, вероятно, подвергнется более внимательному контролю, чем в тех случаях, когда ей удается притаиться в каких-то участках программ- ного кода. Если вы не хотите загрязнять локальное пространство имен - хорошее интуи- тивное качество - и уверены, что литералы действуют только в текущем контексте, то вы можете использовать константу, действующую в рамках функции. void f(short i); void f(long i); const long THE_NUMBER = 65536; // Константа, действующая в рамках локального // пространства имен, или int main() { const long THE_NUMBER = 65536; // ... константа, действующая в рамках // функции, или f(THE_NUMBER); if(• • ) ( const long THEJsJUMBER = 65536; 11 константа, действующая в рамках блока В тех редких случаях, когда вы чувствуете, что необходимо иметь литералы в вашем программном коде, второе решение заключается в явной спецификации их типа тем или иным способом. Для этого можно воспользоваться любой из следующих форм: f(long(65536)); // "Конструирует" тип long - выглядит элегантно f((long)65536); // Приведение типа в стиле С - плохо! f(static_cast<long> (65536)); // Статическое приведение типа - не очень хорошо f(literal_cast<long>(65536)); // Всего лишь пользовательское приведение типа - // хорошо обнаруживается в программном коде Для фундаментальных типов первая форма, известная как функциональное приведение ™па [Stro 1997], идентична второй форме, где выполняется приведение типа в стиле С1, риведение типа в стиле С не рекомендуется использовать в большинстве случаев (СМ- гл. 19 и [Меуе 1998, Меуе 1996, Sutt 2000, Stro 1994, Stro 1997]; также см. раздел 19.3, приводится очень ограниченный набор случаев, когда данный вид приведения типа Почтителен). Даже в данной ситуации, когда их применение (в любой форме) в целом наносит вреда, все же существует вероятность ошибки при приведении к типу, который СЯМ0 По се®е содеРжит некоторый недостаток, т. к. при его использовании в шаблонах будет 4^цла>, яться строгий контроль типа static_casto для большинства типов, а приведение типа в стиле С для бальных типов связано с проблемами из-за потерь при преобразовании и его неэффективности.
316 Часть 3. Языковые проблемы слишком мал для данного литерала. Однако точно такая же проблема возникает при ис- пользовании static_cast. В обоих случаях решение заключается в повышении уровня предупреждений вашего компилятора и в применении нескольких компиляторов. Форма static_cast более предпочтительна, т. к. она легче ищется и поскольку она имеет непривлекательный вид [Stro 1994], напоминающий вам, что все же не следует без особой необходимости использовать литеральные целые числа в вашем программном коде. Если вы хотите, чтобы ваш программный код можно было легче оценивать при помощи автоматизированных средств, или вы просто хотите выделиться, или и то и другое, вы можете его сделать еще менее привлекательным и реализовать оператор li teral_cast (мы узнаем, как это делать в гл. 19), но, возможно, в данном случае вы поступите (немного) неразумно. При любом выбранном вами подходе важно не забывать о проблеме и о максималь- но большом количестве деталей, обусловивших ее. Учет этого поможет вам пойти дальше, когда вы натолкнетесь на программный код, в котором неосторожно обра- щаются с литералами. 15.4.2. Суффиксы Мы только что видели, как осуществляется оценка типа целочисленных литералов. Что я не упомянул, так это «грозную» фразу, которая утверждает следующее: «про- грамма плохо согласована, если одна из единиц трансляции содержит целочисленный литерал, который не может быть представлен никаким из допускаемых типов» (стан- дарт С++-98: 2.13.1.3). Другими словами, литералы типов, размер которых больше типа long, не оговорены в языке. Поэтому не удивительно, что 32-битовые компиляторы неоднозначно представ- ляют 64-битовые целочисленные литералы. Одни используют LL/ULL, другие L/UL; а третьи и то и другое. Нет необходимости лишний раз напоминать, что это препятст- вует написанию переносимого программного кода. Дефект: компиляторы C++ используют неодинаковые суффиксы целочисленных литералов, размер которых не помещается в тип (unsigned) long. Решение банально и непривлекательно, как сама проблема: макросы. Заголовочный файл, контролирующий числовые границы в системе Synesis Software, содержит сле- дующие плохо воспринимаемые макросы: «define __SYNSOFT_GEN_S8BIT_SUFFIX(i) (i) «define __SYNSOFT_GEN_U32BIT_SUFFIX(i) (i ## UL) «if ( ____SYNSOFT_DVS_COMPILER == _SYNSOFT_VAL_COMPILER_DMC || \ ___SYNSOFT_DVS_COMPILER == __SYNSOFT_VAL_COMPILER_DECC |I X ___SYNSOFT_DVS_COMPILER == __SYNSOFT_VAL_COMPILER_XLC) * define _SYNSOFT_GEN_S64BITSUFFIX(i) (i ## LL)
[лава 15. Значения 317 # define __SYNSOFT_OEN_U64BIT_SUFFIX(i) (i ## ULL) «else # define __SYNSOFT_OEN_S64BIT_SUFFIX(i) (i t* L) # define __SYNSOFT_OEN_U64BIT_SUFFIX(i) (i ## UL) #endif /* компилятор •/ и следующие определения символов: /* 8-битовые числа */ «define ___SYNSOFT_VAL_S8BIT_MAX \ (+ _SYNSOFT_GEN_S8BIT_SUFFIX(127)) «define ___SYNSOFT_VAL_U32BIT_MAX \ ( SYNSOFT_GEN_U32BIT_SUFFIX(Oxffffffff)) «define____SYNSOFT_VAL_U32BIT_MIN \ ( SYNSOFT_GEN_U32BIT_SUFFIX(0x00000000)) /* 64-Оптовые числа. */ «define ___SYNSOFT_VAL_S64BIT_MAX \ (+ _SYNSOFT_GEN_S64BIT_SUFFIX(9223372036854775807)) «define ___SYNSOFT_VAL_S64BIT_MIN \ (- SYNSOFT_GEN_S64BIT_SUFFIX(9223372036854775807) - 1) «define ___SYNSOFT_VAL_U64BIT_MAX \ ( SYNSOFT_GEN_U64BIT_SUFFIX(Oxffffffffffffffff)) «define ___SYNSOFT_VAL_U64BIT_MIN \ ( _SYNSOFT_GEN_U64BIT_SUFFIX(0x0000000000000000)) Жуткий вид! Если вы уверены, что можете избежать конфликтов, применяя мень- шее количество символов в ваших макросах (или если вы более опрометчивы, чем я), то можете свободно определить нечто подобное S64Literal () и U64Literal (), с чем работать значительно проще. Каким бы макросом вы ни пользовались, вы можете добиться переносимости (но не красоты), как в следующих операторах: int64_t i =_____SYNSOFT_GEN_S64BIT_SUFFIX(1234567891234567891); uint64_t i = U64Literal(OxDeadBeefDeadBeef); 15.4.3. Строки Литеральные строки в С и C++ обрамляются двойными кавычками, например, это литерал" и L "это тоже литерал". Литерал просто состоит из непрерыв- ной последовательности элементов типа char или wchar_t (при префиксе L), заканчи- ваемых нулевым символом конца строки. Следовательно, литерал L"string" состоит 1,3 семи символов (типа wchar_t) 's’, 't', ’г’, ' i', 'п', 'д'и 0. Символ конца ^роки позволяет считать, что эти строки представлены в стиле С и могут передаваться помощью указателя. (Если бы не было этого нулевого терминального символа, требо- °сь бы также передавать длину строки.) Некоторые языки гарантируют, что все одинаковые литеральные строки занимают ° и то же место в памяти, что позволяет сравнивать только указатели строк (или их Валент для данного языка), не сравнивая их содержимое. По очень существенным
318 Часть 3. Языковые проблемы практическим причинам (см. раздел 9.2) в C++ это осуществляется не так, и поэтому программный код, показанный в листинге 15.4, как синтаксически, так и семантически неверен с точки зрения C++. Листинг 15.4. enum Туре { abc , def , unknown void interpret(char const *s) { switch(s) { case "abc": case "ABC": return abc; case "def": case "DEF": return def; default: return unknown; } } Поскольку все-таки некоторые компиляторы могут и действительно обеспечивают наложение идентичных строк на один экземпляр внутри единицы компоновки, допус- кается делать то, что показано в листинге 15.5. Листинг 15.5. Type interpret(char const *s) { if(s == "abc") { return abc; } else if(s == "def") { return def; ) else { return unknown; ) } Однако это делать не целесообразно по двум причинам. Во-первых, если исполни^ мый процесс состоит из более чем одной единицы компоновки (см. раздел 9.2), 4
Глава 15. Значения 319 происходит довольно часто, то тогда указатель s, ссылающийся на "abc", может не соответствовать строке "abc" в первом же условном выражении. Во-вторых, програм- мы часто оперируют со строками символов, которые генерируются, копируются и составляются из частей других строк. Во многих сценариях вероятна ситуация, когда s будет указывать на строку символов, которая не является созданным компилятором литералом, а сгенерирована в ходе выполнения программы. В обоих случаях простое сравнение указателей завершается неудачей при попытке правильно идентифициро- вать логическую эквивалентность строк, на которые они ссылаются. Ограничение: С и C++ не гарантируют, что идентичные строковые литералы будут занимать одно и то же место в памяти в одной единице компоновки, и этого не будет при их расположении в отдельных единицах компоновки. Выход, конечно, может быть в сравнении строк по значению, используя st гетр () или подобные функции, либо строковые объекты и их операторы operator == (), перегружаемые для char/ wchar_t const*. Однако не следует полностью отказы- ваться от проверки указателей. И в самом деле, существуют обстоятельства, когда функции interpret () может передаваться значительное количество строковых ука- зателей, которые ссылаются на литералы (возможно из-за того, что они взяты из табли- цы перевода). В таких случаях может подойти следующий программный код: enum Туре { abc , def , unknown }; Type g(char const ws) { return abc; } else if(s == "def") ( return def; if(0 == strcmp(s, "abc")) { return abc; ) else if(0 == strcmp(s, "def"))
320 Часть 3. Языковые проблем { return def; } } return unknown; } Пожалуйста, помните о том, что обстоятельства, при которых данный подход допус- тим, немногочисленны и редки, и вам следует принимать решение о полезности такого «улучшения» только в том случае, если методы количественного анализа эксплуатацион- ных показателей приложения свидетельствуют о том, что функция interpret () явля- ется источником очень низкой производительности. Другими словами, прислушивай- тесь к советам многих заслуженных проектировщиков [Кет 1999, Sutt2000, Stro 1997 Меуе 1996, Dewh 2003, Вгоо 1995], которые предупреждают о вреде применения необ- думанной оптимизации! Мы знаем, что указатели на эквивалентные непустые литеральные строки не обяза- тельно неодинаковы, но меня заинтересовало, отмечено ли в стандарте особое положе- ние пустой строки - и". Я не смог найти в стандарте ничего1, что говорило бы об этом, и поэтому я построил простой тест для выбранных компиляторов, которые выполняли следующий программный код: int main () { char const wpl = ""; char const *p2 = ""; printf("%sequal\n", (pl == p2) ? "" : "not-"); return 0; Оказывается, что Borland, Intel (в отладочном режиме) и Watcom считают pl и р2 не- одинаковыми. Компиляторы. Code Warrior, Digital' Mars, GCC, Intel (в рабочем режиме) и Visual C++ считают их одинаковыми. Я полагаю, что все эти компиляторы имеют флажки для принудительного наложения строковых литералов (или часто это называет- ся «duplicate string merging» - слияние одинаковых строк), но несмотря на это имеется достаточно причин относиться к таким предположениям с большой настороженностью. Вероятно, вас интересует, почему я столько внимания уделяю этому вопросу. Ну, было бы удобно использовать пустую строку - " * - как специальное значение- Например, вы можете реализовать класс строки String и использовать ПУС^ строку для пустых экземпляров (например, конструируемых по умолчанию), а не делять память под массив из одного символа (со значением ’ \ 0 ’ )• ДесТР' String мог бы тогда сравнивать экземпляр с литералом пустой строки и пропу операцию освобождения памяти, когда экземпляр «содержит» пустую строку. При таком подходе экземпляры могли бы всегда обеспечивать ненулевой Ука на строку - как предписывается в модели строки String (стандарт С++-98: 21.3) затечь - при Конечно, отсутствие свидетельства не есть доказательство отсутствия.
Гпава 15. Значения 321 эТОм исключая затраты на размещение односимвольных массивов для каждого экзем- рдяра класса пустой строки. Пример этого дается в классе строки, обсуждаемого в раз- деле 2.3.1. Однако мы узнали, что это не предписано стандартом и только частично обеспечива- йся компиляторами, а также убедились в практической невозможности обеспечения этого при работе с динамическими библиотеками. Поэтому можно дать простой совет - никогда не рассчитывайте на то, что эквивалентные литеральные строки всегда будут иметь тот же самый адрес, но вы можете оптимизировать программный код в тех редких случаях, когда такое случается. 15.5. Константы 15.5.1. Простые константы Константы в C++ непосредственно задаются в следующей форме: const long SOME-CONST = 10; Значение SOME_CONST (некоторая константа) фиксировано, и поэтому SOME_CONST может использоваться везде, где допустим целочисленный литерал, на- пример, для установки размерностей массивов, в значениях enum и в целочисленных параметрах шаблонов. int ai[SOME_CONST]; II хорошо enum { х = SOME-CONST }; II хорошо frame_array_d2<inc, SOME_CONST, SOME—CONST» ar; II хорошо Все фундаментальные типы могут использоваться в определении констант, хотя константы чисел с плавающей точкой могут применяться только там, где допустимы литералы чисел с плавающей точкой. Например, они не могут использоваться для определения границ массивов. Компилятор присваивает значения таким константам на этапе компиляции, и константы не занимают памяти в скомпонованном модуле (если не используется их адрес). Все это понятно и довольно очевидно. Проблемы возникают в том случае, когда проектировщики пытаются выполнять приведение типов. Рассмотрим следующий программный код: const int W = 10; int main() ( int &w = const—cast<int&> (W) ; int const *p = &W; w *= 2; int const ’q = &W; int i[w] ; Printf("%d, %d, %d, %d, %d\n", W, NUM-ELEMENTS(i), w. *p, *q) ; return 0;
322 ЧастьЗ. Языковые проблец, Делать так неразумно, и стандарт (С++-98: 5.2.11; 7 и 7.1.5.1; 7) говорит нам, что в этом случае результат будет не определен. Отсюда - ожидаемое разногласие в интерпретации этой ситуации различными компиляторами. Digital Mars выдает 10,10,20,10,10. Borland, CodeWarrior, Intel и Watcom выдают 10,10,20,20,20. Возмож- но, наиболее практичное поведение - по крайней мере, с точки зрения того, кто сопрово ждает данный программный код - показывают компиляторы GCC и Visual C++ (начиная с версии 4.2 и вплоть до 7.1), которые завершаются аварийно при выполнении присваи- вания значения переменной w. Ограничение: результат устранения «константности» константного объекта не определен, и такое действие, вероятно, приведет к непредвиденным последствиям «Ответ» простой: не пытайтесь устранить константность в тех случаях, когда у вас нет уверенности, что в этом случае действительно получится неконстантный объект; в противном случае хаос прольется дождем на вашу голову! 15.5.2. Константы типа класса Можно определять константы типа класса способом, показанным в следующем примере: class Z { public: Z(int i) : z(i) {} public: int z; }; const Z g_z = 3; Однако в таких случаях константный экземпляр нельзя использовать вместо це- лочисленных литералов. int ai[g_z.z]; // ошибка enum { х = g_z.z }; II ошибка frame_array_d2<int, g_z.z. g_z.z); // ошибка Более того, константные объекты создаются не на этапе компиляции, а на этапе вы полнения программы1. Поскольку они имеют статическую область видимости (то есть они существуют за рамками какой-то конкретной функции), они должны конструиР° 1 Это не значит, что в некоторых случаях они не могут удаляться (и они действительно удаляются) на этап оптимизации при компиляции и/или компоновке.
Глава 15. Значения 323 ваться (и уничтожаться) в ходе выполнения программы. Вам необходимо осознавать, что в этой связи имеется три потенциальных недостатка их применения: во-первых, на выполнение конструктора и деструктора конкретного типа может уходить достаточно много процессорного времени. Обычно, но не всегда, такие проблемы возникают при запуске приложения или модуля и при прекращении их работы. Во-вторых, в принципе возможна ситуация, когда один глобальный объект зависит от другого. Рассмотрим следующую (правда, вымышленную) ситуацию: const Z g_z = 3; extern const Z g_z3; const Z g_z2 = g_z3; const Z g_z3 = g_z; int main() { printf("%d %d %d\n", g_z, g_z2. g_z3); Я полагаю, вы понимаете, что здесь не будет выводиться 3, 3, 3. В действитель- ности, вы получаете 3, 0, 3. Причина заключается в том, что g_z2 получает копию g_z3 до инициализации g_z3. Поскольку глобальные объекты находятся в глобаль- ной памяти, которая гарантировано инициализируется нулями (см. гл. 11), все члены g_z3 перед конструированием содержат 0, и поэтому таким же будет значение g_z2. Редкий случай, но неприятный. В-третьих, если константы типа класса (не являющиеся внешними) совместно используются разными единицами компиляции (то есть они объявляются в совместно используемых заголовочных файлах), то каждая единица компиляции фактически получает отдельную копию экземпляра константы. Очевидно, если определение или применение таких констант рассчитано на то, что они обладают свойствами синглето- на, оно потерпит неудачу. Ограничение: константы типа класса вычисляются как глобальные объекты, а не Как константы, вычисляемые на этапе компиляции. Опасно забывать об этом отличии. Бороться с этой опасностью можно просто - по мере возможности избегать приме- нения таких констант, а в противном случае выдавать предостережение.1 15.5.3. Константы-члены Как и объявляемые в глобальной области и в области видимости функций, эти кон- ы могут объявляться в области видимости классов, как в следующем примере: СВЯЗи некоторые компании придерживаются действительно строгой политики в отношении Ния статических объектов, особенно, когда значение будет изменяться.
324 ЧастьЗ. Языковые проблемк class Y { public: static const int у = 5; // константа-член static const int z; // константный статический член }; Форма объявления констант-членов очень похожа на форму объявления статиче- ских членов (с спецификатором const) с тем отличием, что здесь при объявлении переменной используется оператор инициализации, а переменная может иметь только интегральный тип или enum (стандарт С++-98: 9.4.2; 4). Используя форму инициали- зации, вы позволяете компилятору свободно интерпретировать этот член как литераль- ную константу. В данном случае они отличаются от статических членов, т. к. вы можете использовать их в выражениях, вычисляемых на этапе компиляции, и вам не приходится определять их отдельно. Ну, на самом деле последнее утверждение только частично верно. В том же самом параграфе стандарта (С++-98: 9.4.2) говорится, что «статический член все-таки будет определен... если он используется в программе». Это подразумевает, что нам на самом деле необходимо обеспечивать определение, как это мы бы делали для любого другого статического члена. Однако это теория, и мы увидим, что на практике (см. табл. 15.1) ни один из наших компиляторов не требует обеспечения определения для использова- ния константы в скомпонованной и функционирующей программе. Тем не менее, если вы получаете адрес константы-члена или используете ссылку на нее, то потребуется определить константу-член: int const *р = &Y::y; int const &r = Y::y; /* static */ const int Y::y; // Определить здесь Несмотря на необходимость определения, вы не инициализируете константу, по- скольку это сделает за вас компилятор. Это помогает избегать различных определений одной и той же константы в различных единицах компоновки. Естественно, если про- граммный код вашего приложения «видит» константу с одним значением, а ваша дина- мическая библиотека - с другим, маловероятно, что все будет работать гладко в тече- ние продолжительного времени. Однако и без того слабая надежность легко может быть нарушена (преднамеренно или случайно), поскольку динамические библиотеки обычно загружаются без повторного построения системы. Все-таки это лучше, чем ничего, и если ваши процедуры разработки находятся на должном уровне, вам следуеТ избегать таких конфликтов вплоть до развертывания системы. И хотя константы-члены имеют привлекательные свойства, они обладают Дв> недостатками. Во-первых, не все компиляторы в настоящее время их поддерживаК)Т’ как видно из табл. 15.1.
Глава 15. Значения 325 Таблица 15.1. Поддержка констант-членов интегральных типов и чисел с плавающей точкой Компилятор Интегральные типы Поддержка констант-членов Требуется определение Числа с плавающей точкой Borland 5.6.4 Да Нет Нет CodeWarrior 8 Да Нет Нет Comeau 4.3.3 Да Нет Нет Digital Mars С/ C++8.40 Да Нет Нет GCC 2.95 Да Нет Да GCC 3.2 Да Нет Да Intel 8 Да Нет Нет Visual C++ 6 Нет - Нет Visual C++7.1 Да Нет Нет Open Watcom 1.2 Нет - Нет Существует достаточно простое решение этой проблемы, которое подходит для большинства случаев: применение перечислений enum. Его недостаток в том, что оно не будет работать для интегральных типов, размер которых больше диапазона enum, поскольку стандарт вполне определенно говорит о величине пространства, занимаемо- го типом enum. Он говорит, что «базовый тип не должен превышать размер типа int, если значение перечисления может разместиться в int или unsigned int». Следу- ет отметить, что он не говорит, что данный тип действительно будет иметь больший размер, и на практике большинство компиляторов не позволяют перечислениям иметь большие значения, чем их тип int. Следующее перечисление совершенно по-разному ведет себя при применении наших компиляторов: enum Big { big = 0x1234567812345678 Borland, CodeWarrior, Comeau, GCC (2.95) и Intel откажутся его компилировать, У^ерждая что оно имеет слишком большое значение, и они имеют полное право так по- ^Упать. Digital Mars, GCC (3.2) и Open Watcom компилируют его и в состоянии вывести (ВеПеЧаГГЬ пРавильное значение (в десятичной форме): 1311768465173141112. Visual С++ тольк*111 И компилирует и выдает значение, но мы получаем правильное значение и в том случае, если большое значение сначала присваивается целому числу, ’Чени М МЫ использУем Функцию print f (), а не методы lOStream. Я полагаю, что ре- пР°блемы определения в enum значений больших, чем int, просто состоит в за- Такого применения перечисления.
326 Часть З.Языгоеыепроблец Рекомендация: избегайте использования значений епит, размер которых не поме щается в тип int. Несмотря на это, применение enum для констант - очень распространенный под- ход, вполне успешно используемый там, где это допустимо. И в самом деле, я бы ска- зал, что он более предпочтителен для тех, кому приходится обеспечивать хотя бы ми- нимальную обратную совместимость. (Следует отметить, что в библиотеках Boost ис- пользуются макросы для выбора нужного варианта, что полностью обосновано Вы можете, как и я1, предпочитать избегать макросы там, где возможно.) Вторая проблема заключается в том, что константы-члены не могут иметь тип чисел с плавающей точкой. class Maths { public: static const double pi 3.141592653590; // Констанш-члены должны иметь // интегральный тип }; Существует странное противоречие в том, что они допускаются как члены про- странства имен. namespace Maths { const double pi = 3.141592653590; II Нормально } Я должен признаться в том, что не знаю причину, по которой константы-члены не могут быть числами с плавающей точкой при том, что вполне законно определять не константы-члены как типы с плавающей точкой. В [Vand 2003] утверждается, что здесь нет серьезных технических препятствий, и некоторые компиляторы поддерживают их (см. табл. 15.1). Возможно, это происходит из-за того, что переменная числа с пла- вающей точкой заданного размера на данной платформе может иметь неодинаковую точность для разных компиляторов, чего не наблюдается для целых чисел. Но тогда по- ведение констант-не-членов тоже зависит от их потенциальной неточности, и поэтому данный выбор представляется произвольным. Возможно на это повлиял тот факт, что литеральные константы чисел с плавающей точкой не могут участвовать в вычислени- ях константных выражений на этапе компиляции - например, в определении размерно- сти массива - в которых могут использоваться целочисленные константы, и, следова- тельно, их поддержка кажется менее важной. Однако на обе эти причины можно возра- зить, что константы-не-члены имеют такие же проблемы, и в этом противоречие. 1 То есть битва с MFC ожесточается.
Глава 15. Значения 327 Дефект: C++ не поддерживает константы-члены для чисел с плавающей точкой. Если ваш компилятор не поддерживает константы-члены, и значения, которые вы собираетесь использовать, слишком велики для enum, или ваша константа должна иметь тип числа с плавающей точкой, вы можете использовать альтернативный меха- низм на базе статических методов. Это достигается простым применением статиче- ских методов, как в следующем примере: class Maths { public: static const double pi() { return 3.141592653590; } }; Таким образом вызов Maths: : pi () обеспечивает доступ к значению константы. Такой подход использовался в типе свойств numeric_limits<> стандартной биб- лиотеки. Однако следует иметь в виду, что это вызов функции; по-видимому, ваш ком- пилятор будет оптимизировать все реальные вызовы, но это все же тот случай, когда значение вычисляется на этапе выполнения программы и не может входить в выраже- ния, вычисляемые на этапе компиляции, как в следующем примере: class X { static const int il = std: :numeric_limits<char>: zdigits; II Нормально static const int i2 = std::numeric_limits<char>::max(); II Ошибка }; Можно использовать константу-член digits, т. к. это константа времени компиляции, но результат шах () - нельзя, поскольку это функция, даже если она может стать констан- той в результате оптимизации, поскольку всегда возвращает одно и то же значение. 15.5.4. Константы-члены типа класса Мы видели при работе с любыми константами фундаментального типа, что они являются реальными константами, то есть они вычисляются на этапе компиляции, но Для Того> чтобы вы могли получить адрес таких констант, они должны иметь единст- ^*ное, отдельное определение для обеспечения памяти под их размещение, видели при работе с константами типа класса, что они должны быть инициали- на этапе выполнения программы, что потенциально позволяет их использо- До инициализации (или после их де-инициализации), а также то, что они не могут Дить в выражения, вычисляемые на этапе компиляции. Это последнее ограничение Ния 6 пРИМенимо к статическим функциям-членам, предназначенным для обеспече- к°нстант-членов для типов, не поддерживаемых в языке.
328 Часть 3. Языковые проблем Все эти ограничения проявляются при рассмотрении нами констант-членов типа класса, которые не поддерживаются языком, и как мы увидим, по весьма веской причине. class Rectangle { public: static const String category("Rectangle*'); // He допускается }; Ограничение: C++ не поддерживает константы-члены типа класса1. Это происходит по двум причинам, а именно, связанно с идентичностью и с син- хронизацией. Нормальные статические члены класса объявляются при объявлении класса, а память для них выделяется в другом месте, обычно в соответствующем файле реализации, как в следующем примере: // В Rectangle.h class Rectangle { public: static const String category; }; II В Rectangle.срр /* static */ const String Rectangle::category("Rectangle"); Как мы видели в разделе 9.2, в рамках конкретной единицы компоновки (исполняе- мой или динамической библиотеки) существует единственное определение, находящее- ся в Rectangle. срр. Этот экземпляр создается средствами поддержки языка на этапе выполнения, когда единица компоновки загружается и переходит в рабочее состояние (то есть когда процесс запускает программу или когда динамическая библиотека загру- жается в процесс). Он уничтожается, когда единица компоновки выгружается и перехо- дит в нерабочее состояние. Таким образом, имеется единственная сущность, и ее исполь- зование синхронизировано. Однако все только что сказанное в реальных условиях окажется не совсем таковым. При многомодульной разработке возникает несколько проблем с синхронизацией и идентичностью, например, экземпляры могут поступать в процесс из более чем одной единицы компоновки динамической библиотеки (см. раздел 9.2.3). Должно быть ясно, что если бы допускалась форма константы-члена для типов классов, то тогда ком пилятору пришлось бы принимать больше решений, чем он это делает уже сейчас. Например, компилятору и компоновщику пришлось бы совместно работать без кон 1 Ну, в некотором смысле это возможно, поскольку член со спецификатором const может объявля!*^ в классе, а определяться в другом месте. Однако это не одно и то же. что представляет собой константа. ТаК 1 иначе, эта несущественная особенность разрушает мое утверждение.
Глава 15. Значения 329 троля со стороны разработчика для обеспечения неявного определения статического экземпляра. В какую единицу компиляции его следовало бы вставлять? Как это может повлиять на потенциальные проблемы упорядочивания? Если бы вы хотели иметь весь класс в заголовочном файле и/или вы хотели бы сде- лать определение видимым в объявлениях класса, вы могли бы обеспечить константу с помощью статического метода, как в следующем примере: class Rectangle { public: static const String category() { static const String s_category(“Rectangle"); return s_category; } }; Статический метод category () использует локальный статический экземпляр типа String, s_category, который инициализируется один раз при первом вызове метода category (), и этот экземпляр затем возвращается при всех последующих вызовах данного метода. Побочный результат этого - инициализация s_category только по необходимости; если метод category () никогда не вызывается, то не будет затрат, связанных с инициализацией. Это называется отложенным вычислением [Меуе 1996]. Все работает очень хорошо, если этот класс всегда используется только в однопо- точных процессах, но, как мы видели в разделе 11.3, в данном виде он не является по- токозащищенным и, следовательно, не должен применяться в многопоточных средах или в любой библиотеке, имеющей хотя бы малейший шанс использоваться в таких средах в будущем. В большинстве нетривиальных приложений вы с большим успехом можете считывать, например, такие типы, как String, из конфигурационной информа- ции (из файла, Интернет-настроек, ключа реестра), относящейся к приложению. Проблема в том, что один поток может сконструировать s_category, но все же не установить скрытый флажок. К этому моменту второй поток (или несколько потоков) может подключиться и вновь создать объект s_category. Самой меньшей неприят- ностью в этом случае может быть потеря ресурсов, созданных при первой попытке. Конечно, два потока могут не завершить полностью конструирование объекта, и на- личие противоречивого состояния вполне может привести к краху. Действительно неприятной особенностью является то, что это очень редко приво- дит к возникновению проблемы, и даже когда это происходит, она проявляется 8 Мяг«ой форме. Это объясняется тем, что после завершения любым потоком этапа 2, любой следующий поток, вызывающий эту функцию на всем протяжении работы про- Несса, будет работать во вполне определенном режиме. Вы можете миллионы раз вы- 2^иить процессы, содержащие такой программный код1, и никогда не столкнуться '^То возможно при работе веб-сервера, монитора сердца, атомной станции!
330 Часть 3. Языковые проблемы с этим, но все же остается возможность такой ситуации, когда программа работает пра- вильно, то есть в соответствии с вашим «проектом», и тем не менее, все заканчивается крахом. Мы могли бы использовать какой-нибудь «волшебный» спин-мьютекс (см. раздел 10.2.2) для обеспечения эффективной и надежной работы с функционально-локальны- ми статическими объектами во многом подобно тому, как мы это делали в разделе 11.3.2, но это только решает проблему наполовину. Все же проблема идентичности «константы» остается. Если речь идет о типе сущности (см. раздел 4.2), а не о типе значения (см. раздел 4.6), мы по-прежнему будем в затруднительном положении. Поэтому мне остается только еше раз сказать: никогда не используйте этот подход! Рекомендация: никогда не пытайтесь моделировать константы-члены типа класса с помощью функционально-локальных статических объектов.
Глава 16 Ключевые слова В C++ можно легко определять новые типы (см. гл. 13), поскольку это является одной из главных особенностей C++. Труднее определять значения (см. гл. 15), но все же вполне реально. Но почти совсем невозможно ввести или задействовать каким-то надеж- ным способом новые ключевые слова. Единственная возможность добиться этого - использовать макросы, которые, как известно, являются последним средством компе- тентного практика, даже неидеального. Итак, мы идем на прорыв... 16.1. interface Слово interface нашло широкое применение в посреднике запроса типового объекта (Common Object Request Broker Architecture - CORBA) и в модели компонент- ных объектов (Component Object Model - COM), обозначая полностью абстрактный класс, то есть такой класс, все члены которого - чисто виртуальные. Оно обозначает на- столько важную вещь - по крайней мере, для того, кто читает программный код - что стало полноправным ключевым словом в нескольких новых языках, включая D, Java hC#/.NET. В заголовках СОМ ключевое слово interface определяется с помощью #define; компания Microsoft проявила свое обычное пренебрежение к пространству имен препроцессора, хотя в данном случае это простительно. На первый взгляд, когда речь идет о СОМ, отсутствие этого ключевого слова кажет- ся дефектом, который можно было бы легко исправить, добавив его в язык. Но сущест- вует три возражения. Во-первых, это не принесло бы никакой пользы для текущих COM-заголовков, поскольку оно было бы переопределено в struct, как это сделано сейчас. Во-вторых, это могло бы испортить независимый от СОМ программный код, который может «законно» использовать это слово в качестве имени переменной. Нако- НеИ, самое существенное то, что не существует подходящего определения. Как мы увидим в разделе 19.8, существуют веские основания для того, чтобы отка- заться от хороших идиом C++ при реализации интерфейсов, применяющих подсчет ссылок. В интерфейсах СОМ (и других инфраструктур, использующих подсчет ссылок) 370 проявляется только в методах QueryInterf асе (), AddRef () и Release () в их эквивалентах, а также в других специальных методах интерфейсов.
332 ЧастьЗ. Языковые проб^ interface ICompress public : IUnknown { // Методы IUnknown virtual HRESULT Querylnterface(REFIID riid, void **ppvObject) - q virtual uint32_t AddRefO = 0; virtual uint32_t Release() = 0; // Методы ICompress virtual HRESULT Coinpress ( byte_t *pyin , uint32_t cbln , byte_t *ppyOut , uint32_t *pcbOut , int32_t *pdwFlags) = 0; }; Они специально не содержат конструкторы или деструкторы, поскольку правила организации подсчета ссылок запрещают клиенту интерфейса удалять объект, а вирту- альный деструктор нарушил бы компоновку двоичных объектов и, тем самым, взаимо- действие языков. Даже если бы деструкторы не запрещались правилами архитектуры, при нейтральности СОМ к языкам (до той степени, что реализующий язык сам может сформулировать соответствующие соглашения по вызову функций и по типам пара- метров) обеспечение любого деструктора C++ было бы бесполезно для других языков. Следует отметить, что совершенно разумно и вполне обычно организовывать под- счет ссылок, подобный СОМ (то есть без деструкторов), но который не имеет ничего общего с СОМ. И в самом деле, я написал один компонент, который работал в раз- личных версиях UNIX и также в VMS (см. гл. 8). В других интерфейсных сценариях, основанных целиком на C++ и не использующих подсчет ссылок, обычно можно видеть, что интерфейс действительно содержит чисто виртуальный деструктор для гарантии полного уничтожения объекта [Меуе 1998]; иногда такой деструктор является единственным членом. interface IRoot { public: virtual -Root() = 0; }; inline IRoot::IRoot() // Реализация необходима для чисто виртуального // деструктора {} Обе эти версии «интерфейса» подразумевают управление продолжительностью жизни (см. гл. 5): одна версия использует подсчет ссылок, а другая - явное удаление. Однако когда мы не принуждаем программиста в клиентском программном коде забо- титься о продолжительности жизни интерфейса (или более того - реализованного экземпляра), модель такого интерфейса может быть очень простой, полностью окон центрированной на обеспечении функциональности. Такова модель интерфейса в Java и .NET:
I7i^a 16. Ключевые слова 333 interface Ildentity { virtual int GetldO = 0; } Итак, следует ли ключевое слово interface использовать исключительно для иНтерфейсов, применяющих подсчет ссылок, для разрушаемых интерфейсов или заре- зервировать для «нормальных» интерфейсных классов? Должен ли его смысл зависеть от реализации? Не надо искать ответы на эти вопросы, поскольку C++ (к счастью) под- держивает все эти подходы. Возможно, мы могли бы пойти на компромисс, предлагая «ослабленное» определе- ние, по которому все методы интерфейса должны быть чисто виртуальными? Но это также завершилось бы неудачей, поскольку (как мы видели в разделе 8.2) практически бывает полезно включать не виртуальные методы в определение интерфейса, по край- ней мере, когда они видимы в единицах компиляции C++. Для меня имеет смысл только такое определение interface, которое обладает следующими свойствами: • по умолчанию он является открытым для доступа, подобно struct; • он может содержать не виртуальные функции (и их реализации); • он может содержать чисто виртуальные функции. * он не может содержать никаких не чисто виртуальных функций (и их реализаций). В качестве определений виртуальных функций допускаются только любые чисто виртуальные деструкторы, поскольку это требует объектная модель C++. Однако такие реализации чисто виртуальных деструкторов обязательно должны быть пус- тыми (как показано в прошлом примере); • он не должен наследовать никакой тип класса (то есть struct/class/union; стандарт С++-98: 9.1) кроме другого интерфейса (interface). Конечно, все свойства такого определения могут быть выражены существующими средствами языка, и поэтому едва ли возникнет серьезный интерес к добавлению в язык этого ключевого слова. Тем не менее, я думаю стоит выделить эти вопросы, поскольку с ними часто приходится сталкиваться, но они редко обсуждаются. В разде- ле 8.2.7 мы рассматривали родственное ключевое слово, pinterf асе, которое могло бы принести больше пользы. 16.1.1. pinterface Хотя я могу согласиться, что, по-видимому, нет смысла определять ключевое слово lnterfасе, я полагаю, что имеются достаточно серьезные основания для ввода некоего Другого ключевого слова, например, pinterface, с помощью которого можно было бы Инкапсулировать возможности переносимых виртуальных таблиц, vtable, - всю их эффек- тность и, одновременно, невероятную многословность (см. гл. 8). Другими словами,
334 Часп.3. Языковые проб^ pinterf асе являлся бы структурой struct, которая могла бы содержать только чисто виртуальные методы или не виртуальные методы, и не иметь данных-членов, а таюке имела бы для данной архитектуры общую схему размещения в памяти (упаковка в памяти смежных указателей, размер которых определяется архитектурой) своих элементов и таб- лицы vtable. Тогда мы могли бы легко обеспечить переносимый полиморфизм. 16.2. temporary Пенение операторов неявного преобразования обычно ведет к плохим последствиям (см. часть 4 и [Меуе 1996, Sutt 2000, Sutt 2002]), и автор класса, подобного приводимому ниже, столкнется с потоком насмешек в наши дни: class String { public: operator char const *() const; }; String s("Little Nose"); puts(s); // Используют оператор неявного преобразования Поэтому стандартный класс строки не обеспечивает оператор неявного преобразо- вания. Вместо этого обеспечивается метод c_str (), который возвращает указатель на const char (или wchar_t). Давайте изменим String в соответствии со стан- дартом. class String { public: char const *c_str() const; }; String s("Little Nose"); puts(s.c_str()); // Использует метод - лучше Плохие последствия обычно также имеет присваивание возвращаемым значениям необработанных указателей. Рассмотрим следующий потенциально опасный про- граммный код: String s("Little Nose"); char const *p = s.c_str(); puts(p); p содержит указатель на внутренний буфер si, но значение этого указателя досто- верно до тех пор, пока s не изменяется. Как только s подвергнется обработке, которая его изменяет (или потенциально может изменить), его представление в памяти может настолько изменится, что указатель р станет недостоверным.
Слава 16. Ключевые слова 335 Эта проблема сама по себе не является проблемой указателей. В действительности она относится к более общей проблеме, возникающей в тех случаях, когда клиентский программный код содержит ссылку, указатель, итератор и т. д., которые впоследствии могут стать недостоверными. Мы подробно рассматриваем эту проблему в гл. 31, «Продол- жительность жизни возвращаемых значений». String s("Little Nose"); char const *p = s.c_str(); s[0] = 'L'; // Может вызвать перераспределение своей памяти puts(p); // Указатель может как быть, так и не быть достоверным. // Может работать; может закончиться крахом! Представим, что у нас имеется ключевое слово temporary, применение которого означало бы невозможность присваивания возвращаемых значений переменным. Мы могли бы использовать его для метода c_str () следующим образом: class String { temporary char const *c_str() const; Теперь компилятор может отвергнуть неверный оператор, String s("Little Nose"); char const *p s.c_str(); // Ошибка - нельзя присваивать результат // «временного» метода и нам удается избежать непредсказуемого поведения. Естественно, это не может помешать кому-нибудь написать нечто, подобное следующему: void steal_temp(char const *р, char const **pp) { *PP = P; } String s("Little Nose"); char const *p; ®teal_teinp (s. c_str (), &p); // Опасно! Однако если мы уточним определение temporary так, что оно будет иметь рас- ширенное толкование, подобно const и volatile (известные как cv-спепифика- ^°РЫ; стандарт С++-98: 3.9.3), временную природу указателя можно будет навязать Ш’иотеке программ С этапа выполнения. char "scrcpy(char *dest, char const temporary *src); char *strdup(char const temoorary *src); void steal_tenp(char const tenporary *s, char const **pp)
336 ЧастьЗ. Языковые проблем { *рр ; // Недопустимо« неявное приведение типа временного указателя void copy_tenp(char const temporary *s, char **pp) { *pp strdup(s); // Нормально, поскольку strdupO хдеинимает "Г^чццц^ // указатель } String s("Little Nose’); char const *p; char *copy; steal_tenniP (s.c_str(), 11 He будет компювфоваться. Xopcmol copy_temp(s.c_str(), fccopy), // Нормально Поскольку это только идея - я все еще не написал соответствующее расширение компилятора, т. к. был занят написанием данной книги - возможно, применение специ- фикатора temporary остановит в принципе законное присваивание такого указателя переменной, но было бы неплохо иметь выбор; всегда можно воспользоваться const_cast, который возвращает программисту все прежние возможности. Разработчики спецификаций языков (и также создатели компиляторов) не любят добавлять новые ключевые слова, и я знаю, что скорее Вельзевул станет олимпийским чемпионом по танцам на льду, чем будет принято это ключевое слово, но считаю, что это существенно усилило бы систему типов C++ и ощутимо повысило бы надежность программного кода. Конечно, необходимость любого дополнительного ввода текста не может повысить его (и мою) популярность. Приверженцы одной школы считают, что возврат указателя функцией недопустим ни при каких обстоятельствах, и поэтому здесь нет никакой проблемы. Однако сущест- вует много примеров, когда эффективность может оказаться более существенным фак- тором; в любом случае вы должны в какой-то момент обращаться к библиотеке С-про- грамм этапа выполнения и/или к программным интерфейсам С операционной систе- мы. Если подойти более фундаментально, то концепция прокладок (Shims, см. гл. 20) обеспечивает механизм обобщения, который является более полезной альтернативой применению свойств (traits), а прокладки определенных типов действительно возвра- щают указатели. Кроме того, некоторые определяемые пользователем операторы при ведения типов (см. раздел 19.4) тоже могут использовать аналогичный механизм- Т. к. сфера действия таких «продвинутых» методов расширяется, имеется больше сти мулов для добавления в язык ключевого слова temporary. Этот вопрос подроби0 рассматривается в гл. 31. Напротив, сторонники C++, придерживающиеся противоположной точки зрения* могут возразить, что приведение типов const, volatile и const volatile с п° мощью оператора const_cast представляет собой достаточно грубый подход, и д0
Г/|^а 16- Ключевые слова 337 бавление еще четырех вариантов, связанных с ключевым словом temporary, очевид- на свидетельствует о том, что C++ стал слишком сложным языком и/или что остается ^лько использовать сборку мусора. Эти две точки зрения, по моему мнению, отражают крайние подходы. Согласно одной мы должны полагаться на собственную волю и хорошую практику, ограничивая себя использованием не всего множества возможных конструкций. Другие считают, что язык должен стать заметно менее гибким. Оба подхода отражают точки зрения идеалистов, а мы «неидеальные практики», не так ли? Мне бы хотелось видеть нечто среднее, когда я по-прежнему мог бы делать все то, что я делал до сих пор (или еще больше), но при этом можно было бы рассчитывать на большую помощь компилятора. В конце концов, все делается ради этого. 16.3. owner Предусмотрено три спецификатора доступа: private, protected и public. Смысл private и public достаточно ясен: private означает, что никто вне класса (если не брать в расчет «друзей», то есть тех, кто имеет спецификатор friend) не может видеть или использовать данные члены (переменные или методы); public означает доступность членов из любого другого программного кода. Ключевое слово protected означает, что только объекты данного класса (или его друзей) или его производных классов могут видеть или использовать данные члены; оно обеспечивает доступ из производного типа к частям типа базового класса, когда типы связаны отношением наследования. Иногда полезно разрешать доступ из отдель- ного внутреннего типа к недоступным частям внешнего типа, когда типы связаны структурными отношениями (composition relationship). Отличный пример таких отно- шений мы увидим при рассмотрении способов обеспечения свойств в C++ в гл. 35. К сожалению, C++ не поддерживает этот тип управления доступом. Дефект: C++ не обеспечивает управление доступам для типов, связанных струк- турными отношениями. Нам бы хотелось видеть примерно следующее: чтобы новый спецификатор доступа °^пег гарантировал сложным типам доступ к конструктору своего внутреннего Бемента Inner: Листинг 16.1. class Inner { owner: Inner(Resource *r) : m_r(r)
338 ЧастьЗ. Языковые проблемы {} public: ~Х(); }; class Outer { public: Outer (...) : m_inner(GetResource(. . . )) {} private: Inner m_inner; }; Если вы создаете некий специальный тип, владельцем которого будет какой-то другой специальный тип (один или несколько), то при программировании данного класса можно гарантировать доступ со стороны типов владельцев, используя ключевое слово friend. Однако такая связь очень специфическая и очень жесткая. class Inner { private: friend class Outer; // Хрупкое связывание! Inner(Resource wr) : m_r(r) {} Каждый раз при создании нового типа, куда будет входить Inner, потребуется вносить изменение в определение этого класса, и, следовательно, потребуется снова компилировать весь программный код, куда входят существующие составные типы. Таким способом мы фактически не решаем проблему, а лишь создаем для себя много новых проблем. Конечно, нам нужна только некая обобщенная форма этого метода, и ее можно получить двумя способами. Во-первых, можно определить защищенными члены, к которым должен быть обес- печен доступ со стороны владельца, и затем создать производные закрытые классы- члены для каждого составного класса. Листинг 16.2. class Inner { protected: Inner(Resource *r); }; class Outer
Глава 16- Ключевые слова 339 ——•---- { public: Outer (...) : m_inner(GetResource(. . . )) {} private: class Outerlnner i public Inner < public! Outerlnner(Resource *r) // Ретранслирующий конструктор : Inner(г) О Outerlnner m_inner; }; Это фактически обеспечивает доступ владельцу, но все-таки имеется недостаток - необходимо выполнить некоторую скучную работу, добавляя небольшое количество про- граммного кода при реализации каждого ретранслирующего конструктора (см. гл. 23). Более того, это дает только доступ к методам, но не обеспечивает прямой доступ к полям. Второй метод заключается в использовании шаблонов и в определении в качестве дружественного одного из параметризованных типов, как в следующем примере: // форма #1 template -ctypename Т> class Thing { friend T; // позволяет типу Т видеть внутреннее содержимое Thing<T> private: int m_value; }; Такой подход кажется вполне разумным, не так ли? Увы, так нельзя делать в C++. Стандарт устанавливает, что «внутри шаблона класса, имеющего параметр типа шаб- лона Т, объявление ["Jfriend class Т;["] плохо согласовано» (стандарт С++-98: 7.1.53(2)). Однако мы - неидеальные практики и не являемся адвокатами языка, так что это нас не волнует, пока мы делаем разумные вещи, как в данном случае. Приведенная выше версия, которую я обозначил как форма #1, правильно восприни- мается следующими компиляторами: Borland (5.51 и 5.6), Comeau (43.0.1), Digital Mars, GCC (2.95), Intel (6 и 7), Watcom (11 и 12) и Visual C++ (4.2-7.1). Она не работает с ком- инляторами CodeWarrior (7 и 8) и GCC (3.2). Следует отметить, что Comeau (43.0.1) при- нимает эту и все другие формы, когда установлена его конфигурация по умолчанию для нлатформы Win32. В жестком режиме (—А) он не работает ни с какой формой, под- тверждая недопустимость применения этого метода в C++. Однако начиная с версии • -3 компилятор Comeau содержит опцию —friendT, которую Грег Комо (Greg meau) любезно согласился добавить после относительно небольших уговоров. Теперь MbI м°жем использовать Comeau в жестком режиме, применяя эту очень полезную кон- ^Укцию. (До какой степени она полезна, мы точно узнаем в гл. 35.)
340 Часть 3. Языковые проблемы Поскольку Т является классом - мы обеспечиваем его дружественность, и поэтому этот тип не может быть просто int - вероятно, нам следует указать это компилятору как в следующем примере: // форма #2 template <typename Т> class Thing { friend class T; // Позволяет типу т видеть внутреннее содвромое private: int irt_value; }; Это форма #2. CodeWarrior, Digital Mars и Watcom поддерживают эту форму. Итак, при некотором различии в восприятии компиляторами форм #1 и #2 покрывается их большая часть, но по-прежнему выпадает компилятор GCC 3.2. Поскольку эпидемия дружественности меня обошла стороной1, мой опыт больше ничего мне не подсказывал, и мне потребовалось проконсультироваться с участниками сетевой конференции comp.lang.C++.moderated, которые предложили неко- торые альтернативы. Один из подходящих вариантов я назову формой #3: // форма #3 template <typename Т> class Thing { struct friendjnaker { typedef T T2j typedef typename frisnd_jnakers xT2 friend_type; // friend class fri«nd_type; friend friend_type; private: int m_value; }; Здесь мы вновь сталкиваемся с несовместимостью применения спецификатора class в операторе friend, которую мы видели в формах #1 и #2. Компиляторы GCC и Visual C++ требуют, чтобы спецификатор class не использовался, тогда как для других компиляторов он необходим. Но поскольку нам необходимо обеспечить под- держку третьей формы только (в данный момент) для GCC, мы опустим спецификатор class. Поддержка этих форм компиляторами сведена в табл. 16.1. 1 Я полагаю, что все сообщество разработчиков злоупотребляет применением ключевого слова fr'en Разумеется, я не выступаю за полное прекращение его использования. Я полагаю, всему свое место, даже g0,°’ но все же необходимость применения ключевого слова friend возникает очень редко
Глава 16. Ключевые слова 341 Таблица 16.1. Поддержка дружественных связей различными компиляторами Компилятор Форма #1 Форма #2 Форма #3 Borland (5.51 & 5.6) Да Нет Нет CodeWarrior (7 & 8) Нет Да Да Comeau (4.3.3) С —friendT С —friendT С — friendT Digital Mars (8.26 - ) Да Да Да GCC 2.95 Да Нет Нет GCC 3.2 Нет Нет Да Intel (6 & 7) Да Да Да Visual C++(4.2 - 7.1) Да Нет Да Watcom (11 & 12) Да Да Да Язык говорит, что это недопустимо, и поэтому неудивителен разброс в поддержке компиляторами этой «незаконной» возможности. На самом деле мне, как правило, не нравятся макросы, особенно когда они генерируют программный код, но в данном случае они, по-видимому, необходимы. Поэтому мы можем определить макрос DECLAREJTEMPLATE_PARAM_AS_FRI END (), как показано В листинге 16.3. Листинг 16.3. #if defined(__BORLANDC__) || \ defined(__COMO ) || \ defined (_DMC__) | | \ ( defined (_GNUC_) && \ __GNUC__ < 3) || \ defined(__INTEL_COMPILER) || \ defined (_WATCOMC__) | | \ de fined(_MSC_VER) # define DECLARE_TEMPLATE_PARAM_AS_FRIEND(T) friend T # el if defined (_MWERKS_) # define DECLARE_TEMPLATE_PARAM_AS_FRIEND(T) friend class T # elif defined( GNUC_) && \ _GNUC____ >= 3 * define DECLARE_TEMPLATE_PARAM_AS_FRIEND(T) \ struct friend_naker \ { \ typedef T T2; \ }; \ typedef typename friend_maker::T2 friend_type; \ friend friend_type # endif /* компилятор */ Он используется следующим образом:
342 ЧастьЗ.Языюаыепроблац, // форма #2 template <typename Т> class Thing { DECLARE_TEMPLATE_PARAM_AS_FRIEND(T); private: int m_value; }; Следует отметить, что я определил макрос так, что при его использовании в про- граммном коде необходимо указывать точку с запятой; вы можете сделать иначе. Как я ясно показал в примере программного кода, метод дружественных шаблонов может обеспечить доступ как к полям, так и к методам, но, строго говоря, пользоваться этим подходом незаконно, хотя он очень широко применяется. Он также обеспечивает доступ «ко всему или ни к чему» вместо тонкого управления, которое я обрисовал в начале раздела. В данной книге имеется очень немного примеров, где я рекомендую выйти за рамки стандарта. Есть две причины, по которым я считаю, что это именно тот случай. Во-первых, почти все наши компиляторы (см. приложение А) в данный момент поддерживают эту возможность, и кажется очень маловероятным, что ситуа- ция изменится, разрушая потенциально огромную существующую базу программно- го кода. Во-вторых, поступать так вполне естественно, поскольку это всего лишь имитирует совершенно законное поведение при использовании нешаблонных клас- сов. Тот факт, что компилятор Comeau - возможно самый покладистый компилятор среди всех компиляторов C++ - посчитал возможным добавить опцию —f riendT в своей самой последней версии (4.3.3), является хорошим свидетельством того, что желание добавить эту возможность - совсем не глупость. Допустимо использовать подход на основе наследования, но он требует создания нового программного кода для каждого составного класса. Кроме того, он не позволяет непосредственно осуществлять доступ к полям; необходимо предусмотреть соответст- вующие методы доступа, что еще больше увеличивает объем нового программного кода и, следовательно, увеличивает затраты на сопровождение и тестирование. 16.4. explicit(_cast) Опасность применения операторов неявного преобразования очень хорошо отра- жена в литературе за последние годы ([Sutt 2000, Меуе 1996, Dewh 2003, Stro 1997]), и показано, что это может иметь самые различные непредвиденные последствия при обработке типов - как в отношении семантики, так и в отношении эффективности. Не- которые из проблем иллюстрируют пункт 36 из [Dewh 2003] и пункт 5 из [Меуе 1996]. Рассмотрим неуклюже выполненный класс, показанный в листинге 16.4, который обеспечивает операторы неявного преобразования членов. Очень немного людей могли бы написать такой класс. Но в некотором смысле его написание оправдано,
Cfl^a 16. Ключевые слова 343 поскольку при работе с датами и временем приходится иметь дело с различными типа- ми и подходящий класс времени можно сделать совместимым со всеми необходимы- мИ такими типами. Листинг 16.4. class Time { operator std: :time_t () const; operator std::tm () const; #if defined(UNIX) operator struct timeval () const; #elif defined(WIN32) operator DATE () const; operator FILETIME () const; operator SYSTEMTIME () const; # endif /* операционная система */ }; Но мы - неидеальные практики! Принцип #2 (см. «Введение») устанавливает, что мы смиренные программисты, и поэтому, конечно, не хотим использовать неявные преобразования. Но принцип #4 устанавливает, что накладываемые на нас ограниче- ния не останавливают нас, и поэтому давайте искать решение. В C++ explicit уже является ключевым словом, которое предотвращает неявное использование конструктора класса, к которому оно применяется, и такой конструктор называется конструктором преобразования (см. раздел 2.2.7). Мы рассмотрим здесь другое потенциально очень полезное применение этого ключевого слова, которое, к сожалению, не предусмотрено в языке. Рассмотрим переделанный предыдущий пример в виде псевдокода C++, показанного в листинге 16.5. Листинг 16.5. class Time ( explicit operator std::time_t () const; explicit operator std::tm () const; # if defined(UNIX) explicit operator struct timeval () const; # elif defined(WIN32) explicit operator DATE () const; explicit operator FILETIME () const; explicit operator SYSTEMTIME () const; ttendif /* операционная система */ }; Ключевое слово explicit показывает, что два оператора преобразования нельзя Исп°льзовать для неявного преобразования. Это означает, что экземпляры Time не м°гут неявно преобразовываться в тип time_t или struct timeval или в любой ^Фугой тип времени, но при этом допускаются явные преобразования, как в следующем ^Римере:
344 ЧастьЗ. Языковые проблемы Time t = . . .; std::time_t vl = t; // ошибка - необходимо явное преобразование #if defined(WIN32) FILETIME v2 - static_cast<FILETIME>(t); // Явное преобразование - // нормально! #endif /* операционная система */ Большим достижением была бы возможность поддержки множественных операгоров преобразования там, где в настоящее время приходится воздерживаться от некоторых из них (или всех) из-за их неоднозначности. Поскольку язык не предусматривает такого при- менения ключевого слова explicit, у нас имеется три возможных решения. 16.4.1 . Применение явных методов доступа Как правило, рекомендуется следующий подход [Меуе 1996]: пойти на дополни- тельные затраты по вводу текста и использовать обычные вызовы методов вместо операторов неявного преобразования: class Time { std:get_time_t() const; std: :tm get_tm() const; SYSTEMTIME get_SYSTEMTIME() const; #endif /* операционная система */ }; Мы можем пойти на дополнительный ввод программного кода и просто написать: Time t = . - . ; std::time_t vl t.get_time_t(); // Явный вызов метода: все ясно Преимущество данного подхода заключается в осмысленности имени данного метода доступа - вполне очевидно, что он возвращает. Данный подход рекомендуется стандартной библиотекой для своего типа, как показано в следующем примере: std::string s("A string object); puts(s); // Ошибка - нет соответствующего преобразования puts(s.c_str()); // Возвратить строку в формате С Основной недостаток данного подхода заключается в том, что его универсальность достаточно сомнительна. Он рассчитан на то, что все типы, возвращающие данный тип значения, используют одно и то же имя. Если это действительно так, то можно напи- сать обобщенный программный код - шаблон или нечто другое - который можно при менять для многих таких типов. Но все мы знаем, как легко здесь ошибиться и п0 инерции не заметить ошибку. И еще мы не рассмотрели различные точки зрения на образование имен: должно ли это быть get_time_t (), либо get_timet () > лИ 0 get_std_time_t()?
Слава 16- Ключевые слова 345 Другой недостаток, по-моему, заключается в том, что его семантика вводит в заблу- ждение (по крайней мере, человека). Если я для какого-то типа вызывают функцию get__XYZ (), я вполне естественно ожидаю получить XYZ, владельцем которого он является, а не получить какой-то другой XYZ. Разумеется, отличие достаточно тонкое, но такие тонкости, очевидно, являются предвестниками путаницы в той безумной игре, в которую мы играем. Конечно, можно было бы эту функцию назвать as_XYZ (), но принято, чтобы такие методы имели префикс get_, а непоследовательность в образо- вании имен никогда не давала выигрыш. 16.4.2 . Эмулирование явного приведения типов Поскольку компилятору разрешается выполнять не более одного неявного преобразо- вания внутри выражения, мы можем обеспечить операторы неявного преобразования для промежуточного типа, который сам преобразуется в нужный нам тип. Например: Листинг 16.6. struct tm_cast { tm_cast(std:: tm v) : m_v(v) {} operator std::tm() const { return m_v; } std::tm m_v; }; class Time { operator tm_caat() conet; }; Time t = . . .; std::tmvl Btatic_cast<tm_caBt>(t); // Преобразование с помощью // tm_cast Теперь три типа участвуют в наших преобразованиях, например, Time => tm-Cast => std: : tm. Для того чтобы заставить компилятор выполнять второй этап каждого преобразования, нам необходимо передать ему промежуточный тип, и он сможет его преобразовать. Естественно, было бы лучше, если бы мы могли передать Тре™й тип - тот, который требуется функциям fl и f2, - а первое преобразование обес- Печить компилятором неявно: £l(Btatic_caBt<std:xtm>(t)); // Недопустимо, но читателю понятно
346 Часть 3. Языковые проблемы Понятно, что компилятор не может обладать такой интуицией, поскольку тогда можно было бы слишком легко связать концы с концами: что если бы оба промежу- точных типа имели одинаковый тип неявного преобразования? Синтаксис такого пре- образования имеет все же не очень привлекательный вид, не так ли? Что если бы был другой способ достижения того же самого? А что если поступить следующим образом: class Time { operator explicit_cast<std::tm>() const; }; Time t = . . . ; std::tm vl " explicit_cast<std:: tm >(t); // корректно и все ясно Относительно просто создать шаблон explicit_cast, и мы подробно рас- сматриваем его в разделе 19.5. Обратите внимание, что здесь используется более понятный синтаксис: задается явно именно тип приведения, а не промежуточный тип. В значительной степени синтаксис самодокументируем! Другим существенным плюсом является универсальность этого решения. У нас всего лишь один шаблон вместо потенциально бесконечных tm_cast, DATE_cast, std_string_ref_const_cast и прочих промежуточных типов. В программном коде шаблона параметризованный тип может использоваться для параметра explicit_cast, непосредственно обеспечивая универсальность типов. Как мы увидим в разделе 19.5, это очень хорошо подходит для фундаментальных типов и для типов указателей, но не очень хорошо для константных ссылок и не всегда для неконстантных ссылок. А это означает, что мы имеем только частичное решение, несмотря на всю его концептуальную привлекательность. 16.4.3 . Применяйте прокладки атрибутов Последний способ заключается в сочетании подхода, использующего явные методы доступа (см. раздел 16.4.1) с прокладками атрибутов. Такая прокладка пред- ставляет собой набор из одной или нескольких свободных перегруженных функций, которые применяются для выявления общих атрибутов у экземпляров несвязанных типов; прокладки являются мощным механизмом обобщения. Мы все узнаем о них в гл. 20, и поэтому я пока воздержусь от детального их описания. В данный момент давайте рассмотрим две прокладки и соответствующий клиентский программный код: Н Тфжл&яха. атрибута get_tm() - использует Time::get_tm() inline stds :tm get_tm(Time const &t) { return t.get_tm(); } // прокладка атрибута get_time_t () - использует Time: :get_time_t ()
Глава 16. Ключевые слова 347 inline atd: ttime_t get_time_t (Time comet &t) { return t.get_time_t(); } Time t = . . . ; std:: tin vl = get_tm(t); // Вызывает прокладку атрибута get_tm() std::time_t v2 = get_time_t (t); // Вызывает прокладку атрибута get_time_t() fl (get_tm(t)); // Вызывает прокладку атрибута gec_tm() f2(get_DATE(t)); // Вызывает прокладку атрибута get_DATE() Это наилучшее общее решение, и оно работает корректно со всеми типами и их вариантами (ссылками, указателями, со спецификаторами const и/или volatile), не имея недостатков, присущих другим подходам. Как мы увидим в гл. 20 и дальше в этой в книге, применение прокладок представляет собой отличный механизм обоб- щения, хотя и немного многословный. (Здесь также имеется проблема выбора префик- са имен, get_ или as_, и ни один из вариантов не является идеальным!) 16.5. unique Все мы знаем, что любая функция, объявленная со спецификатором inline, инстанциируется лишь однажды, не так ли? В действительности, на это можно рассчи- тывать почти в той же мере, как и на то, что слова агента недвижимости «компактный и изящный» не означают «за решеткой и в тесноте». В чем же проблема? Внутри одного исходного файла или, более того, внутри одной единицы компиля- ции, компилятор обеспечивает один экземпляр функции, вызываемой в различных местах. Это является основой процедурного программирования, в котором преуспел как С, так и C++. Множественные определения внутри одного исходного файла являются ошибками языка, и поэтому вопроса об их наложении друг на друга не воз- никает. Внутри единицы компоновки конкретная встраиваемая функция теоретически имеет только один экземпляр, и язык C++ обязывает компилятор и компоновщик совместно обеспечить справедливость этого утверждения: «встроенная функция с внешними ссыл- ками будет иметь один и тот же адрес во всех единицах трансляции» (C++- 98: 7.1.2; 4). Другими словами, в любой единице компоновки (см. раздел 9.2) существует только один экземпляр. Однако если мы более внимательно рассмотрим этот вопрос, то обнаружим, что стандарт не противоречит реальности и фактически лишь требует, чтобы встроенная функция «имела одинаковый адрес во всех единицах трансляции». Это означает, что вы могли бы, например, с помощью препроцессора вмешаться в определение встроенной функции и сгенерировать различные определения «одной и той же» функции, тем самым нарушая правило единственного определения. Следо- вательно, стандарт только обязывает, чтобы адрес имел одинаковое значение во всех единицах компиляции. И не более того, и вам следует это учитывать.
348 Часть 3. Языковые проблемы -----------------------------------------------------------------------------.—_ После того, как мы выходим за пределы единицы компоновки, нельзя обеспечить существование только одного экземпляра какой-то функции в данном процессе. Даже при больших возможностях языка, которые позволяли бы нам обеспечивать отсутствие в каждой динамической единице внутри процесса любой функции, определенной в каком-нибудь другом модуле (что на практике невозможно обеспечить), мы потерпе ли бы неудачу на уровне операционной системы. Когда два (или более) процесса которые могли разрабатываться в совершенно разное время (и имея, возможно совершенно разные версии), могут совместно использовать модуль операционной сис- темы, невозможно гарантировать отсутствие в любом из них функции, которая уже определена в каком-нибудь совместно используемом системном модуле. Это происхо- дит из-за того, что эти совместно используемые модули могут получить развитие за то время, пока создавались процессы и специальные их подкомпоненты. Мы видели некоторые достоинства и недостатки всего этого, когда рассматривали контексты совместного использования в гл. 9. Теперь мы можем сказать, что там, где допустимо и практически возможно и где не ожидается/допускается злонамеренное вредительство, спецификатор inline хорошо выполняет функции ключевого слова unique. См. раздел 12.1, где обсуждается другое применение ключевого слова inline. 16.6. final Когда впервые возникла идея написания этой книги, я записал много своих мыслей относительно недостатков языка, а также заимствовал некоторые идеи у других. Одно из самых популярных предложений сводилось к тому, что в C++ отсутствует эквива- лент ключевого слова final, имеющегося в языке Java. Для тех, кто с ним не знаком, можно сказать, что его смысл в целом сводится к следующему утверждению: «у меня не может быть наследников», когда оно применяется к классу. Теоретически это выглядит здорово и, несомненно, предохранило бы многие неос- мотрительные души от опасных перегрузок невиртуальных функций, а также не позво- лило бы «плохим парням»1, которые, чему я был свидетелем, создают производный класс от std: : string для обеспечения оператора неявного преобразования. Грубо говоря, совет такой: «Если ваш класс должен наследоваться, необходимо определить виртуальный деструктор, или он должен быть производным от класса, имеющего виртуальный деструктор. Если не опре- делить (или не унаследовать) виртуальный деструктор, то класс не должен иметь наследников» Причина этого заключается в том, что при полиморфном использовании типа воз- можно его удаление через указатель на базовый тип. Без виртуального деструктора производные части не оказались бы де-инициализированными, вызывая утечку Ре- сурсов и/или аварийное завершение программы. Существует также проблема «масси- вов унаследованных типов», которую мы уже рассматривали в разделе 14.5. 1 Вы знаете, кем вы являетесь!
Глава 16. Ключевые слова 349 Я полагаю, что эта точка зрения слишком ограниченная и, если позволите, немного устаревшая. Она в действительности соответствует лишь двум парадигмам програм- мирования. полиморфизму и абстрактным типам данных, которые на самом деле не покрывают весь спектр. Новая, мощная и (по праву) популярная парадигма обобщен- но программирования требует совершенно нового подхода к типам. В частности, типы могут различаться и обрабатываться в соответствии со свойствами, которые мало или ничего не имеют общего с моделью объекта в C++ [Lipp 1996], а это означает, что применение адаптивных облицовочных классов (см. гл. 21), являющихся наследника- ми своего параметризованного типа, может обеспечить подходящий и эффективный способ повышения гибкости и совместимости без изменения модели объекта. Поэтому нам следует радоваться, что имеем свободу выбора (то есть не ограничены ключевым словом final), - до тех пор, пока мы отвечаем за свое решение. (Опыт, другими словами!) 16.7. Неподдерживаемые ключевые слова Поскольку мы живем в реальном мире, в котором различные компиляторы под- держивают на разном уровне различные свойства языка, нам приходится находить механизмы, позволяющие работать как с ключевыми словами, так и без них, особенно без тех, которые введены совсем недавно. Дефект: для введения новых ключевых слов в язык требуются механизмы под- держки переносимости на случай непредвиденных обстоятельств 16.7.1. typename Хотя три ключевых слова mutable, explict и typename были введены в ком- пиляторы одновременно, typename все-таки не полностью поддерживается на неко- торых распространенных компиляторах1. Ключевое слово typename имеет смысл использовать в четырех случаях. Как заменитель ключевого слова класса в списках параметров шаблона. В этом случае typename работает корректно на всех компиляторах, с которыми я знаком. 2. В качестве спецификатора типа [Aust 1999] в рамках тела шаблонной функции. 3* В качестве спецификатора типа в сигнатурах шаблонных функций и в возвращаемых типах. В качестве спецификатора типа в принимаемом по умолчанию значении параметра шаблона. Borland (включая версию 5.6), Visual C++ 6 и Watcom.
350 Часть 3. Языковые проблему Случаи 1,2 и 3 можно представить следующим образом: template <typename /* 1 */ Т> typename /* 2 */ T::size_type get_sizeof(T const &t) { typename /* 3 */ T::size_type si = sizeof(t); return si; } Случаи 2 и 3 используются для указания компилятору на то, что конкретный иден- тификатор с квалификатором имеет заданный тип, а не является членом. Это нужно делать из-за того, что по умолчанию символ с квалификатором интерпретируется как член, а не тип. Почти всегда, когда данное ключевое слово поддерживается в случаях (2), оно также поддерживается в случаях (3). Однако только совсем недавние версии компиляторов надлежащим образом обеспечивают вариант (2). В момент написания книги наиболее корректно работает Visual C++ 7.1, a CodeWarrior и GCC очень немно- гим отличаются от него. Последний случай самый необычный. Его можно представить, как в следующем примере: template< typename /* 1 */ S , typename /* 1 */ С = typename /* 4 •/ S::value_type , typename ✓* 1 *✓ T = char_traits<C> > class fast_string_concatenator; Это показывает компилятору, что value_type - тип-член класса S. Снова сущест- вует много заблуждений относительно допустимости применения typename в этом контексте, хотя здесь нет отступления от правильной интерпретации шаблона - по крайней мере, в том, что я когда-либо предоставлял такому компилятору, - и поэтому ответ заключается в использовании макросов, настраиваемых на компилятор. Например, библиотеки STLSoft определяют препроцессорные символы псевдо- ключевых слов ss_typename_param_k (1), ss_typename_type_k (2) и (3), а также ss_typename_type_def_k (4) в контекстах, определяемых с помощью ключевого слова typename для компиляторов, поддерживающих его в настоящее время, либо с помощью ключевого слова class (для 1), либо никак (для (2)-(4)). Эт° позволяет создавать максимально аккуратный программный код библиотеки, который не очень аккуратен в сложных шаблонах, и локализовать особенности конкретной среды в одном месте. Эти имена представляют собой компромисс между легкостью восприятия и уникальностью.
Глава 17 Синтаксис я боялся браться за написание этой главы, потому что стилевые войны на эту тему обычно бесполезны, и их невозможно выиграть. Голову даю на отсечение, что невоз- можно написать на эту тему главу, которая вызывала бы одобрение у всех или, по край- ней мере, у большинства читателей. Поэтому я не собираюсь убеждать вас принять мой стиль использования фигурных скобок или точные размеры моих отступов при написании условных выражений, или даже убеждать вас в достоинствах постоянного заключения выражений в скобки. Однако существуют такие элементы синтаксиса C++, которые как будто сами притяги- вают ошибки, и именно их мне приходится рассматривать, хотя я и ожидаю, что неко- торые из вас, уважаемые читатели, вовсе не согласятся с моими предложениями. 17.1. Компоновка класса Этот вопрос относится к структуре объявления классов. C++ реализует специфика- цию доступа, объединяя члены с одинаковым типом доступа в одну группу, как пока- зано в листинге 17.1. Листинг 17.1. class ResourcePool { public: ResourcePool(); ResourcePool(ResourcePool const &); , size_t GetCountO const; Open(Rsrc *prsrc); void Close(); protected: ResourcePool(Rsrc *prsrc); virtual -ResourcePool(); private: II Открытый член II Открытый член II Открытый член II Открытый член И Открытый член II Защищенный член II Защищенный член
352 ЧастьЗ. Языковые проблемы Существует охватившая большую часть разработчиков1 тенденция по объедине- нию членов с одинаковым доступом в отдельные блоки, как показано выше. Аргумен- том в пользу такого подхода является группировка открытого интерфейса класса в одном месте2, что делает его легко доступным для читателя/пользовагеля программ- ного кода. В лучшем случае такой подход можно назвать наивным. Такой стиль кодиро- вания является серьезной ошибкой с точки зрения как пользователей, так и тех, кто сопровождает программный код. Во-первых, он предполагает, что все важные аспекты проекта класса инкапсу- лируются в открытом интерфейсе класса. Это не так. В нашем примере, приведенном выше, конструктор ResourcePool (Rsrc*) является защищенным, а это означает, что производные классы могут захватить свой собственный Rsrc* и передать его кон- структору базового класса. Это является важной особенностью проекта Resource- Pool и не должно скрываться в нижней части объявления класса. Вторая причина более прозаическая - сопровождать такой программный код слож- нее. Если вы хотите изменить тип доступа какого-нибудь члена, вам придется его пере- ставить через довольно большое количество строк вверх или вниз объявления класса; такие изменения трудно отслеживать в системе управления версиями (особенно если она не имеет графический пользовательский интерфейс). Дефект: злоупотребление механизмом объединения членов класса C++ по специ- фикаторам доступа ведет к получению программного кода, который трудно исполь- зовать и трудно сопровождать. Решение очень простое: сгруппировать члены класса в секции по функционально- му признаку. Более того, каждая из таких секций получает заголовок-комментарий и спецификатор управления доступом, даже если спецификатор совпадает с предыду- щим и фактически является избыточным. Это значительно снижает вероятность внесения в определение класса - хотя и тривиальных, но все же раздражающих - ошибочных из- менений, которые нарушают структуру программного кода. Класс ResourcePool должен иметь вид, показанный в листинге 17.2. 1 Так делают даже некоторые из моих августейших рецензентов, или, во всяком случае, они это делал» раньше! 2 Хорошо, если в начале класса! В первое время была мода задавать в начале объявления класса переменные- члены, независимо от их доступности, что в результате приводило к нечитаемому программному ход) •’ воспитанию профессиональных программистов C++, которые считают, что при проектировании классов переменные-члены более важны, чем методы.
Глава 17. Синтаксис 353 Листинг 17.2. class ResourcePool { 11 Конструирование public: ResourcePool() ; ResourcePool(ResourcePool const &); protected: ResourcePool(Rsrc *prsrc); virtual -ResourcePool() ; // Операции public: void Open(Rsrc *prsrc); void CloseO; // Атрибуты public: size_t GetCountO const; private: При анализе объявления этого класса ясно, где находятся операции, относящиеся к конструированию класса, где - собственно операции класса и т. д. Вместо того чтобы убрать деструктор и третий конструктор в конец определения класса после перемен- ных-членов, они собраны в одну группу с другими конструкторами. В реальном про- граммном коде в качестве функциональных секций могут использоваться следующие (все или некоторые): «Конструирование», «Операции», «Атрибуты», «Итераторы», «Состояние», «Реализация», «Члены» и моя любимая «Реализация не требуется». Эту последнюю секцию я ставлю в самый конец определения класса; она содержит объявления элементов, которые специально сделаны недоступными (и они не имеют определения). Мы говорили в гп. 2 о различных способах управления доступом клиен- та к какому-нибудь классу, как, например, о сокрытии операторов копирующего при- сваивания, и они размещаются именно в этом разделе, как в следующем примере: // Реализация не требуется private: ResourcePool ^operator =(ResourcePool const &) ; }; Фактически это нарушает правило объединения логически связанных членов клас- са, и вы будете совершенно правы, отмечая это. Я предпочитаю выделить одно место, где будут находиться все запрещенные члены - своеобразное «чистилище» для мето- дов. Однако я сознаю эту непоследовательность, и вы можете поступать по-другому в своем программном коде.
354______________________________________________________ЧастьЗ-Языюеые проблемы Давайте рассмотрим преимущество этого нового подхода. Предположим, что мы хотим изменить доступ к -Resourcepool () с возможностью полиморфного уничтожения иерархических экземпляров ResourcePool. Это означает просто вставку ключевого слова public: перед деструктором, a protected: - после него для сохранения ранее установленного типа доступа в остальной части блока (см. лис- тинг 17.3). Листинг 17.3. class ResourcePool { // Конструирование public: ResourcePool(); ResourcePool(ResourcePool const &); protected: ResourcePool(Rsrc *prsrc); public: virtual -ResourcePool(); protected: Теперь станет очевидным, что и почему произошло, если проанализировать разни- цу в системе управления версиями. Для читателя программного кода структура класса не изменяется. Секция конструирования по-прежнему содержит список конструк- торов, заканчиваемый деструктором. Если посмотреть на программный код класса, который в значительной степени является самодокументируемым, станет ясно, какие из них свободно доступны, а какие - нет. Не забывайте: единственная документация, которая гарантированно не устаревает. - это сам программный код [Dewh 2003, Кет 1999].’ Наконец, мне бы хотелось подчеркнуть очевидное преимущество данного подхода. Из-за того, что применение единственной четко определенной структуры легко входит в привычку, очень маловероятно, что кто-то забудет обеспечить вспомогательные функции (например, наличие копирующих конструкторов и операторов копирующего присваивания для типов, управляющих ресурсами; см. гл. 3 и 4), как предписывает практика хорошего программирования на C++. Более того, существенно упрощается написание анализаторов программного кода (на Perl, Python или Ruby). Одно предостережение: если вы согласны с этим подходом, последнее, что вы должны предпринять - это сразу же взяться за его применение ко всей базе вашего про- граммного кода. Вы сделаете фактически невозможным отслеживание истории суще- ствующей базы программного кода, и те, кто работает с вами над совместными проек- тами, сделают так, что вы не проснетесь! Всегда сохраняйте внедрение в практику измененной структуры программного кода для новой версии и вносите лишь неболь- шие изменения в существующий программный код. * 1 Двое из моих коллег, работы которых я очень уважаю и которым иногда подражаю, возвели этот принн»11 в крайнюю степень. Для них «программный код является документацией!»
рова 17. Синтаксис 355 17.2* Условные выражения 17.2.1. Делайте условное выражение булевым Профессиональные программисты C++ унаследовали от С пристрастие, иногда слишком сильное, к краткости выражений. Совсем нередко можно видеть, например, такое: for(int i=23;i;—i) if(x) do{ . . . } while(j && p) ; Конечно, существует определенная притягательность написания эффективного программного кода с использованием минимального количества строк/символов, но за редким исключением [Dewh 2003] привлекательность этого подхода в лучшем случае обманчива. С этим связаны три проблемы. Во-первых, расчет на то, что компилятор преобразует любой целый тип или тип указателя в булево выражение, приводит к тому, что профессиональные программисты свыкаются с мыслью, что не-ноль соответствует значению «истина», а ноль - значе- нию «ложь» для всех типов. Это справедливо в подавляющем большинстве случаев, но все равно не всегда. Нет необходимости лишний раз напоминать, что именно эти редкие примеры других случаев являются причинами трудно обнаруживаемых оши- бок. Рассмотрим используемый на платформе Win32 тип HANDLE: для большинства объектов ядра Win32 значение 0 или NULL типа HANDLE обозначает несуществующий дескриптор. Однако это не так, когда мы имеем дело с дескрипторами файлов. Символ INVALID_HANDLE_VALUE (который имеет значение OxFFFFFFFF) представляет собой недействительный дескриптор; другие значения используются для достоверных дескрипторов. Так, следующий программный код совершенно неверен: HANDLE hfile = ::CreateFile( . ); if(hfile) { } Вторая проблема состоит в том, что такой синтаксис способствует написанию авторами пользовательских типов операторов неявного преобразования (например, °Perator bool () const) для поддержки этой краткой формы записи. Примером получения по этой причине классического кошмара является оператор basic_ios: : operator void * (), который возвращает бессмысленный ненуле- вой указатель, если бит отказа не был установлен для потока. (Мы увидим более под- ходящий способ представления operator bool () в гл. 24.)
356 Часть 3. Языковые проблемы Дефект: неявная интерпретация не булевых (под)выражений1 дает неверный ре. зультат для значений определенных типов, что вызывает лишние переделки типов определяемых пользователем. Решение заключается в том, чтобы всегда делать свои выражения четко булевыми. (Следует отметить, что это не означает, что они должны явно приводиться к булеву типу; это было бы неэффективно - см. раздел 1.4.2.) Поэтому два предыдущих наших примера могут выглядеть следующим образом: for(int i=23;i!=0;--i) if(x != 0) do{ . . . } while(j != 0 && p != NULL); и HANDLE hfile = ::CreateFile( . . ); iffhfile != INVALID_HANDLE_VALUE) { } За обычным вздохом раздражения слышен крик: «В таком случае приходится на- бирать больше текста!» Однако в этом проявляется неспособность осознать реально- сти разработки программного обеспечения: большинство программного кода должно сопровождаться [Gias 2003], и хотя сначала приходится набирать больше текста, в итоге объем кодирования уменьшается. Откровенно говоря, такие аргументы редко можно услышать от создателей библиотек или разработчиков больших долгосрочных проектов. Итак, это относится к простым типам, но как обстоит дело с определенными поль- зователем типами и их неявными операторами? Если мы осуждаем применение неяв- ных операторов и неявную интерпретацию не булевых выражений как булевых, нам не только придется вводить больше текста (сейчас), с чем, как известно, мы можем смириться, но разве мы не снижаем к тому же гибкость программного кода? Например, пусть у нас имеются алгоритмы, которые работают с «умными» и обычными указате- лями. Наши классы, к счастью, не обеспечивают операторы неявного преобразования для их необработанных эквивалентов (см. часть 4 и 5, где обсуждаются эти вопросы), но они содержат operator bool () const и operator ! () const. Поэтому если мы принимаем решение, что наши выражения в условных операторах не основаны на неявной интерпретации необработанных указателей как булевых величин и они не могут сравниваться с 0 или NULL, потому что такие неявные преобразования указа- 1 Для того чтобы обсуждение было более содержательным, я стараюсь меньше использовать несущественных деталей, и поэтому там, где я употребляю слово «выражение», я также имею в виду «подвыражение».
Глава 17. Синтаксис 357 телей не обеспечиваются для умных указателей, нам остается неприятный выбор - специализировать наши алгоритмы отдельно для указателей и не указателей. В зависи- мости от того, насколько современен используемый нами компилятор (один или не- сколько), будет создано одно или более определений. Похоже, что это убедительный контраргумент не в пользу моей рекомендации применения явных булевых условных выражений. Ответ приходит в форме прокладок атрибутов (мы познакомимся с кон- цепцией прокладок в гл. 20) для того, чтобы алгоритм содержал проверки, исполь- зующие функции-прокладки, как в следующем примере: template <typename Т> size_t calc_something(T р) ( if(is_null(p)) ( return . . .; } return . ; } Прокладка is_null («имеет нулевое значение») автоматически учитывает отличия необработанных и умных типов указателей (в силу соответствующего определения прокладок), и булева природа условного оператора навязывается типом возвращаемого прокладкой значения, а алгоритм оказывается самодокументируемым, т. к. прокладка делает именно то, о чем говорит ее название - в данном случае она проверяет р на «равенство нулю». 17.2.2. Опасные присваивания Этот вопрос очень простой, но вызывает споры. Многие читатели могут иметь здесь реальные проблемы и убеждать других, что «я никогда не делаю такой ошибки!»... и на самом деле делать. Все мы делаем. Рассмотрим следующий программный код: if ((options == ( WCLONE| WALL)) && (current->uid = 0)) retval = -EINVAL; С помощью этого программного кода недавно специально пытались [http://lwn.net/ Articles/57135/\ внести преднамеренный изъян - тайную лазейку - в программную ядра Linux. Если бы я включил также несколько строк, окружающих этот программный код, и не выдал бы себя подсказкой в виде заголовка раздела, я ручаюсь, что не более 10 процен- Тов всех читателей смогли бы сразу обнаружить проблему. Я не собираюсь насмехаться Над вами, поскольку я сам попал бы в другую 90-процентную группу; я недавно обнару- жил одну из таких ошибок в моей программе пятилетней давности. Просто люди пРивыкли создавать шаблоны, и мы часто видим то, что ожидаем увидеть.
358 ЧаяьЗ. Языковые npo^^ Причина проблемы в том, что С и C++ интерпретируют скалярные выражения - целые числа, числа с плаваюшей точкой и типы указателей - как булевы (стандарт С++-98; 6.4; 4). Добавьте к этому способность размещать выражения присваивания внутри условных операторов, и у вас готов рецепт катастрофы. Требуется всего лищь случайный или преднамеренный пропуск второго знака в операторе сравнения на ра- венство, и в итоге мы получаем присваивание. В нашем примере автор фрагмента про- граммного кода специально использовал оператор присваивания вместо оператора сравнения на равенство, чтобы избавиться от соответствующей проверки. Обычно такое случается по ошибке, и эту ошибку легко совершить, но она может иметь необра- тимые последствия. Дефект: неявная интерпретация скалярных типов как булевых в условных операторах способствует применению ошибочного присваивания из-за синтаксических ошибок Упрощенное решение этой проблемы заключается в запрете операторов присваива- ния внутри выражений, как это делает Python. Однако это фактически будет самооб- маном; сообщество C/C++ никогда такое решение не поддержит. Но язык хотелось бы изменить, повышая устойчивость без потери полезных операторов присваивания внутри условных выражений. При наличии в условном выражении присваивания всего лишь требуется гарантировать, чтобы выражение было явно булево (что мы обсуждали в предыдущем разделе). Это была бы пустяковая плата. В конце концов, мы все-таки привыкли так делать во многих случаях: while ((ch = getcharO) ' = EOF) { Возвращаясь в реальный наш мир, мы увидим, что имеется две возможности реше- ния данной проблемы. Во-первых, нужно установить высокий уровень предупрежде- ний компилятора и проверять свой программный код на нескольких компиляторах. Самые хорошие компиляторы обнаружат просчет и предупредят вас, но это не будет полным решением, потому что некоторые этого не делают, а другие нельзя использо- вать с максимальным уровнем предупреждений из-за большого объема сообщений об ошибках в системных заголовочных файлах и в заголовочных файлах стандартной библиотеки. Во-вторых, если один из операндов операции сравнения представляет собой rvalue - то есть ему нельзя присваивать другое значение - его следует записывать с левой сторо- ны в операторе сравнения на равенство значений. Тогда ошибочное использование сим- вола = вместо == приведет к ошибке компиляции на всех компиляторах. Следует отме- тить, что это не всегда удается сделать, потому что иногда будут сравниваться два значе- ния lvalue.
Глава 17- Синтаксис 359 Если это вас не убеждает, то можно привести гораздо более убедительный пример. В гл. 1 мы рассматривали утверждения и обсуждали опасности, которые представляют побочные эффекты применения утверждений. Рассмотрим следующий программный код: int i = . . . assert(i = 1); Утверждение необходимо записывать, используя ==, а не =, т. к. в последнем случае переменная i на самом деле всегда равнялась бы 1. Увы, указанное выше выра- жение является присваиванием. Поскольку i = 1 равно 1, утверждение никогда не будет срабатывать, и всегда будет казаться, что программный код корректен. Из-за на- личия в этом месте утверждения у вас возникнет ложное чувство безопасности. А если бы оно было записано в виде assert (l=i);, компилятор сразу же отреагировал бы. Это не только полностью решает проблему использования ошибочного присваива- ния, но также улучшает читаемость выражений, в которых проверяется результат вызова длинных функций. Какой из следующих вариантов лучше воспринимается? if(long_function( аргумент!, аргумент2, аргументЗ, аргумент4, аргумент5, аргументб) == RETURN_CODE_3) { if(RETURN_CODE_3 == long_function(аргумент!, аргумент2, аргументЗ. аргумент4, аргументе, аргументе)) { Проанализируем, что получается, когда код возврата убирается из видимой справа области вашего текстового редактора. Это выглядит просто, неуклюже и немного неприятно. Можно также сказать, что это противоречит естественному образу мышления людей - мы обычно говорим «у меня 5 яблок», а не «5 яблок у меня», - но мы можем удивительно быстро привыкнуть к такой форме выражений. Не стоит тратить время на обсуждение количества пробелов для отступа и прочих мелочей. Существует простой механизм повышения устойчивости вашего программ- ного кода. Он работает. Применяйте его*. Один из рецензентов (которого нельзя назвать настоящим поклонником языка) высказал мнение, что это говорит в пользу использования lint, а поскольку lint не работает с C++, это значит, что следует вместо C++ пользоваться С Хотя мне понятна такая точка зрения, я с ней не согласен и полагаю, что вы со мной солидарны, иначе вы бы не читали эту книгу!
360 Часть 3. Языковые проблемы 17.3. for 17.3.1. Область видимости оператора инициализации Изменение правила организации выражений оператора for, введенное в стандарт С++-98, стало кошмаром для тех, кто занимается переносом программного обеспечения. В целом, старое правило устанавливало, что оператор инициализации переменной, объявленной в операторе for, будет находиться в области видимости самого опера- тора for. Поэтому вполне законен программный код, показанный в листинге 17.4. Листинг 17.4. for(int i = 0; i < 10; ++i) { ) i = 0; // i по-прежнему доступен for(i =0; i < 10; ++i) // Повторное использование переменной i, // согласно старому правилу { } Внесенное в правило изменение устанавливает, что область видимости перемен- ных инициализирующего оператора ограничена оператором for, как проиллюстриро- вано в листинге 17.5. Листинг 17.5. forfint i = 0; i < 10; ++i) { • • . // i доступен здесь } i 0; // здесь не доступен. Ошибка касиляции. for(int i 0; i < 10/ ++i) // По новому правилу требуется другая переменная i { } Это изменение правила сузило область видимости переменных инициализации, т. к. согласно старому правилу переменные, объявленные в операторе инициализации, могут повторно использоваться в другом месте окружающей области видимости, нару- шая локализацию внутренней области видимости и вызывая неожиданные побочные эффекты и проблемы для тех, кто сопровождает программный код.
Глава 17. Синтаксис 361 Очевидно, оба эти правила устанавливают совершенно разные требования с точки зрения вполне корректного существующего программного кода. Поддержка простой базы программного кода для различных компиляторов становится очень сложной задачей. Дефект: противоречие старого и нового правил организации оператора for при- водит к созданию непереносимого программного кода. Поскольку переносимость (даже если речь идет о разных версиях одного компиля- тора) - крайне желательное качество программного обеспечения, нам необходимо выра- ботать механизм, который работает как со старым, так и с новым правилом. Один подход ([Dewh 2003]) заключается в обеспечении дополнительной огораживающей области ви- димости путем помещения всего оператора for в отдельный блок: { for(int i = 0; i < 10; ++i) { }} Этот прием заставляет в любом программном коде, выполненном по старым прави- лам, организовать работу по новым правилам. Поскольку новое правило в большей степени способствует получению корректного программного кода, этот подход имеет полезный побочный эффект, подготавливая программный код и его автора к новому стилю мышления. Не следует забывать об эффективности доброго старого приема - применения закрывающей скобки } - в чем не раз мы будем убеждаться во многих местах данной книги. 17.3.2. Разнотипные операторы инициализации К сожалению, это не единственная проблема, связанная с оператором for. Фактически, существует недостаток, который вызывает гораздо большее раздражение. Оператор инициализации позволяет объявлять и инициализировать несколько пере- менных, как показано в следующем примере: for(int i = 0, j = 10; i < j; ++i) { } Однако быстро сталкиваешься с ситуацией, противоречащей здравому смыслу при попытке сделать что-то, подобное следующему: for(int i = 0, vector<int>: :const_iterator b = v.beginO; i < 10 && b ! = v.endO; ++i, ++b) {
362 ЧастьЗ. Языковые проблемы Это происходит из-за того, что в операторе инициализации можно указывать только один тип. Я могу понять, почему операторы, содержащие несколько типов, не могут однозначно компилироваться; хотя я не являюсь создателем компиляторов, я признаю что это может быть вызвано серьезными причинами. Так или иначе, это создает неудобства и сразу же отбрасывает нас назад к старому правилу организации опера- тора for, поскольку мы должны использовать только один тип оператора инициализа- ции, как в следующем примере: vector<int>::const—iterator b = v.beginO; for(int i = 0; i < 10 ЬЬ b != v.end(); ++i, ++b) ( } или: int i = 0; for(vector<int>::const—iterator b = v.beginO; i < 10 && b != v.end(); ++i, ++b) { или: int i ; vector<int>::const-iterator b; for(i = 0, b = v.beginO; i < 10 && b != v.endf); ++i, ++b) { } Какую бы форму вы ни выбрали, одну или обе переменные все-таки приходится объявлять за рамками оператора for, и поэтому мы по существу возвращаемся к старому правилу. Дефект: операторы for с двумя или большим количеством операторов инициа- лизации сводят на нет новое правило, ограничивающее область видимости перемен- ных, объявленных в операторе for. В простом примере с оператором for мы видели, что дополнительная область видимости помогает обеспечить соответствие новым правилам для программного кода, выполненного по старым правилам. Здесь мы снова станем использовать этот прием, чтобы способствовать реализации семантики нового правила для оператора for в программном коде, в котором это правило непосредственно не работает- Это сделать очень просто, хотя результат имеет не очень привлекательный вид:
Глава 17. Синтаксис 363 -------------------------------------------------------------------------- { int i; vector<int>::const_iterator b; for(i = 0, b = v.beginO; i < 10 && b != v.end(); ++i, ++b) { }} // Здесь прекращается существование i и b. Если вы думаете, что это правило надумано, рассмотрите ситуацию, когда вам не- обходимо в цикле просматривать два или более контейнера различных типов и прихо- дится организовывать цикл вручную, а не пользоваться оператором std: : f or_each; без него вам придется использовать b_v, b_l, Ь_ш (или begin_vec, begin_lst, begin_map) вместо обычного b (либо begin, либо чего-то другого). В результате программный код трудно создавать, читать и сопровождать. Следует отметить, что этот изъян встречается не только в C++; несколько других языков - С#, D - обладают тем же изъяном в своих конструкциях организации циклов (for, foreach). 17.4. Обозначение переменных Этот раздел предусмотрен не для нравоучений, а скорее как возможность объясне- ния моей системы обозначения членов класса, чтобы при последующем чтении книги больше не возникало вопросов. 17.4.1. Венгерская нотация Я собираюсь быть максимально кратким, поскольку обсуждение этой темы подобно открытию яшика Пандоры! Я не верю, что учет в названии переменных их типа прино- сит какую-то особую пользу, и на самом деле полагаю, что это является причиной кош- маров для тех, кто занимается сопровождением программного кода. Венгерская нотация нацелена на обеспечение читателю программного кода большого количества информа- ции относительно переменой, как показано в следующем примере: short sMaxHandlelndex; // Префикс s означает тип short char const **ppcszEnvBlock; 11 ppcsz означает указатель на указатель, // ссылающийся на // константный тип char, представляющий строку, // заканчиваемую нулем Что случится при переносе программного кода, содержащего sMaxHandlelndex, в Другую архитектуру, в которой максимальное количество дескрипторов превышает Диапазон значений типа short? Возможно, потребуется изменить тип на long, и в этом случае указанный в имени тип переменной будет совершенно ошибочным. long sMaxHandlelndex;
364 ЧастьЗ. Языковые проблемы Конечно, требование переносимости подрывает основы венгерской нотации. С другой стороны, учет всех особенностей типа переменной часто оказывается совсем неуместным и может очень отрицательно сказаться на читаемости. typedef map<string, map<string, int> > string_2_string_2_int_map_jnap_t ; string_2_string_2_int_jnap_jnap_t s2s2immIncludesDependencyTree • He надо смеяться; я видел это (конечно, настоящие имена здесь изменены) в реаль- ном программном коде! В действительности здесь мало пользы от имени s2s2imin, не так ли? Оно крайне хрупкое, не говоря уже о том, что его почти невозможно читать. Гораздо лучше следующий вариант: typedef map<string, map<string, int> > string_2_string_2_int_jnap_jnap_t ; string_2_string_2_int_imap_jmap_t includesDependencyTree; ИЛИ typedef map<string, map<stnng, int> > IncludeDependencyTree_t; IncludeDependencyTree_t includesDependencyTree; Из-за этих проблем большинство людей теперь полностью избегают применять любую форму венгерской нотации. Однако, будучи любителем необычных подходов, я в своем собственном программном коде в действительности использую ограничен- ную форму подобных префиксов, с которой вы встречаетесь повсюду в книге. Я не собираюсь рекомендовать вам поступать так же, а просто объясняю свою позицию и пытаюсь сэкономить ваши усилия и не отсылать мне сообщения по электронной почте, в которых говорится о том, как несовременен мой программный код. Не забывайте, я сказал, что излишне отражать тип переменной в ее имени, и что это не способствует переносимости программного кода. Однако, как я считаю, часто имеет смысл знать назначение переменной. Например, при работе со строкой символов можно использовать различные символьные типы, например, char или wchar_t. Очень часто, когда речь идет о размере буфера, используемого в таком типе, функции принимают или возвращают значения, отражающие количество символов. Однако иногда в таких случа- ях требуется указывать количество байтов. Невнимательное отношение к их отличию может обойтись вам очень дорого и послужить причиной краха программы (и карьеры). Поэтому я применяю префиксы cb и cch, которые означают count-of-bytes (количество байтов) и count-of-characters (количество символов), соответственно. Эти префиксы ни коим образом не отражают тип таких переменных - они могут иметь тип int, short, long и т. д. - и поэтому не возникает проблем с переносом; они определяют назначение переменных, что улучшает читаемость программного кода.
Глава 17. Синтаксис 365 Я не рассчитываю, что вы согласитесь с этой точкой зрения и примете эти соглаше- ния, а также я даже не думаю, что они - «наилучшие». В любом случае лучше, когда в самом типе переменной отражается ее назначение (например, я предпочитаю byte__t типу unsigned char; раздел 13.1), но иногда необходимо предоставить немного больше информации. 17.4.2. Переменные-члены Большинство программистов оформляют имена переменных-членов как-то особо, чтобы можно было их отличать от переменных, которые не являются членами. Однако для этого они применяют несколько схем. Основной вариант предусматривает отсутствие вообще каких-либо «украшений», как в следующем примере: class X { public: void SetValue(int value) { this->value = value; } private: int value; }; Я знаю некоторых парней, которым нравится этот подход, но я считаю, что здесь просто «нарваться» на катастрофу. Слишком легко пропустить this при написании программного кода, и в результате мы получим: void SetValue(int value) { value = value; // Ничего не происходит; "this" не изменяется } Поскольку допускается присваивать переменную самой себе (кроме случая запрета Доступа к оператору копирующего присваивания; см. раздел 14.2.4). компилятор спо- койно будет делать то, что вы ему говорите, и в результате ваш программный код будет содержать ошибку. Конечно, можно в реализации метода указать спецификатор const, как показано в следующем примере: void SetValue(int const value) { value = value; // Ошибка компиляции ) Но это поддерживается компиляторами не всегда и фактически является всего лишь неуклюжим решением серьезной проблемы. Я время от времени использую простые имена в тривиальных структурах, где все Члены имеют открытый доступ, а методы простые и их очень немного (возможно, просто Инструктор), но в сколько-нибудь сложных типах классов я обычно так не делаю.
366 ЧастьЗ. Языковые проблемы Мне известны другие четыре схемы. Первая предусматривает применение знака подчеркивания в начале переменных-членов, как в следующем примере: void SetValue(int value) { _value = value; } Однако стандартом идентификаторы с подчеркиванием в начале зарезервированы для имен в глобальном пространстве (стандарт С++-98: 17.4.3.1.2) и в : : std, и поэто- му я считаю, что их применение для других целей представляет собой просто плохую привычку (от которой я сам все-таки не совсем избавился). Альтернативой является применение подчеркивания в конце имени, как в следующем примере: void SetValue(int value) { value_ = value; ) Это вполне допустимо. Однако обе эти формы, на мой взгляд, слишком ненадежные. Я предпочитаю форму, которая популяризуется в MFC7 и предусматривает применение префикса ш_, как показано в следующем примере: void SetValue(int value) { m_value = value; } Это напоминает старое оформление тегами членов С-структуры struct, напри- мер, члены структуры tm имели имена tm_hour, tm_wday и т. д.; это объяснялось тем, что в ранних версиях С имена всех членов находились в одном пространстве имен. Однако этот подход универсален и потому надежен. На самом деле он мне настолько нравится, что я использую его варианты: sm_ - для статического члена, д_ - для гло- бальных переменных1 и s_-для статических переменных, как в следующем примере: int InitOnce(int val) { static int s_val « val; return s_val; } class Y { static int sm_value; }; Именно такую систему обозначений вы увидите в программном коде повсюду в книге. Конечно, от глобальных переменных столько вреда, что я пользуюсь ими не чаще одного раза в год.
Глава 18 Имена, вводимые typedef Спецификатор typedef в С и C++ обеспечивает новое имя заданному типу. Два его классические применения в С связаны с устранением путаницы при использо- вании указателей функций и с уменьшением объема вводимого текста при использова- нии структур. Рассмотрим программный код листинга 18.1, в котором не используются спецификаторы typedef. Листинг 18.1. // Info.h struct Info {}; int process(int (•)(struct Info*, int*), int*); // client_code.cpp int process_forwards(struct Info*, int); int processjbackwards(struct Info*, int); int main() { struct Info info; int (*pfn[10])(struct Info*, int); for(i =0; . . .; ++i) { pfn[i] = . . . Сопоставьте его с программным кодом листинга 18.2, в котором используется typedef для структуры и указателя функции. Листинг 18.2. // Info.h typedef struct Info {} Info_t; // typedef структуры typedef int (*processor_t)(Info_t*, int*); H typedef указателя функции int process(processor_t , int*); II client_code.cpp int process_forwards(Info_t*, int);
368 ЧаяьЗ. Языковые прое^ int proc«aa_J>ackwarda(Info_t*, int); int main() { Info_t info; procaaaor_t pfn[10); for(i = 0; . .; ++i) { pfn[i] = . Я надеюсь, вы согласитесь, что последний вариант воспринимается гораздо лучше Также допускается (стандарт С++-98: 7.1.3; 2) переопределять имя любого типа (в рамках одной области видимости) на тип, уже веденный с помощью typedef. Довольно бес- смысленно это делать для многих типов - например, typedef int Sint; typedef Sint Sint; - но очень полезно избавиться от необходимости печатать struct или запо- минать связь между именем структуры, веденным typedef, и реальной структурой, для которой оно является синонимом: typedef struct Info {} Info; // синоним самого себя int main() { Info info; Фактически, C++ отличается от С тем, что отсутствует требование указания перед типом класса ключевого слова класса (слово class/struct/union; стандарт С++- 98: 9.1). Это позволяет пользоваться более ясной и краткой формой без помощи специ- фикатора typedef: struct Info О; Info info; Но если вы хотите обеспечить совместимость с С, то лучше определить синоним с помощью typedef. Где спецификаторы typedef действительно используют особенности C++, так это в том, что они могут определять типы членов внутри классов и пространств имен, что в значительной степени способствует применению обобщенного программирования, включая механизм свойств (traits mechanism), как показано в листинге 18.3. Листинг 18.3. template ctypename Т> struct sign_traits; template <> struct sign_traits<intl6_t>
Глава 18. Имена, вводимые typedef 369 typedef intl6_t type; typedef intl6_t signed_type; typedef uintl6_t unsigned_type; template <> struct sign_traits<uintl6_t> typedef uintl6_t type; typedef intl6_t signed_type; typedef uintl6_t unsigned_type; В данной главе подробно рассматривается typedef, некоторые опасности, связанные с его применением, и отличия между использованием typedef для определения концеп- туальных типов и для определения контекстуальных типов. Наконец, здесь вводится кон- цепция «настоящих» typedef (True Typedefs) и иллюстрируется ее реализация на основе шаблонов, которые могут использоваться для обеспечения более высокой степени типобе- зопасности, чем допускает сам язык. 18.1. Использование typedef для указателей Сумеете ли вы найти ошибку в следующем программном коде? class X { public: Х(const PBYTE ); }; Х::Х(const BYTE *) {) Вероятно, вы просто скажете, что объявление и определение конструктора имеют разные сигнатуры. Ну, возможно, вы правы, а возможно, - нет. В объявлении используется вводимый typedef тип указателя PBYTE в то время, как в определении используется про- стой тип (хотя и определенный, несомненно, с помощью другого typedef) BYTE. Если PBYTE определяется с помощью typedef (по-видимому) следующим образом: typedef BYTE *PBYTE; то вы правы, и приведенное выше определение класса неверно. Однако, если этот символ определяется, как: #define PBYTE BYTE* то вы не правы, и (после работы препроцессора) в объявлении и в определении К°нструктора используется один и тот же тип.
370 ЧастьЗ. Языковые проблемы Вы скажете, я уверен, что никому никогда не придет в голову сделать такое Бог с вами! Как будто вы работаете там, где все делается гармонично и профессио- нально, а не среди людей. В реальных условиях такое вполне возможно. Причина использования typedef для указателей - двоякая. Во-первых, сущест- вуют или, по крайней мере, существовали архитектуры, содержащие указатели раз- личной «природы», особенно такие неприятные, как far, near в 16-битовом Windows и при смешивании режимов архитектуры Intel. Другая причина - чрезмерная много- словность многоуровневых типов указателей, например: const int * const * * pl; int const * * const * p2; int const * const * * p3; const int * * const * p4; int * const * const * p5; int * const * * const p6; const inc * * const * far p7; int * far const * const * p8; Выглядит не очень привлекательно, вы согласны? Некоторые выскажут сомнение, что это будет лучше смотреться при использовании typedef для определения таких указателей: PPCPCint pl; PCPPCInt р2; PPCPCint рЗ; PCPPCInt р4; PCPCPInt р5; CPPCPInt рб; FPCPPCInt р7; PCPFCPInt р8; Я признаю, что здесь есть ограниченное снижение пути, который проходит глаз, хотя, возможно, от спецификаторов типа far-near платформы Winl6 было бы не- много больше пользы. Преимущество такой схемы заключается в нормализации имен указателей, особенно когда можно гибко пользоваться спецификатором. int const ♦ * const *р9; const int * * const *plO; PPCInt const *pll; const PPCInt *pl2; В действительности, все они представляют один тип: PCPPCInt. Хорошо это или плохо, но нам из-за этого приходится разрешать конфликты, описанные ранее. Решение двояко: вам необходимо использовать в своей работе те или иные соглаше- ния, и вам не следует забывать об этой проблеме, когда вы встречаетесь с примене- нием typedef для указателей.
Глава 18. Имена, вводимые typedef 371 При работе с новыми библиотеками, рассчитанными на современное подмножест- во компиляторов и операционных систем, я склоняюсь в пользу варианта с простыми указателями, поскольку такой подход естественен и лучше воспринимается и понима- ется новыми пользователями, а также маловероятно, что он будет раздражать сторон- ников традиционного подхода. В библиотеках системы Synesis применяется другой подход, потому что они существуют уже долгое время (и поэтому они вынуждены были сосуществовать со всеми странными моделями памяти, которые использовались раньше), и им приходилось работать с гораздо более широким спектром компиляторов и рабочих сред. Если вы решили не переопределять указатели с помощью typedef, то вам необ- ходимо определиться с местом расположения уточняющих спецификаторов. Я следую примеру Дэна Сэкса (Dan Saks) [Saks 1996, Saks 1999] и всегда (ну, во всяком случае, когда речь идет об указателях) ставлю спецификатор после типа, как в следующем примере: int const volatile *х; Но так: volatile const int *х; const int volatile *x; const volatile int *x; volatile int const *x; Если не стараться быть последовательным, то вы можете читать определение типа справа налево, начиная с переменной, и однозначно и правильно понять, какой это тип, независимо от порядка слов в определении. Если вы используете второй способ, вам придется немного перетасовывать определение в своей голове, что для меня, по-види- мому, оказывается слишком серьезной работой, особенно при многих уровнях разыме- нования. 18.2. Что содержит определение? В программном коде на C++ (и на С) typedef используется повсюду. Однако мало внимания уделяется различиям при определении типов, и поэтому давайте это сделаем сейчас, определив две концепции применения typedef. 18.2.1. Определения концептуальных типов Давайте рассмотрим класс, используемый в системе передачи информации, Построенной на базе протокола IP. Мы могли бы увидеть примерно следующий программный код: // Socket.h typedef int AddressFamily; typedef inc SocketType; typedef int Protocol;
372 ЧастьЗ. Языковые проблемь1 class Socket { public: Socket(AddressFamily family, SocketType type. Protocol protocol). Здесь мы видим определение с помощью typedef трех типов-AddressFamily SocketType и Protocol, - которые определяют три параметра, необходимых для создания сокета [Stev 1998]. В этом контексте typedef используется для определе- ния трех логически различных типов, хотя в действительности они имеют одинако- вый тип int. Отсюда: Определение: в определениях концептуальных типов задаются логически раз- личные типы. Конечно, в этом случае конструктор Socket более самодокументируемый, чем если бы все три параметра были определены просто как int. Одним из преимуществ определения концептуальных типов является именно этот (ограниченный) уровень самодскументируемости. Другое преимущество определения концептуальных типов заключается в обеспече- нии некоторой степени независимости от платформы. Рассмотрим стандартные типы size_t и ptrdif f_t, которые обычно определяются следующим образом: typedef unsigned int size_t; typedef int ptrdiff_t; Когда вы встречаете ptrdif f_t в программном коде, вы сразу же понимаете, что здесь речь идет о разнице значений указателей. Ваше сознание не получит такую же под- сказку, если вы увидите просто int, который обычно подразумевает основные арифме- тические или индексные операции. Аналогично, size_t заставляет думать о количестве байтов, а не о каком-то произвольном целом числе. Однако такие typedef выполняют дополнительную и очень важную функцию. Из-за того, что (unsigned) int может быть неподходящим типом для представления количества байтов или разности значений указателей на данной платформе, реализация может соответствующим обра- зом переопределить эти типы для поддерживаемых платформ, и тогда не потребуется вносить изменения в пользовательский программный код. Именно благодаря этому целые числа фиксированного размера стандарта С99 стали переносимыми. Между прочим, как С, так и C++ допускают множественные определения одного символа с помощью typedef, пока все определения идентичны. Однако обычно счита- ется плохим тоном переопределять символ посредством typedef, и вам следует избегать этого. Определение следует делать в одном месте, обычно в рамках включаемого совмест- но используемого файла, а не иметь независимые определения, что может вызвать очень неприятные побочные эффекты. Если различные единицы компиляции в результате буДУ1 иметь расходящиеся определения, особенно при динамической компоновке, последствия могут быть ужасными, как мы видели в гл. 9. Нельзя этого делать!
Глава 18. Имена, вводимые typedef 373 18.2.2. Определения контекстуальных типов Если определения концептуальных типов определяют понятия, независимые от контекста (данной платформы), то определения контекстуальных типов выполняют точно противоположную роль: они (переопределяют хорошо известное1 понятие для разных контекстов. Любому, кому пришлось хотя бы самую малость программировать на C++, начиная с 1998 года, должна быть знакома стандартная библиотека шаблонов (Standard Template Library - STL2), и он, возможно, видел программный код, подобный показанному в лис- тинге 18.4. Листинг 18.4. template< typename С , typename F > F for_all_postinc(C &c, F f) { typename C::iterator b ” c.begin(); typename C::iterator e ” c.end(); for{; b '= e; b++) { f(*b); } return f; } Этот алгоритм применяет переданную при вызове функцию f ко всем элементам контейнера с, находящимся в асимметричном диапазоне [Aust 1999]: [c.beginO, с. end ()). Возвращаемые методами begin () и end (), итераторы хранятся в пере- менных b и е, а проход по всему диапазону осуществляется при помощи постфиксного оператора инкремента, примененного к Ь.2 В этом алгоритме важно обратить внима- ние на тип переменных итератора. Они объявляются как тип С: : iterator. Это означает, что данный алгоритм может применяться к любому типу С, используемому в определении этого типа. Соответственно, i terator - тип-член, как показано в лис- тинге 18.5. Хорошо известное в том смысле, что конструкции, внешние по отношению к контексту определений, предполагают существование таких определений, которые имеют известную типовую и/или поведенческую таксономию. ^т°т тестовый вариант функции я использую при написании компонент STL. чтобы гарантировать пустимость постфиксного формата оператора инкремента для типов итератора, который менее эффективен (и Эт°му реже применяется) и который труднее эмулировать.
374 ЧастьЗ. Языковые проблемк Листинг 18.5. class String { public: typedeC char «iterator; // 'iterator* является простым указать гтач namespace std { class list_iterator; class list { public: typedeC list_iterator iterator; // 'iterator* является внешним // классом Все контейнеры стандартной библиотеки (включая std: :vector, std: :deque, std: : string) содержат тип-член iterator^ и существует целая масса библио- течных компонент независимых разработчиков, в которых подобным образом под- держивается это требование. Все эти типы ожидают, что член iterator обладает определенными свойствами и определенным поведением во внешнем контексте - в данном случае для алгоритма for_all_postinc (), а в принципе, также для любого программного кода, для которого необходимы переменные-итераторы. Реальный тип, которому соответствует введенный с помощью typedef алиас iterator, может очень сильно отличаться для разных контейнеров: некоторые будут являться указателем, а другие - сложным типом класса с соответствующим набором операций. Однако каждый реальный тип соответствует известной концепции - в данном случае концепции итератора [Aust 1999] - и именно тип-член обеспечивает ожидаемый тип (и соответствующее его поведение) во внешнем контексте. На самом деле, типы- члены могут даже быть вложенными классами (или перечислениями enum, хотя в данном случае это не будет работать), а вовсе не определяться с помощью typedef. class envi гonment_variabl e_sequence { public: class iterator // 'iterator* является вложенным классом { Следовательно, iterator - контекстуально определяемый тип, потому что он переопределяет широко известное понятие в нескольких контекстах. Определение: в определениях контекстуальных типов соответствующие широко известным концепциям типы определяются в конкретных контекстах. Такие типы выполняют роль описателей особенностей (типа и/или поведения) своих базовых контекстов.
Глава 18. Имена, вводимые typedef 375 — Определения контекстуальных типов предназначены не только для шаблонных классов или шаблонных алгоритмов. Кроме того, они не обязательно должны быть членами классов. Они могут также быть членами пространств имен или локальными определениями внутри функций или даже внутри отдельных блоков. Кроме того, что они являются механизмом поддержки обобщенного программиро- вания (через механизм поиска типов, который мы только что рассмотрели), они могут также очень сильно способствовать переносимости. Рассмотрим ситуацию, когда разрабатывается библиотека для просмотра содержимого каталогов базовой файловой системы. Вы можете предположить, что для платформы, на которой определяется кон- станта РАТН_МАХ, все пути ограничены этим значением1. Поэтому вы могли бы реа- лизовывать вашу библиотеку для вашей платформы с фиксированной длиной пути в рамках пространства имен f search: : f ixed_platform, используя следующий класс строки фиксированного размера: namespace fsearch { namespace fixed_platform { typedef acme_lib::basic_fixed_string<char, PATH_MAX> string_t; class directory_sequence { bool get_next(string_t Gentry); В данном случае string_t является контекстуальным типом в пространстве имен fsearch: : fixed_platform. Клиентский программный код может иметь при- мерно следующий вид: using fsearch::fixed_platform::string_t; using fsearch::fixed_platform::directory_sequence; int main() { directory_sequence ds{"/usr/include"); string_t entry; while(ds.get_next(entry)) ( puts(entry.c_str()) ; } Конечно, наша утилита по работе с каталогами становится столь полезной, что мы х°тим ее перенести на другие платформы. Другая платформа, BigOS, может иметь с платФ°рмах UNIX, в которых не определена константа РАТН_МАХ, вы должны вызвать функцию path- ^0 для получения максимально возможной длины пути на этапе выполнения. Реализация класса M^-fi*eJ’ath_bufler<> библиотеки UN1XSTL, которая включена в состав компакт-диска, показывает, как это Но сделать на этапе компиляции и выполнения.
376_______________________________________________________ЧастьЗ. Языковые проблемь| пути любой длины, и поэтому будет не оправдано применение строки фиксированного размера. (Следует отметить, что я специально избрал именно эту стратегию переноса для иллюстрации концепции определения контекстуальных типов; в действительно- сти, существует несколько способов реализации межплатформенных программных интерфейсов, и выбор одного из них связан с поиском нетривиального баланса среди многих, что выходит за рамки данной книги.)1 Пространство имен BigOS могло бы вы- глядеть примерно так: namespace fsearch { namespace bigos_platform { typedef std::string string_t; class directory_sequence { bool get_next(string_t Gentry); Теперь оно имеет преимущество, а именно, совсем немного приходится вносить изменений в клиентский программный код, поскольку оба варианта библиотеки f search, предназначенные для разных операционных систем, имеют логически экви- валентные интерфейсы: #ifdef __BIGOS__ namespace fsearch_platform = fsearch::bigos_platform; #elif defined( unix___) namespace fsearch_platform = fsearch::fixed_platform; #elif . #endif /* операционная система */ using fsearch_platform::string_t; using fsearch_platform::directory_sequence; int main() { Описания, отражающие отличия между платформами, удобно помещать в заголо- вочный файл библиотеки, а это значит, что клиентский программный код может быть совершенно независимым от платформы (за исключением, конечно, репрезентативных отличий между файловыми системами). 1 Возможно, если вы убедите всех своих друзей приобрести данную книгу, мы сумеем убедить издателя подготовить книгу по переносу.
Глава 18. Имена, вводимые typedef 377 18.3. Алиасы Ключевое слово typedef (стандарт С++-98: 7.1.3) обладает именно такими свой- ствами, которые мы наблюдали для определений контекстуальных типов, то есть оно просто определяет другое имя конкретному типу, которое действует в заданном кон- тексте. Фактически, оно является алиасом существующего типа. Например, типы String: : iterator и char * совпадают и могут заменять друг друга, что совсем неплохо. void make_upper(char ‘begin, char *end); void set_chars(String &s) { make_upper(s.begin{) , s.end()); } Однако все определения концептуальных типов определяют новые типы на основе существующих. Создание алиасов, как инструмент определения контекстуальных типов, делает определения концептуальных типов в лучшем случае очень слабым механизмом различения типов. 18.3.1. Ошибочная замена одного концептуального типа другим Если мы снова обратимся к классу Socket, мы можем легко предвидеть возмож- ность написания программного кода, подобного следующему: AddressFamily family = AF_INET; SocketType type = SOCK_DGRAM; Protocol protocol = IPPROTO_UDP; Socket(type, family, protocol); Уф! Типы type и family заменены друг на друга уставшим программистом. К счастью, первое же тестирование системы выявит здесь проблему, поскольку сокет не будет открываться из-за указания недопустимого семейства (family) и типа сокета (type). В действительности, такое может и не произойти. В этом проявляется более непри- ятный аспект данной проблемы. В одной из моих систем символы AF_INET и SOCK_DGRAM фактически имеют одно и то же значение: 2! Поэтому здесь мы стал- киваемся с ужасной ситуацией, когда данный программный код работает правильно, п°скольку наша ошибка проявляется только при переходе на другую платформу. Нетрудно предвидеть ситуацию, возникающую при переносе приложения на другую Платформу, когда символы AF_INET и SOCK_DGRAM будут иметь различные значения и когда придется потратить дни на (корректный) перенос платформо-зависимых частей Из-за того, что система перестает работать.
378 ЧастьЗ. Языковые проблемы В определенных случаях можно так ошибочно поменять местами экземпляры концептуальных типов, что это не будет серьезно нарушать работу системы, а лищь снизит ее производительность. Может оказаться, что такие ошибки будет очень сложно обнаружить. Однажды я столкнулся с ситуацией, когда программный интерфейс реги- страции событий определялся с помощью примерно следующих функций: typedef int TE_DbgLevel; typedef uint32_t TE_Flags; void TraceEntry(TE_DbgLevel level, TE_Flags flags, . . ); Существует много случаев, когда по всей базе программного кода ошибочно заме- няют друг друга флажки трассировки и уровень отладки. (Сначала программный ин- терфейс использовал их иначе, а в результате «улучшений», выполненных каким- нибудь разработчиком, они поменялись местами в текущем определении. Это наруша- ет важнейшее правило сопровождения, которое гласит, что нельзя изменять определе- ния функций, а вместо этого необходимо определить новые функции и «дать отставку» прежним функциям). По совпадению флажки отладки имели значения от 0 до 7, а часто использовались три флажка трассировки: 0x1, 0x2 и 0x4. В результате производи- тельность* системы значительно ухудшилась. Дефект: C++ обеспечивает типобезопасное применение концептуальных типов только в том случае, когда их базовые типы несовместимы. Когда однажды я столкнулся с такой заменой типов, я применил подход, который мы собираемся обсудить в разделе 18.4, и нашел еше более сотки замен. После исправ- ления производительность системы улучшилась на 50%! 18.3.2. Не использовать перегрузку для концептуального типа При создании алиаса (с помощью typedef) возникает другая проблема. Предполо- жим, что мы имеем класс Network, который контролирует процесс связи через сокет. Такой класс может обеспечивать функции, прекращающие работу всех сокетов при выпол- нении конкретного критерия, как в следующем примере: class Network { public: void QuenchMatchingSockecs(AddressFamily family); void QuenchMatchingSockets(SocketType type); void QuenchMatchingSockets(Protocol protocol); Но наш компилятор отвергнет этот класс, поскольку на самом деле нельзя обес- печить три перегрузки с одной сигнатурой, которой в данном случае является void QuenchMatching Sockets (int) ;. Еще хуже, когда перегружаемые методы при-
Глава 18. Имена, вводимые typedef 379 меняют концептуальные типы, базовые типы которых отличаются на какой-нибудь конкретной платформе. При переносе на другую платформу теперь два или более базо- вых типов могут совпасть, и программный код перестанет компилироваться. Дефект: C++ не поддерживает перегрузку для концептуальных типов, за ис- ключением тех случаев, когда оказывается, что такие типы имеют разные базовые типы на конкретной платформе. 18.4. «Настоящие» typedef Если мы будем рассматривать спецификаторы typedef как возможность перехода с одного имени типа на другое, то нам нужно иметь спецификаторы typedef, обеспечи- вающие двустороннюю замену имен для контекстуальных типов, и только односторон- нюю для концептуальных типов. Что если использовать для этого два различных ключе- вых слова? Я бы предложил ключевое слово alias (алиас) для двусторонней замены. Также предлагаю оставить прежним нынешнее использование typedef. Поэтому: alias int IntType; int il; IntType i2; il = i2; // int и IntType полностью взаимозаменяемы, и i2 = il; // фактически они представляют собой один и тот же тип Спецификатор typedef обеспечивал бы только одностороннюю замену. Поэтому: typedef int IntType; int 11; IntType i2; il = i2; // Недопустимо! Нельзя преобразовать IntType в int i2 = il; // Недопустимо! Нельзя преобразовать int в IntType Но реальность такова: typedef имеет прежний смысл, и не существует способа. который позволил бы нам навязать свое представление 5 миллионам разработчиков и миллиардам строк программного кода.1 Поскольку данная книга говорит о том, какие меры следует предпринимать для уменьшения слабостей C++, то что же мы можем сделать в этом случае? фактически, новый язык D [Brig 2002] использует именно такие определения для этих двух ключевых слов Следовательно, обеспечивает определения концептуальных типов как встроенное свойство языка.
380 Часть 3. Языковые проблемы Придерживаясь духа неидеального практика, я применил Принцип #4 - никогда Не сдаваться! - и после многочисленных попыток нашел решение в форме шаблонного класса true_typedef («настоящий typedef»; см. листинг 18.6). Листинг 18.6. template< typename Т , typename U> class true_typedef { public: typedef T Preference; typedef T const Peonst_reference; // Конструирование public: true_typedef() : m_value(T()) {} explicit true_typedef(T const Pvalue) : m_value(value) .. О true_typedef(true_typedef const Prhs) : m_value(rhs.m_value) {} true_typedef const Poperator =(true_typedef const Prhs) { m_value = rhs.m_value; return *this; } // Операторы доступа public: const_reference base_type_value() const ( return m_value; } reference base_type_value() { return m_value; ) // Члены private: T m_value; // Реализация не требуется private: //He обеспечивается, поскольку синтаксис менее двусмысленный // при присваивании явно временного значения true_typedef const Poperator ={т const Pvalue); };
Глава 18. Имена, вводимые typedef 381 Как вы видите, все довольно просто. Этот класс содержит единственный член первичного параметризованного типа Т, который называется базовым типам. Он также параметризован неиспользуемым типом и, который является уникальным типом. Уникаль- ный тип гарантирует уникальность инстанциируемых экземпляров, что мы можем видеть в новых определениях типов нашего сокетного программного интерфейса: acmelib gen opaque(AddressFamily_u) acmelib_gen_opaque(SocketType_u) acmelib_gen_opaque(Protocol_u) typedef true_typedef< int , Addre»»Family_u> Addree«Family; typedef true_typedef< int , SocketType_u> SocketType; typedef true_typedef< int , Protocol_u> Protocol; Генерирующий «непрозрачные» типы макрос acmelib_gen_opaque () - генератор уникальных типов (см. раздел 7.4.4) - используется для определения уникального типа. Я условно использую суффикс _и. Теперь, когда мы попытаемся конструировать Socket с неправильной последова- тельностью параметров family и type, компилятор проинформирует нас о наличии недопустимых параметров, и мы можем сразу исправить ошибку. Мы теперь можем перегружать логический тип независимо от степени общности базовых типов, и поэтому класс Network может определяться именно так, как нам хочется. У вас, вероятно, возникает вопрос о том, насколько просто применять настоящие typedef. При анализе приведенного ранее определения шаблона становится очевидным необходимость вызова base_type_value () для доступа к значению. Это нельзя назвать хорошим решением, не так ли? К счастью, мы можем воспользоваться одной из магических вещей Скотта Майерса - запатентованным универсальным лекарством в виде свободных функций, действующих подобно змеиному яду, - и все окажется проще. Заголовочный файл true_typedef содержит (в момент написания) 73 шаб- лонные свободные функции, как, например, показанные в листинге 18.7. Листинг 18.7. template* typename Т, typename U> true_typedef<Т. U> const operator <+(true_typedef*T, U> &v, int) ( true_typedef<T, U> r(v); v.base_type_value() ++; return r; template* typename T, typename u> bool operator <=( true_typedef*T, u> const &lhs, T const &rhs)
382 ЧастьЗ. Языковые проблемы { return Ihs.base_type_value() <= rhs; } template* typename T, typename U> true_typedef<T, U> operator ~(true_typedef*T, U> const &v) { return true_typedef<T, U>(~v.base_type_value()); } template* typename T, typename U> true_typedef<T, U> const &operator «=( true_typedef*T, U> &v , true_typedef*T, U> const &rhs) { v.base_type_value() «= rhs.base_type_value(); return v; } Это означает, что экземпляры настоящих typedef, которые основаны на примене- нии фундаментальных типов, могут использоваться почти во всех выражениях, где допустимы их фундаментальные типы. Например: true_typedef*int, . .> il = 1000; int i2 = 1001; true_typedef*int, . . .> i3 = i2; il «= 2; il = ~i3; i3 = i2 < i3; Естественно, это невозможно для типов классов, если они не определяют соответ- ствующие операторы, но доступ к значению базового типа осуществляется достаточно просто - с помощью base_type_value (). И вполне допускается ссылаться на значение базового типа, как и на экземпляр этого типа. Важно отметить, что типы по умолчанию рассматриваются как разные. Обеспечивается доступ как к константным, так и к неконстантным типам. Допускает- ся включать операции модификации, поскольку цель применения настоящих typedef - создание сильно типизированных типов, а не типов с жесткими значениями (например, перечисления enum). Настоящие typedef могут также использоваться для базовых типов, отличных от фундаментальных типов, включая типы классов. typedef true_typedef*std::string, . .> Forename; typedef true_typedef*std::string, . . > Surname; bool lookup_programmer( Forename const &fn , Surname const &sn , int &iq);
П&а 18. Имена, вводимые typedef 383 Forename fn("Archie"); Surname sn("Goodwin"); int iq; fn an; 11 ошибка - типы различны if (lookup_progrcunm«r (an, fn, iq)) // Ошибка: fn и an поменялись местами ( printf("%s %s: %d\n", sn.base_type_value().c_str() , sn.base_type_value().c_str() . iq); //и было бы печатьно, если бы это работало } Концепция настоящих typedef полностью решает проблемы, обусловленные слабо- стью определений концептуальных типов: это предотвращает неявную взаимозаме- няемость типов, построенных на основе идентичных типов, и способствует перегруз- кам логически различных типов. Более того, это осуществляется без какого-либо ухуд- шения эффективности, поскольку легко реализуется, и все методы встраиваемые. (Я пока еще не обнаружил компилятор, который генерирует с каким-нибудь отличием в производительности программный код настоящих typedef и эквивалентных им про- стых typedef. Это справедливо даже для нетривиальных базовых типов.) Давайте вновь обратимся к проблеме неявного преобразования целых типов в ком- поненте сериализации из раздела 13.2.1. Применяя настоящие typedef, мы можем переписать класс Serializer, как показано в листинге 18.8. Листинг 18.8. // serialdefs.h acmelib_gen_opaque(sint8_u) acmelib_gen_opaque(uint8_u) acmelib_gen_opaque(s int16_u) acmelib_gen_opaque(uintl6_u) acmelib_gen_opaque(sint32_u) acmelib_gen_opaque(uint32_u) acmelib_gen_opaque(s int 6 4_u) acmelib_gen_opaque(uint64_u) typedef true_typedef<int8_t, sint8_u> sint8_type; typedef true_typedef<uint8_t, uint8_u> uint8_type; typedef true_typedef<intl6_t, sintl6_u> sintl6_type; typedef true_typedef<uintl6_t, uintl6_u> uintl6_type; typedef true_typedef<int32_t, sint32_u> sint32_type; typedef true_typedef<uint32_t, uint32_u> uint32_type; typedef true_typedef<int64_t, sint64_u> sint64_type; typedef true_typedef<uint64_t, uint64_u> uint64_type; // Serializer.h class Serializer
384 ЧастьЗ. Языковые проблемь| { // Операции public: void Write(sint8_type i); void Write(uint8_type i); void Write(sint!6_type i)j void Write(uintl6_type i)j void Write(sint32_type i)j void Write (uint32_type i)i void Write(sint64_type i); void Write(uint64_type i); // Нет необходимости определять все другие (предписанные) методы }; void fn() { Serializer s; sint8_type i8(0); // Необходимо использовать синтаксис инициализации ... uint64_type ui64(0); /А... а не операторы присваивания. int i = 0; s.Write(si8); s.Write(ui64); s.Write(i); // Ошибка, просто и ясно - неоднозначность s.Write(O); // ОШИБКА: неоднозначный вызов uint64_t ui ui64.base_type_value(); // Необходимо использовать метод } Теперь мы имеем стопроцентное усиление типов. Общеизвестно, что немного не- удобно использовать настоящие typedef для целых типов - в том смысле, что прихо- дится пользоваться синтаксисом инициализации для его конструирования на основе переменной или литерала базового типа и base_type_value (), если вам необходи- мо преобразовать обратно в базовый тип - но это небольшая цена, которую приходится платить. И когда вы работаете с межплатформенными типами сериализации, часто помогает небольшая явная строгость как при написании, так и при чтении/сопровож- дении программного кода сериализации. 18.5. Хороший, плохой и ужасный В данной главе рассматриваются определения типов, которые, в основном, говорят о большой полезности спецификатора typedef. Однако мне бы просто хотелось это использовать в качестве небольшой трибуны и прокомментировать некоторые распро- страненные ошибки, которые часто встречаются при злоупотреблениях typedef-
Глава 18. Имена, вводимые typedef 18.5.1. Хорошие typedef 385 Хорошие typedef - это те, которые уменьшают объем вводимого текста, повы- шают переносимость, увеличивают гибкость или все это делают одновременно. Например, когда я определяю любой класс, который наследует другой, я по привычке объявляю с помощью typedef base_class_type, как в следующем примере: template* typename Т , typename А = std::allocator<T> class acme_stack : protected std::vector<T, A> ( private: typedef std::vector<T, A> base_class_type; public: typedef typename base_class_type::reference reference; typedef typename base_class_type::const_reference const_reference; Это имеет два преимущества. Во-первых, когда мне нужно реализовать тип-член или функцию acme_stack, исходя из std: :vector<T, А>, я могу напечатать base_class_type. Соглашаясь в данном случае, что не так уж много экономится на вводе символов, тем не менее, теперь делать это легче, поскольку нам не приходится иметь дело со скобками и этими неприятными двоеточиями! Во многих случаях все же получается значительная экономия на вводе символов; в программном коде Synesis я обнаружил самый длинный базовый класс: ComRefCounter* ComRefCounterBase*IFileUtil> , ComQI3< IFileUtil2 , &IID_IFileUtil2 , IFileUtil , &IID_IFileUtil , IDispatch , &IID_IDispatch , ComRefCounterBase*IFileUtil> > > Попробуйте повторять это в реализации класса, не сделав ни одной ошибки! (И не забывайте, если ваш класс сам является шаблоном, то любая ошибка не проявится вплоть до конкретного вызова функции-члена, что может произойти через какое-то время после «завершения» вами программы и ее выпуска, если блочным тестам не Удается стопроцентно покрыть программный код, а они, конечно, именно такие [Gias 2003].) Неприятное следствие этого заключается в том, что иногда вы можете иметь не- сколько базовых типов. Однако если вы благоразумно воздерживаетесь от применения тяжеловесного множественного наследования [Stro 1997] - то есть когда несколько базовых типов обеспечивают значимые типы и свойства - то почти во всех случаях
386 ЧастьЗ. Языииьге проблем существует один идентифицируемый базовый класс со связью «представляет собой» [Меуе 1998], а остальные базовые типы используются для обеспечения стратегии по ведения или для объявления интерфейсов. Другим отличным применением base_class_type является упрощение задачи изменения базового класса в иерархии наследования. Давайте на минуту забудем все о шаблонах и просто подумаем о простом полиморфном наборе классов class Window { class Control : public Window class FileListControl : public Control { DECLARE_MESSAGE_HANDLERS(FileListControl, Control) // в FileListControl.срр bool FileListControl::Create(Window const &parent, - . ) { . . . // модифицировать другие-параметры return Control::Create(parent, другкв-параметри)i } Учитывая природу таких иерархий, давайте предположим, что имеется огромное количество макросов внутри объявления и определения этих классов (мы предполага- ем, что они генерируются с помощью мастера и контролируются в момент генерации). Может быть много десятков или даже сотен ссылок на Control внутри объявления или определения FileListControl. Конечно, при следующей переработке библио- тек вводится новый класс окна, ThreeDEf fectControl, который строится на базе Control. Ваш менеджер считает, что будет очень здорово иметь эффекты трехмерной графики, и он хочет их увидеть в FileListControl, а также в 30 или 40 других объектах управления, которые прежние соучастники вашего проекта оставили вам, покинув компанию, чтобы начать заниматься на платформе J2EE веб-службами мгно венного обмена мультимедийными XML-сообщениями на базе SOAP с использовани ем протокола WAP - и сказал вам, что «обязательно надо сделать так!» Вы в сложно ситуации. Вы могли бы воспользоваться системой контроля версий и сделать глобаль ный поиск каждого имени Control, заменяя его на ThreeDEffectControl* но существуют сотни классов, производных от Control, которые просто останутся на своих местах. Поэтому вам приходится выполнять эту работу вручную.
Спава 18. Имена, вводимые typedef 387 Ну, по крайней мере, вы можете локализовать заголовочные файлы тех объектов управления, которые вам необходимо менять. Вы вносите изменения, и теперь FileListControl и все его партнеры являются производными OTThreeDEf f ect- Control. Увы, как только вы почти начали изменять все соответствующие заголо- вочные файлы, ваш дружески настроенный менеджер говорит вам, что посылает вас на сайт клиента на следующие три дня, чтобы помочь найти ошибку, и «вы должны уйти через 20 минут». Я уверен, вам все ясно. Вы возвращаетесь к задаче обновления объектов управле- ния в начале следующей недели и вносите все изменения в файлы реализации. Увы, вы забыли об одном файле, находящемся в редко используемом компоненте, и через пару месяцев вы уже на сайте другого клиента пытаетесь найти причину периодически воз- никаемого краха программы, работающей продолжительное время. Проблема в том, что система содержит один класс, методы которого обращаются к непрямой базе (Control) вместо того, чтобы обращаться к непосредственной базе (ThreeDEf- fectControl), а в результате системные ресурсы оказываются неосвобожденными, вскоре их действительно не хватает, и ваш процесс завершается аварийно. Насколько было бы проще, если бы каждый класс объявлялся как закрытый тип base_class_type и можно было бы использовать это имя только для ссылки на базовый класс? Давайте вернемся к нашему первоначальному шаблонному классу acme_stack. Если мы собираемся, обновляя, сделать его более быстрым вектором чем std: : vec- tor, то мы могли бы сделать это за счет всего лишь двух простых изменений. template* typename Т , typename А = std::allocator<T> > class acme_stack s protected fastlib:tvector<T, A> { private: typedef fastlibt:vector<T, A> base_class_type; public: typedef typename base_class_type::reference reference; typedef typename base_class_type::const_reference const_reference; 18.5.2. Плохие typedef Я видел людей, которые познакомившись с typedef, затем теряли голову из-за его возможностей. Примером1 плохого применения typedef может служить следующий пР°граммный код: Этот пример взят из реальной базы клиентского программного кода. Я изменил все имена, чтобы не Являть краснеть его автора (и чтобы избежать судебных исков!).
388 ЧастьЗ. Языковые проблемы #if defined(UNICODE) typedef wchar_t char_t; #else typedef char char_t; ftendif II UNICODE typedef std::string<char_t> string_t; typedef std::vector<string_t>::iterator string_container_iterator_t; typedef std::vector<string_t>::const_iterator s tring_vector_const_i terator_t; Первые два символа string_t и string_container_t определены совершен- но корректно. Возможно, нет особой необходимости в определении string_t, но это помогает избегать раздражающей ошибки, возникающей в ходе синтаксического анализа, которую совершают те, кто только начинает применять шаблоны: typedef std::vector<std::string<char_t>>::iterator string_container_iterator_t; компилятор считает, что вы осуществляете операцию сдвига! Проблема в приведенных выше определениях связана с iterator. Дело в том, что в контейнерах STL его член iterator является контекстуальным типом. Если затем для его представления мы используем другой typedef, мы сместим контекст опреде- ления концептуального типа из std: :vector<string_t>, которому он принадле- жит, в глобальное пространство имен, к которому он, совершенно очевидно, не при- надлежит. Представим, что у нас имеется некий клиентский программный код: void dunp_to_debugger(std: :vector<string_t> const &sv, char const ‘message) { string_container_const_iterator_t begin = sv.begin(); string_container_const_iterator_t end = sv.endO; for(int i = 0; begin ’= end; ++begin, ++i) { printf("%s, %d: %s\n", message, i, (‘begin).c_str()); Определение string_container_const_iterator_t - хрупкое. Если мы изменим определение std: :vector<string_t> (например, если мы хотим использовать более быстрый вектор), то может оказаться, что string_container_const_iterator__t больше не будет совместим с шаблонным типом iterator нового вектора. Это в действи- тельности даже хуже, если бы он был совместим, поскольку этот неприятный изъян скрывает- ся, и использующий его программист не обучается противостоять этому плохому подходу- Существует немного более мягкая форма этой проблемы, которую я видел во вполне реальных условиях: typedef std::vector<string_t> string_container_t, typedef string_container_ts:iterator string_container_iterator_t;
Глава 18. Имена, вводимое typedef 389 typedef string_container_t:sconst_iterator string_container_const_iterator_t; В данном случае программный код будет продолжать работать при изменении string_container_t, но мы по-прежнему скрываем тот факт, что iterator - контекстуальный тип, и распространяем плохую привычку. Кроме того, если кто-то определяет класс с типом-членом string_ container_t и реализует какую-то функциональность на основе типа string_container_ (const_) iterator_t, то они в конце концов столкнутся с проблемой несогласованности, как только им по- требуется изменить свои типы с string_container_t на что-то еше, например, string_list_t. class X { public: // typedef string_container_t container_t; // было так, typedef string_list_t container_t; // стало так void dump(constainer_t const &c) const { string_containar_ccnst_iterator_t begin c.begin() // Несоответствие! Единственно, где эта менее важная форма typedef допустима, так это внутри функции или шаблонного алгоритма, когда известен тип содержимого, а область види- мости производных типов ограничена, как в следующем примере: template <typename С> void dump_container(С const &с) { typedef typename C::const_iterator iter_t; iter_t begin = c.begin(); } 18.5.3. Сомнительные typedef Рекомендация: избегайте концептуальных typedef, которые включают в себя кон- текстуальные typedef. Как я указывал в разделе 2.2, объявления (но не определения) закрытых методов могут использоваться для запрета копирующего конструктора и/или копирующего ирисваивания. При работе с шаблонами некоторые старые компиляторы (например, Visual C++ 4.0, если я правильно помню) имели проблемы с определением действи- тельных типов операндов, когда они задавались следующим образом:
390 Часть 3. Языковые проблемы template ctypename Т> class X { // Конструирование public: Х() ; explicit X(int i); // Реализация не требуется private: Х(Х const &); // Здесь путаница }; Проблема была в том, что некоторые компиляторы не могли установить, что X как тип в области видимости Х<Т> фактически означает Х<Т>, а именно такую интерпре- тацию должны давать все современные компиляторы. Другие компиляторы могут этого не делать и либо выдавать ошибку компиляции, либо рассматривать X как другой тип (какой именно, я никогда не мог определить), и это означало для предыдущего при- мера, что конструктор копирования был бы объявлен неправильно и, следовательно, автоматически генерировался бы (см. раздел 2.2) компилятором! Я получил довольно простое решение: template ctypename Т> class X { public: typedef X«T> class_type; // Реализация не требуется private: X(dass_type const &) i 11 Теперь нет путаницы }; С тех пор это вошло в мою привычку, и я почти всегда определяю class_type для каждого класса. Это означает, что я могу написать канонически запрещаемые опера- ции копирования в указанной выше форме, особо не задумываясь. (Конечно, это, веро- ятно, не совсем хорошо.) Это также очень хорошо помогает сопровождению при работе с вложенными классами шаблонов. Рассмотрим следующего «большого воло- сатого зверя», которого мы обсудим дополнительно в разделе 20.5: templatec typename S /* строковый тип, например, string */ , typename D /* тип разделителя, например, char или wstring 1 , typename В = string_tokeniser_ignore_blanksctrue> , typename V = S /* тип значения */ , typename Т = string_tokeniser_type_traitscS, v> , typename P = string_tokeniser_comparatorcD, S, T> > class string_tokeniser
Глава 18. Имена, вводимые typedef 391 { public: typedef string_tokeniser<S, D, В, V, T, P> tokeniser_type; class const_iterator { // Члены private: tokeniser_type ‘const m_tokeniser; } }; В данном случае шаблон string_tokeniser обеспечивает тип-член «class_type>>, представленный в форме tokeniser_type. Его затем видит вло- женный класс const_iterator, который поддерживает обратный указатель на эк- земпляр string_tokeniser, для которого он является итератором, чтобы ему можно было осуществлять доступ к разбиваемой на лексемы строке, продвигая дальше свою позицию и возвращая значения лексем. Если тип-член tokeniser_type не был бы определен, то во вложенном классе итератора const_iterator пришлось бы как-то обусловить применение string_tokeniser<S, D, В, V, Т, Р>, и вы можете представить, как легко здесь можно поступить несогласованно1. Наконец, вложенный тип class_type может также принести пользу при исполь- зовании макроса2 в определении класса, и мы увидим великолепный пример этого при обсуждении свойств в гл. 35. До сих пор было вполне оправдано применение в этом разделе всех typedef, но я хочу завершить раздел другим сомнительным typedef, использование которого менее оправдано, чем при определении class_type. Когда я писал инструмент для синтаксического анализа Java-программ - конечно, на C++ - я сделал компоненты вывода сообщений и модификации такими, что они соответствовали установленному двоичному стандарту (см. гл. 8), и поэтому я мог пользоваться различными инструмен- тами при анализе дерева исходных текстов на Java. Этот инструмент работает очень хорошо и может обнаруживать неиспользуемые переменные, избыточные операторы импортирования и неэффективные конкатенации строк в дереве исходного программ- ного кода. Он даже может изменить стиль применения скобок в деревьях исходных текстов, содержащих миллион строк программного кода, всего лишь за несколько минут, что гораздо меньше, чем требует дискуссия на такие темы. Поскольку каждый Класс, производный от Reporter/Transformer, содержит очень похожий про- граммный код, т. к. переопределялась одна или обе функции-члены, я выполнял очень Много операций копирования и вставки. Очень надоедает делать одинаковые измене- В первоначальной реализации отсутствовал typedef, и на начальном этапе разработки возникала точно такая пР°блема, а за это время я сформировал оптимальный набор из шести (!) параметров шаблона. при всяком упоминании макросов я подчеркиваю их непривлекательность, иногда они представляют й наилучшее решение, и пренебрегать ими в таких случаях - это просто вредить самому себе.
392 Часть 3. Языковые проблемы ния, переходя от одного файла к другому, а достаточных оснований для создания генератора программного кода [Hunt 2000] не было, и поэтому мне требовалась найти пусть сомнительный, но очень эффективный метод. В головной части каждого файла реализации - такое нельзя представить в заголо- вочных файлах! - находится typedef с определением контекстуального типа Local- Class для класса, соответствующего файлу реализации. Например: // PackageDependency.срр ♦include . . . typedef PackageDependency Localclass; II Braceinserter.срр ♦include . . . typedef Braceinserter Localclass; Все остальные ссылки на класс конкретной реализации осуществляются через тип Localclass, который сокращает усилия, направляемые на повторение действий. Не существует проблем корректности программного кода, потому что заголовочные файлы создаются вручную и очень внимательно. Другое преимущество - в реальной пользе применения визуальных особенностей для сравнения реализаций, скажем, классов JavaDocInserter и Doxygenlnserter, поскольку между ними были только функциональные отличия. В определениях методов все их имена имеют префикс класса Localclass::. Так или иначе, я не собираюсь слишком сильно настаивать на этом, но я полагал, что стоит обратить ваше внимание на еще один пример, показывающий силу typedef. И пользоваться ею надо осторожно!
Часть 4 Осознанные преобразования Если главы в части 1 были слишком простыми, главы в части 2, на ваш взгляд, были слишком ориентированы на С, а главы в части 3 слишком много внимания уделяли недостаткам «в малом», то не стоит беспокоиться. Часть 4 все возместит. Раскрываемый в этих главах материал требует большого напряжения сил, относится на все 100% к C++ и рассматривает действительно недостатки «в большом». Тем моим читателям, которые увлекаются велосипедом, я бы сказал: «Вам предстоит взобраться на Альпы!» В данной части мы войдем в большую горную местность; мне остается просто надеяться, что у вас достаточно жидкости и углеводов1, т. к. пройдет какое-то время, прежде чем мы получим некоторую передышку на продолжитель- ных спусках в частях 5 и 6. В C++ много внимания уделяется принудительному использованию, манипулиро- ванию и преобразованию типов, и его обоснованно можно считать сильно типизиро- ванным языком. Увы, в этой части он не идеален, и некоторые наиболее неприятные проблемы, встречаемые в языке, связаны с тем, что корректная интерпретация языка чуть-чуть отличается от того, что вы считаете правильным в том или ином конкрет- ном случае. Когда кто-то пытается писать обобщенный программный код - будь то настраивае- мые шаблоны или препроцессорные директивы - одной из самых неприятных и опас- ных вещей является управление типами и взаимное преобразование типов. Название части 4 связано с подходом, который используется в большинстве описанных методов и характеризуется глубоким осознанием всего происходящего программистом, что часто находит выражение в применяемых методах. В некоторых случаях это принима- ет форму конструкций, непосредственно предназначенных для управления и манипу- лирования преобразованиями. В других - просто влечет за собой описание проблем и предоставление рекомендаций относительно лучших подходов, позволяющих избе- тать потенциальных ловушек. Каждая из четырех концепций, которые мы будем рассматривать в первых четырех Главах, - «Приведение типов» (гл. 19), «Прокладки» (гл. 20), «Облицовочные классы» 'гл- 21) и «Прикрепляемые классы» (гл. 22) - раскрывает разные аспекты манипулиро- Поскольку речь идет о разработке программного обеспечения, а не о велогонках, вам. вероятно, лучше "Пгсь без углеводов, которые могут склонить вас ко сну; вместо этого лучше выпить кофе. В данной части необходимо быть очень внимательным.
394 Часть 4. Осознанные преобразования вания типами. Приведение типа используется для изменения типа или представлений типа. Прокладки (shims) оказывают помощь в обеспечении (однозначной) интерпрета ции типа. Облицовочные классы (veneers) позволяют «наслаивать» один тип на другой Прикрепляемые классы (boltins) улучшают функциональность существующих и часто полных типов. Мы увидим, что понятие преобразования типа не ограничивается лищь простым старым приведением, но имеет богатый комплекс возможностей с некоторы- ми характерными мощными свойствами. Последняя глава, гл. 23, «Конструкторы шаблона», описывает особо неприятный аспект механизма инстанциирования шаблонов в C++, который проявляется при формировании производных шаблонных классов и ретрансляции вызовов конструк- торов, как это мы делали в облицовочных и прикрепляемых классах. Каждая глава содержит различные примеры применения этих концепций. Многие из этих примеров выдвигают на передний план важные базовые методы, которые служат основой для последующих частей книги, особенно это относится к прокладкам, как к механизму обеспечения прямого обобщения. Другие примеры менее понятные и, возможно, менее практичные, но их важно знать, поскольку они демонстрируют полезные приемы, с помощью которых можно заставить язык и компилятор делать то, что вам нужно.
Глава 19 Приведение типов CastJ _ форма, в которой что-то изготовляется или конструируется. 19.1. Неявное преобразование В наших интересах компилятор C++ будет выполнять большое количество неявных преобразований между типами. Однако лишь тот факт, что они неявные, не означает, что они тривиальные. Преобразования между целыми типами и типами чисел с пла- вающей точкой могут предусматривать значительный объем работы [Меуе 1996, Stro 1997]; преобразования между целыми тешами различных размеров могут приво- дить к усечению значения и расширению знака [Stro 1997]; преобразования между типами указателей виртуальных производных классов на реализуемый экземпляр подразумевают подгонку указателя [Lipp 1996]. О проблемах точности преобразова- ний упоминалось в гл. 13; они подробно рассматриваются в [Stro 1997]. В данном раз- деле мы исследуем, какую поддержку можно получить от компилятора при выполне- нии этих и других преобразований, и как мы можем улучшить эту поддержку. Инструментарий неидеального практика не содержит ничего подходящего для нас в таких случаях, но на самом деле это не значит, что что-то здесь не так, не считая про- блем с усечением значения и расширением знака. Язык был бы очень многословным, если бы он не позволял неявное преобразование int в double или 123 в int. Приведения типов не всегда приводят к какому-нибудь преобразованию. Оно может также использоваться для получения доступа к другим характерным свойствам сущности, которыми она уже обладает. Для экземпляра составного типа, например, производного класса, компилятор будет выполнять - проблемы доступа и обеспечения однозначности отложим в сторону - неявное преобразование переменной собственно- 110 Указателя или ссылки на указатель или на ссылку своего базового класса (которых может быть несколько). В Действительности, можно осуществлять доступ к характерным свойствам сущно- которые непосредственно отсутствуют в ней. Это достигается для типов классов ^тем реализации оператора неявного преобразования [Меуе 1996, Меуе 1998, 2000, Stro 1997]. В таких случаях происходит изменение исходного типа, и это мы ________________________ & пР°гРаммировании этот термин переводится как «приведение типа». - Примеч. пер.
396 Часть 4. Осознанные преобразования обязательно должны иметь в виду, когда пользуемся данным подходом. Естественно этот подход в принципе очень легко использовать ненадлежащим образом, и в боль* шинстве ситуаций не рекомендуется его применять [Меуе 1996, Sutt 2000, Stro 1997 Dewh 2003]. 19.2. Преобразование типов в C++ В тех случаях, когда неявное преобразование оказывается недостаточным, мы можем прийти к выводу, что необходимо непосредственно указать компилятору, какое требуется выполнить преобразование (но, конечно, это не значит, что компилятор нас послушает). Иногда достаточно всего лишь обеспечить переменную промежуточного типа, как показано в листинге 19.1. Листинг 19.1. class А О; class В : public А О; class С : public А О; class D : public В , public С О; D *d = new D () ; А *а d; // Ошибка. Неоднозначное преобразование В *b » d; // первый этап (D* > В*) - нормально А *а2 - Ь; И второй этап (В* > А*) - нормально Можно поступить по-другому и выполнить приведение типа. В языке С вы можете пользоваться приведением типов только в стиле С, что, по меньшей мере, является грубым силовым механизмом. А *а = (B*)d; II гмм ... Если бы мы впоследствии изменили определение D так, что оно перестало бы быть наследником В, данный программный код по-прежнему компилировался бы. В резуль- тате получаем дамп памяти! C++ обеспечивает четыре оператора приведения типов [Stro 1997], которые РеК° мендуется использовать профессиональным программистам вместо приведен»* в стиле С. Эти операторы приведения типов - static_cast, const-cast’
Глава 19. Приведение типов 397 dynamic_cast и reinterpret_cast - функционально отличаются от традици- онного приведения типов в стиле С и сознательно [Stro 1994] устанавливают строгие правила их применения1. Предыдущий программный код теперь можно записать оледующим образом: А *а = static_cast<B*>(d); 11 ... значительно лучше Это будет корректно откомпилировано и приведет к преобразованию D* в указа- тель первоначального класса А*. Однако если бы отношение наследования между В и D было более строгим, это выражение не компилировалось бы, и нам не пришлось бы столкнуться с ошибочным преобразованием на этапе выполнения программы. Этот программный код демонстрирует синтаксис оператора приведения: имя-опера- тора-приведения<целевой-тип> (операнд). Синтаксис совершенно понятен, а единый формат полезен: при удачном завершении операции приведения возвращает- ся значение типа цепе вой-тип, полученное из операнд’а. Разрабатывая идеи в данном разделе, нам не следует забывать о том, что dynamic_cast может выбрасы- вать исключения (а именно, std: :bad_cast), если приведение типа ссылки завершается неудачей. Операторы приведения типов C++ очевидно имеют много других особенностей и важных свойств, о которых вы можете узнать из очень хороших источников, посвящен- ных C++ [Dewh 2003, Меуе 1996, Меуе 1998, Sutt 2000, Stro 1997]. В данный момент мы рассмотрим пример, показывающий достоинства их применения в высококачественном программном коде, и пойдем дальше, хотя впоследствии мы регулярно будем касаться операторов приведения. 19.3. Пример приведений в стиле С Несмотря на все прочитанное нами о приведении типов в стиле С, существуют ситуации, в которых они полезны, но эти случаи очень редки: леность и халтура не в счет. Я могу представить только две законные причины использования привидений этого вида. Во-первых, потребность в этом возникает в тех случаях, когда программный код Должен быть совместимым как с С, так и с C++, и, следовательно, оператор в стиле С - единственно допустимый оператор приведения типа. Это может объясняться тем, что он Нах°Дится в макросе или в исходном файле и включается либо как встроенная функция (см. гл. 12), либо с помощью внутреннего связывания (см. разделы 6.4 и 11.1). Если бы Азимов был программистом C++, его роботы, вероятно, соблюдали бы законы и использовали „в^ения типов в стиле C++. Я подозреваю, что С-стиль был бы любимым инструментом гораздо более немного Attitude Adjuster (регулировщик положения) или одного из его собратьев из «Культуры» Иэна *** (Iain Banks).
398 Часть 4. Осознанные преобразования Поскольку мы должны добиваться максимально возможной эффективности, в биб лиотеках системы Synesis фактически предусмотрен набор макросов по приведению типов - SyCastStatic, SyCastConst, SyCast Volatile, SyCastDynamic SyCastRaw и SyCastC - которые обеспечивают обычное старое приведение типа в С-стиле для С и соответствующее приведение в С++-стиле для C++. Это выглядит непривлекательно, но работает, и благодаря этому подходу найдено бесчисленное количество неправильных операций приведения типов. Вторая причина более тонкая. Из-за того что приведение типов в C++ представляет собой связь именно между операндом и целевыми типами, можно попасть в затрудни- тельное положение с ними в шаблонном программном коде. Например, несмотря на то что reinterpret_cast играет важную роль в «фантастической четверке», этот оператор не будет выполнять приведение, когда в ходе преобразования требуется изме- нить спецификатор const (или volatile). Это иллюстрирует следующий про- граммный код: Листинг 19.2. template< typename Т1 , typename Т2 > struct test_cast { tes t_cas t(T2 *p2) { m_pl = static_cast<Tl*>(p2); // a m_pl = reinterpret_cast<Tl*>(p2) ; // b m_pl = const_cast<Tl*>(p2); // c m_pl = (Tl*)p2; И d ) operator T1 * () { return m_pl; } // Члены protected: T1 *m_pl; }; int main() { test_cast<int , int > tcl(NULL); II 1 test_cast<int , short > tc2(NULL); //2 test_cast<int , int const > tc3(NULL); II 3 return 0;
Г/Йва 19. Приведение типов 399 В первом сценарии (приведение типа int* в int*) будут работать все четыре версии. Во втором (int* в short*) - только reinterpret_cast и приведение в стиле С. В третьем (int* в int const*) - только const_cast и приведение в стиле С. Хотя и возможно при некоторых обстоятельствах определить наличие или отсутствие константности у двух типов и таким образом осуществить при необходимо- сти перестановку операторов const_cast и reinterpret_cast, это усложняет организацию шаблонов до уровня, который не поддерживается некоторыми компиля- торами. Поэтому переносимый вариант решения (если только при приведении типов вообще можно говорить о переносимости!) может заключаться в приведении типов в стиле С. (Естественно, я полагаю, что говорю с читателем, который хорошо понимает пагубность приведений в стиле С и фактически избегает пользоваться этим подходом - необходимо обладать специальными навыками и стальными нервами, чтобы приме- нять приведения типов в стиле С, - но я считаю, что необходимо узнать все альтерна- тивы, прежде чем следовать правилам, даже очень хорошим.) При других обстоятельствах следует избегать применения приведений в стиле С. Практическую трудность представляет то, что они используются в программном коде большого объема и удалить их очень трудно, потому что они просто незаметны на общем фоне; не случайно операторы C++ столь сильно выделяются, что некоторые их называют уродливыми [Stro 1994]. К счастью некоторые компиляторы в этой связи не- много помогают. GCC обеспечивает опцию компилятора -Wold-style-cast для вывода предупреждения при каждом применении приведения в стиле С. Недавние версии компиляторов Digital Mars и Comeau обеспечивают ту же самую функциональ- ность при применении опций -wc и -C_style_cast_warning соответственно*. Указание необходимого флажка компиляторам Comeau (версия 4.3.3 или более позд- няя), Digital Mars (версия 8.29 или более поздняя) или GCC приводит к выводу преду- преждений для каждой операции приведения в стиле С - еще одна причина использо- вать в вашей работе несколько компиляторов. 19.4. «Стероидные» приведения Применяя поддерживаемые в C++ функции-члены операторов преобразования [Stro 1997], можно написать классы, которые будут способны выполнять роль опера- торов приведения. Более того, такие приведения могут осуществляться между семан- тически различными типами. Как отмечается многими [Меуе 1996, Меуе 1998, Dewh 2003], применение таких операторов может стать причиной многих проблем, и ими сильно злоупотребляют. Но они предусмотрены в языке, т. к. могут быть очень полезными, и применение классов, обеспечивающих приведение типов, и законно и необходимо. Рассмотрим следующий класс, который преобразует строку (в стиле С) в Целое число. Поставщики обоих этих компиляторов были достаточно доброжелательны и удивительно быстро отвечали м°ч запросы по этому поводу. Как вам обслуживание?
400 Часть 4. Осознанные преобразования Листинг 19.3. class str2int { II Конструирование public: explicit str2int(char const *s) : m_value(atoi(s)) {} Il Операторы public: operator int() const { return m_value; } II Члены private: int m_value; }; Имея строку, мы можем теперь «привести» ее в целый тип, используя следующее выражение: int val = str2int("34") ; или: int val = (str2int)"34"; или даже: int val = static_cast<str2int>("34"); Выглядит достаточно эффектно, хотя в данном случае мы могли бы также вызвать atoi (). Если воспользоваться шаблонами C++, то этот метод можно существенно обобщить с некоторыми приятными и удивительными результатами. Представим теперь, что нам хотелось бы немного расширить возможности нашего преобразования и распространить его на все интегральные типы (включая bool)- Конечно, нам потребуются некоторые шаблоны. Возможно, вас удивит то, что решение в действительности очень простое. (Следует отметить, что оно совместимо только с компилятором CodeWarrior компании «Metrowerks» и поэтому использует тип long long. Я также не побеспокоился ни о типах без знака, ни о кодировках символов, отличных от char. В реализации, предназначенной для работы в реальных условиях» конечно, использовались бы корректные абстракции, то есть int64_t, и обеспечива- лось бы более переносимое решение.) Листинг 19.4. template <typename I> class str2int { 11 Конструирование public:
Глава 19. Приведение типов 401 explicit str2int(char Const *s); // Операторы public: operator I() const { return m_value; } // Члены private: I m_value; }; template <typename I> inline str2int<I>::str2int(char const *s) x m_valus(static_cast<l>(atoi(a))) {} template <> inline str2int<long long>::str2int(char const *s) x m_valus(strtoll(a, NULL, 10)) () template <> inline str2int<bool>::str2int(char const *s) x m_value(0 (stranp(s, "true")) (} Приятным следствием этой реализации является эмуляция с помощью этого класса приведения синтаксиса встроенных операторов приведения: short s = str2int<short>("34"); int i = str2int<int>("65536") ; bool b = str2int<bool>("true") ; long long 11 = str2int<long long>("-9223372036854775808"); Итак, мы увидели, как можно наряжаться в одежды встроенных операторов приве- дения C++. Это приятно. Но насколько это серьезно? Ну, я уверен в том, что обеспече- ние нами также типа bool и его нечисловых значений (которые можно легко рас- ширить на другие строки, например, «1» и «TRUE») заставило вас задуматься, но вам, вероятно, захочется увидеть решение какой-то реальной проблемы. В конце концов, str2int можно было бы реализовать в виде набора таких родственных функций, как str2short, str2bool и т. д. 19.5. explicit cast Теперь мы знаем значительно больше о приведении типов и можем внимательно Рассмотреть оператор explicit_cast, упомянутый в разделе 16.4. Проблема Заключалась в обеспечении явных приведений - в типы std: : tm и DATE - нашего класса Time, избегая при этом неявных преобразований. Второй предложенный вари- ант предусматривал применение компоненты explicit_cast. 26-225
402 Часть 4 Осознанные преобразования class Time { operator explicit_cast<tm> () const; operator explicit_cast<DATE> () const; }; Учитывая то, что мы только что узнали о реализации операторов приведения в виде шаблонных классов, мы можем реализовать explicit_cast следующим образом: Листинг 19.5. template <typename Т> class explicit_cast { 11 Конструирование public: explicit_cast(T t) : m_t(t) {} // Преобразования public: operator T () const { return m_t; } // Члены private: T m_t; }; Это работает действительно хорошо для фундаментальных типов и типов указате- лей. Увы, это C++, и такая простая вещь никогда не может рассматриваться как оконча- тельное решение. Проблема в том, что m_t - переменная-член, и она копируется из t в конструкторе приведения. В том случае, когда т - фундаментальный тип, это ко- пирование обычно выполняется нормально. В действительности, компиляторы легко оптимизируют все эти вещи, когда речь идет о фундаментальных типах и типах указа- телей1 (а также ссылках при описанных ниже ограничениях). Кажется, это решение достаточно полное для фундаментальных типов. Как оно будет работать для определенных пользователем типов? Ну, если затраты на кон- струирование типа Т не тривиальны, например, когда он является вектором строк, то вам потенциально придется заплатить неприемлемо высокую цену за копирование. 1 Конечно, если вы хотите передавать значение long double (которое занимает 60 или 80 бит на большинстве 32-битовых компиляторах [Kaha 1998]) в 32-битовой архитектуре, то вы можете захотеть представить его в немного более эффективной форме explicit_cast<long double const &> вместо explicit_cast<long double^, поскольку ваш клиентский программный код может затем передавать это значение по ссылке (чтобы сделать его константным); тем самым вы избегаете дополнительного копирования. Однако в данном случае это не имеет никакого непосредственного отношения к эффективности оператора explicit cast; просто ваш выбор вида приведения может сказаться в другом месте вашего программного кода.
Глава 19. Приведение типов 403 class Path ( public: operator explicit_cast<string> () const; }; void ProcessPath(Path const &path) { string s explicit_cast<string>(path); // многократное копирование! Фактически, в подобном программном коде может осуществляться несколько ко- пирований при выполнении одной операции приведения типа. Borland, Digital Mars, GCC, Intel и Watcom создают три копии; CodeWarrior и Visual C++ (без расширений компании Microsoft) создают четыре копии; Visual C++ (с расширениями компании Mi- crosoft) создает 5 копий! (И это было при максимальном уровне оптимизации быстро- действия.) Излишне говорить, что этого необходимо избегать. Однако поскольку все программисты должны знать, что копирование нетривиальных типов связано с допол- нительными затратами, мы, вероятно, можем воспринимать возврат объектов по значе- нию как общепризнанный артефакт. Другая проблема возникает при использовании ссылок. Это происходит из-за того, что язык не допускает неконстантные ссылки на временные экземпляры. В целом это чрезвычайно разумное правило, т. к. в противном случае какой-нибудь фрагмент про- граммного кода был бы способен изменить временный экземпляр, что могло бы уди- вить сбитого с толку программиста, не понимающего, что случилось с изменением, которое исчезло вместе с временным экземпляром, причем исчезло навсегда. Это обсу- ждается в правиле #30 из книги «Effective C++» (Эффективный C++) [Меуе 1998], и там также очень хорошо объясняется, почему не следует возвращать неконстантные ссылки на члены из методов класса. Это тем более относится к возможности приведе- ния к неконстантным ссылкам, и поэтому я бы сказал, что хотя ссылка на неконстант- ный временный экземпляр была бы допустима в данном случае - поскольку времен- ный экземпляр просто передает «законно» полученную ссылку - мы без затруднения Должны указать, где такое применение не будет компилироваться. class Path ( public: operator explicit_cast<string t> О; // Опасность! }; void ProcessPath(Path &path) ( string &s expliclt_cast<strlng&>(path); // Недопустимо //и непривлекательно
404 Часть 4. Осознанные преобразования ------- _ Так что нам остается только выполнять приведение к ссылке на константный экзем- пляр. Нет причин, по которым наше приведение не должно работать; просто это случа- ется при использовании некоторых компиляторов. В действительности такое приведе- ние компилируется, но проблема в том, что некоторые компиляторы создают промежу- точные копии временных экземпляров типа, ссылка на которые возвращается, и это де- лается без выдачи каких-либо предупреждений. В частности, оказывается, что Intel 6.0 7.0 и 7.1, а также Visual C++ 6.0 и 7.0 создают дополнительный временный экземпляр. Компиляторы Borland, Digital Mars, CodeWarrior, GCC, Visual C++ 7.1 и Watcom ведут себя желательным для нас образом. class Path ( public: operator explicit_cast<string const &>() const; }; void ProcessPathfPath const &path) ( // Работает, но здесь может создаваться временный экземпляр string const &s = explicit_cast<string const &>(path); Вывод следующий: в языке нет одной нужной нам возможности - явных приведений - которые полезны, хотя нужны только в редких случаях. (Мы увидим некоторые примеры их использования в части 6.) У нас есть компонент, который ведет себя идеально с фунда- ментальными типами и типами указателей, но со ссылками у него проблемы. Он не работа- ет для ссылок на неконстантные экземпляры, но мы удовлетворены этим, т. к. даже если бы в этом случае не нарушались правила языка, пользоваться этой возможностью было бы неразумно, и это редко приносило бы пользу. Однако этот компонент имеет изъян, посколь- ку он может создавать нежелательные копии некоторыми компиляторами и не создавать другими. Такая несогласованность в работе программного кода, полученного разными ком- пиляторами, неприемлема для качественного программного обеспечения, даже если этот «отказ» находится внутри самих некоторых компиляторов1, и поэтому нам необходимо что-то предпринять. Просто недопустимо писать универсальный компонент и говорить, что он может и должен использоваться не для всех, а только для некоторых компиляторов, причем не выдавая предупреждений там, где он работает ненадлежащим образом. Люди по праву откажутся его применять и, вероятно, не станут пользоваться также всеми други- ми вашими работами. Что же делать? Ну, нам необходимо предотвратить использование explicit_cast для ссылок на определенные пользователем типы, разрешая их применение для ссылок на (константные) фундаментальные типы. Существует два способа, с помощью которых мы можем принудительно запретить ненадлежащее применение этого компонента: специализация и ограничения. 1 Строго говоря, это вопрос оптимизации, а не корректности, и связан он с соответствующей интерпретации» стандарта в конкретной реализации, избравшей исключение копирующих конструкторов. (См. гл. 18).
Глава 19. Приведение типов 405 Первый способ, предусматривающий как частичную, так и полную специализа- цию. выглядит следующим образом: Листинг 19.6. // Запретить все типы ссылок template <typename Т> class explicit_cast<T &> ( // Конструирование private: explicit_cast(Т &); // Преобразования private: operator T & () ; }; // Явным образом разрешить специальные (фундаментальные) типы template <> class explicit_cast<char const &> { // Конструирование public: explicit_cast(char const &t) : m_t(t) (} // Преобразования public: operator char const & () const ( return m_t; } // Члены private: char const &m_t; }; ...H повторить для for bool, wchar_t • . . // повторить для signed & unsigned char ...II повторить для (unsigned) short/int/long/long long ...II повторить для float, double & long double // Включить все типы указателей template «typename T> class explicit_cast<T *> ( 11 Конструирование public: expliclt_cast(I *t) : m_t(t) (}
406 Часть 4. Осознанные преобразования // Преобразования public: operator Т * () ( return m_t; } // Члены private: Т *m_t; }; Первая частичная специализация explicit_cast<T&> объявляет закрытым его конструктор и оператор неявного преобразования, что фактически предотвращает использование любых ссылок в явных приведениях. Это хорошо, но слишком сильно, поскольку мы хотим, чтобы фундаментальные типы работали для приведений в ссылки на константные экземпляры. Здесь приходит черед полной специализации. Она показана для типа char, и соответствующие специализации предусматриваются для всех фундаментальных типов1. Картину завершает обеспечение частичной специа- лизации для типов указателей, чтобы ими можно было пользоваться, т. к. некоторые компиляторы могут спутать собственно шаблоны со специализацией ссылок, когда потребуется их инстанциировать для типов указателей. (Если сомневаетесь, выражай- тесь ясно - извините за каламбур.) С помощью этих специализаций мы обеспечиваем именно тот режим работы, который нам нужен. (Следует отметить, что если вы хотите проявить особую осторожность и запретить возвращение любых указателей на неконстантные экземпляры, вы можете изменить специализацию указателя и использовать спецификатор доступа private точно так же, как это сделано для ссылок, и затем дополнительно специализировать указатель на константный экземпляр, то есть explicit_cast<T const *> с открытым кон- структором и оператором преобразования. Выбор за вами.) Увы, не все широко используемые компиляторы поддерживают частичную специали- зацию, и поэтому при таких обстоятельствах благоразумно обеспечить относительно ограниченную версию explicit_cast. Она основана на применении ограничений. Мы применяем ограничение constraint_must_be_pod() (раздел 1.2.4) в деструк- торе, что означает невозможность использования шаблона для типов, отличных от POD. Листинг 19.7. template <typename Т> class explicit_cast < #ifndef ACMELIB_TQ(PIATB_PARTIAL_SPBCIALIZATION_SUPPORT 1 На (неадекватных) компиляторах, которые не поддерживают wchar_t как отдельный тип, вам придете* с помощью препроцессора соответствующим образом скрыть его определение, поскольку не допускается иметь две специализации для одного типа, даже если определения идентичны.
Глава 19. Приведение типов 407 // Для компиляторов, не поддерживающих частичную специализацию, мы // принудительно ограничиваем возможность использования только типов POD. ~explicit_caat() { coastraint.jnuBt_be_pod(T); } «endif /* I ACMKLIB_TBMPIATK_PARTIAL_SP®CIALIZATION_SUPPORT */ }; Вот так! Это решение не идеально, т. к. допускается применение этого шаблона к нетривиальным типам POD, например, к большим структурам. Однако поскольку мы уже решили, что о затратах на копирование при параметризации шаблона значением пусть беспокоится пользователь, то в тех редких случаях, когда компилятор не спосо- бен оптимизировать промежуточные копии структуры, вы сами отвечаете за выбор передачи параметров по значению. Что ограничение нам все-таки позволяет - не за- будьте, что это делается на этапе компиляции - так это отклонение любых нетривиаль- ных типов, определенных пользователем, копирование которых потенциально обхо- дится дорого. Одно из замечательных достоинств explicit_cast заключается в возможности его применения в клиентском программном коде как для типов, которые могут быть представлены в операторах explicit_cast, так и для тех, которые обеспечивают операторы неявного преобразования. Это означает то, что вы можете создавать обоб- щенный программный код обоих типов - написанный внимательно и написанный не- брежно - следовательно, точно так же explicit_cast может являться основой обобщенного механизма преобразования, позволяющего выбирать, в каких случаях отдавать функцию преобразования в руки программиста, а не осуществлять ее компи- лятором. Именно так должно быть. 19.6. literal cast Хотя применение литеральных целочисленных констант в нашем программном коде не является решенным вопросом, тем не менее, мы довольно часто пользуемся по- именованными константами. Иногда константу можно использовать ненадлежащим образом, и возникает риск потери информации из-за усечения. Хотя самые хорошие компиляторы предупреждают об усечении констант, некоторые этого не делают вообще или в наиболее распространенных конфигурациях. В любом случае это всего «ишь предупреждение. Я знаю, что ни один из вас, уважаемые читатели, не позволил такому предупреждению оказаться незамеченным в ходе построения рабочей ВеРсии, но существуют менее прилежные разработчики, либо те, кто подавляет неко- ^Рые предупреждения или «наследует» такое подавление предупреждений в библио- Те,сах» плохо написанных независимыми разработчиками. Было бы очень хорошо ИМеть конструкцию, которая обнаруживала бы усечение и принудительно выдавала бы с°°бщение об ошибке на этапе компиляции.
408 Часть 4. Осознанные преобразования ----- ------------------------------------------------------------------ Именно такой конструкцией является шаблонная функция literal_cast, пока занная в листинге 19.8. Листинг 19.8. #ifdef ACMELIB_64BIT_INT_SUPPORT typedef int64_t literal_cast_int_t; #else /* ? ACMELIB_64BIT_INT_SUPPORT */ typedef int32_t literal_cast_int_t; #endif /• ACMELIB_64BIT_INT_SUPPORT */ template* typename T , literal_cast_int_t V > inline T literal_cast() { const int literal_cast_value_too_large = V <= limit_traits<T>: :maximum_value; const int literal_cast_value_too_small = V >= limit_traits<T>::minimum_value; STATIC-ASSERT(literal_cast_value_too_large); STATIC_ASSERT(literal_cast_value_too_small); return T(V); } Здесь используется шаблонный класс limits_traits, который обеспечивает константы-члены (см. раздел 15.5.3) minimum_value и maximum_value. Он рас- сматривает константу как целое число со знаком с максимальным размером, который допускает компилятор, - literal_cast_int_t; например, 64 бита для 32-битовых компиляторов. Это число затем сравнивается с минимальным и максимальным значе- ниями типа приведения в статическом утверждении (см. раздел 1.4.8). В сравнениях все значения сопоставляются с literal_cast_int_t, и поэтому для всех типов меньшего размера в данном примере обеспечивается полная оценка значения типа. Предполагая, что размер literal_cast_int_t равен 64 битам, рассмотрим следующий программный код: const int I = 200; sint8_t i8 = literal_cast<sint8_t, I>(); // Ошибка компиляции uint8_t ui8 = literal_cast<uint8_t, I>(); // Компилируется нормально sintl6_t il6 = literal_cast<sintl6_t, I>(); // Компилируется нормально Существует ограничение в применимости приведений для 64-битовых типов в силу того, что оно использует для константы 64-битовый тип со знаком. Если вы задаете uint64_t в качестве типа приведения и передаете значение, большее максимального положительного числа со знаком, вы получите усечение или преобразование знака. В этом проявляется недостаток метода, и в языке нет возможности его каким-то обра- зом преодолеть; мы никак не можем проигнорировать невозможность найти тип боль- шего размера, чем тип максимального допускаемого в языке размера.
Слава 19. Приведение типов 409 Единственно решение - не допустить приведение максимального целого типа без знака. Это не сложно сделать, используя частичную специализацию, но это требует перехода от функции к классу приведения, как показано в листинге 19.9. Листинг 19.9. #ifdef ACMELIB_64BIT_INT_SUPPORT typedef int64_t literal_cast_int_t; typedef uint64_t invalid_int_tj #else /* ? ACMELIB_64BIT_INT_SUPPORT •/ typedef int32_t literal_cast_int_t; typedef uint32_t invalid_int_tj #endif /* ACMELIB_64BIT_INT_SUPPORT */ tempiate< . . . > class literal_cast { public: operator T () const { . . . // остальная часть предыдущей реализации } }; template<literal_cast_int_t V> class literal_cast<invalid_int_t, V> { private: operator invalid_int_t () const { const int cannot_literal_cast_to_largest_unsigned_integer = 0; STATIC_ASSERT(cannot_literal_caet_to_largeat_unsigned_integer Специализация для нового типа invalid_int_t, представляющего максималь- ный целый тип без знака, скрывает оператор преобразования, тем самым предотвра- щая его применение при приведении этого типа. Хорошо, что этот оператор содержит статическое утверждение (см. раздел 12.4.8), которое поможет неосторожному разра- ботчику, выдавая немного более содержательное сообщение об ошибке компиляции, чем «operator is inaccessible» (оператор не доступен), которое он получил бы в противном случае. Теперь вы можете в вашем программном коде приводить литералы к любому типу 1кроме максимальных значений целого типа без знака) в полной уверенности, что Усечения не произойдет. Перед завершением этого раздела мне бы хотелось упомянуть о том, что библиоте- Ки Boost содержат применяемый на этапе выполнения аналог этого приведения,
410 Часть 4. Осознанные преобразования названный numeric_cast и написанный Кевлином Хенни (Kevlin Henney). Теперь охватывается весь ваш программный код вне зависимости от того, на каком этапе вы хотите обнаруживать усечение: при компиляции или при выполнении. 19.7. unioncast В [Stro 1997] Бьерн Страуструп обращает внимание на возможность приведения не- связанных типов с помощью объединения (union) даже там, где такое преобразование не поддерживается ни одним из операторов приведения в C++. Листинг 19.10. template* typename ТО , typename FROM > union union_cast ( union_cast(FROM from) : m_from(from) {} operator TO () const ( return m_to; } private: FROM m_from; TO m_to; }; Основная идея Бьерна состоит в том, что такие вещи являются злонамеренным хакерством, и тот, кто пользуется подобным программным кодом, - наивный и/или опас- ный человек. Мне нечего добавить... кроме того, что иногда этот прием приносит пользу. Дйе конкретные проблемы, которые описывает Бьерн, относятся к размеру и к выравниванию. В некоторых архитектурах размеры указателя и типа int от- личаются, и поэтому в результате преобразования может возникнуть опасное усече- ние. Кроме того, в нескольких архитектурах необходимо обеспечивать специальное выравнивание указателей, и попытка разыменования неверно выровненного указателя приведет к чему-нибудь неприятному, например, к исключению, сгенерированному оборудованием. long 1=3; //Не очень похоже на адрес строки . . . string *ps = union_cast<string*, long>(l); // Бац! Применение union_cast дает гарантий не больше, чем reinterpret_cast, а в некоторых случаях - еще меньше, поскольку с его помощью можно выполнить некоторые приведения, которые будут отвергаться reinterpret_cast.
рлава 19. Приведение типов 411 ~~.— Итак, если все так плохо, зачем нам вообще рассматривать приведение типов с помощью объединения? Существует две причины, причем очень прозаические. Во-первых, как отмечалось в разделе 19.3, некоторые преобразования могут потре- бовать выполнения нескольких операций приведения, что приводит к «тягучему» программному коду, который трудно читать и сопровождать. Во-вторых, некоторые компиляторы выдают предупреждение на каждое приведение типа, причем не только для неявных приведений и приведений в стиле С, что может сильно рас- сердить сторонника философии отсутствия предупреждений в рабочей программе. Существуют широко распространенные системные архитектуры, в которых приведе- ние типов просто навязывается. Прежде всего на ум приходит программный интерфейс Windows. Каждое сообщение в этой системе связано с двумя неясными значениями данных типа WPARAM и LPARAM, которые на платформе Win32 представлены как uint32_t и sint32_t соответственно. Они используются для передачи самых разно- образных объектов, включая дескрипторы системных объектов, указателей С-строк и указателей на объекты C++. Поэтому на платформе Win32 полезно определять кон- струкции, помогающие улучшить читаемость и облегчить сопровождение программного кода, а также повысить типобезопасность, насколько это возможно в таких условиях. Следовательно, применение специальной параметризации union_cast может принес- ти пользу, как в следующем примере: typedef union_cast<LPARAM, wchar_t const*> StrW2LPARAM; typedef union_cast<HDROP, WPARAM> WPARAM2HDROP; Из-за того что union_cast является очень опасным средством, нам необходимо предпринять некоторые серьезные меры для сдерживания его возможностей перед тем, как мы сможем его использовать с чистой совестью. Во-первых, никогда нельзя его использовать в «сыром» виде. Если бы вы попытались найти его в Моей системе управ- ления исходным кодом, вы бы не нашли ни одного экземпляра union_cast в программном коде реализации любого продукта или компонента. Он используется только в одном месте - в определениях конкретных typedef (таких, как приведенные выше два typedef), расположенных в заголовочных файлах библиотек, относящихся к конкретным технологиям и операционным системам. Можно обоснованно предполо- жить, что такие typedef потребовали более серьезного обдумывания, чем одиночный конкретный пример приведения с помощью конструкции объединения, глубоко спрятан- ный внутри файла реализации. Во-вторых, необходимо минимизировать риск ненадлежащего использования с помо- ’Чью нескольких ограничений, встраиваемых в класс приведения, как показано в листинге •Н. Первое из них обеспечивает одинаковый размер участвующих в преобразовании ^Пов. Это устраняет опасность усечения. Нам могло бы потребоваться второе ограниче- Ние» обязывающее использовать только типы POD, но поскольку union предназначен Тапько для таких типов, это обеспечивается автоматически. Из педагогических целей на 1,83 типа накладывается ограничение constraint_must_be_pod() (см. раздел 1.2.4), Х°Тя в этом нет необходимости: ограничение срабатывает при попытке определить union, держащий данный тип.
412 Часть4. Осознанные преобразования Листинг 19.11. template* typename ТО , typename FROM > union union_cast { explicit union_cast(FROM from) : m_from(from) { II 1. Размеры должны быть одинаковы STATIC_ASSERT(sizeof(FROM) == sizeof(TO)); II 2. Оба должны быть типами POD constraint_must_be_pod(FROM); constraint_must_be_pod(TO); # if defined(ACMELIB_TEMPLATE_PARTIAL_SPECIALIZATION_SUPPORT) II 3. Оба не должны быть указателями или должны быть // указателями на типы POD typedef typename base_type_traits*FROM>::base_type f r om_bas e_t ype typedef typename base_type_traits<TO>::base_type to_base_type; constraint_jnust_be_pod_or_void(from_base_type); constraint_must_be_pod_or_void(to_base_type); # endif /* ACMELIB_TEMPLATE_PARTIAL_SPECIALIZATION_SUPPORT */ } }; В-третьих, приведение с помощью объединения не должно позволять преобразовы- вать указатели в тип класса и наоборот, т. к. это может способствовать осуществлению неконтролируемых нисходящих или перекрестных приведений, для правильного выполнения которых предназначен dynamic_cast. Это обеспечивает еше одно огра- ничение, накладываемое на базовые типы операции приведения. Другими словами, кроме гарантирования участия в приведении только типов POD, мы также гарантиру- ем, что если один из этих типов является указателем - не забывайте, что указатели также являются типами POD (см. «Введение») - то допускается только преобразование указателя в указатель (или в тип void). Это делается путем определения базового типа и применения constraint_must_be_pod_or_void() (см. раздел 1.2.4). Для компиляторов, которые поддерживают частичную специализацию, шаблон base_type_traits способен устанавливать базовый тип любого типа. Этот про стой шаблон с соответствующей (частичной) специализацией имеет следующим вид- Полная реализация этого и других используемых в книге ограничений представлена на компакт-Д,,ске‘
Глава 19. Приведение типов Листинг 19.12. 413 template <typename Т> struct base_type_traits { enum { is_pointer = 0 ); enum { is_reference = 0 }; enum { is_const = 0 ); enum { is_volatile = 0 ); typedef T base_type; typedef T cv_type; }; ...II Специализации для различных вариантов cv и указателя/ссылки template <typename Т> struct base_type_traits<T const volatile *> { enum { is_pointer = 1 ); enum { is_reference = 0 ); enum { is_const = 1 }; enum { is_volatile = 1 }; typedef T base_type; typedef T const volatile cv_type; }; Теперь мы разобрались с большинством проблем, возникающих при использовании union_cast, и любое ненадлежащее его применение обрабатывается на этапе ком- пиляции. Остается решить последнюю проблему, связанную с возможностью получе- ния неправильно выровненного указателя (что может произойти и при использовании reinterpret_cast). Естественно, это нельзя проверить на этапе компиляции, но base_type_traits может помочь нам и здесь. В моей реализации имеется утверждение, которое проверяется, когда исходный тип не является указателем, а целе- вой тип - указатель, и гарантирует, что исходное значение выровнено в соответствии с размером базового типа, используемого для целевого типа. Другими словами, если Целевой тип - uint64_t (const) (volatile)*, то значение исходного типа Должно быть выровнено на границу восьми байтов. В своей собственной реализации вы можете остановить свой выбор на выбрасывании исключения. При использовании всех четырех мер предосторожности сомнительно, что Union_cast более надежен, чем reinterpret_cast, и единственная разница в их иРнменимости заключается в невозможности приводить указатели в типы классов и обратно. Поскольку такие операции представляют собой очень большую опасность Для программистов C++, я полагаю, что это хорошо: данный класс приведения осуше- Ствляет «мягкие» преобразования (и подтверждает их допустимость), предоставляя в°зможность программисту самому выполнять опасные преобразования. Следует отметить, что это не воспрепятствует приведению, скажем, типа char c°nst* в wchar_t*, поскольку мы пытаемся инкапсулировать применение только ВеС1^)Дьких приведений. Это представляет опасность, и поэтому я строго придержи-
414 ЧастьД. Осознанные преобразования -------- ваюсь правила использовать их только через typedef, например, WPARAM2hdrqp поскольку обоснованно можно считать, что такие typedef создаются и используются достаточно обдуманно1. 19.8. com$U::interfacecast Хотя и хорошо, когда приводимые примеры не связаны с технологией какой-то ком- пании, я считаю, что может наступить время для специальных технологий. Для тех не- многих, кто еще не слышал о СОМ, я проведу небольшую «экскурсию» по этой техно- логии, которая им не помешает. (Если вы хотите глубоко разобраться в этом вопросе то существует много хороших книг по этой теме [Box 1998, Вгос 1995, Eddo 1998]; приготовьтесь узнать много нового!2) СОМ расшифровывается как «component object model» (модель многокомпонент- ных объектов) и выполняет именно то, что говорится в названии: это модель описания и реализации многокомпонентного программного обеспечения, предназначенная для создания и управления многокомпонентными объектами. Она строится на основе интерфейса IUnknown, имеющего три метода. AddRef () и Release () отвечают за подсчет ссылок. С помощью метода Queryinterface () можно опрашивать объект СОМ, чтобы узнать, обладает ли он другой сущностью (указанной в запросе), и запро- сить указатель на нее. Сущности, о которых мы говорим, - это другие интерфейсы, производные от IUnknown, идентифицируемые уникальными идентификаторами интерфейсов (IIDs), представленные 128-битовыми числами. Листинг 19.13. interface IlmpCpp : public IUnknown ( virtual HRESULT CanSupportOO(BOOL *bOO) = 0; }; extern const IID IID_IImpCpp; interface ITmpC : public IUnknown { virtual HRESULT CanSupportOO(BOOL *bOO) = 0; }; extern const IID IID_IImpC; 1 Почти ничего другого сколько-нибудь существенного мы ие сможем сделать, чтобы оградить с потенциально макккиавеллиевского поведения программистов. В некоторых случаях нам прих полагаться на профессионализм. 2 Я должен признаться, что мне очень нравится COM. Не DCOM, MTS, OLE, ATL или__________declspec(u a COM в чистом ваде вызывает восхищение, несмотря иа его сложность.
Глава 19. Приведение типов 415 Из-за того, что объект СОМ может реализовать несколько интерфейсов и поскольку еМу не предписывается наследовать ни один из них, нельзя выполнять приведения в указатели на интерфейсы СОМ, что мы могли бы делать для других наследуемых тИПов. Если мы имеем указатель на IlmpCpp и хотим преобразовать его в IlmpC, мы не можем осуществить приведение типа, поскольку они непосредственно не связаны; они всего лишь имеют одного родителя. (Другие важные правила, которым подчи- няются объекты СОМ, не позволяют применять dynamic_cast для перекрестного приведения [Stro 1997] при таких связях.) Фактически, из этого простого примера видно, что если бы мы хотели наследовать как limpCpp, так и IlmpC, мы могли бы потратить время и реализовать специально поддерживающие их методы CanSup- portOOO1. Когда мы хотим обратиться к другой сущности объекта СОМ, мы, естественно, должны спросить текущий интерфейс (выдавая запрос), реализует ли он требуемый интерфейс. Отсюда: Листинг 19.14. IlmpCpp *ic = . .; IlmpCpp *icpp; // Получает указатель на интерфейс IlmpC HRESULT hr ic->Query!nterface(IID_IImgpCppr relnt«rpr«t_cast<Told**>(&icpp)); if(SUCCEEDED(hr)) { BOOL bOO; icpp->CanSupportOO(&bOO); icpp->R«l«ase(); // Освободить интерфейс } Это достаточно стандартный под ход получения другого интерфейса из существующего. С этим механизмом связаны две проблемы. Во-первых, необходимо указать правильную константу идентификатора IID. Вторая проблема заключается в том, что легко можно получить неверное приведение, т. к. обычно забывают указывать оператор адресации варгументе reinterpret_cast, кодируя reinterpret_cast<void**> (icpp). Это обнаруживается только в тестах, полностью охватывающих программный код, что очень трудно сделать в значительных проектах [Gias 2003]. Предпринималось много попыток инкапсулировать эту процедуру, некоторые из них включали «умные» указатели, но они не были удачными (на мой взгляд). Посколь- ку никто не любит неконструктивную критику, я рискну своей головой и покажу вам СВое Решение: приведение интерфейсов2. Е^ствеино, оба метода возвратили бы «да» в ответ на запрос, как было продемонстрировано в гл. 8. Это составляет часть COMSTL. который является подпроектом STLSoft. относящимся к СОМ, и который 10Чен в компакт-диск.
416 Часть 4. Осознанные преобразования 19.8.1. interfacecastaddref Мы рассмотрим один из них в действии и перепишем пример. IlmpC *ic = . • . ; IlnpCpp *icpp interface_caat_addref<IImpCpp*>(ic)7 if(NULL 1- icpp) { BOOL bOO; icpp->CanSupportOO(&bOO); icpp->Releaae()j // Освободить интерфейс } Поскольку идентификатор интерфейса и указатель, используемые для получения запрашиваемого интерфейса, связаны с типом требуемого интерфейса, мы их оба вы- водим из типа интерфейса. У нас нет вызова Queryinterface () и связанной с ним абракадабры, а также мы избавились от двух обычных проблем. Успешное приведение дает в результате ненулевой указатель, причем клиентский программный код отвечает за освобождение соответствующего интерфейса после завершения работы с ним. Это хорошее начало, но все же предстоит сделать остальные стандартные действия, включая проверку успешности преобразования и непосредственное освобождение интерфейса после завершения работы с ним. 19.8.2. interfacecastjioaddref Иногда вызов единственного метода интерфейса, как это сделано в нашем примере, - это все, что вам нужно. В этом случае восемь строк программного кода выглядят доста- точно неуклюже. Здесь на помощь приходит второе приведение интерфейса. Применяя это приведение, мы можем переписать вызов метода в три строки: IlmpC ж1с = . . .; BOOL bOO; interface_cast_noaddref<11лрСрр*>(ic)->CanSupportOO(&ЬОО); Согласно названию, это приведение не использует вызов AddRef О для данного интерфейса. В действительности, оно. временно увеличивает счетчик ссылок объекта с помощью вызова Query Interface () для получения интерфейса IlmpCpp, но этот интерфейс вновь освобождается в конце реализации оператора: таким образом счетчик ссылок на объект не изменяется. Поскольку в ходе выполнения этого приведения запрос интерфейса и вызов метода осуществляется одним оператором, отсутствует возможность проверки в клиентском программном коде ошибочности преобразования, и поэтому данное приведение вы- брасывает исключение - по умолчанию это bad_interface_cast (неудачное при- ведение интерфейса) - если интерфейс нельзя получить.
Глава 19. Приведение типов 417 19.8.3. interface_.cast.test Как мы увидим в части 7, независимо от того, используете ли вы исключения или воз- вращаете значения для обработки ошибок, вам все-таки придется в каком-то месте взять на себя ответственность за обработку неудачного результата. Поэтому при использова- нии interface_cast_addref мы должны проверить указатель на NULL, а при ис- пользовании interface. cast.noaddref нам необходимо перехватить исключе- ние bad_interf ace.cast. Из-за того, что СОМ является двоичным интерфейсом, мы не можем выбрасывать исключения из единицы компоновки (см. гл. 9), иначе нам при- шлось бы сформировать много блоков try-catch вокруг этого места. Правила идентичности COM-объектов [Box 1998] устанавливают, что если некий экземпляр когда-нибудь возвращал данный интерфейс в ответ на вызов Query- Interface (), он всегда должен это делать на протяжении всей своей жизни. Поэто- му на сцену выходит третий компонент из этого набора, interface_cast_test. По существу здесь приведение «облачается в одежды» логической прокладки {Logical Shim; см. раздел 20.3), используемой в условных выражениях. Она может применяться для того, чтобы гарантировать невозможность выбрасывания исключения при данном использовании interface_cast_noaddref или невозможность возврата interface_cast_addref значения NULL. Но его применение приносит настоящую пользу при сочетании с сохраняемым interface_cast_noaddref, как показано в следующем примере: IUnknown *punk = . . .; if(interface_cast_test<IThing*>(punk)) { int«rfac«_cast_noaddraf<IThing*> thing(punk)i thing->Methodl(); thing->MethodN()г } // деструктор thing освободит ссылку Конечно, того же самого можно было бы достигнуть путем создания сохраняемого экземпляра thing и перехвата исключения, выбрасываемого в том случае, если punk не был преобразован в IThing*. Но на практике создание компоненты СОМ представ- ляет собой не очень сложное упражнение, причем в некоторых случаях в средах, в которых нет доступа к библиотеке программ C/C++ этапа выполнения [Rect 1999]. При таких обстоятельствах используемое по умолчанию в interface.cast.noaddref исключение может задаваться (с помощью препроцессора) так, что исключение фак- тически не будет выбрасываться, позволяя вышеприведенной форме быть краткой, на- дежной и к тому же облегченной. Это может отличаться от «правильного» C++, но мы - иеидеальные практики, и это дает прагматическое повышение качества программного кода в условиях технологических ограничений.
418 Часть 4. Осознанные преобразования Я солидарен с Керниганом (Kemighan) и Пайком (Pike) [Кеш 1999], которые счи- тают, что исключения необходимо использовать только в случае возникновения дейст- вительно неожиданных ситуаций, и поэтому в большинстве случаев, как правило предпочитаю именно этот подход. 19.8.4. Реализации операторов К этому времени у вас должен возникнуть вопрос о реализации этих операторов триведения, и поэтому давайте рассмотрим ее. Все три класса наследуют в защищен- ном режиме шаблон interface_cast_base, но делают это немного по-разному. Во-первых, intегf асе_сast_noaddref: Листинг 19.15. template* typename I , typename X = throw_bad_interface_cast_exception > class interface_cast_noaddref : protected interface_cast_base<I, noaddref_release<I>, x> { public: typedef interface_cast_base<. . . > parent_class_type; typedef I interface_pointer_type; typedef . . . protected_pointer_type; public: template ctypename J> explicit interface_caat_noaddref(J &j) : parent_class_type(j) {} explicit interface_cast_noaddref(interface_pointer_type pi) : parent_clas s_type(pi) {} public: protected_pointer_type operator -> () const { return static_cast<. . .>(parent_class_type::get_pointer()); } // Реализация не требуется private: ...II Недоступный конструктор копирования и оператор копируютег0 // присваивания }; Родительский класс является специализацией interface_cast_base, постро- енной на основе класса функтора noaddref_release и заданного типа стратегии исключений. Этот функтор используется, чтобы гарантировать отсутствие в итоге Уве личения (или уменьшения) счетчика ссылок приводимого экземпляра путем освобо* дения интерфейса, полученного в конструкторе.
рлава 19. Приведение типов 419 Конструктор шаблона обеспечивает гибкость приведения к любому интерфейсу СОМ- Поскольку эта операция характеризуется умеренными затратами, второй кон- структор, который принимает указатель нужного типа, реализуется путем эффективно- го вызова AddRef () для данного указателя. В обоих случаях конструкторы ссылают- ся на соответствующие конструкторы базового класса.1 Запрашиваемый интерфейс доступен только с помощью метода operator -> () const, который помогает обеспечить безопасность этого приведения. Поскольку не обеспечивается ни один оператор неявного преобразования, компилятор отвергнет программный код следующего вида, который в противном случае содержал бы опас- ную и потенциально потерянную ссылку. IX *рх = interface_cast_noaddref<IX>(ру); // Ошибка компиляции px->SomaMathod(); // Крах. К счастье, до этого оператора дело не дойдетI Это классический пример смиренного программирования: защита пользователей наших типов от ненадлежащего применения в компиляторах, которые «сговорились» с языком о возможности слишком легких решений. Однако это затрудняет применение приведения в сценарии, подобном следующему: func(IX *рх) ; IY *ру = . . .; func(interface_cast_noaddref<1Х> (р)); // Ошибка компиляции По правилам СОМ вы не можете быть владельцем ссылки, которая передается функции (кроме тех случаев, когда это непосредственно предусматривается семанти- кой данной функции). Поэтому, хотя здесь оператор interf ace_cast_addref был бы откомпилирован, это привело бы к образованию висячей ссылки. interface_cast_noaddref - это то, что нам нужно. Конечно, было бы желание, а возможность найдется. Если вы склоны к извращени- ям, вы всегда можете написать так: IStraam *pi lntarfaca_caat_noaddref<IStream, . . .>(p).operator ->()j P±->SomeMethod(); // Непредсказуемое поведение. Может привести к крахуI Но тогда я вызову полицию C++, и приговор будет следующим: «Вам противопока- зано программировать! Приходите через год!» Естественно, решение существует бла- годаря тому, что опыт неидеального программирования подсказывает нам, что необхо- Димо прислушиваться к мнению (опытных) разработчиков. Мы можем использовать соответствующую прокладку атрибутов {Attribute Shim) get_ptr() (мы познако- Мимся со всеми достоинствами концепции прокладок в следующей главе), как показано в следующем примере: func(gat_ptr(intarface_cast_noaddref<IX>(p))); // Нормально ynjke классы приведений обеспечивают обе версии конструкторов, а не только шаблонный вариант, чтобы етворить компиляторы, которые неправильно поддерживают конструкторы шаблонов.
420 Часть4. Осознанные преобразования Реализации двух других приведений аналогичны реализации interface_cast_noaddref. interface_cast_test использует тип неактив ной стратегии исключений ignore_interface_cast_exception так, что отказ не приводит к выбрасыванию исключения, а вместо этого выполняется оператор неяв- ного преобразования1. interface_cast_test использует noaddref_release чтобы гарантировать отсутствие итогового изменения счетчика ссылок. Листинг 19.16. template<typename 1> class interface_cast_test : protected interface_cast_base<I, noaddref_release<I>, ignore_interface_cast_exception> { operator bool () const { return NULL != parent_class_type::get_pointer (); } interf ace_cast_addref тоже использует неактивную стратегию исключений, хотя она и определяется параметром стратегии, задаваемым по умолчанию в шаблоне, но при этом применяется addref_release для обеспечения необходимого увеличе- ния счетчика ссылок. Он обеспечивает доступ к полученному интерфейсу с помощью оператора неявного преобразования. Листинг 19.17. template* typename I , typename X = ignore_interface_cast_exception > class interface_cast_addref : protected interface_cast_base<I, addref_release<I>, X> { operator pointer_type () const { return parent_class_type::get_pointer(); } * На самом деле в этом интерфейсе ие реализуется булев оператор. Мы увидим, как это правильно Дела в гл. 24.
421 Глава 19. Приведение типов 19.8.5. Защита счетчика ссылок Возможно, вы заметили, что interface_cast_noaddref возвращал указатель на полученный интерфейс в виде protected_pointer_type. Это используется для того» чтобы предотвратить выполнение патологических вызовов интерфейсных методов AddRef () или Release () в клиентском программном коде; поскольку это приведе- ние управляет продолжительностью жизни приводимого интерфейса, вызов этих мето- дов не входит в обязанности клиентского программного кода. Соответственно, protected_pointer_type определяется в виде шаблона protect_ref countl4, который выглядит следующим образом: Листинг 19.18. template <typename I> interface protect_refcount : public I { private: STDMETHOD_(ULONG, AddRefX) ( I *pi = static_cast<I*>(this); return pi->AddRef(); } STDMETHOD_(ULONG, Release)() { I *pi = static_cast<I*>(this); return pi->Release(); } }; Эти два метода недоступны в клиентском программном коде, использующем interface_cast_noaddref, хотя все другие характерные интерфейсные методы остались доступными1. interface_cast_noaddref<IX>(py)->Releaae()j // Ошибка компиляции! 19.8.6. interface_castbase Три класса приведения просто параметризуют базовый класс соответствующими кассами стратегий, и делают доступными определенные свойства. Все осуществляемые интерфейсе interface_cast_base действия показаны в листинге 19.19. ГаРантиРУется только для компиляторов, поддерживающих частичную специализацию шаблонов. Но Нел,„Ы ^^"Рно компилируете свой исходный код на нескольких компиляторах, как и должно быть, то у вас ^Дет проблемы.
422 Часть 4. Осознанные преобразования Листинг 19.19. template< typename I , typename R , typename X > class interface_cast_base { protected: typedef I interface_type; typedef R release_type; typedef X exception_policy_type; protected: template <typename J> explicit interface_caet_baee(J *j) : nt_pi(do_caet( j)) {} explicit interface_caat_baee(interface_type pi) : m_pi(pi) { addref (nk_pi); ) ~interface_cast_base() { if(NULL != m_pi) { release_type()(nupi)» ) ) •tatic interface_type do_cast(LPUNKNOWN punk) { interface_type pi; if(NULL == punk) { pi = NULL; ) else { RXFIID iid IID_traita<interface_type>().iid()1 HRESULT hr punk->QueryInterface(iid, reinterpret_caet<void**>(*pl> >г if(FAILED(hr)) { exception_policy_type () (hr, iid); pi = NULL; ) ) return pii
Глава 19. Приведение типов 423 interface_type const &get_pointer_(); interface_type get_pointer_() const; private: interface_type const m_pi; private: ...II Недоступные конструктор копирования и оператор копирующего // присваивания }; Следует отметить несколько моментов. Во-первых, все методы являются защищен- ными, и поэтому невозможно ими пользоваться (ненадлежащим образом) непосредст- венно; они могут применяться только через производные классы. Во-вторых, как обсу- ждалось ранее, конструкторы определяются в виде пары шаблонной и нешаблонной версии для поддержки и универсальных и эффективных решений. В-третьих, указа- тель интерфейса, если он ненулевой, освобождается функтором release_type, который является параметром шаблона, причем способ освобождения (или не освобо- ждение) определяется типом стратегии release_type. Таким образом, do_cast () - это статический метод, где выполняются все дей- ствия. do_cast () вызывается из шаблонного конструктора, чтобы попытаться осу- ществить приведение. Если задан ненулевой указатель интерфейса, вызывается Que- ryinterface () для получения требуемого интерфейса. При успешном его заверше- нии возвращается новый интерфейс, а в противном случае вызывается функтор exception_policy_type. После вызова функтора exception_policy_type указатель устанавливается на NULL в случаях, когда exception__policy_type не предусматривает никаких действий. 19.8.7. IIDtraits Относительно реализации остается невыясненным единственный вопрос: как опре- деляется идентификатор интерфейса? Мы просто используем классический подход [Lipp 1998] для доступа к значениям из типа: свойства (traits). Класс свойств иденти- фикатора интерфейса, IID_traits, определяется следующим образом: template <class I> struct IID_traits { public: static REFIID iid(); 1; Ложная тревога: там, где имеются трансляторы, поддерживающие расширение ~~.uuidof() компании Microsoft, я иду простым путем и определяю метод lld () (для общего случая) следующим образом: template <class I> inline /* static */ REFIID IID_traits<I>::iid() { return uuidof(I); }
424 Часть 4. Осознанные преобразования В других случаях общее решение не обеспечивается, и должны определяться индивидуальные специализации (как для Interface, так и для Interface*) Для этого предназначен макрос COMSTL_IID_TRAITS_DEFINE (), и все текущИе стандартные интерфейсы именно так специализированы в заголовочном файле comstl_interface_traits_std.h, который включается при таких обстоя- тельствах. Он определяется следующим образом: # i fdef IClassFactory_FWD_DEFINED COMSTL_IID_TRAITS_DEFINE(IClassFactory) #endif /* __IClassFactory_FWD_DEFINED_ */ Пользователи приведений интерфейсов, компиляторы которых не обеспечивают __uuidof (), должны определять специализации своих собственных интерфейсов тем же самым способом. Этот подход нельзя назвать идеальным, но он также и не обре- менителен, и к тому же вполне защищен от неправильного использования: отсутствие нацеленности на общий случай предотвращает прохождение любых ошибок неза- меченными на этапе компиляции. Теоретически нехорошо, когда кому-то приходится отступать от принципиального решения воздерживаться от применения частных расширений, но прагматизм побеждает идеализм в данном случае (и в большинстве других, я полагаю). Нам не следует избегать таких вещей до тех пор, пока мы от них не зависим (и не станем зависеть). Цель - получить программное обеспечение самого высокого качества, а не чистейшую душу! 19.8.8. interfacecast: заключение Как много программного кода, ориентированного на СОМ! Лучше бы я предпочел представить пример, не ориентированный на конкретную технологию, но трудно синтезировать нечто столь же выразительное1. Мы сделали запрос интерфейсов типо- безопасным, кратким и даже предусмотрели применение стратегий обработки ошибок. Так, что же не так? Ну, это не связано с эффективностью: все методы встроенные, и программный код по эффективности не отличается от первоначального. Определенно имеется заметный выигрыш в ошибкоустойчивости: мы убрали сложность, связанную с применением метода QueryInterf асе (), и склонность к совершению ошибок при его использо- вании. Более того, снизили вероятность ненадлежащего применения подсчета ссылок путем ограничения до функционального минимума доступности интерфейса (и мето- дов AddRef () и Release (), как мы вскоре увидим). Мы сделали программный код более ясным за счет снижения его объема и приме- нения синтаксиса приведения типов. Тот, кто читает ваш программный код, может дос- таточно ясно видеть, что вы пытаетесь запрашивать, а также кто отвечает за любые со- путствующие увеличения счетчика ссылок. Должен признаться в том, что доходило до головной боли, когда я создавал приведения интерфейсов!
Глава 19- Приведение типов 425 Улучшилась переносимость, хотя и не стала идеальной. При использовании пользо- рательских интерфейсов в компиляторах, которые не поддерживают расширение uuidofО, необходимо обеспечить специализацию IID_traits. Но важно то, хотя применение этого расширения облегчает применение приведений, использо- вать расширение необязательно, и поэтому мы все-таки имеем великолепную перено- симость. Сопровождение теперь упростилось, т. к. объем программного кода существенно уменьшился, и вероятность внесения ошибки сведена к минимуму. Честно говоря, претензии можно предъявить лишь к именам операторов приведе- ния. Я сознаю, что их имена недостаточны кратки, но очень важно, чтобы те, кто их применяет и кто сопровождает программный код с ними, как можно меньше сталкива- лись с двусмысленностью. Когда речь идет о подсчете ссылок, слишком большое или слишком маленькое их количество может быть существенной ошибкой. Поэтому я выбрал понятную, но неуклюжую систему обозначения имен. Я рассматривал другие системы именования, но ни одна не сохраняла недвусмысленную семантику приведе- ний, и поэтому имена остались прежними. 19.9. boost: :polymorphic_cast Приведения типов не должны быть (и такое действительно редко бывает) столь сложными, как приведения интерфейсов. Часто их сложность значительно меньше. В [Stro 1997] Бьерн Страуструп предложил читателю выполнить упражнение, в ко- тором нужно реализовать шаблон ptr_cast, работающий аналогично dynamic_cast затем исключением, что должно выбрасываться исключение bad_cast для указателей и ссылок. Ну, в действительности он сказал следующее: «Напишите шаблон ptr_cast, который работает аналогично dynamic_cast за тем исключением, что вместо возвра- щения 0, он выбрасывает исключение bad_cast>>. Ничего кроме имени требуемого шаблона ptr_cast не предполагает, что он работает только с типами указателей, но можно не сомневаться, что именно это имелось в виду и что именно так обычно интерпретировалось. Шаблон polymorphic_cast библиотеки Boost имеет сле- дующую реализацию: template <class Target, class Source> inline Target polymorphic_cast(Source* x) { Target tmp = dynamxc_cast<Target>(x); if ( tmp == 0 ) throw std::bad_casc(); return tmp,- }
426 Часть 4. Осознанные преобразования Он используется следующим образом: try { Base *b = new Base(); Derived *d = boost::polymorphic_cast<Derived*>(a); 1 catch(std::bad_cast &x) { } Сначала кажется странным, что аргумент х объявляется как указатель (то есть х имеет тип Source*, а не Source или Source &), когда вывод об этом компилятор мог бы сделать сам. Но на самом деле это имеет смысл, поскольку явное объявление указателя не позволяет это приведение применять к типам, отличным от указателей. Что если бы нам понадобилось более строго интерпретировать этот оператор, то есть ptr_cast может работать с ссылками или типами указателей, и в обоих случаях он выбрасывает исключение bad_cast при неудачном завершении? Быстрый поиск в сети Интернет находит очень немного статей с именами ptr_cast, и поэтому я пола- гаю, что проблема решается достаточно хитроумным способом. Лучшее, что я смог придумать одним (очень напряженным) утром, показано в листинге 19.20. Листинг 19.20. template <typename Т> struct ptr_cast { public: typedef typename base_type_traits<T>::cv_type cv_type; typedef cv_type Preference; typedef cv_type ‘pointer; public: template <typename Source» ptr_cast(Source Ps) : m_p(Pdynamic_cast<Target>(s)) { // Ничего не надо делать: приведение типов ссылок выполняет // dynamic_cast } template <typename Source» ptr_cast(Source *s) ; m_p(dynamic_cast<Target»(s)) { if(NULL == m_p) { throw std::bad_cast();
Глава 19. Приведение типов 427 ptr_cast(pointer_type pt) : m_p(t) {) ptr_cast(reference_type t) : m_P(&t) {) public: operator reference () const { return const_cast<reference>(*m_p); } operator pointer () const { return const_cast<poxnter>(m_p); } protected: pointer m_p; }; Этот оператор приведения использует шаблон base_type_trai ts (см. раздел 19.7) для выделения соответствующего базового типа с cv-спецификаторами. Этот тип затем ис? пользуется для получения типов pointer и reference, которые затем применяются для определения операторов неявного преобразования, необходимых для возврата преобразо- ванного значения в вызывающую программу. Картину завершает проверка результата применения dynamic_cast к указателю и выбрасывание исключения bad_cast при не- удачном завершении приведения с возвратом значения NULL. Теперь мы можем его использовать для указателей и ссылок. class X О; class D (}; D d; dynami c_cas t <X& >(d); // Выбрасывает bad_cas t ptr_cast<X&>(d); // Выбрасывает bad_cast dynamic_cast<X*>(&d); 11 Возвращает NULL Ptr_caet<X*>(ad)/ // Выбрасывает bad_caat Эта реализация работает только для компиляторов, поддерживающих частичную специализацию шаблонов, так что это сразу исключаются Visual C++ (6.0 и 7.0) и Watcom В реальной реализации используется некий специальный прием, позволяющий рабо- тать с Borland, и имеется неоднозначность при работе с GCC, связанная с передачей Указателей промежуточному экземпляру, а не отдельной переменной. Но с CodeWarrior, Comeau, Digital Mars, Intel и Visual C++ 7.1 он работает прекрасно Во всех случаях.
428 Часть 4. Осознанные преобразования Конечно, приятнее при возможности иметь универсальный способ работы с указа- телями и ссылками, но имея «почти, но не совсем» такую версию, мы не можем крити- ковать реализацию Boost за то, что она ограничивается только указателями. 19.10. Приведение типов: заключение Мы видели, как можно реализовать приведение типов в виде либо функций, либо классов. Первый подход более прямой, но он не подходит для решения многих задач. Применение классов позволяет нам использовать специализацию для вынесения из не- скольких операторов приведения общего программного кода в общий родительский класс и более легкого ограничения потенциальных возможностей приведений. При реализации операторов приведения с помощью классов необходимо принять меры для сведения к минимуму возможности ненадлежащего использования экземпляров (приведенных) классов, с которыми необходимо обращаться только как с временными объектами. Такими мерами является ограничение набора операторов класса, типов, возвращаемых этими методами, и ясное документирование семантики классов. Основное внимание мы также уделили отказам при выполнении приведений. Приве- дения в C++ обладают фиксированным и хорошо определенным поведением при отказе их выполнения. При отказах в const_cast, static_cast и reinterpret_cast все они не будут компилироваться, а на этапе выполнения dynamic_cast возвращает нулевой указатель при неудаче приведения указателей или выбрасывает экземпляр ис- ключения std: :bad_cast при неудаче приведения ссылок. При реализации таких операторов, как операторы приведения, необходимо тщательно рассматривать реак- цию в случае отказа. По возможности отказы на этапе компиляции следует предпочи- тать отказам на этапе выполнения. explicit_cast и literal_cast отказывают на этапе компиляции. union_cast завершается неудачей главным образом на этапе компиляции. Интерфейсные приведения и ptr_cast обязательно отказывают на этапе выполнения. При отказах на этапе выполнения может быть не совсем ясно, что лучше: возвра- щать нулевое значение или выбрасывать исключение. Это особенно справедливо в тех случаях, когда речь идет о библиотеках общего назначения и/или о библиотеках с от- крытым исходным кодом, где невозможно предусмотреть все контексты применения вашего программного кода и бесполезно устанавливать ограничения. В таких ситуаци- ях хорошо помогает возможность применения параметризируемых стратегий, как это делалось с interface_cast_addref.
Глава 20 Прокладки Прокладка (shim): тонкий кусок материала, часто имеющий клиновидную форму и используемый для заполнения промежутков, выравнивания или правильной подгонки чего-нибудь. Эта глава целиком посвящается одному очень важному дефекту, присущему C++ и почти всякому другому языку, о котором можно вспомнить, и поэтому я собираюсь начать сразу с определения этого дефекта. Дефект: логически связанные типы могут иметь в C++, и обычно действительно имеют, несовместимые интерфейсы и операции, что иногда затрудняет, а часто делает невозможным применение обобщенного подхода при работе с типами. Нет необходимости лишний раз говорить о том, что это довольно смелое утвержде- ние отражает серьезную проблему. Концепция описываемых здесь прокладок и под- держиваемая ею, более широкая концепция явного обобщения за несколько лет разви- лись в два самостоятельных направления. Ирония в том, что прокладки первоначально возникли в результате моего наивного раздражения спецификацией шаблона std: :basic_string из-за отсутствия в нем оператора неявного преобразования. К счастью, я вовремя пришел в себя и не успел сделать что-нибудь столь ужасное как, например, построение производного класса и реализация в нем оператора преобразования1. Метод прокладок, первоначально разработанный для этого случая, в своем развитии прошел два этапа и завершился про- кладкой доступа c_str_ptr, которую мы рассмотрим в разделе 20.6.1. Второй причиной являлось постоянное разочарование несоответствиями между так называемыми умными указателями и эквивалентными «сырыми» указателями. Разра- ботанный метод обеспечивает единый синтаксис для работы с любыми указателями: «сырыми», простыми, умными или даже «слишком умными». При проведении своих консультаций я встречался с этим несколько раз. (Вздох!)
430 Часть 4. Осознанные преобразования 20.1. Всеохватность изменений и усиливающаяся гибкость В нашей сфере деятельности единственной постоянной вещью является неослабе- вающее стремление к изменениям. Пишите ли вы прикладное или системное про- граммное обеспечение, в любом случае неизбежно возникает необходимость измене- ния существующего программного кода [Gias 2003]. Вам хочется знать, когда внесение изменений может сопровождаться наименьшими сложностями и сведением к миниму- му появление новых ошибок. При написании библиотек с открытым исходным кодом вам необходимо сделать ваши библиотеки максимально разумно гибкими, т. к. невоз- можно предугадать все возможные способы их применения программистами. Давайте познакомимся с двумя проблемами, связанными с этими изменениями. Поскольку C++ поддерживает широкий набор базовых концепций, он может иногда страдать от того, что имеется слишком много вариантов представления одной концеп- ции. Очевидный пример - возможность представления строк многими различными способами. Имеются в виду не только различные фундаментальные кодировки (то есть, char или wchar_t), но также большое количество классов строк, многие из которых имеют взаимно несовместимый синтаксис. Введение шаблона стандартной библиотеки basic_string в какой-то степени помогло, но также вызвало критиче- ские замечания ([Henn 2002]) и не отвечает всем требованиям. Будучи общим решени- ем, в нескольких случаях эта концепция не может быть применена или не позволяет воспользоваться некоторыми эффективными приемами. Рассмотрим следующий фрагмент определения класса, контролирующего состояние текущего каталога. (В гл. 6 мы рассматривали эффективность некоторых классов, контролирующих диапазон действия ресурсов.) templatectypename С, . . •> class current_directory_scope { public: explicit current_directory_scope(C const *dir); 1; Этот интерфейс, насколько можно видеть, вполне нормальный. Но если мы хотим использовать std: :string в нашем клиентском программном коде, нам потребуется вызывать его метод c_str () в клиентском программном коде, как в следующем примере. current_directory_scope scope(s.c_str()); Это привязывает клиентский программный код к конкретному типу строки (или более того, к модели строки, в которой поиск или формирование базовой строки в стиле С осуществляется с помощью метода c_str ()). Может казаться, что это создает не слишком большую проблему в программном коде приложения, хотя я согла шусь с тем, что это ограничение - необязательное и нежелательное. Но если исполь зовать current_directory_scope внутри программного кода шаблона, это огра
Глава 20. Прокладки 431 нИчило бы его универсальность и применимость к тем классам строк, которые обес- печивают c_str () (и когда их метод c_str () возвращает строки в стиле С1). Конечно, если бы мы имели возможность контролировать или влиять2 на автора библиотеки, мы могли бы попросить его обеспечить перегрузку конструктора для типа srd:: string const &. Но это вновь привязывает нас к специальному типу строки, снижая без нужды универсальность нашего компонента. Второй источник страха заключается в применении указателей и в использовании умных указателей и в их смешении, особенно в условных выражениях. Большинство авторов умных указателей разумно не обеспечивают операторы неявного преобразова- ния. Но вследствие этого усложняется или делается невозможным написание алгорит- мов, рассчитанных на работу как с «сырыми», так и с умными указателями. template <typename Т> void pass_to_API_if_non_null(Т pt) { if(NULL != pt) { ::SomeGlobalApiFunction(pt); } } Данная функция прекрасно работает с «сырыми» указателями. Она также будет ра- ботать с умными указателями, обеспечивающими неявное преобразование в указатель типа, который они содержат, - нам, конечно, такие типы не нравятся. Но это не будет работать для умных указателей, которые благоразумно держат под спудом свои указа- тели. Для них мы могли бы перегрузить метод: template <typename Т> void pass_to_APl_if_non_null(stdssauto_ptr<T> fcpt) { if (NULL !- pt.getO) { ::ScmeGlobalApiFunction(pt.get()); } } Что если нам необходимо обеспечить подобные «безопасные» функции для ряда Различных глобальных функций и функций программного интерфейса и/или для типов Умных указателей? На это уйдет много рутинной, скучной работы. Существует бесчисленное число примеров такой связи синтаксиса с семантикой. Работать с ними очень утомительно: может возникнуть ощущение, что компилятор - ЭТо скупая, излишне придирчивая нянька, а не ваш ординарен. Когда я перехожу от Ну, этого вы никогда не знаете! Поставщики библиотек исключительно чувствительно относятся к запросам по поводу разумных _ енений; это является серьезным стимулом к усовершенствованию, а знание того, что кто-то применяет ваши Л|*отеки, также повышает вашу самооценку.
432 Часть 4. Осознанные преобразования одного типа к семантически похожему, я хочу, чтобы компилятор уладил для меня все вопросы. Конечно, этого не случится, пока мы ему не поможем, и поэтому позвольте закрыть бреши некоторыми прокладками и правильно подогнать вещи друг к другу Концепция прокладок доступа (см. раздел 20.6.1; [Wils 2003с]) относится к вопросу строгости реализации класса current_directory_scope и его использования Такие классы могут обладать высокой степенью универсальности и могут работать фактически с любым типом строки, избегая жесткого связывания или потерь в эффек- тивности. Мы увидим несколько примеров этого в оставшейся части книги. Используя атрибутные и логические прокладки (см. разделы 20.2 и 20.3; [Wils 2003с]) устраняются проблемы между «сырыми» и умными указателями, а универсальность и ус- тойчивость (поскольку теперь нам приходиться значительно реже их изменять) алгоритмов существенно увеличивается. Универсальным классическим решением, используемым в разработке программно- го обеспечения, является добавление лишнего уровня разыменования. В этом суть кон- цепции прокладок. Но этот уровень очень эффективный, и в большинстве случаев он не связан с дополнительными затратами - в целом он столь же эффективен, как и экви- валентное преобразование, выполненное в клиентском программном коде. В любом случае, это делается явно, и поэтому компилятор не будет неожиданно ругаться (и сни- жать быстродействие) вашего программного кода. 20.2. Прокладки атрибутов Определение: прокладки атрибутов (Attribute Shims) Прокладки атрибутов предназначены для получения атрибутов или состояний экзем- пляров типов, для которых они определены. Обозначаются прокладки атрибутов в форме get xxx, где ххх представляет собой кон- кретный атрибут, к которому осуществляется доступ (кроме случаев, когда они являются частью концепции составной прокладки). Значения, возвращаемые прокладками атрибутов, всегда достоверны за пределами экземпляра прокладки в тех случаях, когда прокладка реализуется посредством созда- ния временного объекта. Рассмотрим иллюстративный пример. Прокладка атрибутов get_ptr представляет собой комплекс функций, получающих атрибуты указателей типов, к которым они при- меняются. Мы можем определять некоторые из этих членов следующим образом: Листинг 20.1. template «typename Т> inline Т *get_ptr(T *р)
Глава 20. Прокладки 433 return р; } template <typename Т> inline Т *get_ptr(std::auto_ptr<T> &p) { return p.get(); } template <typename T> inline T const *get_ptr(std::auto_ptr<T> const &p) { return p. get (); } template <typename T> inline T *get_ptr(comstl::interface_ptr<T> &p) { return p.get_interface_ptr(); } Прокладка get_ptr позволяет нам обобщить применение указателей. Рассмотрим сценарий, в котором мы пишем функтор, предназначенный для работы с рядом указа- телей на экземпляры типа Resource (или лучше его полиморфных производных классов) для предварительной их обработки в визуальной системе перед воспроизведе- нием. Наш функтор мог бы выглядеть следующим образом: Листинг 20.2. struct resource_prerenderer : public, std::unary_function<Resource *, void» { resource_prerenderer(VisualSystem *vs) : m_vs(vs) {} void operator ()(Resource ’resource) { m_vs->PreRender(resource); } private: Resource *m_vs; }; Визуальные ресурсы могут храниться в экземпляре VisibleList с помощью «сырого» ^Каззтеля (возможно, в vector<Resource*>), т. к. синглетоном ResourceManager [Garnm 1995] осуществляется раздельное управление продолжительностью их жизни, 11 Содержимое экземпляра VisibleList подтверждается документированной семан- ТИкой класса (см. раздел 5.1). Это означает, что VisibleList может быть в соответст- Вии с нашими требования очень эффективным. Мы можем теперь осуществить предвари- тельное воспроизведение всех видимых объектов следующим образом:
434 Часть 4. Осознанные преобразования std::for_each( visible.begin(), visible.end() , resource_prerenderer(g_vs)); Однако систему теперь нужно перенести в другую архитектуру, и больше не преду- сматривается централизованное управление временем жизни экземпляров Resource В новой архитектуре VisibleList будет хранить их в экземплярах, использующих подсчет ссылок, например, в некотором классе Resource_ptr. Что нам необходимо сделать? Создать отдельный функтор для обработки объектов, применяющих подсчет ссылок, и выбирать нужный с помощью препроцессора? Поддерживать единственный функтор resource_prerender, но с помощью директив условной компиляции вызывать различные методы оператора operator () () ? Что если спецификация вновь изменится? Похоже, что все это слишком много для одного человека! Решение состоит в однократной перезаписи нами re source_pr erender er 4, после чего нам никогда не придется его снова изменять (по крайней мере, когда речь идет об изменении указателя типа). Теперь это выглядело бы следующим образом: template ctypename R> struct resource_prerenderer { void operator ()(R &resource) { HL_vs->PreR«nder(g«t_ptr(resource)); } Учитывая приведенное ранее определение get__ptr, наш программный код будет работать независимо от того, как хранятся экземпляры Resource: как «сырые» указатели, или указатели, использующие подсчет ссылок. Он работал бы даже, если бы они хранились в виде std: :auto_ptrs, но я знаю, что и вы знаете, что нельзя пы- таться помещать auto_ptr в контейнерах стандартной библиотеки [Меуе 1998, Dewh 2003]. 20.3. Логические прокладки Определение: логические прокладки (Logical Shims). Логические прокладки являются уточнениями прокладок атрибутов: они сообщают о состоянии экземпляра, к которому они применяются. Обозначаются логические прокладки в виде вопроса, связанного с конкретным запрашиваемым атрибутом или состоянием. Примерами могут быть is_open (является ли открытым), has_element (имеет ли элемент), is_null (имеет ли нулевое значение).
Глава 20. Прокладки 435 Очевидной и совместимой со многими типами логической прокладкой является is__empty (), которая обобщает доступ к состоянию любого типа контейнера (д ля которого она определена). По моему мнению, авторы стандартной библиотеки ошиблись, выбрав empty () в качестве названия методов различных типов, которые отвечают на вопрос «этот экземпляр пустой?», а не означают императив «сделать этот экземпляр пустым!». Это не только неестественно, но и непоследовательно, причем не только по отношению к другим элементам стандартной библиотеки - erase () является командой - но также по отношению к другим используемым в наше время библиотекам, где чаще для этого применяется IsEmpty () или is_empty (). Не желая тянуть с решением, я быстро дал жизнь логической прокладке is_empty (). Некоторые определения: Листинг 20.3. template <typename С> bool is_empty(C const &c) { return c.«mpty(); } bool is_empty(CString const &s) { return s.IsEmpty(); } bool is_empty(comstl::interface_ptr const &p) { return NULL == p.get_interface_ptr(); } Следует отметить, что из-за стандартизованности и большой, все усиливающейся роли стандартной библиотеки, прокладка общего назначения is_empty () - вторая указанная в приведенном выше списке - определяется в расчете на ее применение Для контейнеров стандартной библиотеки. Хотя это граничит с самонадеянностью и заставляет меня немного нервничать, аргумент этой функции прокладки является константным, и поэтому если вы не имеете класс с методом empty () со специфи- катором const, но все же обладающим модифицирующей семантикой, применение в общем случае должно быть безопасным. Однако следует помнить, что не сущест- ®Ует предела фантазии человека, приводящей к извращениям в программном коде (см- приложение В). 20.4. Управляющие прокладки Определение: управляющие прокладки (Control Shims).
436 Часть 4. Осознанные преобразования Управляющие прокладки определяют операции, применяемые к экземплярам типов для которых они определяются. Обозначаются управляющие прокладки в виде императивного элемента, связанного с конкретным атрибутом или состоянием, которое будет модифицировано. Примерами могут быть make empty (сделать пустым), dump_contents (выдать дамп содержимого). Управляющие прокладки не так часто используются, как атрибутные и логические прокладки, т. к. обобщение операций менее распространено, чем доступ к атрибутам и состояниям. (Мы видели пару управляющих прокладок в разделе 6.2, когда говорили об управлении диапазоном действия ресурсов, отражающих состояние программы.) Управляющие прокладки используются не только для проектирования гибких универсальных компонент на основе применения стратегий; они также являются чрезвычайно полезным инструментом сопровождения, особенно в переносимом программном коде. Пусть в нашем распоряжении имеется некий нетривиальный программный код, в котором широко используются экземпляры одного или нескольких типов контей- неров. Допустим, он используется для игры на персональном компьютере, рабо- тающим в системе Linux. Затем вам потребовалось перенести его на встроенную игро- вую платформу. В связи с этим вы хотите изменить тип используемого контейнера (одного или нескольких) и применить высоко оптимизированную библиотеку контей- неров собственной разработки, специально написанную для новой платформы. К сожалению, библиотека контейнеров была написана задолго до того, как концепция последовательного и ассоциативного контейнера стандартной библиотеки шаблонов (STL) [Aust 1999, Muss 2001] получила широкое распространение, и поэтому не соот- ветствует ей. Что же вам делать? Ну, вы могли бы настаивать, чтобы библиотека контейнеров была переписана в соот- ветствии с требованиями STL. К сожалению, создавшие ее авторы давно покинули ком- панию, и хотя они оставили вполне исчерпывающий комплект тестов1, позволяющий вам уверенно оценивать качество библиотеки контейнеров; в компании не оказалось никого, кто достаточно хорошо понимает внутреннее устройство этой библиотеки. Изменения были бы нетривиальными, строки сжатые, и риск массовых изменений был бы неоправданным. Вы могли бы поступить по-другому, наполнив исходный текст директивами #ifdef, специализируя с помощью препроцессора применение в клиентском программном коде библиотек контейнеров (помните, что мы теперь мысленно работаем с двумя библиотеками). В течение короткого периода это решение буДеТ работать, но, как опытный разработчик межплатформенных систем, вы знаете, этим решением вы просто обрекаете себя на массу неприятностей в будущем. 1 Я понимаю, как это все выглядит реалистично, но послушайте, в данном случае это всего лишь м фантазии.
Глава 20. Прокладки 437 Классический совет в таких сценариях - оформлять весь платформо-зависимый программный код в виде отдельных файлов реализации и выбирать нужные на этапе компоновки, сводя возможность путаницы к минимуму. Хотя это прекрасно подходит для действительно платформо-специфических областей - файловых систем, отображе- ния памяти, объектов синхронизации - в некоторых случаях такой подход безнадежно наивен. В данном случае эта идея плоха по следующим причинам: . у вас нет времени. Это реальный мир, и вам приходится следовать за маркетингом; • программный код очень сильно опутан существующей библиотекой классов контей- неров; . преимущество в быстродействии встроенной библиотеки контейнеров в большой степени основывается на применении встроенных функций, встроенных служебных средств и встроенного ассемблера. Добавление новых вызовов функций снизило бы быстродействие и увеличило бы размер модулей, а вы не можете позволить себе ни того, ни другого. Учитывая все это, большинство из нас выбрало бы второй вариант, построив все, что только можно, с помощью директив #if def. Продукт был бы поставлен вовремя, но стал бы кошмаром1 для сопровождающего персонала. Как только разработчик, выполнявший первый перенос на новую платформу, покинул компанию, перешел на другой проект или даже, возможно, просто проспал всю ночь, оказывалось невозможно внести изменения, не добавив ошибок в одну или другую (или в обе) реализации. Не говоря уже о добавлении еше одной целевой платформы! Так каким же должно быть решение? Использующее прокладки, конечно: прокладки атрибутов для получения элементов; логические прокладки для выяснения состояний кон- тейнера и элементов; управляющие прокладки для изменения содержимого контейнеров make_empty () для очистки контейнера от его элементов; insert_element () для вставки элемента; remove_element () для удаления элемента; get_nth_element () для обращения к элементу по индексу2. Вы можете иметь единую базу программного кода, возможно, с единственным услов- ным препроцессорным оператором, определяющим с помощью typedef типы контей- неров, и вы пишите и используете прокладки, которые очень эффективны и встраиваются компилятором, не требуя дополнительных затрат. Изменять программ- ный код практически не надо, и эти изменения носят положительный или в худшем На первой моей работе в коммерческом секторе я занимался базой программного кода, используемого для Реализации серверной и клиентской стороны базового канала ISDN (Integrated Services Digital Network - цифровая ееть комплексного обслуживания) и серверной стороны первичного канала ISDN, которая имела 13-вариангные операторы условной компиляции для трех архитектур процессоров, четырех операционных систем и трех физических представлений. Я был свидетелем внесения изменений за один раз в 20 различных ветвей! Мне нрищлось столкнуться со всеми проблемами управления исходным кодом, о которых вы можете только ®°образить. Это никогда не повторится! Следует понимать, что прокладки не решают все проблемы. См. предостерегающее замечание в разделе •П по поводу реальной оценки возможностей прокладок.
438 Часть 4. Осознанные преобразования случае нейтральный эффект - make_empty (cont); значительно понятнее разработчи- ку, не знакомому с STL, чем cont. erase (cont .begin (), cont. end ()); - причем теперь вы можете использовать все тестовые примеры первоначальной платформы, зная что семантика, что бы там ни делалось, не изменилась. Когда очередь доходит до добавле- ния новых типов контейнеров для вашей следующей платформы, вы можете делать это, не изменяя характерные особенности ценного и хрупкого программного кода приложения. 20.5. Прокладки преобразований Определение: прокладки преобразований (Conversion Shims). Прокладки преобразований выполняют преобразование экземпляров типов из совмес- тимого ряда в один целевой тип. Обозначаются прокладки атрибутов в виде to_xxx, где ххх - имя или представле- ния целого типа, например, to_int. Значения, возвращаемые прокладками преобразований, могут обеспечиваться про- межуточными временными объектами и поэтому должны всегда использоваться только с помощью выражения, содержащего эту прокладку. Мы собираемся узнать много другой информации о преобразовании в следующем подразделе, и поэтому оставим это в основном на потом. Однако интересный пример - поскольку он демонстрирует альтернативную прокладкам стратегию - можно найти в реализации шаблона string_tokeniser8 из библиотеки STLSoft, который мы разбирали в разделе 18.5.3. Этот шаблон-чудище принимает шесть «страшных» пара- метров, - оправданием может служить его очень высокое быстродействие [Wils 2004а, Wils 2003е], обеспечивающее различные условия выделения лексем из строки, такие как: тип разделителя или параметр, определяющий, что делать с пробелами и т. Д- К счастью, все кроме двух параметров этого шаблона имеют значения по умолчанию; этими двумя являются тип, используемый для хранения анализируемой строки (S), и тип значения итератора value_type (V). template< typename S /* тип строки, например, string */ , typename D /* тип разделителя, например, char или wstring */ , typename В = string_tokeniser_ignore_blanks<true> , typename V s /* тип значения */ , typename T = string_tokeniser_type_traits<S, V> , typename P = string_tokeniser_comparator<D, S, T> > class string_tokeniser; Разыменование итератора с помощью оператора operator * () для получения его текущего значения приводит к созданию экземпляра V. Поскольку он является пара- метром шаблона, для string_tokeniser необходим механизм создания строк, и он использует для этого управляющую прокладку.
Глава 20. Прокладки 439 Параметр шаблона свойств Т используется для абстрагирования от манипулирова- ния типами S и V. Одно из главных его назначений - создание статического метода под именем create (), который принимает два параметра (типа S:: const_iterator), которые должны возвращать экземпляр типа V. Поэтому Т: : create () является управляющей прокладкой и представляет собой пример прокладки, реализованной с помощью механизма свойств [Lipp 1998]. По умолчанию этот параметр имеет значение s tring_tokeniser_type_trai ts<S, V>, что предполагает соответствие типов S и V модели строки String стандартной библиотеки (стандарт С++-98:21.3). Его метод create () реализуется следующим образом: static V create(S::const_iterator f, S::const_iterator t) { return V(f, t); } Эта реализация по умолчанию работает с любым типом строки, который может кон- струироваться итераторами, задающими ограничивающий диапазон. Реализация прокладки для другого типа строки может выполняться путем либо специализации шаблона string_tokeniser_type_traits, либо путем обеспечения пользова- тельского типа свойств. В обоих случаях вы эффективно расширили определение прокладки для включения нового типа. Представим на минуту, что мы хотим использовать string_tokeniser с CString из MFC9. В этом классе вообще не определяются никакие типы членов, и поэтому, на первый взгляд кажется, что потребуются некие дополнительные усилия, чтобы его задействовать. Мы имеем две возможности. Во-первых, мы могли бы специализировать stlsof t: : string_tokeniser_type_traits для CString из MFC: Листинг 20.4. namespace stlsoft { template <> struct string_tokeniser_type_traits<CString> { static CString create(TCHAR const ‘f, TCHAR const *t) { return CString(f, t - f); } }; }; Мы могли бы затем использовать это для заполнения элемента управления Win32 <<список» содержимым переменной среды INCLUDE, применяя функтор вставки в эле- Менты управления Windows [Wils 2003g, Muss 2001], как в следующем примере:
440 Часть 4. Осознанные преобразования CString include = . . . stlsoft::string_tokeniser<CString, TCHAR> tokens (include, ',•); for_each(tokens.begin(), tokens.end(), listbox_back_inserter(hwndListBox)); Можно было бы поступить по-другому и определить наш собственный пользова- тельский тип свойств: stlsoft::string_tokeniser< CString , TCHAR , stlsoft: :string_tokeniser_ignore_blanks<true> , CString_tokenis«r_type_trait« > tokens(include, Как видно, последний вариант более многословный, но вы могли бы заключить всю параметризацию этой функции в typedef, и поэтому здесь не будет больших трудностей. Преимущество в том, что вы ничего не выискиваете в другом пространстве имен - это связано с практическими и теоретическими сложностями, как мы увидим в разделе 20.8. (В этом конкретном случае в действительности существует третья альтернатива, но применяемый ею метод мы рассмотрим в следующей главе; см. раздел 21.3.) 20.6. Концепции составных прокладок Определение: составные прокладки (Composite Shims)). Составные прокладки представляют собой комбинацию из двух или более концеп- ций базовых прокладок. Имена составных прокладок не имеют какой-то фиксированный формат, а просто отражают их назначение. Составная прокладка подчиняется наиболее строгому правилу или комбинации правил из тех, которым подчиняются составляющие ее прокладки. Мы проиллюстрируем концепцию составной прокладки на примере ее первого и чаще всего используемого представителя - прокладки доступа. 20.6.1. Прокладки доступа Определение: прокладки доступа (Access Shims). Прокладкой доступа является комбинация прокладки атрибутов и проклодки преобразований, которые используются для обеспечения доступа к значениям экземп ляров типов, для которых они определены. Значения могут формироваться с помощью прокладки преобразований.
Глава 20. Прокладки 441 Значения, возвращаемые прокладками доступа, могут обеспечиваться промежу- точными временными объектами, и поэтому должны всегда использоваться только в рамках выражения, содержащего эту прокладку. Для иллюстрации концепции прокладок доступа нам потребуется пересмотреть реализацию нашего класса current_directory_scope. В ответ на прежнюю критику мы теперь добавим еще один конструктор следующего вида: Листинг 20.5. template<typename С, . . . > class current_directory_scope { public: explicit current_directory_scope(C const *dir) { init_(c_etr_ptr (dir)) J } template <typenane S> explicit current_directory_ecope(S conet &dlr) < init_(c_etr_ptr(dir))J ) private: void init_(C const *dir); Оба конструктора1 применяют прокладку c_str_ptrll для преобразования своих типов аргумента в С const*, который передается методу init_() так, что класс может использоваться с любым типом строки, для которой определена и доступ- на прокладка c_str_ptr. Имея следующие определения прокладок: Листинг 20.6. inline char const *c_str_ptr(char const *s) ( return s; } inline wchar_t const *c_str_ptr(wchar_t const *s); ( return s; } template <typename T> inline T const *c_str_ptr(std::basic_string<T> const &s) ( return s.c_str(); Вы могли бы подумать, что необходим только второй, шаблонный конструктор. В идеальном мире было бы но такое решение сбивает с толку некоторые компиляторы. На практике необходимо прибегнуть к помощи пРепроцессора и для некоторых компиляторов обеспечить оба варианта, для других - только второй.
442 Часть 4. Осознанные преобразования } template <typename Т> inline Т const *c_str_ptr(stlsoft::basic_frame_string<T> const &s) { return s.c_str(); } мы можем использовать класс current_directory_scope с любым из следующих типов: char const *dirl = •/•; std::basic_string<char> dir2(*/’}; stlsoft::basic_frame_string<char> dir3(*/*); current_directory_scope<char> current_directory_scope<char> current_directory_scope<char> scopel(dirl); // Нормально scope2(dir2); // Нормально scope2(dir3); // Нормально 20.6.2. Продолжительность жизни возвращаемых значений Те, у кого острый взгляд, могли заметить, что только что рассмотренные нами четыре c_str_ptr являются прокладками атрибутов. Почему нельзя просто опреде- лить c_str_ptr как прокладку атрибута? Чтобы понять это, нам необходимо рас- смотреть прокладку в нетривиальной реализации, правильное использование которой основывается на соблюдении правила, относящегося к возвращаемым значениям. Одна из причин, по которой модель String в стандартной библиотеке (стандарт С++- 98: 21.3) не оговаривает оператор преобразования, - содействие хранению в памяти последовательности символов без завершающего нуля. Эта идея широко используется в различных библиотеках. Одним из примеров является тип LSA_UNICODE_STRING программного интерфейса системы безопасности (Security API) в Win32, который опре- деляется следующим образом: typedef struct _LSA_UNICODE_STRING ( unsigned short Length; unsigned short MaximumLength; wchar_t ‘Buffer; } LSA_UNICODE_STRING; Размер строки, описываемой этой структурой, определяется не нулевым символом завершения, а членом Length (длина). Фактически Buffer может вообше не содержать нулевой символ завершения. Учитывая это, как нам обеспечить доступ к строке в стиле С в обшем виде (то есть с помошью C-Str-ptr)?1 1 Мы не можем просто записать в буфер нулевой символ завершения, поскольку мы совершенно не знаем, к работает с этим типом другой программный код. Даже если нас это не волнует, иногда нам приходится ра с константными экземплярами. И даже если бы мы достаточно опрометчиво сняли константность с помо^ const_cast, иногда у нас не будет свободного места для размещения каких-либо дополнительных символов есть Length == MaximumLength); в этом заключается суть данного типа.
Глава 20. Прокладки 443 Решение состоит в применении класса прокси, экземпляр которого возвращается функцией прокладки c_str_ptr, принимающей этот тип, как в следующем примере: iniine c_s tr_ptr_LSA_UNICODE_STRING_proxy c_str_ptr(LSA_UNICODE_STRING const &s) ( return c_str_ptr_LSA_UNICODE_STRING_proxy(s); } Получение из этого строки в стиле С требует обеспечения и инстанциирования классом прокси соответствующего буфера символов, а для поддержки идентичного синтаксиса в других прокладках типа c_str_ptr, реализации оператора неявного преобразования. Упрощенное определение этого класса показано в листинге 20.7. Листинг 20.7. class c_str_ptr_LSA_UNICODE_STRING_proxy { public: typedef c_str_ptr_LSA_UNICODE_STRING_proxy class_type; public: explicit c_str_ptr_LSA_UNICODE_STRING_proxy( LSA_UNICODE_STRING const &s) : m_buffer(new WCHAR[1 + s.Length]) ( wcsncpy(m_buffer, s.Buffer, s.Length); m_buf fer[s.Length] = L'\0'; } ~c_str_ptr_LSA_UNICODE_STRING_proxy() ( delete [] m_buffer; } operator LPCWSTR () const { return m_buffer; } private: LPWSTR m_buffer; 11 Реализация не треОуется private: void operator =(class_type const &rhs); }; Когда c_str_ptr применяется к экземпляру LSA_UNICODE_STRING, содержи- те строки копируется в буфер с завершающим нулем. Значение этой прокладки Увлекается при помощи оператора неявного преобразования. В этом случае эта про- ^алка c_str_ptr и класс прокси совместно формируют и обеспечивают доступ СтР°ке в стиле С, - именно этот тип ожидается на выходе прокладки c_str_pcr.
444 Часть 4. Осознанные преобразования Теперь мы можем понять причину ограничения применимости возвращаемого ею значения. Объект прокси существует, только пока существует выражение, в кагором используется прокладка. Если бы возвращаемое прокладкой значение сохранялось и использовалось за рамками выражения, то это привело бы к непредсказуемому пове- дению. LSA_UNICODE_STRING Isa = . . . ; wchar_t const *s = c_str_ptr(Isa); wputs(s); // Опасность, Уилл Робинсон! Мы подробно рассмотрим проблемы продолжительности жизни возвращаемых значений в гл. 31. 20.6.3. Манипулирование обобщенными типами К этому времени, будем надеяться, вы поняли эффективность прокладок, и на- сколько легко они обычно реализуются. Теперь мы можем работать с типом строки любого формата в едином согласованном стиле. Это чрезвычайно полезная вещь. Но можно пойти намного дальше. Давайте рассмотрим другой сценарий. UNIX обеспечивает два распространенных способа просмотра содержимого файло- вой системы. Программный интерфейс opendir () обеспечивает функции открытия каталога для перебора его элементов с помощью своей функции readdir (). Она воз- вращает указатель на структуру dirent, которая необходима для предоставления единственного члена d_name - буфера символов, содержащего имя текущего элемента. Другой программный интерфейс основан на применении очень мощной функции glob (), которая возвращает в вызывающую программу массив указателей на элемен- ты, которые удовлетворяют заданному критерию поиска. UNIXSTL13 обеспечивает классы последовательностей readdir_sequence и glob_sequence, обрамляющие эти два программных интерфейса. В качестве типов значений value_type они используют, соответственно, struct dirent const* и char conscx. Писать программный код для того и другого, включая функторы и ал- горитмы, достаточно просто. Но из-за использования ими различных типов значений создавать программный код, подходящий для обоих типов, может быть по настоящему мучительной задачей. Давайте предположим, что нам нужно написать алгоритм sub_dir_count, который может подсчитывать количество подкаталогов в заданном каталоге файловой системы и также записывать их имена в некий контейнер. Его можно было бы предста- вить следующим образом: Листинг 20.8. bool is_dir(char const ‘entry); // Определяет тип элемента template* typename S , typename C
Глава 20. Прокладки 445 size_t sub_dir_count(S const &s, C &c) { typedef typename S::const_iterator const_it_t; const_it_t begin = s.beginO; const_it_t end = s.end(); size_t eDirs = 0; for(; begin != end; ++begin) { if(is_dir(*begin)) { c. push_back (*begin); ++cDirs; } } return eDirs; } Он мог бы использоваться следующим образом: void process_entry(string const &s); findfile_sequence entries("/"); vector<string> directories; size_t eDirs = sub_dir_count(entries, directories); printf("Number of dirs = %u\n", eDirs); for_each(directories.begin(), directories.end(), process_entry); Это компилируется без каких-либо жалоб на glob_sequence, т. к. его тип значения - char const*. Однако это не будет компилироваться для readdir_sequence. Что мы можем здесь сделать? Мы могли бы создать отдельную версию алгоритма для readdir_sequence, но это означает «Кошмар на улице Сопровождения». Более того, затраты на програм- мирование линейно зависят от количества взаимно несовместимых типов, которые нам необходимо поддерживать в этом алгоритме. Разве не было бы существенно лучше централизовать общую часть и переписать его толыю один раз? Используя прокладку доступа c_str_ptr, мы можем делать именно это и можем создать версию, которая сработает со всеми типами: Листинг 20.9. template* typename S , typename С > size_t sub_dir_count(S const &s, C &c) ( for(; begin ! = end; ++begin)
446 Часть4. Осознанные преобразования if(is_dir(c_str_ptr(‘begin))) < с.push-back(c_etr _ptг(‘begin)); ++cDirs; } } Теперь это может работать как для glob_sequence, так и для readdir_sequence в силу того, что c_str_ptr является прокладкой для struct dirent. inline char const ‘c_str_ptr(struct dirent const *d) { return (NULL != d) ? d->d_name : } 20.6.4. Проблемы эффективности В функции sub_dir_count фактически выполнялось два вызова прокладки c_str_ptr. Для таких типов, как struct dirent, для которых данная прокладка является прокладкой атрибута, стоимость получения указателя на строку в стиле С очень низкая и будет оптимальной на большинстве компиляторов. Однако там, где эта прокладка действует в качестве прокладки преобразования, как, например, для LSA_UNICODE_STRING, объем обработки будет нетривиальным. Хотя каждый вызов будет фактически эквивалентен любому доступу к строке, выполненному вручную, делая это дополнительно несколько раз, мы не обеспечим эффективность программно- го кода. Нам остается только переписать алгоритм. Листинг 20.10. template* typename CH , typename С > size_t record_if_dir(CH const ‘entry, C &c) ( return is_dir(entry) ? (c.push_back(entry), 1) : 0; } template* typename S , typename C > size_t sub_dir_count(S const &s, C &c) { for(; begin != end; *+begin) { cDira + record_if_dir(c_«tr_ptr(‘begin), c); ) return eDirs;
Глава 20. Прокладки 447 Теперь мы имеем единственную прокладку в вызове функции record_if_dir(), в которой С-строка используется дважды без обработки и с максимальной эффективностью. 20-7- Пространства имен и поиск Кенига Нарисованная мною картина относительно прокладок почти идеальна:1 мы упро- стили сопровождение (улучшилось восприятие программного кода, и изменения теперь делать проще), повысили универсальность (повторное использование), не жертвуя при этом эффективностью (но при правильном их использовании). Но мы живем в реальном мире, и в нем нет ничего совершенного. Кроме ограничения в при- менении прокладок доступа только внутри выражений, существует еще одна малень- кая проблема. До сих пор я нигде не упоминал пространство имен, и все примеры оказывались в глобальном пространстве имен. Если все типы, для которых определена используе- мая вами прокладка, являются определенными пользователем типами, и они опреде- ляются внутри пространств имен своих «прокладочных» типов, и применяемые вами компиляторы поддерживают поиск Кенига2, то вам нет необходимости предусматри- вать какие-либо объявления using, и все пройдет как по маслу. Неужели вы все именно так и сделали? Поиск Кенига (стандарт С++-98: 3.4.2) - известный также как поиск, зависимый от аргументов [Vand 2003]), - представляет собой механизм, позволяющий осуществлять доступ в пространстве имен к символам из других пространств имен без объявлений using или директив typedef из-за существования связи с вводимым символом. Например, в следующем программном коде функция f определяется в пространстве имен ns и не вводится в (глобальное) пространство имен функции g (). Однако поскольку переменная s имеет тип S, который определяется в пространстве имен ns, функция f может браться из пространства имен типа S. Листинг 20.11. namespace ns ( struct S (}; void f(S &) {} } void g() ( ns : : S S; f(«); // f берется из пространства имен, где определен тип S } По крайней мере, я надеюсь, что вы так думаете! Digital Mars до версии 8.34, Visual C++ до версии 7.1 и Watcom не поддерживают поиск Кенига
448 Часть 4. Осознанные преобразования К сожалению, реальность такова, что несколько компиляторов не обеспечивают в полной мере поиск Кенига. Более того, нам часто приходится определять прокладки включающие базовые типы и те, которые определяются в глобальном пространстве имен. В таких случаях нам необходимо применять объявления using, связывающие объявления типов и алгоритмы, классы или клиентский программный код, который реализуется с помощью прокладок. В большинстве случаев это просто реализовать и понять, но иногда может возникать путаница, когда вы имеете дело с сильно зависи- мым программным кодом. В конце концов, было бы неплохо популярные прокладки (такие, как get_ptr, c_str_ptr или c_str_len) объявлять и определять в одном пространстве имен с необходимыми для них типами, включая типы из пространства имен std, например, c_str_ptr (basic_string<T> const &). Маловероятно, что такое вскоре случится (если это вообще произойдет когда-нибудь), и поэтому вам нужно помнить, что, возможно, вам придется воспользоваться объявлением using для их применения. Более того, несколько типов, для которых нам бы хотелось определить прокладки, существуют в глобальном пространстве имен (например, char const*, struct di rent, LSA_UNICODE_STRING). Поскольку было бы достаточно самонадеянно определять прокладки в глобальном пространстве имен или даже в пространстве имен std,1 я определяю все мои прокладки в пространстве имен stlsof t. Цель этого двой- ная - не засорять глобальное пространство имен и, кроме того, иметь единое простран- ство имен, в котором определяются все используемые мною прокладки. Для любых компонентов, применяющих прокладки, которые определяются в пространстве имен stlsof t или в любом его подпространстве имен [Wils 2003b], они находят необходи- мые определения прокладок даже в том случае, когда поиск Кенига не применяется (либо потому, что этот тип является фундаментальным, либо потому, что он не под- держивается компилятором). Для любых написанных мною классов, которые не входят в STLSoft, например, в программном коде приложения, я просто определяю вместе с компонентами их прокладки - либо в пространстве имен конкретного приложения, либо в глобальном пространстве имен - и затем «использую» их в пространстве имен stlsof t. Давайте посмотрим, как это работает на практике. Рассмотрим некоторый клиентский программный код в пространстве имен client, который использует компонент независимого разработчика tp_string, находящийся в пространстве имен third_party, и тип LSA_ UNICODE_STRING, определенный в Win32. Чтобы при написании нашего клиентского программного кода можно было применять прокладку c_str_ptr для каждого типа, нам необходимо сделать так, чтобы необходимые функции прокладок были видимы в пространстве имен client- Один способ достижения этого показан в листинге 20.12. 1 Фактически, стандартом предусматривается возможность добавления к пространству имен std только специализаций шаблонов, существующих в std.
Глава 20. Прокладки 449 Листинг 20.12. // tp_string.h namespace third_party { class tp_string { wchar_t const *c_str() const; }; inline wchar_t const *c_scr_ptr (tp_string const &s) { return s.c_str(); } } // Client.cpp namespace Client { using stlsoft::c_str_ptr; Ц для LSA_UNICODE_STRING •if Idefined(ACMELIB_COMPILER_SUPPORTS_KOENIG_LOOKUP) using third__party: :c_str_ptr; // для tp_string •endif /* I ACMELIB_COMPILER_SUPPORTS_KOENIG_LOOKUP */ template <typename S> void puts(S const &s) ( ::putws(c_str_ptr(s)); } } Первое использование объявления необходимо, т. к. тип LSA_UNICODE_STRING определяется в глобальном пространстве имен, и поэтому поиск Кенига не применяет- ся. Второе применение объявления необходимо только в том случае, когда компилятор не поддерживает поиск Кенига. Но фактически мы обошлись четырьмя строками там, где требуется только одна. Это лучше сделать путем следующей добавки в конец tp_string.h: namespace stlsoft { using third_party::c_str_ptr; } Теперь можно опустить препронессорные условные операторы и объявление using Для third__party, оставляя нам просто один using stlsoft: :c_str_ptr. в своей собственной работе вы должны следовать этому образцу: определить простран- но имен, в котором будут находится ваши прокладки, и затем либо определить их в этом пространстве имен, либо ввести их в это пространство имен с помощью объявле- Ния using. В любом программном коде (будь то библиотека или приложение), который Исп°льзует прокладки, вы применяете одно объявление using для ввода всех функций прокладок, которые потенциально могут использоваться.
450 Часть 4. Осознанные преобразования Естественно, мне бы хотелось, чтобы в перспективе прокладки были включены в стандартную библиотеку. Однако в этом случае я бы предложил выделить проклад- кам их собственное пространство имен - std: : shims - в котором пользователи могли бы свободно определять свои собственные перегрузки существующих прокла- док или вводить свои собственные прокладки. 20.8. Почему не шаблоны свойств? Мы видели, как некоторые прокладки реализовывались как шаблоны свойства, другие - как функции и третьи - тоже как функции, возвращающие промежуточные экземпляры классов прокси. По ходу обсуждения у вас, вероятно, возникал вопрос о возможности использования свойств [Stro 1997] для обеспечения аналогичных воз- можностей обобщения для прокладок. И в самом деле, некоторые комментаторы пред- ложили, чтобы прокладки реализовывались только при помощи свойств. Существует ряд причин, по которым основанный на свойствах подход не может быть единствен- ным методом реализации прокладок. Во-первых, функции обладают способностью автоматически выводить тип, тогда как свойства, будучи шаблонными классами, всегда должны быть явно квалифициро- ваны. Это важно не только для синтаксиса. Это означает, что свойства могут приме- няться только в контекстах с известным типом. Во-вторых, прокладки могут непосредственно включать существующие (или новые!) С-функции без какого-либо их обрамления функцией или классом C++. В-третьих, концептуально шаблоны свойств не очень хорошо подходят для этой цели. Заботой прокладки является только один конкретный аспект, преобразование или манипулирование в рамках (обычно) не жестко определенного набора типов. Напро- тив, свойства могут (и обычно именно это делают) определять и связывать некоторое количество параметров и операций в рамках (обычно) жестко определенного набора типов. В этом случае классы и/или функции, составляющие конкретную прокладку, не обрастают необязательным и нехарактерным багажом, что как раз (к сожалению) происходит со свойствами. Однако наиболее серьезная причина отказа от реализации исключительно на базе свойств связана с пространствами имен. Если бы прокладки были основаны на свойст- вах, у нас могло бы быть две возможности специализации. Мы могли бы включать заго- ловок с определением шаблона в заголовок нашего программного кода и затем выпол- нить специализацию в рамках главного пространства имен в собственном заголовке. Это означало бы введение существенной физической связи [Lako 1996], которая мне очень не нравится (и я уверен, вам тоже). Или мы могли бы заблаговременно объявить шаблон в его первоначальном пространст- ве имен в рамках нашего заголовка, все-таки определяя нашу специализацию в перво- начальном пространстве имен, чтобы избежать физического связывания. Это завершается неудачей по многим причинам. Одна из них связана с тем, что конструкции стандартной
Глава 20. Прокладки 451 библиотеки предоставляют определения типов, которые совместимы, но не обязательно идентичны тому, что ожидается от публикуемых определений [Dewh 2003]. Конструкция может определяться в рамках пространства имен, которое фактически определено (непо- средственно или косвенно) с помощью макроса; если бы мы не включили ни одного заго- ловка, в котором этот макрос был определен, мы бы специализировали шаблон в неверном пространстве имен, и поэтому специализировали бы то, чего нет. 20.9. Структурное соответствие Прокладки представляют собой квинтэссенцию того, что называется структурным соответствием. В основном, оно означает, что от идентичных вещей ожидают иден- тичного поведения. Традиционно (то есть я имею в виду дошаблонные обобщения) C++ большое вни- мание уделяет тому, что иногда называют именным соответствием, которое по сущест- ву относится к ситуации, когда перегруженный метод производного класса должен соответствовать методу родительского класса. Естественно, это основано на поли- морфизме наследования, построенном на базе применения виртуальных таблиц vtable. Структурное соответствие - более полезное качество, и с ним связано больше про- блем. Обычно оно относится к шаблонным функциям. Как мы видели в разделе 20.6.3, классы readdi resequence и glob_sequence проявляют структурное соответст- вие по отношению к алгоритму sub_dir_count. Упрошено говоря, структурное соответствие гарантирует, что типы совместимы на этапе компиляции, тогда как именное соответствие гарантирует совместимость на этапе выполнения. Однако существует немало тонкостей в их отличии. Слабостью структурного соответствия является то, что нетрудно иметь структурно совместимые типы, которые делают семантически несовместимые веши. Однако часто упускается из виду, что не так уж трудно иметь семантически несовместимые классы, которые прояв- ляют мнимое именное соответствие. Они отличаются только набором типов, к ко- торым обобщенный программный код может применяться в принципе без ограниче- ний, в то время, как набор типов, отличающихся именным соответствием, ограничен отношением наследования. Второй набор меньше и в большей мере зависит от прони- цательности опытных программистов C++1, и несоответствие встречается гораздо Реже, но все-таки возможно. Вопрос полезности структурного соответствия (с ожиданием семантического соот- Ветствия) относится к тем вопросам, которые вызывают бесконечно бурные дебаты: пРосто выполните быстрый поиск в сетевых конференциях comp.* *, и у вас будет Работа на несколько дней. Согласно точке зрения некоторых, доверие к структурному Предполагается, что по мере того, как следующее поколение программистов C++ вырастает, рассматривая намический (с помощью vtable) и статический (шаблонный) полиморфизм как одинаково естественные • анизмы, это отличие сглаживается. Но «старых волков» в этом сложно убедить, поскольку мы уже ^Ршили наш процесс обучения.
452 Часть 4. Осознанные преобразования соответствию носит ограниченный, директивный и хрупкий характер. Это привязыва ет нас к соответствию между синтаксисом и семантикой, которое, оглядываясь назад было бы лучше во многих случаях отвергнуть или модифицировать. Более того ожидание, что все стороны будут уважать представленные, но не обязательные согла- шения в неограниченном наборе типов, представляемых нашими обобщенными ком- понентами, могло бы рассматриваться как невероятно оптимистичное. Я, конечно, могу понять эту логику. При рассмотрении неприятностей, связанных с наименованием нескольких функциональных методов стандартной библиотеки например, empty (), нам становится заметен конфликт между согласованностью и свободой. В наше время редко можно встретить класс с методом empty (), который не означал бы вопрос «являетсяли пустым (is empty)?», но это происходит только в ре- зультате того, что стандартной библиотекой навязано это структурное соответствие всему сообществу разработчиков. Раньше можно было найти библиотеки, где имена не были двусмысленными. Недоброжелатели возражают, что структурное соответствие в лучшем случае уязви- мо, а в худшем - вообще непригодно. Они в значительной степени правы. Но каковы альтернативы? Во многом каждый аспект именования в C++ (и в большинстве других языков) основан на соглашении, и это совершенно необходимо для удобства понимания. Что нам делать с empty () ? Следует ли нам согласиться, что этот метод поименован не- подходящим образом, оговорить, что все наши собственные библиотеки будут использо- вать его в смысле «сделать пустым (make empty)!», и предусмотреть отдельный метод is_empty () для реализации текущей его семантики? Все это лишь затянет нас в соз- данное нами же болото. 20.9.1. Семантическое соответствие Прокладки представляют собой соглашение, в котором структурное соответствие расширяется прямым включением семантики каждой прокладки. Естественно, это достигается лишь добровольным соблюдением семантики данной прокладки. Мы должны обеспечить подходящее именование прокладок; отсюда для именования разных типов прокладок применяются разные соглашения. В чем мы идем на компромисс, так это в обеспечении хорошей читаемости за счет по- тенциальной двусмысленности. Так, можно утверждать, что метод c_str_ptr() немного двусмысленный и должен называться access_c_str() (доступ к С-строке). Однако я бы не согласился, что можно найти какой-нибудь разумный довод в пользу ДВУ" смысленности методов is_null (), get_ptr () или make_empty (). В целом, я признаю проблемы структурного соответствия, но я не вижу лучшей альтернативы. Применение прокладок позволяет в значительной мере избегать проблем структурного соответствия, но они не решают проблему полностью, поскольку полного решения не существует.
Глава 20. Прокладки 453 20.Ю. Разрушение монолита Одной из самых больших проблем объектно-ориентированного проектирования для большинства объектно-ориентированных языков, но особенно для C++, является определение уровня инкапсуляции операций для конкретного типа. Для некоторых типов невозможно найти оптимальный баланс между требованиями инкапсуляции и удобством использования. Рассмотрим пример с файлом. Если мы инкапсулируем в классе дескриптор системного файла, мы обеспечим операции открытия, закрытия, считывания и записи в этот файл. Что если затем нам потребуется создать интерфейс с другим программным интерфейсом, использующим файл? Например, мы могли бы захотеть применять ввод-вывод с предварительным распределением памяти. Мы имеем две возможности. Одна возможность представляет собой включение операций распределения памяти в наш класс файла. Затем мы вступаем на скользкую дорожку. После этого мы могли бы, скажем, применить дескриптор файлов Win32 в сочетании с портом завершения ввода-вывода (IO Completion Port) [Rich 1997]. Вскоре наш класс станет больше похож на класс канала ввода-вывода. Что следующее - методы открытия сокетов и манипу- лирование адресами IP? Второй подход заключается в создании фреймворка классов и объявлении друзья- ми различных классов, и поэтому классы MemoryMap и ComplerionPorr объявля- лись бы как друзья нашего класса File. Едва ли мне требуется комментировать то, что это усиливает физическое связывание [Lako 1996] и логическую хрупкость всего фреймворка. Не говоря уже о кошмаре, который сопровождает распространение двоичной версии фреймворка. Мы видели примечательные примеры, показывающие, насколько неудачен этот подход в реальных условиях. Эта проблема характерна не только для C++. Хотя существует несколько хороших примеров этой плохой ситуации в библиотеках C++, существуют еще лучшие примеры в языках, которые считаются эволюционными приемниками C++. Третий подход заключается в обеспечении свободной доступности дескриптора базового ресурса. Проблема здесь, конечно, в том, что слишком легко нарушить инкап- суляцию, поскольку она основана всего лишь на предположении понимания всеми разработчиками деталей любого выполняемого ими прямого доступа.и безошибочно- сти их действий. Поскольку даже лучшие разработчики делают простые ошибки, поль- зоваться таким подходом опрометчиво. Последний подход заключается в применении операторов неявного преобразова- ния, работающих только на чтение. Это не менее страшно, чем все другое. Вы можете закрывать дескриптор файлов за спиной экземпляра класса File. Надо ли мне еще что- то Добавить? Не удивительно, что при рассмотрении этих вариантов, многие программисты °тдают предпочтение применению программным интерфейсам С или предпочитают придерживаться стандарта C++, где все эти вопиющие взаимозависимости «скрыты»
454 Часть 4. Осознанные преобразования внутри или, по крайней мере, никак не прокомментированы. Фактически здесь нет верного подхода, и я не собираюсь пытаться притворяться, что такой подход имеется Если только это возможно, вы добьетесь большего успеха, делая ваши классы неболь- шими и простыми. Но неизбежно придется пойти на компромисс. Однако я могу сказать вам, что прокладки чрезвычайно полезны в этом смысле Применяя прокладки, вы можете писать классы, которые могут взаимодействовать ничего по существу не зная друг о друге. Вам не нужно писать монолитные классы или монолитные фреймворки классов (то есть вы можете позабыть об объявления friend); вам нет необходимости делать доступ к членам открытым; вам нет необхо- димости обеспечивать операторы неявного преобразования. Вам все же придется идти на компромисс и делать дескрипторы базового ресурса доступными, но это может осуществляться с помощью прокладок, которые никогда не могут вызываться неявно. Таким образом, я бы написал класс File, который имел бы простой набор операций и который обеспечивал бы свой внутренний дескриптор - int в системе UNIX; HANDLE в системе Win32 - с помощью прокладки get_handle (). Тогда класс Мет- огуМар определялся бы с шаблонным конструктором, использующим прокладку get_handle () для извлечения дескриптора файла, связанного с его аргументом, как в следующем примере: class MemoryMap { public: template <typename F> explicit MemoryMap(F f) : m_f(get_handle(f)) И поэтому классы могут взаимодействовать, ничего не зная друг о друге: File f("/ImperfectsC++/readme.txc"); MemoryMap mm(f); Естественно, ничто не удержит кого-нибудь от выполнения патологических вызовов: File f("/ImperfectsC++/readme.txt") ; close(get_handle(f)); // Теперь деструктор f завершится крахом! Но тогда ничто не сможет удержать кого-нибудь от приведения int к типу vec- tor<string>*, когда дело дойдет до этого; если вы хотите иметь в своей гостиной слона, я не собираюсь говорить, что нужно упаковать его хобот! 20.11. Прокладки: заключение Я надеюсь, вы убедились в полезности и эффективности прокладок, и я рекомен- дую вам использовать их в вашей работе и воспользоваться возможностью предостав-
Глава 20. Прокладки 455 ляемого ими дешевого и хорошо понятного обобщения. Но прежде чем скороспелые поклонники прокладок выйдут и начнут все и вся делать с помощью прокладок, мне бы хотелось вас предостеречь. Прокладки, как и все другое в разработке программного обеспечения (как в жизни вообще, если перейти на философский лад), не являются панацеей и могут использоваться в определенных пределах. Они не могут быть основ- ным механизмом манипулирования объектами. Несмотря на практические проблемы с пространством имен, если каждая вторая строка в вашем программном коде исполь- зует прокладку, то это означает, что вы оставили достаточно далеко позади себя весь объектно-ориентированный мир1 и начали работать в новом «царстве» почти деклара- тивного программирования, построенного на атрибутах. Слишком большое количество прокладок делает ваш программный код фактически нечитаемым. Мы видели, что применение прокладок преобразований имеет недостатки и что любая составная прокладка включает в себя концепцию прокладки преобразования, когда возвращаемые значения могут использоваться только в течение времени, пока существует выражение, содержащее эту прокладку. Следствием этого является потен- циальная необходимость в многократных вызовах прокладок внутри функции или, по возможности, в реструктурировании функции для обеспечения единственного вызова прокладки. На практике эти проблемы вполне преодолимы, но, тем не менее, требуют некоторого уровня понимания концепции конкретной прокладки, которую вы приме- няете, что во многом совпадает с необходимостью понимания концепции конкретного итератора при использовании конкретного контейнера последовательности STL и его итераторов. По моему мнению прокладки при правильном использовании являются мощным приложением к «нормальному» программированию на C++ и представляют собой великолепное дополнение к набору инструментов неидеального практика. Конечно, кто-то мог бы сказать, что это не так уж плохо.
Глава 21 Облицовочные классы Облицовка (veneer): тонкая пленка или слой более ценного или красивого материала для покрытия менее качественного материала. Облицовки используются для тонкой оправы структурных и/или функциональных особенностей существующих типов. Облицовка часто используется для добавки «последнего штриха» к существующему, важному типу. Она может также быть спосо- бом наделения специфическим поведением простого типа. Рассматривайте облицовки как баночку со средством тонкой полировки в вашем инструментарии, которым вы пользуетесь при выполнении осознанных преобразований. Определение: облицовочные классы (veneers). Облицовочный класс - это шаблонный класс со следующими свойствами: 1. Он является производным от своего основного типа параметризации (обычно с открытым доступом к нему). 2. Он приспосабливается к полиморфной природе своего основного типа параметри- зации и твердо ее придерживается. Это означает, что облицовочный класс не мо- жет определять никакие свои собственные виртуальные методы, хотя он может переопределять методы своего основного типа параметризации. 3. Он не может определять никакие нестатические переменные-члены. Следствием свойств 2 и 3 является невозможность изменения в облицовочном классе отображения в памяти своего основного типа параметризации, и это достигается в силу оптимизации пустых производных классов {Empty Derived Optimization - EDO, см. раздел 12.4), очень широко поддерживаемой оптимизацией. Другими словами, размер экземпляров облицовочного класса совпадает с размером основного типа пара- метризации. Эти ограничения отличают концепцию облицовочных классов от подобных кон цепний, и они предусмотрены для обеспечения легитимности выполнения типами облицовочных классов двух вещей, которые обычно запрещаются принятой в С++ хорошей практикой: передачи массивов унаследованных типов в виде указателя и созда ния производных классов из неполиморфных типов.
Глава 21 • Облицовочные классы 457 Мы рассматривали проблему массивов унаследованных типов в гл. 14 вместе с шаблонным классом аггау_ргоху, который может использоваться в библиотеках для зашиты от ненадлежащего их применения. Массив _ргоху принудительно откло- няет передачу в виде массивов неподходящих типов, специально разрешая определен- ный тип массивов и любые его производные типы, которые имеют то же самое отобра- жение в памяти. Облицовочные классы представляют собой решение другой стороны этой проблемы - они помогают навязывать проектные решения и обеспечивать защит- ные меры при использовании функций, которые не защищены классом аггау_ргоху или эквивалентным механизмом. Перед комментированием второй, более прикладной характерной особенности облицовочных классов я хочу еще раз вернуться к призыву, сказанному в гл. 14 по поводу применения массивов унаследованных типов: «Не делайте этого!» Посту- пать так опасно, и это часто свидетельствует о плохом (или, по крайней мере, подозри- тельном) проекте и приводит к получению резких замечаний во время проведения визуального контроля программного кода, пока вы не сможете оправдать свои дейст- вия. Несмотря на это, у вас, возможно, иногда были основания для определения таких функций, и я не сомневаюсь, что вам придется столкнуться с ними, и поэтому один аспект концепции облицовочных классов связан с тем, что она дает вам некоторое средство защиты в тех случаях, когда вы вынуждены поступать таким образом. Вторая особенность облицовочных классов, вызывающая существенно меньше дискуссий, заключается в требовании уважать полиморфную природу типа параметри- зации, которая делает законным применение облицовочных классов к неполиморфным типам, и, следовательно, наследовать от них - практика, которую обычно не одобряют. Облицовочные классы покрывают широкий концептуальный диапазон. Одни огра- ничиваются полиморфными возможностями C++, основанными на классических виртуальных функциях, другие находят пользу в средствах обобщенного програм- мирования C++. Облицовочные классы могут иметь очень широкое применение, иногда совсем удивительное, и данная глава иллюстрирует обе стороны этой концеп- ции, демонстрируя некоторые их применения. 21.1. Облегченный RAII Иногда мы можем обнаружить, что конкретный класс делает почти все, что нам нужно, за исключением одной маленькой особенности. Это - классический случай, когда прибегают к наследованию. Библиотеки STLSoft определяют модель классов счетчиков производительности, одпроекты UNIXSTL и WinSTL определяют класс perf ormance_counter который Реализует функциональность принятой модели. Эти классы имеют две переменные периода времени, представляющие начало и конец измеренного интервала. Чтобы Не снижать эффективность, переменные-члены не инициализируются в конструкторе, аУстанавливаются с помощью методов start () и stop О соответственно. Однако ПРИ определенных обстоятельствах было бы полезно инициализировать члены при их ^нструировании.
458 Часть 4. Осознанные преобразования Это можно легко сделать с помощью облицовочного класса performance_counter_initialiser, предусмотренного в STLSoft. Он определяется следующим образом: Листинг 21.1. template <typename С> class performance_counter_initialiser : public С { public: performance_counter_initialiser() { C::start О; // Инициализировать член начала интервала C::stop(); // Инициализировать член конца интервала } }; Реализация очевидна, и мы имеем классический облицовочный класс. Он является производным от типа параметризации и ничего не добавляет к размеру последнего. Вместо этого он улучшает функциональность типа параметризации: в данном случае инициализируются обе переменные-члены периода времени, чтобы гарантировать осмысленность значений интервала при всех последующих вызовах экземпляра. Он может применяться для любого класса, удовлетворяющего простому набору огра- ничений, а именно, отсутствию значений по умолчанию для параметров метода start () и метода stop () типа параметризации. #ifdef WIN32 typedef winstl::performance_counter base_counter_t; #else typedef unixstl::performance_counter base_counter_t; #endif /* операционная система */ typedef performance_counter_initialiser<base_counter_t> counter_t; Функциональность обеспечивается в конструкторе, в котором вызовы методов квалифицируются типом базового класса С. Это обеспечивает наличие этих методов в типе параметризации. Если бы вызовы не были квалифицированы подобным обра- зом, этот шаблон можно было бы применять к любому типу при наличии свободных функций start () и stop (). 21.2. Связывание данных и операций При использовании структуры данных и программного интерфейса (языка С) (см. раздел 3.2) распространена практика создания классов-оболочек для структур данных и использования преимуществ встроенной в C++ поддержки очистки ресурсов с помощью механизма уничтожения объектов. Как мы все слишком хорошо знаем по
Слава 21. Облицовочные классы 459 своему опыту, обычно очень быстро свыкаешься с функциональностью, предназначен- ной «всего лишь для обеспечения одного дополнительного свойства», и заканчиваешь раздутым и сильно зависимым классом. Как мы видели в гл. 3 и 4, жизнь в C++, к счастью, гораздо богаче, чем можно было бы представить на основании некоторых обучающих материалов, в которых типы - либо простые старые данные (POD; см. «Введение»), либо богатые классы с жестким управлением доступа и полностью отработанной семантикой. Часто требуется нечто среднее. Давайте предположим, что в нашем случае нам необходимо пройти только часть пути. Тип COM VARIANT - это размеченное объединение (discriminated union), имеющее форму 16-байтовой структуры, которая используется для хранения типов С и СОМ или указателей на них. Т. к. объекты СОМ не являются характерными только для C++, VARIANT - С-совместимый тип. Поэтому программный интерфейс типа VARIANT - это набор С-функций, которые инициализируют, копируют и де-инициализируют структуры. Очевидно, такая конструкция - «сырые» структуры, (иногда) содержащие выделенные ресурсы, - является главным кандидатом для ее обрамления надежным классом C++, и было разработано несколько таких классов, например, CComVariant (ATL), COleVariant (MFC), _variant_t (обеспечивается компилятором Visual C++) и мой собственный Variant. Хотя все эти классы обоснованно критикуют, я не собираюсь непременно утверждать, что все они плохие и не должны использоваться. Что мы собираемся сделать, так это просто рассмотреть их необходимость и заодно привести пример облицовочного класса еще одного вида. Работа, которую я собираюсь описать, представляет собой расширяемую типобезо- пасную обобщенную подсистему регистрации событий, построенную на основе пре- дыдущей работы меньшего масштаба, которую я спроектировал для сервера приложе- ний со средним трафиком пару лет назад. Требования были следующими: • ориентация на сообщения - облегчить поддержку многих языков. * Ориентация на экземпляры - сообщения связываются с экземплярами приложений. Сменяемость - способность подключать различные объекты для записи реги- стрируемых событий (файл, сеть, память) на этапе выполнения программы. Эффективность - минимум передаваемых данных, минимальные затраты на вызовы в вызывающем программном коде. Универсальность - возможность работы с типами из широкого диапазона (хотя и ограниченного). Расширяемость - идентификаторы новых сообщений могут добавляться, не нару- шая существующий программный код. Типобезопасность - никакой непрозрачности типа printf (...); операторы необхо- димо отклонять на этапе компиляции, если изменяется определение сообщения так, что изменяется список аргументов.
460 Часть 4. Осознанные преобразования Для удовлетворения этих требований программный интерфейс строится на основе интерфейса ILog, который выглядит следующим образом: struct ILog { virtual void Log( AIID_t const &instanceld, Msgld_t const &msgld, size_t cArgs, VARIANT args[]) = 0; }; Можно подключать экземпляры различных реализаций - через функцию SetLog () - причем даже связанные в цепочку, если этого желает создатель данного клиентского про- цесса. Текущий активный журнал регистрации делается доступным с помощью свободной функции GetLogO. Поэтому выдать сообщение в журнал регистрации можно сле- дующим образом: GetLog()->Log(instId, msgld, 2, &args[0]); Похоже, что это дает нам систему, основанную на сообщениях, ориентированную на экземпляры, сменяемую и универсальную, но пока у нас еще нет простой в обраще- нии (и привлекательной1) системы. 21.2.1. pod_veneer Если мы хотим получить типобезопасный и одновременно универсальный метод работы с типами аргументов, это предполагает применение шаблонов. Первый нужный нам шаблон - это pod_veneer, который применяет неизменяемый RAII (см. раздел 3.5) для типов POD. Листинг 21.2. template* typename Т , typename CF // Функция конструктора , typename DF // функция деструктора > class pod_veneer : public T { typedef pocLveneer<T, CF, DF> class_type; public: pocLveneerO CT()(static_cast<T*>(this)); // Конструирование экэвмивфа типа Vой } -pocLveneer () 1 Программное обеспечение во многом напоминает математику. Часто если в вашем решении не ощушаетс элегантность, вероятно, оно еще не готово.
[лава 21. Облицовочные классы 461 { constraint_jnust_be_pod (Т >; constraint_jnust_be_same_size(class_type, Т); DF()(•tatic_ca»t<T*>(thie)); // Уничтожение экавмпшфа типа pod } }; Облицовочный класс принимает параметр типа POD, наследником которого он являет- ся, и два типа функтора. В конструкторе он выполняет функцию для конструирования самого себя, а в деструкторе он выполняет функцию для уничтожения самого себя; фактически он привязывает операции к «сырым» данным (POD). Следует отметить, что он использует два ограничения. Ограничение constraint_must_be__pod () гарантирует, что его первый параметр имеет тип POD. Ограничение constraint_must_be_same_ size () (см. раздел 1.2.5) используется для того, чтобы параметризация шаблона гаран- тировано подчинялась требованиям EDO. Итак, как это помогает нам в работе с программ- ным интерфейсом регистрации? Конечно, мы собираемся использовать pod_veneer для типа VARIANT. Мы могли бы определить следующие функторы конструктора и деструктора: Листинг 21.3. struct variant_init { void operator ()(VARIANT &var) { ::Variantlnit(&var); } }; struct variant_clear { void operator ()(VARIANT &var) { ::VariantClear(&var); } }; Эти функторы просто переводят требуемые функции из программного интерфейса Variant в удобную для шаблона форму; т. к. pod_veneer - шаблон класса, мы не можем использовать функции Variantlnit () и VariantClear () для его инстан- цирования, которое было бы возможно, если бы это был шаблон функции. 21.2.2. Создание сообщений журнала Порождаемый класс MSG_BASE используется для перехода от обобщенного набора параметров к вызову Log: : Log (). Для этого он принимает аргументы, упаковывает их в массив соответствующего размера из элементов pod_veneer<VARIANT, . . .>, и записывает этот массив в журнал регистрации.
462 Часть 4. Осознанные преобразования Листинг 21.4. typedef pod_veneer< VARIANT , variant_init , variant_clear > VARIANT_veneer; class MSG_BASE { public: template <typename Tl> MSG_BASE(AIID_t const &aiid, Msgld_t const tansgld, Tl const &al) { VARIANT_veneer args[l]; InitialiseVariant(args[0], al); STATIC_ASSERT(dimensionof(args) == 1); GetLog()->Log(aiid, msgld, dimensionof(args), args); ) template <typename Tl, typename T2> MSG_BASE(AIID_t const &aiid . . ., Tl const &al, T2 const &a2) { VARIANT_veneer args[2]; InitialiseVariant(args[0], al); InitialiseVariant(args[1], a2); STATIC_ASSERT(dimensionof(args) == 2); GetLog()->Log(aiid. msgld. dimensionof(args). args); } template <typename Tl, typename T2, typename T3> MSG_BASE(AIID_t const &aiid . . . Tl const &a2, Tl const &a3) { VARIANT_veneer args[3]; InitialiseVariant(args[0], al); InitialiseVariant(args[l], a2); InitialiseVariant(args[2], a3); STATIC_ASSERT(dimensionof(args) == 3); . GetLog()->Log(aiid, msgld, dimensionof(args), args); ) . . . // Конструкторы с дополнительными параметрами // He реализованные методы private: ...II Скрыть конструктор копирования и оператор присваивания ); Функции InitialiseVariant - это набор перегруженных встраиваемых свобод ных функций, которые инициализируют VARIANT набором типов и не выбрасывают никаких исключений. void InitialiseVariant(VARIANT &var, char const *v); void InitialiseVariant(VARIANT &var, wchar_t const *v); void InitialiseVariant(VARIANT &var, uintl6_t v);
463 Глава 21 • Облицовочные классы void InitialiseVariant(VARIANT &var, sint32_t v); void InitialiseVariant(VARIANT &var, double v); 21.2.3. Снижение затрат Фактически, в только что нарисованной мною картине имеется неэффективность. Т. к. перегруженные функции преобразования инициализируют VARIANT до его инстанциирования, нам не нужно его инициализировать в конструкторе массива; мы можем ничего не делать, зная, что функции инициализации сделают это для нас. фактическая реализация использует тип noop_function<VARlANT>, который выпол- няет удивительно немного действий. Листинг 21.5. template <typename Т> struct noop_function : public std: :unary_function<T const &, void> { void operator ()(T const & /* t */) {} II Минимальный функтор ); typedef pod_veneer< VARIANT , noop_function<VARIANT> , variant_clear > VARIANT_veneer; Теперь мы по существу сделали шаг назад от RAII к RRID (см. раздел 3.4). В данном случае это нормально, т. к. программный интерфейс регистрации событий не выбрасывает исключений и не выполняет функции инициализации: если не удается распределить некоторые ресурсы для инициализации VARIANT соответствующими значениями, они гарантированно инициализируются значением VT_EMPTY. Это явля- ется артефактом контекста выполнения, продиктованным требованиями подсистемы Регистрации событий. В общем случае вы можете поступить по-другому и выбрасы- вать исключение при безуспешном распределении ресурсов в функции инициализации (хотя в этом случае остается открытым вопрос о том, кто зарегистрирует отказ при Регистрации), и при этом вы могли бы использовать variant_init, как описано в начале. Во^ЛеД^еТ отметить два важных обстоятельства относительно того, что мы сделали. первых, мы убедились, что в любом случае уничтожаются инициализированные экземпляры массивов VARIANT, т. к. деструктор pod_veneer будет вызывать функ- альный оператор variant_clear для каждого сконструированного экземп- ₽а. Язык гарантирует, что это произойдет. Конечно, это было бы в случае, если бы variant был функционально завершен. Однако мы знаем, что массив элемен- Pod_yeneer<VARIANT, ...> безопасно конвертируем в VARIANT*, т. к.
464 Часть 4. Осознанные преобразования pod_veneer это гарантирует. Другой вариант этого класса может как проявлять те же самые свойства, так и нет. Вы не узнаете об этом на этапе компиляции (см. раздел 1.4), и ничто не сможет предотвратить изменение реализации класса, даже если в данный момент он работает. Вторая особенность связана с эффективностью. Т. к. в фактической реализации функций InitialiseVariant О гарантированно не выбрасывается исключение и VARIANT инициализируется нулями при неудаче, мы предпочитаем, чтобы pod_veneer не работал в его конструкторах. Это достигается его параметризацией другим типом, который мог бы быть типом-членом MSG_BASE. Поэтому мы можем обусловить поведение применением некой стратегии, и выбор типа этой стратегии может быть централизован. Это обеспечивает достаточно большую гибкость при при- менении этого программного интерфейса для другого проекта или при допущении изменения текущего проекта. Единственный путь такого же изменения стратегии при использовании классов-оболочек VARIANT из внешних источников заключается либо в их размещении в параметризованных облицовочных классах, либо в выборе разных классов-оболочек с помощью класса стратегии. Ни один из этих вариантов не является привлекательным с точки зрения сопровождения или затрат. Как вы помните, в этом контексте не существует причины иметь полнофункциональные классы-оболочки для VARIANT кроме автоматического уничтожения. Мы должны использовать функции InitialiseVariant () для того, чтобы заработал механизм обобщения для аргу- мента с регистрируемым значением. Поэтому нам необходимо сформировать оболочку только для инициализации ресурсов и уничтожения ресурсов. Мы получили все что нам нужно и избежали ненужных зависимостей и затрат (то есть избежали неэф- фективности). 21.2.4. Типобезопасные классы сообщений Мы рассмотрели суть pod_veneer, но мне бы просто хотелось завершить дискус- сию описанием того, как все согласуется друг с другом и удовлетворяет всем требова- ниям. Идентификаторы сообщений содержатся в базе данных, на основании которой формируются два файла. Один является двоичным файлом, который используется механизмом быстрого поиска сообщений при просмотре журналов (на этапе выполне- ния программы или в автономном режиме). Другой - заголовочный файл с определе- ниями сгенерированных классов сообщений, имеющий следующей вид: Листинг 21.6. class MSG_RESOURCE_NOT_FOUND : public MSG_BASE { public: MSG_RESOURCE_NOT_FOUND(UINT id) : MSG_BASE(AIID_NULL, MSGID_RESOURCE_NOT_FOUND, id) {}
Слава 21. Облицовочные классы 465 MSG_RESOURCE_NOT_FOUND(AIID_t const binstanceld, UINT id) : MSG_BASE(instanceld, MSGID_RESOURCE_NOT_FOUND, id) () }; class MSG_BAD_CAST : public MSG_BASE { public: MSG_BAD_CAST(char const ♦expression, int level) : MSG_BASE (AIIDJQULL, MSGID_BAD_CAST, expression, level) О MSG_BAD_CAST(AIID_t const binstanceld, char const *expr, int level) : MSG_BASE(instanceld, MSGID_BAD_CAST, expr, level) {) ); Сообщения регистрируются, используя временные экземпляры этих классов, как показано в следующем примере: iff!resmgr.Contains(rsrcld)) ( MSG_RESOURCE_NOT_FOUND(rsrcld) ; ) Итак, чего же мы добились? Достигли ли мы оговоренного в наших требованиях (сверх того, что обеспечивает программный интерфейс регистрации)? • Типобезопасность: она на максимально возможном для C++ уровне, то есть стопро- центная, за исключением нескольких неявных целочисленных преобразований и возможной путаницы с 0 (хотя мы могли бы теперь использовать более надежный NULL, который был бы полезен). • Расширяемость: новые сообщения определяются в базе данных, и их типобезопас- ные классы-оболочки, имеющие точные аргументы, генерируются автоматически. Универсальность: он работает с любым типом, совместимым с VARIANT. Эффективность: все, допускающее встраивание, встраивается, и мы можем пара- метризовать наш облицовочный класс, чтобы избежать выполнения лишней работы. 21.3. Уточнение концепций Когда мы рассматривали прокладки преобразований, мы видели, что одно из реше- Ни& по обеспечению работы типа CString с классом, выделяющим лексемы СтР°ки (см. раздел 20.5), было построено на специализации шаблона свойств и обес- печении необходимых типов. Хотя это решение работало отлично, мы столкнулись проблемой выбора метода реализации свойств. Все отлично, если мы собираемся ис- ьзовать CString только для одного типа классов, выделяющих лексемы. Но пред- °Жим- что теперь мы хотим использовать шаблон html_link_parser компании
466 Частъ4. Осознанные преобразования --------------------------------------------------------------- «Acme Web Software», который анализирует документы HTML и размещает все ги пересылки в контейнере строк. Как и в случае с классом, выделяющим лексемы из строки, acmeweb: :html_link_parser параметризуется типом строки и использует шаблон свойств html_string_traits для вывода свойств строки. В принятом по умолчанию определении acmeweb: :html_string_traits ра. зумно требуется, чтобы параметризующий его тип строки удовлетворял модели строки String стандартной библиотеки (стандарт С++-98: 21.3), и предполагается, что в боль- шинстве случаев он будет использовать тип std:: string. Однако это не всегда воз- можно или оптимально. Итак, что мы можем сделать для применения CString совме- стно с html_link_parser? Мы могли бы пойти по пути жесткого кодирования и специализировать html_string_traits для CString. Но мы уже сделали специализацию для клас- са, выделяющего лексемы из строки. Здравый смысл, профессиональный программи- стский опыт и инстинктивное игнорирование всеми хорошими проектировщиками программного обеспечения говорят нам о том, что это неверный путь. Практика разра- ботки хорошего программного обеспечения учит делать все дважды, один раз для того, чтобы научиться, и другой раз - чтобы обобщить. Если столкнемся с третьим обраба- тывающим классом компании «Job Creation Incorporated», мы определим третью специализацию? Единственная альтернатива заключается в обеспечении серьезного продвижения типа CString в «главную лигу» и предоставлении ему возможности продемонстрировать свои возможности. Похоже, что это задача для облицовочного класса. В действительности, это делается просто и выглядит следующим образом: Листинг 21.7. class CString_veneer : protected CString { public: typedef TCHAR value_type; typedef TCHAR ‘iterator; typedef TCHAR const *const_iterator; typedef TCHAR ‘pointer; typedef TCHAR const *const_pointer; typedef size_t size_type; // Конструирование public: explicit CString_veneer(const_pointer s); explicit CString_veneer(const_poincer from, const_pointer to); II Итераторы public: const_iterator begin() const; const_iterator end() const; II Операции доступа
Глава 21. Облицовочные классы 467 public: const_pointer c_str() const; ); inline TCHAR const *c_str_ptr(CString_veneer const &s) // Shim { return s.c_str(); } Вы могли бы сказать: «Но здесь пропущены некоторые методы: я не вижу неко- торых перегрузок конструкторов и некоторых операторов, а также такой-то очевидный метод». Ну, это очень хорошо. Здесь мы имеем отлично оборудованную площадку, некоторой можно потренироваться. Мы знаем начальный пункт: смущающе-голый (во всяком случае, с точки зрения STL) CString. Мы знаем конечный пункт: типы, переменные (на самом деле, переменная npos) и методы модели String стандартной библиотеки (стандарт С++-98: 21.3). Мы не собираемся выходить за эти рамки, если мы специально не выбираем извращенный путь, но это не означает, что нам придется закончить его преждевременно. А как насчет заполнения свободного времени: вы можете закончить его в свое свободное время, и не будет никакого недостатка. (Для тех из вас, кто защищает шаблон std: :basic_string от обвинений в излишнем количестве его методов, я призываю вас создать облицовочный класс в обсуждаемой нами форме, всегда использовать его в своей работе, заполнять его только должным образом и написать мне в том случае, когда он станет удовлетворять требованиям модели String. Следует отметить, что к 2030 году я рассчитываю жить в облаках горы Олимп, и поэтому вам придется посылать это на адрес марсианского отделения изда- тельства «Addison-Wesley».) Вы могли бы сказать: «Но вы наследуете класс, который не имеет виртуального деструктора, и каждому известно, что так нельзя делать!» Ну, классический совет при использовании наследования - «убедитесь, что базовые классы имеют виртуальный деструктор» [Меуе 1998]. Этот совет на 100 процентов правильный в тех случаях, когда вы имеете дело с полиморфными типами, и его нарушение не принесет вам радо- сти. Это объясняется тем, что если ваши производные классы имеют нетривиальный деструктор и механизм виртуального деструктора не используется для обеспечения корректного уничтожения, то будет происходить утечка ресурсов при их полиморфном Удалении (то есть с помощью указателя на базовый класс). Однако опытные разработчики знают, что этот совет неуместен при рассмотрении неполиморфных классов, хотя этот вывод обычно сопровождается предупреждением ° том, что неполиморфные классы не должны использоваться в качестве базовых клас- Сов- Поскольку облицовочные классы обязаны уважать полиморфную природу своих Основных типов параметризации, они будут полиморфными (и, следовательно, будут °беспечивать безопасное уничтожение через указатель на базовый класс), если тако- ВЬ1М является их основной тип параметризации; и они не будут полиморфными (и, сле- довательно, не будут обеспечивать безопасное уничтожение через указатель на базо-
468 Часть 4. Осознанные преобразования вый класс), если их основной тип параметризации не является полиморфным. Более того, пользователи, которые параметризуют ваш облицовочный класс, не отвечают за неправильное использование его полиморфизма (или за неправильное использование его отсутствия). Проще говоря, если кто-то создает свой облицовочный класс вокруг чего-то, что не имеет виртуальный деструктор, то именно он должен гарантировать его правильную работу и не должен пытаться уничтожать его виртуально. Если они не читали вашу документацию, то вежливо напомните им об этом. Если вы не докумен- тировали свой облицовочный класс (в том числе не указали правила применения обли- цовочных классов), то вам лучше приготовиться к обидным замечаниям в свой адрес. 21.4. Облицовочные классы: заключение Идея облицовочных классов вызвана желанием обеспечить тонкую полировку существующих классов, но она развилась в концепцию, нашедшую широкое примене- ние. Облицовочные классы могут использоваться для внедрения нового свойства в иерархию классов, для завершения инкапсуляции, для адаптации типов, для обеспече- ния преобразования. Они могут добавлять новые методы, выполнять дополнительную обработку при конструировании или уничтожении или определять новые типы-члены. Облицовочные классы делают все это простыми средствами, придерживаясь набора правил, от которых зависят многие их применения. В следующей главе, мы рассмотрим более серьезный тип адаптации, которую представляют прикрепляемые классы.
Глава 22 Прикрепляемые классы Болт (bolt): твердый стержень из металла или другого материала, используемый для закрепления чего-нибудь и часто имеющий головку на одном конце и резьбу для на- винчивания гайки на другом. Термин bolt-in (прикрепляемый) был впервые создан, если я не ошибаюсь, моими друзьями и бывшими коллегами Лайфом (Leigh) и Скоттом (Scott)1, которые имели компа- нию в Сиднее, Австралия, занимавшуюся консалтингом по программному обеспечению. Вообще говоря, прикрепляемые классы «прикрепляются» для того, чтобы существенно улучшить функциональность существующих классов. Это очень напоминает концепцию облицовочных классов, и, действительно, прикрепляемые классы можно считать очень близкой концепцией. Однако они обладают гораздо большей самостоятельностью и имеют меньше ограничений, чем облицовочные классы. Определение: прикрепляемые классы (bolt-ins). Прикрепляемые классы - это шаблонные классы со следующими свойствами: 1. Они являются производными от своего основного типа параметризации (обычно с открытым доступом к нему). 2. Они приспосабливаются к полиморфной природе своего основного типа пара- метризации. Обычно они твердо ее придерживаются, но не всегда, и могут опреде- лять свои собственные виртуальные методы в дополнение к переопределяемым методам своего основного типа параметризации. 3. Они могут усилить возможности основного типа параметризации путем определе- ния переменных-членов, виртуальных функций и дополнительного наследования непустых типов. Не существует четкой границы между облицовочными классами (см. гл. 21) И пРикРепляемыми классами, но у них разное назначение. В то время, как облицо- в°чные классы в основном предназначены для полировки существующих типов, прикрепляемые классы предназначены для существенного изменения или пополнения функциональных В03М0ЖН0СТей (часто частично определенных) типов. евеРоятно высокие братья Перри (Perry).
470 Часть 4. Осознанные преобразования 22.1. Добавление функциональности Главная цель прикрепляемых классов - добавление или усовершенствование функ- циональности. Это может достигаться тремя способами. Во-первых, это может быть новая функциональность, которая будет затем доступна в производных классах и/или в составном типе клиентского программного кода. Во-вторых, это может означать замену существующей функциональности. Это может достигаться через широко известный в C++ механизм обеспечения поли- морфизма на этапе выполнения [Stro 1997], когда производный класс может переопре- делять виртуальные методы своего родительского класса (одного или нескольких). Однако допускается также переопределение невиртуальных функций. Это известно как метод сокрытия функций, который обычно приводит к ненадлежащему примене- нию класса, методы которого скрываются, и он становится вероятным источником ошибок [Меуе 1998, Dewh 2003]. Однако существуют обстоятельства, рассматривае- мые в данной главе, при которых такие действия оказываются вполне разумными и безопасными. Наконец, это может быть связано с обеспечением функциональности, которой до этого не существовало. Это можно достигнуть двумя друг друга дополняющими ме- тодами: полиморфизмом абстрактных типов на этапе выполнения и моделированием полиморфизма на этапе компиляции (см. раздел 22.5). В то время, как первый метод имеет в виду наследуемую от базового класса функциональность, то последний метод имеет в виду функциональность, обеспечиваемую наследуемым классом. Если представленная картина вам также ясна и прозрачна, как овощной суп, не стоит беспокоиться: в данной главе он будет процежен до получения легкого бульона. 22.2. Выбор оболочки Как мы упоминали ранее, одно из применений прикрепляемых классов связано с завершением функциональности незавершенного класса. Фреймворк библиотеки активных шаблонов (ATL) компании Microsoft обеспечивает ряд примеров такого их использования. Вы определяете свой класс, который наследует один или несколько интерфейсов СОМ, но не определяете свой механизм подсчета ссылок. В частности, вы не реализуете чистые виртуальные методы интерфейса IUnknown (см. раздел 19.8). Поэтому ваш класс является абстрактным и вы не можете инстанциировать его экземп- ляры. Однако вы можете определять составные типы, используя один из определенных bATL прикрепляемых классов - CComObject, CComAggObject, CComObject- Stack и т. д. - или один из своих собственных, и этот составной тип реализует нужным вам образом методы IUnknown и может инстанциироваться.
Гл^а Прикрепляемые классы 471 class MyServer : public IMyServer , public . . . О; new MyServer; // ошибка - абстрактный тип new CComObject<MyServer>; // Нормально; создается нормальный объект СОМ MyServer msl; // ошибка - абстрактный тип CComObjectStack<MyServer> ms2; // Нормально; в стеке создается // нормальный объект СОМ Поэтому прикрепляемые классы представляют собой способ добавления функциональ- ности к частично определенным типам, например, при реализации интерфейса. Это означает, что реализация определенных частей может быть абстрагирована и обобщена в виде набора связанных прикрепляемых классов, позволяя задерживать и/или пересматривать проектные решения без каких-либо (существенных) усилий, связанных с программированием. Это не только помогает улучшать гибкость и возможность повторного использования компонентов программного обеспечения, но позволяет вносить исправления в проекты на поздних этапах процесса разработки и, следовательно, означает возможность осуществления внешних изменений при низких затратах [Beck 2000], что является некой утопией для программного обеспечения. Естественно, этот механизм подходит для переопределения любых виртуальных функций, то есть не только для тех, для которых элемент в виртуальной таблице еще не существует. Создание оболочки завершает абстрактные классы и/или изменяет характерные особенности конкретных полиморфных классов. Далее мы рассмотрим модификацию функциональности, обеспечиваемой невиртуальными методами. 22.3. Переопределение не виртуальных методов При написании прикрепляемых классов (и также иногда облицовочных классов), КОторые нацелены на общую модель основного типа параметризации, может возник- иуть потребность в сокрытии методов, а не в их переопределении для некоторых целе- ВЫх типов- Переопределение невиртуальных функций обычно сильно осуждается вполне заслуженно. Такой подход может приводить к тому, что поведение экземп- *ЯРа ^асса будет меняться в зависимости от того, где он используется. Рассмотрим с TradingRegister из листинга 22.1, который не проектировался как базовый Сс» поскольку его метод Add () не является виртуальным. Листинг 22.1. class TradingRegister { Public: void Add(Trade const &trade); // Добавить сделку в реестр сделок
472 Часть4. Осознанные преобразования }; void DoTrade(TradingRegister &reg, Trade const &trade) { reg.Add(trade); } Впоследствии оказалось, что кому-то необходимо переопределить функциональ- ность, обеспечиваемую классом LoggingTradingRegister, приведенном в лис- тинге 22.2. К сожалению, передача экземпляра LoggingTradingRegister методу DoTrade () приводит к ошибке, связанной с вызываемым в нем методом Add () класса TradingRegister. Листинг 22.2. class LoggingTradingRegistr : public TradingRegistr { public: void Add (Trade const &trade); // Добавить сделку и зарегистрировать ее }; Trade trade; LoggingTradingRegister Itr; DoTrade(Itr, trade); // Неприятный результат. Сделка не зарегистрирована Это результат совместной работы механизмов C++ по разрешению и наследованию типов, позволяющий интерпретировать экземпляры типа именно как этот тип или как любой из производных от его родительских типов с открытым доступом к последним. Без этих механизмов C++ был бы довольно бесполезным языком, но это означает, что в целом мы должны избегать переопределений невиртуальных методов. Однако при отсутствии возможности использования типа через его родительский тип проблемы переопределения невиртуальных методов не возникнет . Это произой- дет при применении шаблонов. Рассмотрим, что было бы при реализации DoTrade () в виде шаблона: template* typename Т , typename U > void DoTrade(T &reg, U const &trade) { reg.Add(trade); } DoTrade(Itr, trade); // Прекрасно. Сделка регистрируется
Глава 22. Прикрепляемые классы 473 Эта версия DoTrade () не знает (и фактически не может знать) о родительском классе (одном или нескольких) типа Т. Поэтому при инстанциировании DoTrade () известно лишь то, что Itr имеет тип LoggingTradingRegister, и поэтому может вызываться только метод LoggingTradingRegister:: Add (). Хотя их применение с шаблонами безопасно, типы с переопределяемыми невирту- альными методами могут применяться также в других нешаблонных контекстах, что по-прежнему представляет опасность. Проблема любого метода, применяющего трансформацию шаблона - будь то облицовочные классы, прикрепляемые классы или любой подобный механизм - как показывает опыт, заключается в легкости нарушения правила сокрытия при проектировании вами шаблонов с потенциально большим на- бором типов параметризации. Это является существенным изъяном этих методов, и об этом необходимо помнить при их использовании. На практике пользователи должны рассматривать такие компоненты как прозрачный ящик - как точно так же они должны поступать при использовании библиотек, основанных на полиморфизме этапа выпол- нения1. 22.4. Увеличение возможностей Интересным побочным эффектом механизма разрешения шаблонов является отсут- ствие необходимости символам, разрешенным в рамках шаблона прикрепляемого класса, быть частью шаблона или класса (одного или нескольких) его параметризации. Это может являться изъяном (и скрытой причиной отраженного на лице испуга), но иногда может использоваться для обеспечения более гибкого шаблона. Библиотеки системы Synesis содержат набор взаимодействующих шаблонов, которые «прикрепляют» логику подсчета ссылок к классам, причем эти классы могут как иметь, так и не иметь интерфейс подсчета ссылок2. Эти шаблоны строятся на основе применения набора прикрепляемых классов Synesis Std: : Ref Counter и нескольких его оболочек (см. раздел 22.2), которые имеют следующую форму: template* typename Т , typename S = Sync , typename C = InitialCount<l> > class RefCounter : public T , public S { В настоящее время (2003 год) создается впечатление, что в интенсивных дебатах по поводу реализации ^•очевого слова export пропускается этот момент. Я считаю иначе: единственными компонентами, которые нриближаются к черному ящику, являются те, что скрыты в программном интерфейсе С независимых от Эпилятора библиотек. Эти классы существенно не изменились с середины 1990-х годов и поэтому выглядят немного «хилыми», ожалуйста, учитывайте это, и почти наверняка сейчас я бы их проектировал по-другому, а здесь они применены для иллюстративных целей.
474 Частъ4. Осознанные преобразования Прикрепляемый класс получается как производный от основного типа параметри- зации Т и от «примесного» типа стратегии S, определяющего стратегию синхрониза- ции. Третий параметр, С, определяет начальную стратегию подсчета ссылок. Страте- гия синхронизации определяет функциональность блокировки и неделимых операций над целыми числами в следующем интерфейсе: struct S { // Неделимые операции static int AtomicDecrement(int volatile &); static int AtomicIncrement(int volatile &); Il Операции блокировки void Lock(); void Unlock(); }; Определены четыре класса стратегии синхронизации: SyncST, SyncMTNoLock, SyncMT и шаблон SyncMTOucerLock, а условные операторы препроцессора используются для определения с помощью typedef имени Sync, обозначающего либо SyncST, либо SyncMT в зависимости от использования одно- или многопо- точной среды компиляции. Последний из четырех типов стратегий реализует свою блокировку путем разыменования составного класса, который позволяет прикрепляе- мым классам работать с классами, уже обладающими своей собственной функцио- нальностью блокировки, тем самым экономя на пространстве и потенциально дорогих объектах синхронизации. Его определение приводится в листинге 22.31. Листинг 22.3. template <class О> struct SyncMTOuter : public SyncMTNoLock { SyncMTOuter() : m_po(0) О void Lock() { m_po->0::Lock(); } void Unlock() { m_po->0::Unlock(); } void InltSync(O *po) 1 Следует отметить, что здесь используется переопределение невиртуального метода (см. раздел 22.3), но в данном случае это совершенно безопасно, поскольку шаблон применяется в качестве параметра прикрепляемого шаблона RefCounter.
Глава 22. Прикрепляемые классы 475 ---------------------------------------------------------------------------- { т__ро = ро; } void UninitSync(О *ро) { m_po = NULL; } private: 0 *m_po; }; После этого методы InitSync () и UninitSync () вызываются в конструкторах и деструкторе прикрепляемого класса, как показано в листинге 22.4: Листинг 22.4. template* typename Т , typename S = Sync , typename C = InitialCount<l> > class RefCounter : public T { RefCounter::RefCounter() : m_cRefs(C: :Count) { InitSync(thia); } RefCounter(RefCounter const &rhs) : Parentclass(rhs) , m_cRefs(C::Count) { InitSync(this); ) ~RefCounter() { UninitSync(this); ) Это прекрасно подходит для SyncMTOuterLock, но как обстоит дело с другими стратегиями синхронизации? Вообще говоря, можно было бы предложить определить Для них методы InitSync () и UninitSync (), но такой подход кажется немного НеУклюжим. Я решил положиться на механизмы разрешения шаблонов и определить ® свободные функции в том же самом пространстве имен, в котором находятся при- миряемые классы: inline void InitSync(void *) О inline void UninitSync(void *) О
синхронизации) себя. Тепрт . 476 Часть 4. Осознанные преобразования Шаблон подбирает их за пределами своего определения класса (который включает все содержащиеся в нем типы, в том числе примеси стратегий и «видит» только свободные функции, если не может найти их внутри можете добавлять класс синхронизации любого понравившегося вам вида и только при необходимости определять InitSync () и UninitSync (). 22.5. Моделирование полиморфизма на этапе компиляции: реверсивные прикрепляемые классы Это является еще одним примером тех методов, которые, кажется, подобно счетчи- ку Шварца (см. раздел 11.2.3), изобретаются снова и снова. Данный подход имеет не- сколько названий. Перед тем, как дать оценку различным названиям, давайте сначала обсудим его суть. Рассмотрим следующий шаблон: template <typename Т> struct RecursiveBoItIn { void DoItO { static_cast<T*>(this)->Do(); } }; RecursiveBoltln не является прикрепляемым классом, т. к. он не является про- изводным от своего типа параметризации. Однако, как мы увидим, в некотором смысле его можно считать реверсивным прикрепляемым классам (Reverse Bolt-in)^. На первый взгляд, он выглядит довольно просто и устанавливает ограничение на свой тип пара- метризации, требуя наличия в нем метода Do (). Посмотрев на это немного вниматель- нее, мы, конечно, увидим, что он фактически выполняет приведение самого себя (this) в свой тип параметризации. Но он не является производным от типа пара- метризации, и поэтому как это может работать? Как известно, механизм реализации виртуальных функций поддерживает поли- морфизм посредством разыменования указателя таблицы виртуальных функций для доступа по таблице виртуальных функций к конкретному классу [Lipp 1996]1 2. Это про- исходит на этапе выполнения программы. Поиск, который мы видели в Recursive- Boltln, выполняется в реверсивных прикрепляемых классах на этапе компиляции, избегая затрат разыменования на этапе выполнения программы. По этой причине мне нравится называть это полиморфизмом, моделируемым на этапе компиляции. 1 Эту концепцию я назвал так из-за отсутствия более подходящего названия. 2 Это не оговорено стандартом (см. гл. 7 и 8), но механизм реализации обычно именно такой
Глава 22. Прикрепляемые классы 477 Первое известное мне описание этого метода было сделано Джеймсом Коплиеном (James Coplien) [Lipp 1998], в котором он назвал его странно-рекуррентной моделью шаблона {Curiously Recurring Template Pattern - CRTP). Этот метод используется повсюду в библиотеке активных шаблонов (ATL) компании Microsoft, где он вызывается моделируемой динамической связью {Simulated Dynamic Binding) [Rect 1999], и в работе [Rect 1999] предполагается, что он открыт командой разработчиков ATL. Авторы также предполагают, что этот метод не обязательно поддерживается языком, но мне не удалось обнаружить компилятор, который бы не справился с ним в двух продемонстрированных здесь примерах; даже в жестком режиме компилятор Comeau воспринимает его без про- блем. (Он очень полезен, и, вероятно, поэтому стоит его применять, несмотря на потен- циальное несоответствие.) Мы можем легко понять, как он работает, рассмотрев пример класса, который его использует. class RecChild : public RecursiveBoltIn<RecChild> { public: void Do() {} }; Теперь понятно, как может работать static_cast. RecChild является произ- водным от класса Recurs iveBolt!n<RecChild>, причем с открытым доступом к последнему и без использования его виртуальных методов, и поэтому при инстан- циировании Recursive BoltIn<RecChild> собственный экземпляр (this) может статически приводиться к типу RecChild. Все это кажется достаточно стран- ным, но этот метод работает и вполне пригоден для установки ограничения на тип параметризации прикрепляемого класса. Следует отметить, что этот подход применим не только к функциям; можно также потребовать от параметризующего производного класса обеспечить нефункциональ- ные члены, то есть переменные-члены и члены-перечисления. 22-6. Параметризованная полиморфная упаковка Классический компромисс между эффективностью и гибкостью лучше всего про- является при решении вопроса о полиморфности класса. Может не быть убедительно- го аргумента ни у одного решения, и было бы лучше вообще не принимать решение, рименяя прикрепляемые классы, мы можем во многих случаях поступать именно Так’ позволяя отложить принятия решения до соответствующего момента - до его про- шения в проекте программиста. Рассмотрим класс, используемый для блокировки взаимоисключающего доступа п°токов одного процесса, показанный в листинге 22.5.
ЧастъД.Осознаннмпрео^^ 478 Листинг 22.5. class ThreadMutex { public: ThreadMutex(); -ThreadMutex(); // Операции public: void Lock(); void Unlock(); // Члены private: ..,11 специфические для платформы данные }; Для повышения эффективности все методы - невиртуальные, а методы Lock () и Unlock () - встраиваемые. Это прекрасно работает, обеспечивая максимальную эффективность, но ограничивает возможности программного кода, использующего ThreadMutex из-за принятия определенных решений в момент написания класса. Тем не менее, эта модель обладает высокой гибкостью, например, вы могли бы опреде- лить также классы ProcessMutex и Noop Mutex, но гибкость обеспечивается на этапе компиляции и усиливается выбором одного или другого типа, возможно, с помо- щью препроцессорных директив условной компиляции. Можно поступить по-другому, и если вам необходима гибкость на этапе выполнения, вы могли бы определить интерфейс IMutex, в котором методы Lock () и Unlock () определялись бы как виртуальные, как в следующем примере: struct IMutex { virtual void Lock() = 0; virtual void Unlock() = 0; После этого вы могли бы написать следующий программный код: IMutex *mx = FastLookupMutex(API_ID_XYZ); mx->Lock(); XYZFunction(); //из программного интерфейса XYZ mx->Unlock(); Теперь блокировка и разблокирование реального конкретного класса мьютекса поднялась бы с помощью vtable. (В реальных условиях вызовы Lock () и Un контролировались бы областью действия мьютекса; см. раздел 6.2.) Но тепе^сте. имеем две версии программного кода. В то время, как некоторые операционные мы имеют простые и очевидные программные интерфейсы синхронизации, не имеют, что создает дополнительные трудности для сопровождающего перс
Слава 22. Прикрепляемые классы 479 (Между прочим, я знаю из своего горького опыта, что применение полиморфных объектов синхронизации вероятно, будет сопровождаться нетривиальными издержка- мИ# Учет этого может помочь вам избежать тех трудностей, которые я испытывал не- сколько лет, когда навязал подобную полиморфную версию ничего не подозревавшему клиенту: она расходовала 15 процентов процессорного времени, пока я не понял свою ошибку и не устранил ее. Они были очень довольны и поздравили меня за проявлен- ную высокую компетентность; мне было очень неприятно признаваться в том, что я сделал. Вот такие бывают некоторые консультанты!1) Итак, что же делать? Как мы можем обеспечить полиморфную гибкость, когда она нам необходима, и эффективную негибкость, когда она нам необходима? Ответ: при- крепляемые классы и натиск ЕВО (см. раздел 12.3). Мы можем переопределить наш класс ThreadMutex в виде следующего шаблона: template <typename I> class ThreadMutex : public I { Теперь ThreadMutex является производным от типа I, причем I может быть любым, и ничего в определении класса не изменяется. Если мы хотим иметь мьютекс потока, полиморфный по отношению к IMutex, мы используем параметризацию ThreadMutex< IMutex>. Если бы мы хотели иметь не полиморфную версию, мы бы параметризовали его ну- левым типом, например, boost: : null_t, который не добавляет никаких новых дан- ных, методов и vtablelvptr (см. главы 7 и 8). Поскольку большинство компиляторов очень хорошо используют ЕВО, мы будем иметь тип, который физически идентичен первоначальному определению ThreadMutex. Даже для немногих других компиля- торов (см. табл. 18.3) этот изъян не является существенным, поскольку мы больше всего обеспокоены затратами на разыменование vtable на этапе выполнения; едва ли мы будем создавать тысячи экземпляров ThreadMutex<boost: : null_t>. Вы можете получить самый большой прикрепляемый класс, который будет иметь именно такой размер. В составном типе ThreadMutex<boost: :null_t> тип ThreadMutex<> обеспечивает все: данные, методы и тип (если не брать в расчет бес- численную способность приведения к типу boost: : null_t&). 22.7. Прикрепляемые классы: заключение Рикрепляемые классы, как бы по-другому они ни назывались, несомненно, пред- тот собой очень мощный механизм существенного усовершенствования сущест- ^^^типов; наиболее распространенный для построения компонентов СОМ |Ьзв(и1и^СТВИтельности, они оценили мое честное признание. Урок: всегда будьте честными; это, возможно, и М пРи°брести друзей в этой области деятельности, где ответственность имеет немногочисленные Редко встречается.
480 Часть 4. Осознанные преобразования фреймворк - ATL - фактически полностью состоит из прикрепляемых классов На своем собственном опыте я убедился в том, что они существенно усиливают воз можность повторного использования компонент. В то время, как классическое насле дование позволяет обеспечить скромный выигрыш относительно этой возможности в связанных классах, а используемые в шаблонах методы, особенно в STL способны обеспечить повторное использование реализации и алгоритмов, прикрепляемые классы дают нам способ объединения достоинств и того, и другого.
Глава 23 Шаблонные конструкторы Очевидный недостаток прикрепляемых классов, облицовочных классов и других шаблонов, которые являются производными от своего типа параметризации (одного иди нескольких), заключается в том, что они скрывают конструкторы этого типа. Рассмотрим гипотетический класс, показанный в листинге 23.1. Листинг 23.1. class Double { public: typedef double value_type; public: Double(); explicit Double(double d); public: double GetValueO const; }; template <typename T> class Wrapper : public T { }; typedef Wrapper<Double> Double_t; Double_t dl; Double_t d2(12.34); // Ошибка! Wrapper фактически скрывает второй конструктор для Double, и поэтому попыт- ка К0НСТРУирдвания с передачей значения приводит к ошибке. Решение этой проблемы для библиотеки активных шаблонов (ATL) компании Microsoft устоит в применении другого подхода. CComObject, как и большинство его собратьев екоторые другие имеют только конструктор по умолчанию), имеет единственный к°НстРУктор со следующей сигнатурой: CComObject(void *pv = NULL);
482 Часть 4. Осознанные преобразования Этот конструктор используется для передачи различных объектов (вашему) укреп. ляемому классу, включая указатели интерфейса, указатели на внутренние классы и т. д Не так давно эта ограниченность заставила меня написать два специальных класса и два новых шаблона просто для создания компонента, позволяющего применять кон- тейнер STL внутри объекта компонента; мягко говоря, я испытал разочарование! Должен су шествовать более подходящий способ1. Общим решением могло бы быть использование шаблонных конструкторов. В нашем случае мы могли бы добавить следующий шаблонный конструктор: Листинг 23.2. template <typename Т> class Wrapper : public T { public: Wrapper() О template «typename Tl> explicit Wrapper(Tl tl) t baae_daaa_type(tl) <} Прежде всего следует отметить то, что нам пришлось добавить конструктор по умолчанию. Если бы мы этого не сделали, то первый объект в нашем примере был бы недопустим. Как мы видели в разделе 2.2, во всех случаях, когда вы определяете кон- структор не по умолчанию при отсутствии конструктора по умолчанию, последний фактически скрывается. Я должен также отметить, что шаблонные конструкторы нико- гда не используются для генерирования копирующих конструкторов [Dewh 2003], так что если вы имеете прикрепляемый класс, который распределяет ресурсы, вам в связи с этим необходимо проявлять осторожность при его определении и либо обеспечить конструктор копирования и оператор копирующего присваивания, либо скрыть их (см. раздел 2.2). Основная роль шаблонного конструктора кажется вполне очевидной. Конструктор просто принимает один аргумент своего шаблонного типа Т1. В Wrapper<Double> через его конструктор передается значение double для конструктора Double- Что может быть проще? Увы, это максимум, чего мы можем достигнуть. Как только мы захотим обрабаты- вать типы классов или ссылки, станут возникать забавные события. * Необходимо отдать должное разработчикам ATL: большая часть инициализации объекта ATL выполняется вне конструктора, т. к. ATL спроектирована независимой от стандартной библиотеки C/C++ и, следовательно, от исключений. Как часто происходит в реальных условиях, нельзя иметь все сразу.
рева 23- Шаблонные конструкторы 23.1. Скрытые недостатки 483 Давайте рассмотрим другой класс, String: class String { public: typedef std::string const &value_type; public: explicit String(std::string const &value); explicit String(char const ‘value); public: std::string const &GetValue() const; Мы можем использовать его так же, как мы это делали с Double в текущем опре- делении Wrapper: typedef std::string String_t String_t Wrapper<String> String_t; ss(’A std::string instance’); si(’A c-style string’); 11 Нормально 2(as)> // Нормально» но . . . Конструкторы компилируются нормально, и программный код работает так, как ожидается, но имеется скрытый недостаток. Выполнение конструктора s2 приводит к созданию двух копий строки ss там, где мы ожидали и хотели получить лишь одну. Дополнительная копия создается, т. к. компиляторы инстанциируют шаблон на основе передаваемых ему типов аргументов без учета того, как они могли бы использоваться внутри него. Таким образом, выполняемая работа по созданию s2 фактически эквива- лентна следующему: Wrapper<String>: .-Wrapper(String s) : m_value(s) {} Дефект: механизм инстанциирования шаблонов в C++ использует принятые аргу- менты без учета того, как и в какой форме эти аргументы впоследствии применяют- ся внутри шаблонов. Это может привести к генерации неэффективного и/или °Шибочного программного кода, поскольку временные экземпляры типа класса могут создаваться по ходу продвижения аргументов по шаблону. 23.2. Висячие ссылки Для подгонки классов Double и String мы можем изменить конструктор не по Умолчанию Wrapper для приема Т const&, а не просто Т. Это позволяет передавать
484 Часть4. Осознанные преобразования экземпляр строки из шаблона в базовый тип без лишнего копирования, а также переда вать константную ссылку, а не значение. Во всяком случае, теоретически это может вы полниться более эффективно, поскольку тип double, вероятно, занимает 64 или 80 бит а ссылка - 32 или 64 бита; на практике, во-видимому, разницы не будет, поскольку компи- ляторы, оптимизируя внешний вызов, смогут без каких-либо проблем свести все к внутрен- ней инициализации. template <typename Т> class Wrapper : public T ( template <typename Tl> explicit Wrapper(T1 const fttl) t base_class_type(tl) {} Естественно, у вас возникнет вопрос о том, что произойдет, когда нам необходимо передавать неконстантную ссылку, как это требуется делать в конструкторе нашего следующего класса, StringModif ier (листинг 23.3). Листинг 23.3. class StringModifier { public: typedef std::string const &value_type; public: explicit StringModifier(std:tstring fivalue) : m_value(value) О public: std::string const &GetValue() const; void SetValue(std::string const &value); private: std::string &m_value; }; Для текущего определения Wrapper это не будет работать: typedef Wrapper<StringModifier> StringModifier_t; std::string ss("A std::string instance*); StringModifier_t sl(ss); // Ошибка!
Глава 23. Шаблонные конструкторы 485 Хуже то, что многие компиляторы в действительности будут компилировать это1, не выдавая предупреждения при использовании первого варианта Wrapper. В резуль- тате si будет содержать неконстантную ссылку на переменный объект, которого не будет после возврата управления из конструктора! Доброй ночи, сеньор Дамп Основной Памяти! 23.3. Специализация шаблонных конструкторов Все нормально, вы скажете, поскольку мы можем воспользоваться специализацией. Мы можем специализировать шаблон для значения, для ссылки и для константной ссылки. К сожалению, это не так. Конструкторы являются членами шаблонов и поэто- му являются шаблонными функциями. Язык (пока еще2) не поддерживает специализа- цию шаблонных функций. Поэтому нам остается обеспечить перегрузки конструктора, как в следующем примере: Листинг 23.4. template ctypename Т> class Wrapper : public T { template <typename Tl> explicit Wrapper(Tl tl) // эначеиие : base_class_type(tl) О template ctypename Tl> explicit Wrapper(Tl btl) // ссылка ; base_class_type(tl) {} template ctypename Tl> explicit Wrapper(Tl const fttl) // константная ссылка : base_class_type(tl) {} К сожалению, такой подход уносит нас настолько далеко в сторону от цивилизации, Что мы почти «уплываем» за границы мира C++. Объединяя результаты применения ^^приведенных ранее примеров использования этого класса - которые проверяют 6Ы ^есм°тря на ошибку, не удалось заставить ни одни из наших компиляторов (см. приложение А) выдать хотя предупреждающее сообщение. В настоящее время это один из вопросов, который обсуждается для включения в следующий вариант ^*«apTa[Vand2003].
486 Часть4. Осознанные преобразования типы double, char const*, std: : string const& и std: : string&, - мы no лучаем мало обещающий разброс в поведении ряда компиляторов, что пройд, люстрировано в табл. 23.1. Когда CodeWarrior и Comeau ведут себя одинаково, я склоняюсь к тому, чтобы им верить, даже если будет другим поведение GCC, Intel и Visual C++ 7.1. Но несущест. венно, кто из них прав, т. к. мы имеем дело с некоторыми серьезными противоречиями Это по-настоящему неприятная проблема, и ни одно из двух решений не является иде- альным. Таблица 23.1. Компилятор Передача значения, ссылки и константной ссылки Передача ссылки и константной ссылки Borland (5.6) Ошибка из-за неоднозначности Нормально CodeWarrior (8.0) Нормально Нормально Comeau (4.3.3) Нормально Ошибка из-за неоднозначности Digital Mars Ошибка из-за неоднозначности Компилируется с предупреждениями GCC (2.95) Нормально Нормально GCC (3.2) Ошибка из-за неоднозначности Нормально Intel (7.1) Ошибка из-за неоднозначности Нормально Visual C++ (6.0) Ошибка из-за неоднозначности Ошибка из-за неоднозначности Visual C++ (7.1) Ошибка из-за неоднозначности Нормально Поскольку все компиляторы или, по крайней мере, их самые последние версии под- держивают то, что нам нужно в той или иной форме, одно решение могло бы быть основано на применении условных операторов препроцессора для обеспечения наших прикрепляемых классов и подобных шаблонных классов соответствующими кон- структорами. Результатом этого конкретного решения будет комбинаторный взрыв. Нам придется поддерживать две или три перестановки для каждого аргумента. Поскольку шаблонные конструкторы используются в типах, которые по определению, вообще говоря, не знают, в каких всевозможных типах они будут применяться, нам обязательно придется предусмотреть потребности большого количества потенциаль- ных приложений. Другими словами, нам необходимо обеспечивать в этих шаблонах большое число шаблонных конструкторов. В своей собственной работе я рассматри- ваю число восемь как предельное их количество: представляется достаточно неразум- ным, если конструктор имеет более шести параметров. Даже если ограничить себя только двумя типами параметров для шаблона с восемью конструкторами, это потре- бовало бы реализовать 510 конструкторов, и это без учета возможности применения спецификаторов volatile и const volatile для ссылок!
Глава 23. Шаблонные конструкторы 23.4. Прокси аргументов 487 Первое решение, которое может работать во всех случаях, но является еше более неприятным, чем сама проблема, использует то, что я называю прокси аргументов (argument proxies). Существует несколько типов прокси аргументов в соответствии с различными сочетаниями const/He-const, volatile/He-volatile и указате- ля/ссылки/значения, хотя и не все из них должны быть реализованы. Я продемон- стрирую лишь два из них. Мы можем заставить наш прикрепляемый класс работать со всеми четырьмя типами параметров, обеспечивая шаблонный конструктор с передаваемым только по значению аргументом и применяя классы прокси аргументов. Пусть, например, нам потребовалось бы два прокси аргументов: ref erence_proxy и const_ref erence_proxy. Опреде- ление reference_proxy показано в листинге 23.5. Класс const_ref erence_proxy имеет идентичное определение, в котором все ссылки константные1. Листинг 23.5. template ctypename А> class reference_proxy { public: explicit reference_proxy(A &a) : m_a(a) {} reference_proxy(reference_proxy«A> const &rhs) : m_a(rhs.m_a) {} // Операторы доступа public: operator A&() const { return m_a; } // Члены private: A &m_a; 11 Реализация не требуется private: ...H Предотвращает копирующее присваивание В этом определении нет ничего своеобразного. Важной особенностью любого такого Шаблона является константность всех операторов неявного преобразования, так что он м°жет вызываться для временных экземпляров, которые формируются внутри вызовов Uj^OHHbix конструкторов, и все же возвращать полный «тип», то есть неконстантную ло ** Сомневаюсь, что можно обобщить эти различные классы в единственное определение, поскольку они ьно «древние» по меркам C++. Но это используется очень редко и поэтому не заслуживает особого ачия, которого я им как раз и не оказал.
488_________________________________________________Часть 4. Осознанные преобразо^ ссылку для ref erence_proxy и неконстантный указатель для pointer__proxy qh могли бы использоваться в предыдущих наших примерах следующим образом: String_t в2(con«t_reference_proxy<«tdii string»())t StringModifisr_t !(reference_proxy<8tdt t string»(ss)); В первом случае не будет создаваться дополнительной копии, а во втором случае неконстантная ссылка передается экземпляру StringModifier_t. Это корректно работает в 100% случаев. Также можно использовать ретранслирующие функции, как показано в следующем примере (что мы видели в примере с аггау_ргоху в разделах 14.4 и 14.6): template <typename А> inline const_reference_proxy<A> const_ref_proxy(А &a) { return const_reference_proxy<A>(a); } String_t s2(const_ref_proxy(sb)); StringModif ier_t si (ref.proxy(ss)); В библиотеках Boost определен подобный набор компонент, ретранслирующие функции которых названы ref и cref. Я предпочитаю используемые здесь более длинные имена по той же причине, по которой операторы приведения типов в C++ (см. раздел 19.2) имеют длинные и уродливые имена: их назначение понятно читате- лю, они легко находятся с помощью дгер-операторов и вызывают дискомфорт оттого, что ими приходится пользоваться. Последнее особенно важно для прокси аргументов, поскольку они предназначены для работы с той частью языка, которая имеет принци- пиальный недостаток. Проблема совершенно очевидна: бремя корректного их применения ложится на клиентский программный код, чтобы избавится от проблем, возникающих из-за (нару- шенного?) механизма построения шаблонных конструкторов1 * *. Это было бы оправда- но, по крайней мере, частично, если бы клиентский программный код в противном случае никогда бы не компилировался. Но как мы видели, это происходит только в не- которых случаях. В других компиляторы «весело» генерируют программный код, который либо неэффективен, либо ошибочен. Я не рекомендую вам применять эти методы в любом программном коде, которь непосредственно используется клиентами, или, более того, полагаться на знакомство пользователей ваших шаблонов с этим подходом и на строгое его применение ими. весьма непрактично. Причина их обсуждения мною - не считая педагогически^ заключается в существовании ограниченного круга обстоятельств, при которых 1 Эта ситуация аналогична деструкции/финализации в языке Java и в .NET, при выполнении коТОр^1Хп^оД ответственности за очистку ресурсов ложится на пользователя классов. Можно ли назвать такой объектно-ориентированным? Я так не думаю!
Глава 23. Шаблонные конструкторы 489 —-------------------------------------------------------------------- действительно оказываются полезными, причем даже существенно полезными при использовании шаблона в рамках реализации программного кода некоторой библиотеки, значительно отличающегося от любого клиентского программного кода. Пользуйтесь этим осторожно! 23.5. Ориентация аргументов на определенные типы В действительности это все сильно разочаровывает, и, по-видимому, нам остается лишь надеяться на то, что в следующем стандарте эта проблема будет решена непосред- ственно. По моему мнению, компиляторы должны правильно работать при любых пере- становках (const/He-const, volatile/He-volatile, значение/ссылка/указатель) и выбирать наиболее специализированный вариант, а не жаловаться на неоднозначность. Один бывший мой коллега однажды с пренебрежением сказал, что компиляторы не могут работать так, как необходимо в каждом конкретном случае, но, я думаю, было бы слишком требовать от компиляторов, чтобы они видели «сквозь» шаблон и определяли, как аргументы будут использоваться при конкретной его реализации. Прозаическое и неполное решение состоит в ориентации на определенные типы. Многие прикрепляемые классы и другие шаблоны производных классов обычно имеют предсказуемый набор типов, с которыми они должны работать. В таких случаях можно вполне нацеленно определять шаблонные конструкторы. Например, облицо- вочный шаблон sequence_container_veneer (см. гп. 21; он включен в компакт- диск) спроектирован так, что параметризуется контейнерами последовательностей стандартной библиотеки - list, vector и deque6 - и требует обеспечения только следующих конструкгоров: // С = контейнер; V = тип значения; А = распределитель памяти explicit С(A const &al = А()); explicit C(size_type n, V const &v = VO, A const &al = AO); С(C::const_iterator first, C::const_iterator last , A const &al = A()); Имея дело с типами, которые действительно широко используются, вы можете понадеяться на свою судьбу и заключить все возможные сочетания в макрос, успокаи- вая себя тем, что это не ваша вина. 23.6. Шаблонные конструкторы: заключение Итак, что же мы имеем? Прокси аргументов технически являются отличным реше- НИем, но они рассчитаны на преобладающую в C++ особенность, которая проявляется т°м, что типы, даже полученные посредством параметризации шаблонов, должны нР^ставлять простой и устойчивый интерфейс и обеспечивать необходимые пользо- ^Телю вещи. На практике нельзя от пользователей требовать, чтобы они знали, когда Где применять прокси аргументов, и поэтому этот подход полезен только в исключи- ьньгх случаях в хорошо управляемых контекстах.
490 Часть 4. Осознанные преобразования Это заставляет нас использовать типы аргументов нацеленно. Едва ли можно считать такое решение хорошим, но предпочтительно, чтобы все составные типы конструировались по умолчанию. Что-нибудь слышали о RAII? Комбинаторная задача решается мучительно, но однажды ее надо решить, и поэтому, я полагаю, мы не можем жаловаться (если не брать в расчет увеличения времени компиляции). Эти веши легко делаются с помощью языков сценариев. Мне следует отметить один момент, если он еще не является очевидным: проблема передачи аргументов «сквозь» шаблоны не ограничивается конструкторами. Просто так получается, что обычно нам не требуется обеспечивать ретранслирующие функции для методов, которые не являются конструкторами. Такая проблема возникает только при использовании неоткрытого наследования, которая едва ли будет встречаться при использовании прикрепляемых классов и подобных обобщенных шаблонов. Этот вопрос относится к тем немногим рассмотренным в данной книге, на которые нам придется фактически бесполезно тратить время до тех пор, пока комитет по стан- дартизации не установит, каким должно быть разумное поведение, и поставщики ком- пиляторов не реализуют его, как это делают (в лучшем случае, частично) обсужденные здесь решения. Возможно, я пошлю своим любимым поставщикам еще несколько сообщений по электронной почте.
Часть 5 Операторы К наиболее мощным и важным аспектам C++ относится способность перегружать операторы. Это способствует расширению языка путем определения пользовательских типов, позволяя им иметь естественный синтаксис и обрабатываться вместе со встро- енными типами в обобщенном программном коде. Даже еще до того, как шаблоны стали частью языка, бесценную пользу приносил обобщенный подход, построенный на базе применения перегрузок операторов. Теперь, когда мы имеем шаблоны, возмож- ности обобщения почти бесконечны. Однако у перегрузки операторов имеется темная сторона, обусловленная несколь- кими факторами. Некоторые программисты небрежно пользуются перегрузками и входят в противоречие с ожидаемой семантикой1. Другая проблема связана с тем фактом, что C++ по-прежнему живет «двойной» жизнью, как супер-С, неся в себе все те правила неявного преобразования, с которыми нам приходится считаться, и неся за- висимость от фундаментальных типов, что может стать источником различных про- блем при сочетании с перегруженными операторами определенных пользователем типов. Наконец, семантика некоторых перегружаемых операторов слегка отличается от семантики их встроенных эквивалентов, и этого нельзя избежать. В предыдущих частях мы немного говорили об операторах, но не вникали глубоко в недостатки, связанные с их применением, и не рассматривали лучшие способы их реализации, когда определяли, что они нам необходимы. В данной части мы рас- смотрим различные вопросы, связанные с операторами. Начинаем в гл. 24, «operator booty»' с того, как следует применять операторы для отображения булева состояния, какие тут возникают проблемы из-за нежелательных преобразований и как это может быть реализовано с обеспечением максимальной безопасности и переносимости. Затем в гл. 25, «Быстрая, неагрессивная конкатенация строк», мы представим вполне оптимистический взгляд на реализацию конкатенации строк. В частности, теперь мы можем обеспечивать разительное повышение производительности путем нРименения компоненты быстрой конкатенации, которая может быть интегрирована НеагРессивным способом с любыми библиотеками строк. Затем мы снова «спустимся на землю» в гл. 26, «Какой ваш адрес?», чтобы рас- смотреть применение - причем в основном неправильное - оператора адресации. ------------------------------ См- приложение Б.
492 Ч^5-Оперщорь1 Мы покажем несколько ловушек, возникающих при перегрузке этого конкретн оператора, но в духе неидеального практика мы также рассмотрим несколько изящных трюков, которые можно использовать, если вы все-таки решитесь на применение пере грузки. В следующей гл. 27, «Операторы индексации», дискутируется вопрос предпочти тельности обеспечения оператора неявного преобразования или оператора индексации для типов массивов, а также она позволяет глубже понять недостатки взаимоотноше ний между массивами и указателями. В гл. 28, «Операторы инкремента», представлено краткое, но важное обсуждение механизма перегрузки операторов инкремента и декремента, возводится трибуна для критики стандартов образования в наших университетах и повсеместной непоследова- тельности профессионалов-практиков, а затем показывается способ эффективного мониторинга возвращаемых значений на базе шаблонов, что может использоваться для предотвращения неверного применения ваших тщательно спроектированных операторов инкремента и декремента. В предпоследней гл. 29, «Арифметические типы», мы вновь встречаемся с нашими старыми друзьями - 64-битовыми целыми числами из гл. 1 - и постараемся придать им естественный синтаксис. В конце концов, мы потерпим неудачу, поскольку сделать это нам не удастся, но в процессе мы немного позабавимся и научимся некоторым полез- ным вещам. Наконец, в самой короткой главе всей книги, гл. 30, «Быстрое вычисление!», мы посмотрим, как перегрузка логических операторов нарушает семантику быстрых вычислений в С и рекомендуем избегать таких перегрузок в практической работе.
Глава 24 operator bool() Мы видели в разделе 13.4.2, что условные выражения переводятся в интегральную форму (int в С; bool в C++) перед вычислением. С и C++ способны применять неяв- ные преобразования для различных скалярных типов (см. «Введение»), включая указа- тели, которые позволяют использовать такие достаточно полезные конструкции, как: void *р = . . .; if(p) // Проверяет, является ли р нулевым указателем (} if(!р) // Проверяет, является ли р ненулевым указателем (} Существуют случаи, когда необходимо разрешать делать то же самое с экземпляра- ми типов, определенных пользователем. Хороший пример - идиома цикла извлечения данных из lOStreams: while(std::cin » name » salary) ( } Другим примером являются такие умные указатели, как std: :auto_ptr, но существует много других случаев, где применим такой подход. Иногда мы можем возвращать экземпляр простого класса по значению и хотим, чтобы он «был» или «не был», а не выбрасывалось исключение. Независимо от мотивации, не говоря уже о правильности или неправильности такого подхода, вам придется довольно часто сталкиваться с такими вешами, и в некоторых случаях вам захочется обеспечить такой Режим работы для своих собственных классов. 24.1. operator int() const До введения в язык ключевого слова bool самым очевидным способом обеспечения ^их вещей мог бы быть оператор operator int () const, как показано в следующем примере:
494 Ча^5-Операторы Листинг 24.1. class ExpressibleThing { public: operator int() const; }; ExpressibleThing e; if(e) // Проверяет «существование* e {} if(!e) // Проверяет «не существование» e {} Большие трудности возникают из-за того, что int легко преобразуется во многие другие типы, и его использование в конструкции с совершенно несвязанными типами может быть бессмысленно: ExpressibleThing е; std::vector<String> vs(e); //А что, скажите на милость, все это значит?! Это бессмысленно и потенциально является источником непредсказуемого пове- дения. Поскольку ExpressibleThing требует возврата любого ненулевого значе- ния, он мог бы быть корректно реализован, возвращая при каждом вызове другое ненулевое значение. Это могло бы привести к молчаливому отказу или к нехватке памяти, или, что еше хуже, могло бы фактически работать правильно в большинстве случаев: operator int () const, например, мог бы возвращать 1000, что всегда было бы достаточно большой величиной для последующих применений vs в ваших тестах, но недостаточно при использовании вашего продукта в реальных условиях. 24.2. operator void *() const Но мы знаем, что int - не единственный тип, который может использоваться для оценки выражений (см. раздел 15.3). До выхода стандарта С++-98 проблемы, связан- ные с int, в некоторой степени уменьшались путем обеспечения оператора opera- tor void* () const. Так делалось в классе ios первоначальной версии системы lOStreams, и его современная шаблонная форма basic_ios имеет при себе тот же метод. Листинг 24.2. class ExpressibleThing { public: operator void *() const; }; ExpressibleThing e;
Глава 24. operator bool() 495 if(e) // Проверяет «существование» e (} if(!e) // Проверяет «не существование» e О Увы, проблема неявного преобразования никуда не исчезла, просто она отошла в сторону. Теперь у вас имеется возможность с помошыо простого приведения нечаян- но выполнить преобразование в любой тип указателя. Еще хуже то, что следующее выражение допустимо, хотя оно более чем некорректно: ExpressibleThing е; delete е; // Опасно! Нет необходимости лишний раз напоминать, что это никуда не годится. 24.3. operator bool() const Поскольку необходимый нам оператор по сути является булевым, что может быть лучше, чем operator bool () const? class ExpressibleThing ( public: operator bool() const; }; Этот подход получил распространение с тех пор, как был введен тип bool в стан- дарт С++-98. К сожалению, bool может неявно преобразовываться в int, и поэтому мы по-прежнему сталкиваемся с проблемами расширенной применимости интеграль- ных типов, которые видели в разделе 24.1, так что ExpressibleThing может принимать участие в арифметических выражениях, как в следующем примере: ExpressibleThing etl; ExpressibleThing et2; int nonsense = etl + et2; Но, к сожалению, ситуация еще хуже. Все три предложенные нами до сих пор реше- ния возвращают основной, открыто доступный тип int, void (const)* или bool. Это означает, что эти типы могут принимать участие в ошибочных сравнениях на ра- венство с любыми другими типами, которые обеспечивают преобразования в наш «булев» тип или в любой тип, в который он может быть преобразован. Рассмотрим СлеДУющИй очень некорректный фрагмент программного кода: class SomethingElseEntirely { public: operator bool() const;
496 Часть 5- Операторы SomethingElseEntirely set; ExpressibleThing et; if(set == et) ( На самом деле делать так нельзя. 24.4. operator!() - нет! Другая стратегия, которую я встречал1, заключается в поддержке только логического отрицания с помощью оператора operator ! () const. Для проверки истинности необходимо продублировать отрицание, как показано в следующем примере: Листинг 24.3. class ExpressibleThing { public: bool operator !() const; }; ExpressibleThing e; if(lie) // Проверяет «существование» e (} if(!e) // Проверяет «не существование» e {} Этот подход слишком неприятно выглядит, чтобы его рассматривать всерьез. Пред- ставьте, какими путанными будут выглядеть даже не очень сложные условные выра- жения и не только для программистов-новичков. Очень велика вероятность проникно- вения сюда ошибок в процессе сопровождения. Наконец, это не соответствует обычно- му виду программного кода, и его читаемость только ухудшится. 24.5. operator boolean const *() const Поскольку все интегральные типы вне вопроса, а тип void* - слишком обший, возможно, сработал бы указатель типа, отличного от void, как показано в следующем примере: class ExpressibleThing { private: struct boolean { private: void operator delete(void*); >; Большое уважение к конкретным лицам не позволяет мне упоминать какие-либо имена.
Глава 24. operator bool() 497 —. ------------------------------------------------------— public: operator boolean const *() const; }; Причина применения struct заключается в том, чтобы позволить ему быть закрытым вложенным типом. Затем ему можно придать вид скрытого оператора op- erator delete () для того, чтобы предотвратить передачу экземпляров Express- ibleThing оператору delete. Он также предотвращает объявление переменных, возвращаемых этим оператором, boolean const*, а преобразование в другой указа- тель типа потребует применения двух операторов static_cast. 24.6. operator int boolean::*() const До самого последнего времени я предпочитал пользоваться для таких вещей опера- тором operator boolean const * () const, но большинство компиляторов теперь настолько улучшилось, что могут поддерживать более удовлетворительный метод [Vand 2003], предложенный Питером Димовым (Peter Dimov) в сетевой кон- ференции по системе Boost. Проблема с любым методом, основанном на применении указателей, заключается в том, что они могут преобразовываться в void (const) *, что означает возможность выполнения нежелательных преобразований для типа, допускающего такой оператор. Однако существуют классы типов указателей - указате- ли членов - которые не могут быть преобразованы в обычный указатель типа ни неяв- но, ни путем приведения типа. В результате мы получаем следующий оператор: Листинг 24.4. class ExpressibleThing ( private: struct boolean { int i; }; public: operator int boolean < < *() const < return <condition> ? ftbooleantii < NULL; } }; Это отличное средство, поскольку оно обладает всеми свойствами, которые необхо- димо иметь «булеву» оператору. 24.7. Применение операторов в реальных условиях Конечно, когда я говорил, что компиляторы стали более совершены и обеспечивают °ПеРатор operator int boolean: : * () const, это не значит, что все они рабо- '•Д’от в унисон как всеГда, в реальных условиях существует значительная разница
498______________________________________________________________Часть 5, Оператор в том, как поддерживаются такие сложные методы1. Например, некоторые компиля торы не позволяют использовать этот оператор в сложных выражениях, как в еле дующем примере: ExpressibleThing etl; ExpressibleThing et2; if(etl st2) H Ошибка; 'operator не реализован для Ex ... { Хуже всего ведет себя Visual C++ 6, который спокойно компилирует все выражения включающие этот оператор, но неверно интерпретирует истинность (под)выражений на этапе выполнения программы! Поэтому, несмотря на то, что большое количество современных компиляторов уверенно используют этот оптимальный подход, если вы озабочены переносимостью, вы должны рассмотреть комбинированное решение: Листинг 24.5. private: struct boolean { int i; private: void operator delete(void*); public: # i fde f ACMELIB_OPERATOR_BOOL_AS_OPERATOR_POINTER_TO_MEMBER_SUPPORT operator int boolean::*() const { return m_b ? &boolean::i : NULL; } «else operator Boolean const*() const { boolean b; return m_b ? &b : NULL; } #endif ACMELIB_OPERATOR_BOOL_AS_OPERATOR_POINTER_TO_MEMBER_SUPPORT Но я уверен, что вы, так же как и я, недовольны тем, насколько сильно все это пере- гружает все ваши удивительно краткие классы. И чтобы довести решение до нужного уровня необходимо, наконец, посвятить ему «эти несколько дней», но я полагаю, вы согласитесь, что результат действительно будет неплохой. Давайте сначала рассмотрим, как это работает: private: typedef operator bool generator<class type» boolean—type; public: operator boolean_type::return.type() const ( return boolean.type::translate(m_b); } 1 Это не поддерживает Visual C++ 6.0, а также Borland (в том числе его последняя версия 5.6.4). Интерес*10 что компилятор Watcom оказывает больше поддержки, чем Borland.
Глава 24. operator bool() 499 Реальный тип оператора и механизм обеспечения значений «истина» и «не истина» предоставляет шаблон operator_bool_generator: Листинг 24.6. template <typename т> struct operator_bool_generator { public: typedef operator bool qenerator<T> class_type; #i fdef ACMELIB_OPERATOR_BOOL_AS_OPERATOR_POINTER_TO_J!EMBER_SUPPORT typedef int class_type::*return_type; III Возвращает значение, представляющее условие «истина» static return_type true_value() ( return &class_type::i; } private: int i; «else typedeC class_type const *return_type; III Возвращает значение, представляющее условие «истина» static return_type true_value() { class_type t; void *p = static_cast<void*>(&t); return static_cast<return_type>(p); } «endif // ACMELIB_OPERATOR_BOOL_AS_OPERATOR_POINTER_TO_MEMBER_SUPPORT public: III Возвращает значение, представляющее условие «ложь» static return_type false_value() ( return static_cast<return_type>(0); } III Выполняет для вас тернарный оператор «ifdef ACMELIB_MEMBER_TEMPLATE_FUNCTION_SUPPORT template -ctypename U> static return_type translate(U b) «else /* ? ACMELIB^MEMBER_TEMPLATE_FUNCTION_SUPPORT */ static return_type translate(bool b) «endif 11 ACMELIB^MEMBER_TEMPLATE_FUNCTION_SUPPORT ( return b ? true_value() : false_value(); } private: void operator delete(void*);
500 Часть 5. Операторы Теперь компиляторы, поддерживающие operator int boolean:: * () const или лишь operator Boolean const* 0 const, одинаково хорошо удовлетворяют- ся шаблоном operator_bool_generator. Это может напоминать пустую болтовню но все неприятные операторы условной компиляции скрыты внутри шаблона и не засоряют ваши классы. И это достаточно эффективно: компиляторы легко сводят на нет все это при оптимизации, но основная булева проверка передается методу translate (). Параметр шаблона здесь используется просто для обеспечения однозначности типа, возвращаемого оператором. В этом примере используется тип-член class_type, который я по привычке использую для определения обрамляющего класса (раздел 18.5.3), но вы могли бы использовать другое имя, которое вы сами предпочитаете. Фактически, это мог бы столь же легко быть и внутренний класс или любой другой класс, а использование обрамляющего класса не потребует определения никаких дополнительных типов. Увы, картина пока еще не завершена и не столь благополучна. Visual C++ (с версии 4.2 и до самой последней 7.1) ведет себя странно, когда жалуется, что недопустимо задавать typedef, связывающий ключевое слово operator и тип оператора. «Ха? Неужели такое может быть?» - вы можете удивиться. Конечно, этот загадочный пара- докс является умело замаскированной ошибкой, принявшей вид характерной особен- ности. Аналогично, Borland оказывается сбитым с толку, когда какой-нибудь оператор определяется с помощью оператора области видимости :: в типе оператора. Обойти эту проблему в Visual C++ (только не ждите от меня разъяснений) можно путем использования каким-то образом класса перед оператором. Это сделано в сле- дующем примере: private: typedef operator bool generator<class type» boolean_type; typedef boolean_type::return_type operator_bool_type; public: operator boolean_type::return_type() const { Мне стало немного грустно от того, что это испортило мое решение. К счастью, хорошо выспавшись, я нашел решение - я понял, что один способ определения и исполь- зования типа в одном операторе заключался в определении имени boolean_type Для обрамляющего класса, исходя из конкретизации самого class_type типом operator_bool_generator, и в результате все это сводится к: private: typedef operator_bool_0enerator<claes_type> t < claaa_type boolean_type; public: operator boolean_type::return_type() const
Глава 24. operator bool() 501 Увы, для Borland это по-прежнему не работает. Когда обрамляющий тип сам является шаблоном, то применение его конкретизирующего типа- class_type - не будет рабо- тать, и мы вынуждены определять возвращаемый оператором тип как внешний по отношению к самому оператору, что показано в следующем примере: Листинге 24.7. private: typedef typename operator_bool_generator<class_type>::class_type operator_bool_generator_type; typedef typename operator_bool_generator_type:: return_type operator_bool_type; public: operator operator_bool_type() const { return operator_bool_generator_type::translate(. . . Итак, в результате необходимо иметь макрос, который инкапсулирует приведенные выше объявления typedef и выглядит следующим образом: private: DEFINE_OPERATOR_BOOL_TYPES(class_type, bool_gen_type, bool_type); public: operator bool_type() const { return bool_gen_type::translate(• . . В действительности, нужны два макроса: один для случая, когда этот оператор опреде- ляется внутри шаблона, и другой, когда ключевое слово typename требуется или нет для спецификации типа. Примеры этих двух макросов, DEFINE_0PERAT0R_B00L_TYPES () и DEFINE_OPERATOR_BOOL_TYPES_T (), вы найдете на компакт-диске. И все! Чтобы получить концептуально простой результат, потребовались большие усилия, но я надеюсь, вы согласитесь, что максимально надежный, унифицированный метод реализации булева оператора стоит того. Мы можем теперь обеспечить опера- тор, который ведет себя должным образом в условных выражениях произвольной сложности и для которого не характерны опасные неявные преобразования, проявляе- мые в других формах, а также он совместим с самыми различными компиляторами, обеспечивая максимально возможную безопасность при работе с каждым из них. 24.8. operator! При рассмотрении способов реализации «булева» оператора мы рассчитывали, что компиляторы смогут применять логическое отрицание к экземпляру, поддерживающе- му наш «булев» оператор. ExpressibleThing е; if(!е) // Логически отрицает operator boolean_type::return_type() const {}
502 Часть5. Оператор При использовании формы как Т const * О, так и int Т:: * () все протестирован- ные компиляторы оказались способны применять отрицание, и поэтому нет необходимости непосредственно обеспечивать operator' () в добавлении к нашему «булеву» оператору Однако иногда такое решение оказывается слишком грубым. Хорошим примером являются типы, используемые в базах данных, например, VARCHAR, которые могут как и многие реализации классов строк, иметь более двух состояний: нулевое, пустое и непустое. По существу, для логического отрицания VARCHAR или классов строк - будь то неявно выполненное компилятором или явно заданное с помощью метода op- erator ! () const - недостаточно информации. Поскольку было бы полезно выпол- нять логические операции над подобными типами, имеющими несколько состояний, нам необходимо найти механизм их осуществления. Естественно, мы должны исполь- зовать методы: class varchar_field { public: bool is_empty{) const; bool is_null() const; }; Но тогда мы столкнемся с отсутствием универсальности. Если мы хотим использо- вать программный код, проверяющий поля базы данных в каких-то других операциях, то может оказаться, что для них не будут предусмотрены методы is_null () или is_empty (). Решение, конечно, здесь состоит в том, чтобы положиться на наших старых друзей - прокладки атрибутов (раздел 20.2). Отсюда: bool is_null(varchar_field const &); bool is_empty(varchar_field const &); bool is_not{varchar_field const &); Преимущество такого подхода в том, что мы не оказываемся в положении, когда вынуждены заранее принимать необратимое решение, жестко связывая с типом смысл «существования» и «не существования» значения, а можем отсрочить его принятие до подходящего момента в клиентском программном коде. Недостаток в том, что кли- ентский программный код получается не столь лаконичным, хотя я бы сказал, что чи- таемость его улучшается, - и нам придется позаботиться о его согласованности, избе- гая большого количества прокладок, приводящих к плохому взаимопониманию между авторами программного кода и персоналом, обеспечивающим его сопровождение.
Глава 25 Быстрая, неагрессивная конкатенация строк В языке Java широко известно о неэффективности [Larm 2000] конкатенации строк. Обойти эту неэффективность можно, обеспечив выполнение конкатенации внутри одного оператора, что способствует осуществлению компилятором оптимизации, при которой рядом стоящие аргументы оператора + молча переводятся в вызовы скрытого экземпляра StringBuffer, более эффективно конструируя строку из ее составляющих. Следова- тельно, String s = S1 + " " + s2 + " " + S3; автоматически преобразуется в StringBuffer sb = new StringBuffer(); sb.append(si); sb.append(• "); sb.append(s2); sb.append(• "); sb.append(s3); String s = sb.toString(); В результате значительно повышается эффективность выполнения конкатенации [Wils 2003е] по сравнению с тем случаем, когда она вручную разбивается на несколько операторов. Большинство библиотек C++ перегружают семантику operator + () для конкате- нации строк, что в результате приводит к подобной неэффективности из-за формиро- вания промежуточных объектов, необходимых для цепочек конкатенаций. Неэффек- тивность конкатенации строк в C++ обусловлена двумя факторами. Во-первых, промежуточная строка, связанная с каждой промежуточной конкатена- ЦИей (каждым оператором + в выражении), будет вызывать, по крайней мере, одно рас- членение памяти1 для размещения результата конкатенации двух параметров. [М СКЛ10чая слУчаи, когда класс строки использует оптимизацию малых строк (small-string optimization - SSO) л^^‘001] и такая оптимизация допустима Конечно, как только общая длина промежуточных строк внутреннего ограничения SSO, это становится уже антиоптимизацией.
504 ЧастьБ. 0^^ Во-вторых, для получения каждого промежуточного значения будут копироваться значения двух аргументов. Для выражения с N конкатенациями (с N операторами +) аргументы 0 и 1 будут копироваться N раз, аргумент 2 будет копироваться N-1 раз и т д Предположим, что показанный ниже программный код компилируется компиля тором, который поддерживает оптимизацию NRVO (см. раздел 12.2.1); в этом случае вероятно, будет выполнено от 4 до 8 распределений памяти и 4, 3 и 2 копии содержи* мого строк s3, s2 и sl соответственно. String sl = ’Goodbye’; char const *s2 = ’cruel’; String S3 = ’world!’; String s = sl + ' * + s2 + ' • + s3; В принципе, поскольку никакой из промежуточных результатов не используется (или от него нет пользы) за пределами этого оператора, требуется лишь одно распреде- ление памяти и одна копия содержимого каждой исходной строки. В идеальном случае нам бы хотелось, чтобы каждое отдельное подвыражение конкатенации приводило к записи размера принимаемых аргументов, и это значение передавалось бы дальше по цепочке, пока не потребуется сгенерировать строку, а в этот момент можно было бы лишь один раз выполнить выделение памяти и скопировать в нее отдельные фрагмен- ты результирующей строки. 25.1. fast_string_concatenator<> Даже если вы, как и я, считаете, что большинство книг по программированию невоз- можно полностью «переварить», я бы советовал вам иметь экземпляр книги Бьерна Страуструпа «Язык программирования C++» [Stro 1997] у своей постели, в туалете или рядом с «ящиком» (goggle-box1) и время от времени лениво заглядывать в нее. Это, несомненно, самый лучший катализатор новых идей, с каким я встречался в C++, возможно, потому что Бьерн лишь кратко излагает любую идею, но затрагивает самую ее суть и затем идет дальше. (Иногда я задаюсь вопросом, а не намерено ли он делает так, что у всех нас, «второразрядников», возникает чувство, будто мы изобретаем то, что он уже предвидел.) В гл. 22 [Stro 1997] он описывает, как цепочка из нескольких операторов может представлять одну логическую матричную операцию. Это заставило меня задуматься, а нельзя ли применить этот принцип при конкатенации строк для повышения произво- дительности: в результате получился шаблон f ast_string_concatenator3. 1 Так обычно телевизор называл мой отец, когда он заставал меня за просмотром мультиков в не подхоДЯ ш время.
Глава 25. Быстрая, неагрессивная конкатенация строк 505 ——-------------------------------------------------------------------------- 25.1.1. Работа с пользовательскими классами строк Перед подробным рассмотрением реализации шаблона f ast_string_ concat- enator давайте посмотрим, как мы могли бы его применить с пользовательским клас- сом строки String. Канонический способ реализации конкатенации [Меуе 1998] заключается в использо- вании функции, не являющейся членом, которая реализуется с помощью оператора-члена operator +=(): String operator +(String const &lhs, char const *rhs) { String result(Ihs); result *= rhs; return result; } Существует ее альтернативная форма, когда operator += () возвращает ссылку на экземпляр: String operator +(String const &lhs, char const *rhs) { return String(Ihs) += rhs; ) Из этой реализации становится ясно, чем вызвано появление затратных промежу- точных экземпляров, операций выделения памяти и операций копирования. Можно было бы аналогично реализовать другие четыре перегрузки: String operator +(String const &lhs, char rhs); String operator +(char const *lhs. String const &rhs); String operator +(char Ihs, String const &rhs); String operator +(String const &lhs, String const &rhs); При использовании fast_string_concatenator определения пяти опера- торов имеют простой, идентичный вид. Листинг 25.1. fast_string_concatenator<String> operator +(String const &lhs, char const *rhs) { return fast_string_concatenator<String>(lhs, rhs); ) fast_string_concatenator<String> operator ♦(String const blhs, char rhs) { return fast_string_concatenator<String>(Ihs, rhs); ) fast_string_concatenator<String> operator +{String const &lhs, String const &rhs); fast_string_concatenator<String>
506 Часть 5. Операторы operator +(char Ihs, String const &rhs); fast_string_concatenator<String> operator +(String const &lhs, String const &rhs); Наиболее очевидное их отличие проявляется в том, что теперь они возвращают не экземпляры String, а экземпляры fast_string_concatenator<String>. При реализации каждого оператора два аргумента просто передаются конструктору безы- мянного экземпляра конкатенатора (concatenator), который подвергается оптимизации RVO (см. раздел 12.2). 25.1.2. Связывание конкатенаторов Поскольку каждый operator + () возвращает конкатенатор, нам, очевидно, потребуются дополнительные средства, если мы собираемся создавать многоэлемент- ные последовательности конкатенаций. Этому поспособствует ряд стандартных пере- грузок операторов operator + (), которые определяются вместе с классом. Каждая из первых трех, которые нами будут рассмотрены, принимает экземпляр конкатена- тора в качестве левого параметра: template <typename S, typename C, typename T> fast_string_concatenator<S, С, T> operator +(fast_string_concatenator<S, C, T> const &lhs, S const &rhs); operator +(fast_string_concatenator<S, C, T> const &lhs, C const *rhs); operator +(fast_string_concatenator<S, C, T> const &lhs, C const rhs); Поскольку оператор + обладает ассоциативностью слева направо, эти три опера- тора позволяют результат самой левой конкатенации использовать в следующей и т. д. В нашем примере «Goodbye» (до свиданья) и « » соединяются и формируют экзем- пляр конкатенатора, используя один из пользовательских операторов String oper- ator + (), который затем соединяется с «cruel» (жестокий) с помощью второго стандартного оператора, показанного ранее. В этом случае любое сочетание символа, С-строки и класса строки может использоваться в последовательности конкатенаций, давая в итоге экземпляр fast_string_concatenator. И более того, существуют такие вещи, как метод посева конкатенации (concatenation seeding; см. раздел 24.4), и метод, обеспечивающий правильную работу при патологическом при- менении скобок (см. раздел 25.5), но по существу нет необходимости ими пользоваться в обычных условиях. 25.1.3. Класс конкатенатора Теперь настало время рассмотреть сам класс конкатенатора. Вы уже знаете, что это шаблон, который имеет три параметра. Эти три параметра - S, С и Т - представляют собой тип строки, тип символа и тип свойств соответственно. template< typename S , typename С = typename S::value_type
Глава 25. Быстрая, неагрессивная конкатенация строк 507 , typename Т = std::char_traits<C> > class fast_string_concatenator; В определении этого класса С по умолчанию принимает значение 5. ;value_type, а Т - std::char_traits<C>. Эти параметры обеспечиваются значениями по умолчанию, а не просто предполагают возможность более широкой применимости шаблона. Поэтому в большинстве случаев требуется указывать только тип строки, оставив значения по умолчанию для остальных параметров. Поскольку большинство совре- менных классов строк определяют тип-член value_type, это превосходно работает. Когда это не происходит, не трудно обусловить первый и второй типы при инстан- циировании конкатенатора или использовать облицовочный тип (см. гл. 21) для обеспече- ния необходимых типов-членов для строки. Мы видели, что все реализации operator + () имеют совершенно одинаковый вид, возвращая безымянный экземпляр конкатенатора, построенного на основе левого и правого параметров оператора. Это отражается в открытом интерфейсе класса, как показано в листинге 25.2. Листинг 25.2. template< ...» class fast_string_concatenator { public: typedef S string_type; typedef C char_type; typedef T traits_type; typedef fast—string—concatenator<S, C, T> class_type; 11 Конструирование public: fast_. . . (string_type const &lhs, string_type const brhs); fast_. . . (string_type const blhs, char_type const *rhs); fast_. . . (string_type const blhs, char_type const rhs); fast_. (char_type const *lhs, string_type const brhs); fast_. (char_type const Ihs, string_type const brhs); fast_. . (class_type const blhs, string_type const brhs); fast_. . . (class_type const blhs, char_type const *rhs); fast_. . . {class_type const blhs, char_type const rhs); // Преобразование public: operator string—type() const; Вы заметите также только один дополнительный открытый метод класса, являющий- Ся оператором неявного преобразования в string_type. (Как вы помните, я говорил ®ам» что бывают случаи, когда полезно иметь такой оператор!) Теперь вам понятно, как получаем результирующую строку из конкатенатора. Этот метод реализуется просто:
508 Часть 5. template* ...» fast_string_concatenator<S, С, T>::operator S() const { size_t len = length(); string_type result(len, write(fcresult[0]); assert(len == traits_type::length(result.c_str())); return result; } Вызывается метод конкатенатора length (), затем создается экземпляр строки result заданного размера, используя конструктор модели String стандартной библио- теки (стандарт С++-98: 21.3.1), который принимает длину и символ, присваиваемый всем элементам. Так мы обеспечиваем единственное распределение памяти для всей последовательности конкатенаций. Здесь вы могли бы использовать любой символ - в представленном программном коде в качестве бросающегося в глаза символа исполь- зуется «~» - поскольку они будут перезаписаны. Фактически, это дает возможность оптимизации этого метола в будущем, когда можно будет иметь закрытый конструктор класса строки, к которому конкатенатор имеет доступ и который мог бы распределить память и устанавливать завершающий ноль для содержимого строки, а не инициали- зировать ее содержимое. (Однако, как мы увидим позже, с учетом отменной производи- тельности текущего решения эта оптимизация, вероятно, становится необязательной.) Затем вызывается метод write (), передавая адрес начала внутренней памяти результата result. Этот метод проходит по последовательности конкатенаций и запи- сывает значения в заданную память. В конце result содержит результат конкатенации, и он возвращается методом. Если ваш компилятор поддерживает оптимизацию NRVO, то result фактически будет являться переменной, содержащей результат последова- тельности конкатенаций в клиентском программный коде. 25.1.4. Внутренняя реализация Перед рассмотрением реализации методов length() и write () я покажу вам остаток определения класса - листинг 25.3 - и вы увидите, что аргументы отдельных операторов конкатенации составляют сеть. Листинг 25.3. template* ...» class fast_string_concatenator ( . . . // Открытый интерфейс private: struct Data ( struct CString ( size_t len;
Глава 25. Быстрая, неагрессивная конкатенация строк 509 char_type const *s; }; union DataRef { CString cstring; char_type ch; class_type const *concat; }; enum DataType { seed // Аргументом был тип семени посева , single // Аргументом был один символ , cstring // Аргументом была С-строка или объект строки , concat // Аргументом был другой конкатенатор }; Data(string_type const &s); Data(char_type const *s); Data(char_type ch s); Data(class_type const &fc); Data(fsc_seed const &fc); size_t length() const; char_type *write(char_type *s) const; DataType const type; DataRef ref; }; friend struct Data; private: char_type *write(char_type *s) consc; private: Data m_lhs; Data m_rhs; }; Вложенная структура Data имеет размеченное объединение одиночного символа ch, последовательности символов cstring (типа struct CString) и указателя на кон- катенатор concat. Каждый экземпляр конкатенатора содержит два экземпляра этой структуры, m_lhs и m_rhs, представляющих два аргумента оператора operator + (). Теперь ясно, как представляется сеть. Для обращения ко всем типам (кроме одиночных символов) - С-строкам, строковым объектам и экземплярам конкатенатора - исполь- зуются указатели. Это допустимо, поскольку C++ требует, чтобы временные объекты оставались актив- ами (то есть чтобы для них не вызывался деструктор) вплоть до конца выполнения оператора, в котором они создаются. Поэтому все временные объекты в последовательно- 0711 конкатенаций по-прежнему доступны в момент использования конечного экземпляра ast__string_concatenator<String> для создания окончательного экземпляра строки. Ь нашем первоначальном примере сеть строится следующим образом:
510 Операторы // FC = сокращенное обозначение временного экземпляра И fast_string_concatenator<String> FC#1: Ihs == c-string ("Goodbye"); ni_rhs == character (* *) FC#2: Ihs == ptr to FC (FC#1); ni_rhs == c-string ("cruel") FC#3: Ihs == ptr to FC (FC#2); n\_rhs == character (' ') FC#4: Ihs == ptr to FC (FC#3); n\_rhs == c-string ("world!") FC#4 представляет собой экземпляр, для которого вызывается operator string_type () const для возврата результата в клиентский программный код. Учитывая то, что теперь мы знаем, как конструируется сеть, методы length () и write () реализуются очень просто. Сначала length (). template* ...» size_t fast_string_concatenator<S, C, T>::length() const ( return m_lhs.length() + m_rhs•length(); } Длина заданного конкатенатора просто представляется как сумма длин двух его аргументов. Это позволяет отсрочить использование его членов m_lhs и m_rhs. Реализация Data: :length() зависит от типа данных, образуя рекурсию в случае использования типа concat: Листинг 25.4. switch(type) ( case seed: len = 0; break; case single: len = 1; break; case cstring: len = ref.cstring.len; break; case concat: len = ref.concat->length(); break; ) Следует отметить, что здесь не используется обычно применяемый в таких случаях вариант default. Это объясняется тем, что тестирование показало снижение произ- водительности для данного варианта от 1% до 5% на нескольких компиляторах, участ вующих в тестах (см. ниже). Реальный программный код содержит утверждение времени выполнения (см. раздел 1.4), подобное следующему: assert( type == cstring || type == single ||
Глава 25. Быстрая, неагрессивная конкатенация строк 511 ---------------------------------------------------------------------------------- type == concat || type == seed); Результат самого последнего вызова length () используется в операторе неявного преобразования для создания строки точно известного размера; отсюда требуется только одна операция выделения памяти. Но как мы сможем обеспечить только одну операцию записи? Эта операция запус- кается в методе конкатенатора write (): template* . . . > С *fast_string_concatenator<S, С, T>::write(C *s) const { return m_rhs.write(m_lhs.write(s)); ) Левая сторона записывает свое содержимое в буфер, переданный методу с помощью Data:: wr i te (), который возвращает место завершения записи. Оно затем передается правой стороне, и результат вновь возвращается в вызывающую программу. Реализация метода Data: : write () выполняется по образцу метода length (), выполняя переключение по типу данных. Одиночные символы копируются в память, и указатель записи перемешается на одну позицию дальше. Содержимое строковых объектов и строк в стиле С копируется в буфер с помощью шешсру (), и указатель уве- личивается на соответствующую величину. Вызывается метод write (), и полученное значение возвращается конкатенатору. Листинг 25.5. template* . . . > С *fast_string_concatenator<S. С, Т>::Data::write(С *s) const { size_t len; switch(type) { case seed: break; case single: *(s++) = ref.ch; break; case cstring: len = ref.cstring.len; memcpy(s, ref.cstring.s, sizeof(C) * len); s += len; break; case concat: s = ref.concat->write(s); break; ) return s;
512 Часть 5. Операторы Поэтому write () записывает нужное количество символов данного соответствующего типа и затем возвращает адрес следующей ячейки для новой записи Эго означает, что сдвоенный вызов методов записи nt_rhs .write dn_lhs .write (s)) в fast_string_concatenator<>: : write (), заданных в обратном порядке в результате обеспечивает правильный порядок записи строки, причем за один проход. Может показаться, что объем этого программного кода слишком большой, но вы можете не сомневаться, что хорошие компиляторы сделают его достаточно кратким. При построении программы в режиме оптимизации он действительно становится очень эффективным. Стоит отметить, что использование лишь обычных открытых операций классов строк делает применение конкатенатора для произвольных типов строк достаточно естественным. 25.2. Производительность Я протестировал конкатенатор на довольно большом количестве различных клас- сов строк, и он показал одинаково превосходные характеристики работы со всеми типами. Мы специально рассмотрим производительность для трех классов строк. Первый класс trivial_string является пользовательским классом, написан- ным специально для этого теста. Он имеет два члена, хц_1еп (size_t) и m_s (char_type *), которые содержат длину и динамически выделяемый буфер симво- лов. Он использует операторы new и delete для распределения памяти и хранит точный объем памяти, требуемый для размещения текущего значения и нулевого сим- вола завершения. Другими словами, это самый обычный класс строки. Вторая используемая строка представляет собой тип стандартной библиотеки, basic_string. Поскольку различные поставщики компиляторов применяют раз- личные реализации, отличие в эффективности обработки различных стандартных строк будет зависеть в одинаковой мере и от реализации библиотеки, и от компиля- тора. Несмотря на возможность такого варьирования, я включил эту строку, поскольку она является для многих разработчиков собственно классом строки; другие представ- ленные здесь две строки ясно продемонстрируют отличия применимости конкатена- тора, возникающие из-за компилятора, а не из-за библиотеки. Третья строка - это basic_simple_string<> из библиотеки STLSoft, которая вместе с содержимым строки хранит ее длину и объем в динамически распределяемом буфере переменного размера и выделяет дополнительные участки памяти блоками, вмещающими 32 символа. Одна и та же программа тестировала три типа строк, измеряя производительность по- следовательностей из 1,2,3,4,8,16 и 32 конкатенаций с использованием и без использова- ния fast_string_concatenacor4. В представленных здесь результатах приводятся выраженные в процентах относительные времена для fast_string_concatenator в сравнении с обычной реализацией оператора operator + () для заданного класса стро
ррава 25. Быстрая, неагрессивная конкатенация строк 513 —.—, . . jjH. Значения, меньшие 100%, указывают на более высокую производительность конкатена- ^ра. Программа компилировалась и тестировалась для компиляторов Borland (версия 5.6), CodeWarrior (версия 8), Digital Mars (версия 8.38), GCC (версия 3.2), Intel (версия 7.0) и Visual C++ (версии 6.0 и 7.1). Табл. 25.1 показывает производительность для класса trivial_string, табл. 25.2 по- казывает производительность для класса std: :basic_string<char>, а табл. 25.3 пока- зывает производительность для класса stlsoft: :basic_simple_string<char>. До проведения тестов я ожидал, что применение конкатенатора для последователь- ностей из одной и двух конкатенаций в действительности ухудшит производитель- ность, и только при более большем количестве конкатенаций будет положительный эффект от оптимизации. К счастью, оказывается, что в большинстве случаев я недо- оценил эффект оптимизации. Хотя данные для 16 и 32 конкатенаций имеют лишь академический интерес - если вы начали писать программный код, в котором есть 32 конкатенации, то вам, вероятно, требуется отпуск - то экономия для, скажем, 8 и менее конкатенаций доходит до 80%. При использовании trivial_string (см. табл. 25.1) каждый результат демон- стрирует превосходство конкатенатора, поэтому безусловно выгодно его применять для этого класса. Грубо говоря, последовательность из двух конкатенаций обрабатывается едва раза быстрее, а последовательность из трех конкатенаций - в три раза быстрее. Это очень вдохновляет, но этот тип строки реализуется довольно просто, и поэтому нам не следует на этом строить наши выводы. Таблица 25.1. Относительная производительность конкатенатора для класса trivial.string Кол-во конкате- наций Borland CodeWarrior Digital Mars GCC Intel VC++ (6.0) VC++ (7.1) Среднее 1 90.8% 77.8% 87.8% 77.5% 64.8% 92.6% 99.6% 84.4% 2 47.8% 42.8% 47.9% 38.4% 36.8% 52.3% 52.8% 45.5% 3 35.2% 29.7% 34.5% 29.1% 25.9% 36.9% 37.0% 32.6% 4 29.1% 24.3% 28.4% 25.0% 23.9% 29.7% 29.6% 27.1% 8 24.2% 19.6% 24.0% 21.3% 19.1% 24.3% 23.6% 22.3% 16 3.3% 7.7% 13.8% 12.2% 11.0% 11.4% 10.7% 11.4% 32 .6% 4.2% 8.9% 10.2% 7.7% 6.5% 7.7% 7.8% Таблица 25.2. Относительная производительность конкатенатора для класса std::basic_string<char> Кол-во конкате- наций Borland CodeWarrior Digital Mars GCC Intel VC++ (6.0) VC++ (7.1) Среднее 1 101.4% 89.4% 132.8% 91.3% 55.1% 188.1% 170.8% 118.4% 56.1% 57.4% 91.7% 52.9% 44.9% 108.7% 137.5% 78.5% 43.0% 44.2% 71.3% 39.0% 32.1% 76.2% 76.3% 54.6%
514 ^5- Операторы Таблица 25.2. Относительная производительность конкатенатора для класса std::basic_string<char> Кол-во конкате- наций Borland CodeWarrior Digital Mars GCC Intel VC++ (6.0) VC++ (7.1) вреднее 4 36.9% 33.8% 64.4% 35.2% 23.7% 59.5% 48.1% 43.1% 8 30.7% 30.6% 63.9% 31.8% 20.5% 49.0% 47.1% 39.1% 16 15.6% 14.3% 49.3% 25.3% 10.7% 26.2% 16.2% 22.5% 32 12.9% 10.2% 26.1% 16.7% 8.5% 19.6% 11.0% 15.0% Для типа basic_simple_string библиотеки STLSoft (см. табл. 25.3) произво- дительность почти столь же хороша, за исключением того, что одиночная конкатена- ция при использовании CodeWarrior и Digital Mars выполняется менее эффективно на 27% и 2% соответственно. Несмотря на это, я считаю, что, несомненно, применение конкатенатора дает явный выигрыш. Результаты для std: :basic_string (см. табл. 25.2) не столь убедительны. Некоторые реализации стандартной библиотеки используют подсчет ссылок и копиро- вание при записи, что может повлиять в худшую сторону на эффективность конкатена- тора. При единственной конкатенации Borland и Digital Mars показывают небольшое снижение производительности, хотя 1% и 33% не так уж существенны. Плохо то, что обе версии Visual C++ показывают ухудшение эффективности для последовательно- стей из 1 и 2 конкатенаций. Конечно, мы могли бы просто предположить, что библио- тека программ этапа выполнения Visual C++ очень хорошо реализует строку, и поэто- му конкатенатор не может соответствовать этому уровню, пока в последовательности не будет трех или более элементов. Однако поскольку результаты производительности для Intel были получены с использованием библиотеки Visual C++ 7.0, которая имеет фактически идентичную версии 7.1 реализацию строки1, такое объяснение на самом деле не выдерживает критики. Другими словами, там, где Visual C++ имеет относи- тельную производительность 171% и 138%, Intel имеет относительную производи- тельность 55% и 49%. Это показывает, какое влияние может оказывать на применение такой схемы способность компилятора оптимизировать шаблоны. Я полагаю, что по мере того, как компиляторы будут продолжать совершенствовать свои возможности по оптимизации шаблонов, показатели производительности конкатенатора, показан- ные для компилятора Intel, станут более распространенными. Несмотря на потенциальные улучшения компиляторов в будущем, я бы предполо- жил, что применение быстрого конкатенатора с библиотеками строк будет более пред- почтительным, причем значительно, хотя необходимо признать, что, по крайней мере, для Visual C++ это необходимо доказать путем профилирования производительности. В этой связи любопытно взглянуть на абсолютные времена конкатенации в миллисе- кундах для двух версий этого компилятора (см. табл. 25.4). 1 И действительно, выполнение того же теста для Intel 7.1 с использованием библиотек Visual C++ 71 показывает производительность фактически идентичную той, которая показана здесь для Intel 7.0.
Глава 25. Быстрая, неагрессивная конкатенация строк 515 —-— Таблица 25.3. Относительная производительность конкатенатора для класса stlsoft::basic_smple_stnng<char> Кол-во Borland CodeWar- Digital GCC Intel VC++ VC++ Среднее кон- кате- наций rior Mars (6.0) (7.1) i 98.6% 127.2% 102.1% 67.7% 50.8% 99.3% 99.7% 92.2% 2 62.4% 82.8% 98.8% 42.8% 33.9% 67.0% 65.4% 64.7% 3 50.3% 61.5% 74.7% 31.7% 29.4% 51.1% 49.8% 49.8% 4 44.6% 53.6% 66.3% 29.0% 27.6% 46.5% 41.2% 44.1% 8 37.6% 45.6% 55.3% 23.3% 23.8% 40.8% 35.6% 37.4% 16 21.8% 21.4% 25.7% 13.7% 15.6% 21.9% 18.0% 19.7% 32 17.9% 13.7% 15.2% 10.6% 13.7% 14.6% 13.8% 14.2% Таблица 25.4. Кол-во VC++ (6.0) VC++ (6.0) + VC++ (7.1) VC++ (7.1) + конкатенаций конкатенатор конкатенатор l 143 269 226 386 2 263 286 275 378 3 408 311 476 363 4 543 323 761 366 8 665 326 969 456 16 2338 613 4116 667 32 5180 1016 9232 1011 Применение конкатенатора не обеспечивает линейную зависимость временных затрат в зависимости от количества элементов конкатенации, но существенно умень- шает экспоненциальную зависимость. Поэтому в любом программном обеспечении, в котором средняя длина последовательности больше 2, вероятно, будет наблюдаться существенная экономия даже для Visual C++. 25.3. Работа с другими классами строк До сих пор мы рассматривали применение конкатенатора с вашим собственным классом строки, когда реализовывались ваши перегрузки оператора operator + (), Исх°Дя из наличия fast_string_concatenator<>. В этом случае нет никаких Проблем и все работает просто. Однако вы могли бы также захотеть использовать конкатенатор с другими классами СтРок, и в этом случае возникает несколько проблем, о которых вам необходимо иметь Представление.
516 Част>5-Оп^оры 25.3.1. Внедрение в стандартные библиотеки Для гарантирования совместимости конкатенатора с реализациями стандартной библиотеки (см. раздел 25.6), я внедрил его в заголовки стандартной библиотеки на всех проверяемых компиляторах. Хотя их и нельзя включать в компакт-диск из-за зашиты авторских прав, поверьте мне, это было сделано легко и просто, и не было ника- ких сюрпризов. 25.3.2. Обновление существующих классов, которые можно модифицировать Операторы конкатенации существующих классов строк могут «обновляться» просто и безопасно путем замены существующих операторов на эквивалентные версии с внедренным конкатенатором. Если ваша организация имеет свои собственные классы строк, то вы сможете обновить их, и на любой клиентский программный код это повлияет единственным образом (после его перекомпиляции): он станет работать быстрее, что не часто происходит при разработке программного обеспечения! Для проверки я эту процедуру проделал с несколькими существующими классами строк из различных библиотек, включая CString из MFC и basic_simple_string из STLSoft. 25.3.3. Взаимодействие с немодифицируемыми классами Здесь приходится поступать немного хитрее. Если вы используете строку из биб- лиотеки стороннего разработчика, например, CString из MFC, вам нельзя изменять заголовочные файлы этой библиотеки. Всякое новое обновление библиотеки сведет на нет ваши изменения. Еще хуже то, что некоторые библиотеки, такие, как MFC, час- тично доступны только в двоичном формате, и поэтому любые изменения заголо- вочных файлов не будут отражены в двоичной части библиотеки. В лучшем случае вы столкнетесь с аварийным завершением при тестировании. Нельзя это делать! Но нам нравится оптимизация конкатенации, и мы хотим использовать ее для сторонних библиотек. Так что же нам делать? Если интересующий вас класс строки не обеспечивает своих собственных опера- торов, то ваша задача становится сравнительно простой. Вы можете либо определить новые операторы в глобальном пространстве имен, которые доступны на уровне при- ложения, либо в своем собственном пространстве имен, если вы используете эти операторы в своих собственных библиотеках. Конечно, если класс строки изначально находится в стороннем пространстве имен, то вы могли бы определять операторы в этом пространстве имен. Однако для этого вы должны нарушить общепринятые пра- вила использования пространств имен, поскольку совершенно неразумно добавлять что-то в пространства имен, за которые вы не отвечаете; авторы библиотек в любое время могут что-нибудь поместить в это пространство, что приведет к конфликту.
[-дэва 25. Быстрая, неагрессивная конкатенация строк 517 Если класс строки уже имеет собственные операторы, то вы находитесь в более сложном положении. Если это шаблонный класс, то вы можете определять свои собст- венные операторы для специальных конкретизаций шаблона строки. Другими слова- мИ> если строкой является tp_string, вы можете определить свои собственные операторы для tp_string<char> или tp_string<wchar_t> и т. д., поскольку компилятор сможет разрешить неоднозначность, выбирая нешаблонную функцию, а не шаблонную. Если этот класс не является шаблонным, то вы попали в трудное положение. Если вы даже напишете свои собственные перегрузки, возвращающие кон- катенатор в «близком» пространстве имен, поиск Кенига (см. разделы 6.2 и 20.7) при- ведет к тому, что компилятор обязательно будет видеть несколько эквивалентных операторов, и компиляция справедливо закончится неудачей. Здесь нам потребуется применить метод посева конкатенации (concatenation seeding). 25.4. Метод посева конкатенации Поскольку оператор + ассоциативен слева направо, компилятор сначала анализиру- ет левый аргумент, а затем - правый. Мы можем извлечь из этого пользу для продви- жения дальше по цепочке нужного нам свойства. В том же пространстве имен, где определен fast_string_concatenator<>, определяется другой класс - f sc_seed: class fsc_seed О; Мы видели ссылки на него в конструкторе вложенного класса Data и в DataType enum. Этот класс расширяет неагрессивные свойства метода, позволяя нам выполнять «посев» последовательности конкатенаций, обеспечивающий использование любого типа строки для быстрой конкатенации. String s = fsc_seed() + si + ’ " + s2 + " " + s3; В данном случае для того, чтобы это работало, нам достаточно определить единст- венную дополнительную перегрузку operator + (): fast_string_concatenator<String> operator +(fsc_seed const &lhs, String const &rhs) { return fast_concat_t(Ihs, rhs); ) Компилятор сделает все остальное. Результатом первой конкатенации является ast__string_ concatenator<String>, поэтому делается вывод (с помощью п°иска Кенига), что следующий оператор - это стандартный оператор в пространстве ИИен конкатенатора. Естественно, это выглядит не совсем красиво, но лучше, чем вме- шательство в заголовочные файлы сторонних библиотек, причем в данном случае дру- Решения нет. Это также позволяет вам непосредственно использовать быструю
518 5-Опадго^ конкатенацию в одних частях вашего программного кода и не использовать в других Это аналог оператора new (nothrow) (стандарт С++-98: 18.4). При написании шаблонов вы должны учитывать ограничение (хотя это едва ли можно рассматривать как недостаток), связанное с тем, что первый элемент в последе- вательности после посева должен быть строкой типа класса, а не символом или стро- кой в стиле С, поскольку тогда нельзя было бы вывести тип строки из типа символа в соответствующем operator + (): template* . , . > fast_string_concatenator<S, С, Т> operator +(fac_aeed const &lhs, C const *rbs) // Что такое st? { return fast_string_concatenator<S, C, T>(lhs, rhs); ) Использование посева очень незначительно сказывается на показателях производи- тельности быстрой конкатенации. 25.5. Патологическое применение скобок Возможно, вы думаете, что обнаружили изъян в этом методе, связанный с тем, что необязательное, но совершенно законное применение скобок может нарушить данный метод или, по крайней мере, сделать его неэффективным. В приведенном ниже про- граммном коде благодаря скобкам обеспечивается обратный порядок выполнения вычислений. String s = si + (" " + (s2 + (" " + S3))); Хотя такие вещи, вероятно, могли бы возникнуть только из-за старания какой- нибудь доброй души продемонстрировать возможность нарушения работы быстрой конкатенации, этот вопрос уже решен. У нас имеются дополнительные перегрузки, способные охватить все случаи: Листинг 25.6. template < . . . > fast_string_concatenator<S, С, Т> operator +(fsc_seed const &, fast_. . .< > const &) ; operator +(fast_. . .< > const &, fast_. .< > const &); operator +(S const &. fast_. . .< > const &); operator +(C const *, fast_. . .< > const &); operator +(C const &, fast_. . .< > const &); И в классе предусмотрены соответствующие конструкторы:
[-дэва 25. Быстрая, неагрессивная конкатенация строк 519 —--------------------------------------------------------------------— fast_. . . (class_type const &lhs, class_type const &rhs); fast_. . . (string_type const &lhs, class_type const &rhs); fast_. . . (char_type const * *lhs, class_type const &rhs); fast_. . . (char_type const Ihs, class_type const &rhs); Интересная особенность такого применения скобок заключается в том, что при этом не только не происходит никакого нарушения работы программного кода, но - как и для посева - это почти никак не сказывается на его эффективности1. 25.6. Стандартизация Поскольку этот метод приносит только пользу, возможно, у вас возникнет вопрос, а почему бы ему не стать стандартным механизмом. Не будучи членом комитета по стандартизации, я ничего не могу сказать по этому поводу, и, возможно, существуют какие-то мне неизвестные причины, осложняющие его адаптацию. (Единственное, что приходит мне в голову, это невозможность объявлять указатели таких функций, как S (*) (S const &, S const &), и назначать им адрес std: : operator + () .Я не могу представить, зачем кому-нибудь могут понадобиться подобные вещи, поэтому нельзя это рассматривать в качестве серьезного довода против.) Естественно, было бы очень здорово, если бы этот механизм был принят, и в на- стоящее время я веду переговоры с поставщиками компиляторов и библиотек, жду от них ответа; по крайней мере, один поставщик уже проявил большой интерес. Может оказаться так, что вы когда-нибудь обнаружите fast_string_concatenator в ис- пользуемой вами библиотеке! Однако независимо от того, случится это или нет, нет причин, по которым вы не могли бы использовать его в ваших собственных библиотеках строк или ненавязчиво применять его в своем собственном клиентском программном коде, чтобы воспользо- ваться повышением производительности, которое он обеспечивает. Полные результаты тестирования посева и патологического применения скобок включены в состав *0Мпакг-диска.
Глава 26 Какой ваш адрес? «Программистов, перегружающих унарный оператор &, необходимо приговорить к написанию библиотек, которые обязаны работать правильно с такими классами». Питер Димов, уважаемый член команды разработчиков библиотеки Boost, сетевая конференция по Boost, июнь 2002 года. Хотя и сказанное в шутку, это достаточно сильное утверждение. Чем объясняется такая антипатия к этому оператору? В данной главе мы рассмотрим некоторые проблемы, возникающие в результате перегрузки оператора operator & (). Наше решение не будет каким-то особенным; оно просто рекомендует следовать неявно высказанному Питером совету вообще отка- заться от применения этой перегрузки ради краткосрочных выгод, чтобы избежать неприятностей в будущем. Я должен разъяснить один момент. Там, где в данной главе я ссылаюсь на operator & (), я буду иметь в виду его унарную форму, то есть оператор адресации. Бинарная форма, то есть логический оператор OR - это нечто совсем другое. class Int { Int operator &(Int const &); 11 Логический оператор void ‘operator &() // Оператор адресации 26.1. Можно не получить реальный адрес Как и для большинства операторов C++, вы можете возвращать в результате выпол- нения operator & () все, что угодно. Это означает, что вы можете изменять значе- ние или тип, или и то и другое. Это может быть полезно в редких случаях, но может также стать причиной глобальной проблемы. 26.1.1. Внутреннее устройство контейнеров STL Контейнеры стандартной библиотеки хранят содержимое своих элементов непосреД ственно в контейнере. Например, контейнеры, реализующие модель Vector'а (стандарт С++-98: 23.2.4), поддерживают блок памяти, в котором элементы располагаются дрУг за другом. Поскольку их размеры могут изменяться, необходим механизм добавления
Слава 26. Какой ваш адрес? 521 и удаления элементов из контейнера, который обеспечивается распределителями памяти Концепцией распределителя памяти (Allocator) [Aust 1999, Muss 2001] предусмотрены методы construct () и destroy О, канонические определения которых имеют следующий вид: template ctypename Т> struct some_allocator ( void construct(T* p, T const &x) ( new(p) T(x); } void destroy(T* p) ( P->-T(); } Метод construct () используется контейнерами для конструирования элементов по месту их хранения, как показано в следующем примере: template ctypename Т, . - . > void list::insert(. . T const &x) ( Node *node = . . . get—allocator().construct(anode->value, x); Если тип T, который вы храните в списке, перегружает operator & () для возвра- та значения, которое не может быть преобразовано в Т*, то данная строка программ- ного кода не будет откомпилирована. 26.1.2. Классы-оболочки ATL и CAdapt Один из самых больших недостатков библиотеки активных шаблонов Microsoft (ATL) (в основе которой, как и для большинства других фреймворков, лежали высокие идеи) является чрезмерная перегрузка оператора operator &. Он переопределяется достаточно большим числом классов, включая CComBSTR, CComPtr и CComVariant. Для учета несовместимости типов ATL и контейнеров STL проектировщики ATL ввели шаблон CAdapt, который пытается решить эту проблему путем размещения в себе экземпляра своего типа параметризации. Затем он обеспечивает операторы Неявного преобразования и операторы сравнения, позволяя им использоваться вместо евоего типа параметризации. Из-за того что CAdapt<T> не перегружает operator & > он может применяться для сокрытия перегрузки любого типа Т.
522 Часть 5. Операторы Листинг 26.1. template ctypename Т> class CAdapt { public: CAdapt() ; CAdapt(const T& rSrc); CAdapt (const CAdapt& rSrCA) CAdapt &operator =(const T& rSrc); bool operator <(const T& rSrc) const ( return m_T < rSrc; } bool operator ==(const T& rSrc) const; operator T&O ( return m_T; } operator const T&() const; T m_T; }; К сожалению, это все равно, что наложить бактерицидный пластырь на сломанную руку. Как мы видели в гл. 23, шаблоны, которые являются производными от своих типов параметризации, испытывают значительные трудности при обеспечении одно- значного доступа к нужным конструкторам своего родительского класса. Та же самая проблема существует для таких типов, как CAdapt, которые улучшают свой тип пара- метризации благодаря хранению его у себя, а не через наследование. Все конструкторы Т не доступны, за исключением конструктора по умолчанию и копирующего конструк- тора. Это вносит беспорядок в ваш программный код, снижает применимость обоб- щенных алгоритмов и предотвращает применение RAII (см. раздел 3.5). 26.1.3. Получение реального адреса Итак, существует ли способ получения реального адреса? Поскольку нет альтерна- тивы перегружаемому оператору, позволяющему извлекать ссылку объекта, мы можем использовать достаточно сомнительное приведение ссылки для получения нашего адреса с помощью следующей прокладки атрибутов (см. гл. 20): template<typename Т> Т *get_real_address(Т &t) { return reinterpret_cast<T*>(&reinterpret_cast<byte_t &>(t));
Слава 26. Какой ваш адрес? 523 Возникают дополнительные осложнения, связанные с учетом спецификаторов const и/или volatile, но в этом суть дела. Библиотеки Boost имеют хитроумную функцию addressof (), которая все учитывает. Но применение reinterpret_cast вызывает некоторую озабоченность. Стандарт (С++-98:5/2.10; 3) говорит, что «выполняемое отображение... определяется реализацией. (Примечание: это не должно удивить тех, кто знает механизм адресации базовой маши- ны)». Поскольку нет уверенности в безусловной достоверности результата, нельзя ут- верждать, что этот метод действительно обладает переносимостью. Однако довольно трудно представить, чтобы компилятор не смог выполнить ожидаемое преобразование. Мы можем теперь уклониться от применения в типах патологических перегрузок оператора operator & (), но это потребовало бы усеять весь наш программный код вызовами прокладки, получающей реальный адрес. Это выглядит неуклюже, и корректность этого подхода зависит от реализации. Разве хотели бы вы пользоваться стандартной библиотекой, реализованной с помощью бесчисленных операторов reinterpret_cast? 26.2. Что происходит во время преобразования? Поскольку operator & () является такой же функцией, как и все другие, его пере- грузка может не ограничиваться лишь возвратом преобразованного значения. Это имеет серьезные последствия. Дефект: перегрузка оператора operator &() нарушает инкапсуляцию. Это смелое утверждение. Позвольте мне объяснить, почему я так думаю. Как я уже упоминал, ATL имеет большое количество классов-оболочек, которые перегружают operator & (). К сожалению, их реализации имеют различную семан- тику. Все типы, показанные в табл. 26.1, имеют утверждение в реализующем этот оператор методе, гарантирующее, что текущее значение равно NULL.. Не стоит беспокоиться о специальных типах TYPEATTR, VARDESC и FUNCDESC - они являются структурами POD открытого типа (см. раздел 4.4), используемыми для манипулирования метаданными СОМ. Важно отметить, что с ними связаны опреде- ленные выделенные ресурсы, но они не обеспечивают семантику значений, а это означает, что работать с ними нужно осторожно, чтобы не допустить утечку ресурсов пли использование висячих указателей. Этот оператор перегружается в классах-оболочках, чтобы можно было применять эти ™nbl в функциях программного интерфейса СОМ, которые манипулируют базовыми типа- Ми» и тем самым их инициализировать. Конечно, это не известная и любимая нами инициа- лизация RAH типов C++, но все же это инициализация в том смысле, что любая после- *№°Щая попытка повторения процесса приведет к ошибке, по крайней мере, в отладочном Режиме. Я предоставляю вам самим решить, насколько хорош этот способ проектирования
524 Часть 5. Операторы классов-оболочек, но, как вы видите, вам придется заглянуть внутрь библиотеки, чтобы ра- зобраться, что там делается. В конце концов, она использует перегруженный оператор, не вызывая функцию с именем get-jone-time-Content-pointerO1. Широко используемый класс CComBSTR, который служит оболочкой для приме- няемого в СОМ типа BSTR, также перегружает operator & () для возврата bstr* но он не имеет утверждения. Делая контрвывод, мы можем предположить, что это оз- начает возможность многократного получения адреса CComBSTR, и, поскольку опера- тор не константный, мы можем многократно и безболезненно модифицировать инкап- сулированный BSTR. Увы, это не тот случай. Очень легко сделать так, что в CComBStr произойдет утечка памяти: Таблица 26.1. Классы-оболочки Тип, возвращаемый оператором &0 CComTypeAttr TYPEATTR** CComVarDesc VARDESC** CComFuncDesc FUNCDESC** CComPtr / CComQIPtr T** CHeapPtr T** void SetBSTR(char const *str, BSTR *pbstr); CComBSTR bstr; SetBSTR("Doctor", &bstr); // Пока все нормально SetBSTR("Proctor", fcbstr); // "Doctor" теперь навсегда потерян! Мы можем предположить, что в CComBSTR не используется утверждение по той причине, что оно оказалось очень неудобным. Например, не столь необычно видеть в СОМ функцию программного интерфейса или метод интерфейса, принимающие массив BSTR. Откладывая в сторону проблему передачи массивов производных типов (см. разделы 14.5; 33.4), мы могли бы попытаться использовать наш CComBSTR при передаче только одной строки. Альтернативной является стратегия освобождения инкапсулированного ресурса в методе operator & (). Этот подход используется в другом популярном классе-обо- лочке объектов СОМ компании Microsoft, шаблона Visual C++ _com_jptr_t. Недоста- ток данного подхода заключается в том, что эта оболочка предварительно освобождается * Конечно, в идеальном мире достаточно было бы прочитать документацию, чтобы понять, что происходит, и впитать в свою память тонкие нюансы библиотек, подобные упомянутым в данном разделе. Однако это совсем не тот случай. Документация, по крайней мере, на один шаг концептуально отстает от реального программного кода, уже устаревает в момент написания и трудно создается (как теми авторами программного кода, кто знает слишком много, так и другими, кто знает слишком мало). В действительности, программный код часто сам является документацией [Gias 2003].
Слава 26. Какой ваш адрес? 525 в тех случаях, когда вам необходимо передать указатель на инкапсулированный ресурс функции, которая просто его использует, а не уничтожает или удаляет из вашей оболочки. Вы можете посчитать, что решить эту проблему удастся путем объявления константных и неконстантных перегрузок operator & (), как показано в листинге 26.2. Листинг 26.2. template ctypename Т> class X ( Т const ‘operator &() const ( return &m_t; } T *operator &() ( Release(m_t); m_t - T()> return &m_t; } К сожалению, это не поможет, т. к. компилятор выбирает перегрузки в соответствии с константностью экземпляра, для которого он вызван, а не для создаваемого возвра- щаемым значением. Даже если вы передадите адрес неконстантного экземпляра Х<Т> функции, которая принимает Т const *, будет вызвана неконстантная перегрузка. На мой взгляд, все это настолько опасно, что я давно вообще прекратил использо- вать такие классы. Теперь мне нравится непосредственно применять поименованные методы и/или прокладки, чтобы уберечься от всей этой неопределенности. Например, я использую изумительно поименованный1 класс BStr в качестве оболочки BSTR. Он обеспечивает методы DestructiveAddress() (получение адреса с уничтожени- ем экземпляра) и NonDestructiveAddress () (получение адреса без уничтожения экземпляра), которые, хотя и выглядят очень неуклюжими, но ни у кого их назначение не вызывает сомнения. 26.3. Что мы возвращаем? Другой источник неверного применения перегрузки operator & () связан с воз- вращаемым типом. Поскольку мы можем возвращать все, что угодно, легко сделать так» что будет возвращаться что-нибудь плохое; естественно, это может произойти с любым оператором. Мы рассмотрели в гл. 14 некоторые проблемы, возникающие при передаче масси- вов унаследованных типов функциям, которые принимают указатели на базовый тип. Существует еще одно проявление этой неприятной проблемы, когда перегружается °ператор operator & (). Рассмотрим следующие типы: Это, несомненно, тот случай, когда кодируют, предварительно не подумав. Обозначения BSTR и BStr слишком похожи друг на друга, что вызывает у меня немало беспокойства.
526 Часть 5. Операторы Листинг 26.3. struct THING ( int i; int j; }; struct Thing { THING thing; int k; THING *operator &() ( return &thing; } THING const *operator &() const; }; Теперь мы находимся в таком же положении, как если бы класс Thing унаследовал THING с открытым доступом к последнему. void func(THING *things, size_t cThings); Thing things[10]; func(&things[0], dimensionof(things)); // Вот-так!! Обеспечивая ради «удобства» перегрузки operator & (), мы подвергаем себя опасности из-за неправильного использования типа Thing. Я не собираюсь предла- гать здесь какие-либо меры, описанные в гл. 14, т. к. просто считаю, что перегрузка operator & () вообще недопустима. По-настоящему странное стечение обстоятельств возникает в том случае, если этот оператор деструктивен - он освобождает ресурсы - и вы передаете массив экземп- ляров (даже правильного размера) класса-оболочки какой-нибудь функции, как показа- но в листинге 26.4. Листинг 26.4. struct ANOTHER ( ); void func(ANOTHER *things, size_t cThings); inline void func(array_proxy<ANOTHER> const ^things) ( func(things.base(), things.size()); } class Another
Слава 26. Какой ваш адрес? 527 ( ANOTHER *operator &() { RaleasaAndRaset (xn^another); return &m_another; } private s ANOTHER m_another; }; i Давайте предположим, что вы исходите из самых лучших побуждений и используе- те аггау_ргоху (см. раздел 14.5.5), а также метод трансляции для обеспечения воз- можности совместного применения ANOTHER и Another. Another things[5] ; ...Н Модифицировать things func(things); II sizeof(ANOTHER) должен == sizeof(Another) Вне зависимости от семантики функции func (), при ее вызове будет очищен things [ 0 ], a things [ 1 ] - things [4 ] не будут затронуты. Это происходит из-за того, что конструктор массива аггау_ргоху непосредственно использует синтаксис операции индексации массива, как должен делать любой хороший программный код по обработке массивов (см. гл. 27). Если бы вам пришлось это делать самому, все-таки не- обходимо было бы применять этот оператор в том случае, когда Another унаследован от ANOTHER с открытым доступом к последнему, а вы вызвали версию func () с двумя параметрами и рассчитываете на вырождение массива в указатель (см. раздел 14.2). Если функция func () не изменяет содержимое переданного ей массива, то оказыва- ется, что этот вроде бы нормальный вызов на самом деле дает неприятный побочный эффект, проявляющийся в уничтожении первого переданного ему элемента. Если func () модифицирует содержимое массива, то может произойти утечка ресурсов для элементов things [ 1 ] - things [4 ], т. к. их содержимое, которое они имели до вызо- ва, просто перезаписывается функцией func (). 26.4. Какой ваш адрес?: заключение Я надеюсь, мне удалось убедить вас в том, что Питер был совершенно прав. Пере- грузка operator & () связана со слишком большими проблемами. Учитывая объем времени, затрачиваемого на кодирование, обдумывание и отладку при попытке понять Работу библиотек и использовать их, я очень старался представить, как ее применение Может оказаться полезным сообществу разработчиков программного обеспечения1. Работа разработчиков над исправлением проектов не в счет, поскольку они предпочти бы работать над Новыми проектами, если бы у них была возможность выбора.
528 Часть 5. Операторы Короче говоря, так нельзя делать. Осуществляя поиск по всей базе данных моих исходных текстов, я обнаружил одиннадцать случаев применения этой перегрузки В трех из них использовались «соответствующие» классы - то есть не служебные и не классы метапрограммирования - вероятно, я мог бы по-настоящему оправдать только один из них. Я сразу же убрал две перегрузки1. Третью я не могу оправдать, но я сохра- нил ее из-за ее выгодности. По иронии судьбы я опишу ее в следующем подразделе. 26.4.1. Неодобряемый кульбит назад Я не собираюсь пытаться оправдывать перед вами эту перегрузку; вы можете сами составить собственное мнение относительно того, перевешивает ли ее полезность многие веские причины, говорящие против перегрузки operator & (). В программном интерфейсе Win32 определяется много нестандартных базовых структур, причем часто для сильно связанных типов. Более того, поскольку многие компиляторы платформы Win32 не обеспечивали 64-битовые целые числа в первые годы существования этой операционной системы, существует несколько 64-битовых структур, которые восполняют этот недостаток. Двумя такими структурами являются ULARGE_INTEGER и FILETIME. Они имеют следующий вид: struct FILETIME ( uint32_t dwLowDateTime; uint32_t dwHighDateTime; }; union ULARGE_INTEGER ( struct ( uint32_t LowPart; uint32_t HighPart; }; uint64_t QuadPart; }; Выполнение арифметических операций с использованием структуры FILETIME представляет собой, в лучшем случае, нудное занятие. В системах с прямым порядком байтов размещение в памяти будет идентично формату ULARGE_INTEGER, так что можно приводить экземпляры этих типов друг к другу; отсюда для нахождения разно- сти двух структур FILETIME можно привести их в тип ULARGE-INTEGER и затем выполнить вычитание членов QuadPart. 1 Существует еще одна причина написания книги: вам приходится пройтись по всему вашему собственном) программному коду и убедиться в том, как много вы раньше не знали.
Глава 26. Какой ваш адрес? 529 FILETIME ftl = . FILETIME ft2 = . . . FILETIME ft3; GetFileTme(hl, NULL, NULL, &ftl); GetFileTme(h2, NULL, NULL, &ft2); 11 Найти их разность - страшно смотреть! reinterpret_cast<ULARGE_INTEGER£) (ft3) .QuadPart = reinterpret_cast<ULARGE_INTEGER&)(ftl).QuadPart - reinterpret_cast<ULARGE_INTEGER&)(ft2).QuadPart; Это также довольно утомительно, и поэтому я приготовил класс ULargeInteger. Он обеспечивает различные арифметические операции (см. гл. 29), имеет совместимое с этими двумя структурами размещение в памяти и содержит перегрузку operator & (). Этот оператор возвращает экземпляр Address_proxy, определение которого показано в листинге 26.5. Листинг 26.5. union ULargelnteger ( private: struct Address_proxy { Address_proxy(void *p) : m_p(p) () operator FILETIME •() ( return static_cast<FILETIME*>(p); } operator FILETIME const *() const; operator ULARGE_INTEGER •() ( return static_cast<ULARGE_INTEGER*>(p); } operator OT>AROE_INTEOER const *() constI private: ~~ void *m_p; // Реализация не требуется private: Address_proxy ^operator =(Address_proxy const&); }; Address_proxy operator &() ( return Address_proxy(this); ) Address_proxy const operator &() const;
530 Часть 5. Операторы Он содержит ссылку на используемый им экземпляр Ulargelnteger и обес- печивает неявные преобразования как в FILETIME*, так и в ULARGE_INTEGER* Поскольку класс прокси является закрытым и его экземпляры возвращаются только с помощью операторов адресации, применяемых к ULargelnteger, его довольно трудно использовать ненадлежащим образом, хотя у вас возникнут трудности, если вы попытаетесь поместить его в какой-нибудь контейнер STL. Но с ним значительно легче использовать эти структуры Win32: ULargelnteger ftl = . . ULargelnteger ft2 = . . GetFileTme(hl, NULL, NULL, GetFileTme(h2, NULL, NULL, &ft2); // Получить их разницу - теперь подходящий синтаксис ULargelnteger ft3 = ftl - ft2;
Глава 27 Операторы индексации 27.1 > Операторы преобразования в указатели и операторы индексации Ранее я упоминал (см. раздел 14.2.2) о том, что необходимо сделать выбор между обеспечением операторов неявного преобразования в указатели (на управляемую последовательность элементов) и операторами индексации. В данном разделе мы увидим, почему. Рассмотрим программный код в листинге 27.1. Листинг 27.1. typedef size_t subscript_arg_t; typedef int indexer_t; struct DoubleContainer { typedef size_t size_type; operator double const *() const; operator double *(); double const «^operator [] (size_type index) const; double &operator [](size_type index); size_type sizeO const; ); DoubleContainer de; indexer_t index = 1; de[index]; // S#1 dc[0]; // S#2 Большинство типов, обеспечивающих индексацию, могут поддерживать только неотрицательные индексы, и пЬэтому чаще всего такие типы определяют оператор индексации для индекса без знака. Согласно требованиям стандарта C++ (С++-98: 23.1) тип size_type-контейнеров, в том числе тех, которые обеспечивают операторы индексации, не имеет знака и обычно сводится к size_t. Многие программисты, хорошо ли это или плохо, как правило, в качестве типа индекса в программном коде используют int. Даже если вы используете size_t в качестве типа своей индексируемой переменной, литеральные целые числа (без дополнительных «украшений») будут интерпретироваться как целый тип со знаком, обычно int (см. раздел 13.2).
532 Часть5. Операторы Проблема с такими классами, как DoubleContainer, заключается в том, что синтаксис индексации может применяться также для указателей. В зависимости от типов аргумента оператора индексации и индексируемой переменной наши компиляторы (см. приложение А) ведут себя немного по-разному, но эти отличия очень существенны, как показано в табл. 27.1. Таблица 27.1. Компилятор Тип аргумента оператора []0 Тип индексируемой переменной Двусмысленность? Visual C++ 6.0, int int Нет Visual C++7.1 int size_t Да size_t int Да size_t size_t Нет Borland, int int Нет CodeWarrior, int size_t Нет Digital Mars. size_t int Нет Watcom size_t sizej Нет Comeau, GCC, int int Нет Intel int sizej Нет sizej int Да sizej sizej Нет Самая непопулярная комбинация для наших компиляторов - size_t для аргумента оператора индексации и int для индексируемой переменной - и именно с ней мы будем больше всего встречаться. Конечно, обеспечение как неявного преобразования, так и индексации не способствует переносимости, и я настоятельно рекомендую вам не стремиться их использовать. Представьте, с какими препятствиями вам придется столкнуться, если вы работаете с одним из четырех компиляторов, которые без проблем обеспечивают оба подхода, и, с радостью пользуясь этим, определяете эти операторы. При переходе на другой компилятор вам пришлось бы делать бесчисленное количество небольших изменений, и в каждом конкретном случае вам необходимо обдумывать, какие новые изменения это может повлечь. Вы можете посчитать, что (умеете обезопасить себя путем установки высокого - уровня предупреждений вашего компилятора для обнаружения использования типа int для индексатора, что позволит вам строго придерживаться типа size_t. Увы, будучи добропорядочными гражданами, вы будете должным образом получать указатель на элементы заданного массива в обобщенном программном коде с помощью оператора &аг [ 0 ] (см. гл. 33), в котором 0 будет интерпретироваться как int1. Для того чтобы ОНИ работали с такими типами, как DoubleContainer, вам потребуется переписывать такие выражения, как &ar [static_ cast<size_t> (0) ]. Естественно, в результате 1 Пожалуйста, не думайте, что я не сознаю частичную тавтологию в этой аргументации. Мы используем обозначение &аг[О], поскольку часто не хотим обеспечивать неявное преобразование. Одна из причин этого - двусмысленность, возникающая при его сочетании с индексацией.
Глава 27. Операторы индексации 533 мы споткнемся на некоторых компиляторах, если программный код применялся для типов, в качестве аргумента индексации которых использовался тип int. Я привык определять тип-член index_type при написании классов массивов (см. гл. 33), который мог бы быть использован в этих случаях - &ar[static_cast<C: :index_ type> (0) ] — но это нестандартный подход, и поэтому он не очень-то подходит для программного кода, который будет широко применяться. В любом случае такие приведения типов выглядят слишком уродливо. Следует иметь в виду, что в целом использование операторов неявного преобразования дает не очень уж хороший результат, и я не выступаю за их применение здесь. Но существуют обстоятельства, когда они оказываются полезными, в том числе при совместной работе с операторами индексации. Вероятно, с большим успехом можно объяснить, почему оператор индексации не может быть более приоритетным по сравнению со встроенной индексацией оператора неявного преобразования, но это совершенно не относится к делу. Мы работаем в реальных условиях, когда единственно разумное решение заключается в том, чтобы всегда воздерживаться от таких попыток. Дефект: обеспечение операторов неявного преобразования в указатели совместно с операторами индексации нарушает переносимость. Следует отметить, что проблема возникает даже в том случае, когда базовый класс имеет оператор неявного преобразования, а производный класс обеспечивает оператор индексации. Именно по этой причине pod_vector (см. раздел 32.2.8) использует auto_buf fer посредством композиции, а не с помощью неоткрытого наследования, что я сначала делал, пока старался использовать операторы индексации. Следует отметить, что компиляторы все-таки выдают сообщение о неоднозначности преобразования даже в том случае, когда применяется неоткрытое наследование, а операторы неявного преобразования базового класса недоступны в клиентском программном коде производного класса. 27.1.1 . Выбор операторов неявного преобразования Учитывая то, уто нам приходится делать выбор, возникает вопрос, а при каких условиях нам следует отдавать предпочтение неявному преобразованию по сравнению с операторами индексации? Мне известна только одна такая ситуация - когда необходимо приспособить широко применяемый массив к форме обращения через Указатель. Именно по этой причине auto_buffer (см. раздел 32.2) обеспечивает неявное преобразование, поскольку этот тип нацелен на обеспечение максимальной совместимости со встроенными массивами.
534 Часть 5. Операторы 27.1.2 . Выбор операторов индексации Я полагаю, что почти при любых обстоятельствах следует предпочесть операторы индексации операторам неявного преобразования. Это объясняется тем, что оператор индексации получает индекс, на основании которого он вычисляет местоположение соответствующего возвращаемого значения. В случае применения неявного преобразования компилятор сам выполняет расчет смешения индекса. Преимущества индекса в том, что можно удостовериться в его допустимости. double &DoubleContainer::operator [](size_type index) { . . . // Проверить допустимость индекса return m_buffer[index]; } Способы реализации этой проверки - тема следующего раздела. 27.2. Обработка ошибок Существует две вещи, которые нам необходимо делать, когда речь идет об ошибках индексации: обнаружить их и затем обработать. Но перед тем, как мы решим, как делать и то и другое, нам необходимо решить, а стоит ли вообще этим заниматься. Контейнерами последовательностей стандартной библиотеки deque, basic_string и vector предусмотрено два способа получения отдельных элементов: с помощью оператора индексации (одного или нескольких) и метода at () (одного или нескольких)1. При недостоверном индексе метод at () будет выбрасывать исключение std:: out_of_range, а оператор индексации в этом случае имеет «неопределенное поведение», что обычно означает вообще отсутствие проверки2. Другими словами, вы сами отвечаете за достоверность индекса, передаваемого оператору индексации. Конечно, здесь проявляется компромисс. Обнаружение ошибки влечет дополнительные затраты процессорного времени. Отсутствие контроля ошибок налагает на пользователей классов обязанность предоставления гарантировано достоверных индексов. (Мы, кроме того, увидим интересный поворот этой истории в гл. 33, когда нам необходимо обеспечивать эффективный доступ к элементам классов многомерных массивов.) Сам я согласен с подходом, используемым в стандартной библиотеке, и предпочитаю иметь максимально эффективный оператор индексации. Естественно, даже если я не согласен, структурное соответствие (см. раздел 20.9) ввело бы в заблуждение пользователей моих контейнеров, так что из-за несоответствия семантики контейнеров STL они стали бы неправильно использоваться, и затем вскоре ими вообще перестали бы пользоваться. 1 Для того и другого существуют константные и неконстантные версии. Предусмотрены также методы firontO и ЬаскО, которые работают так же. как операторы индексации. 2 Мне не известна реализация стандартной библиотеки, которая обеспечивает одинаковый режим работы операторов индексации и методов at(), но поскольку все зависит от реализации, вы имеете полное право это сделать в своих собственных контейнерах
Глава 27. Операторы индексации 535 —----------------------------------------------------------------------------- Но здесь есть нечто большее, чем выбрасывание исключения в at () и отсутствие контроля в operator [ ] (). Мы можем использовать утверждение для выполнения проверки в operator [ ] () в отладочном режиме, когда нас не волнуют дополнительные затраты процессорного времени: double &DoubleContainer::operator [](size_type index) { assert(index < size()); return m_buffer[index]; } Однако посмотрите на реализацию стандартной библиотеки поставщика вашего любимого компилятора - едва ли вы там найдете нечто подобное. Я полагаю, это объясняется тем, что часто хотят передавать внешней функции асимметричный диапазон управляемой последовательности, как показано в следующем примере: void DumpDoubles(double const ‘first, double const ‘last); DoubleContainer de; Добиться этого можно следующим, наиболее синтаксически кратким и, по моему мнению, элегантным способом: DumpDoubles(&dc[0], &dc[de.size()]); Существуют другие способы, как, например, достаточно напыщенный: DumpDoubles(&dc[0], &dc[0] + dc.sizeO); Или неудобоваримый: DumpDoubles(&dc[0] , (&dc[0] ) [dc.sizeO ]) ; Но мне бы не хотелось рассматривать ни один из них. Поэтому я предполагаю, что причина, по которой контейнеры стандартной библиотеки воздерживаются от применения утверждений для проверки достоверности индекса, связана с желанием избежать ложных сообщений об ошибках при обращении во время индексации к элементу, следующему за последним. Поскольку никогда не осуществляется разыменование второго адреса, такой индекс фактически не является недостоверным. У вас может возникнуть вопрос, а почему они не проверяют достоверность индекса С учетом возможности его равенства ссылке на элемент, следующий за последним. Мне самому это интересно знать. При написании библиотек контейнеров я всегда делаю именно так, как показано в следующем примере:1 Единственная причина, по которой я не пользуюсь записью index <= size(), связана с моей склонностью 'ФНМенять только оператор <. Обеспечение зависимости всевозможных операций сравнения всего лишь от °го этого оператора может оказаться важным в отдельных случаях. Увы, я настолько увлекся исполнением Tj110 правила, что, привыкнув, забыл о мотивах, поэтому вам придется самим их исследовать, если есть на то ^ание.
536 Часть5. Оператор double &DoubleContainer::operator [](size_type index) { i assert(!(size() < index)); return m_buffer[index]; } Кстати, в великолепной реализации стандартной библиотеки MSL компилятора CodeWarrior в операторе basic_string: : operator [ ] () выполняется проверка которая обеспечивает именно такую семантику, поэтому я считаю, что нахожусь в хорошей компании. 27.2.1. Операторы индексации и итераторы Как вам, надо надеяться, хорошо известно [Меуе 2001], недопустимо применять метод begin () контейнера последовательности для получения указателя на управляемую последовательность элементов. Хотя это может работать для некоторых контейнеров, например, для вектора, в большинстве реализаций нет гарантии, что это будет работать всегда, поскольку контейнеры последовательностей могут свободно обеспечивать итераторы типов классов. В приведенном выше примере функция DumpDoubles () принимает диапазон указателей. Она в принципе отличается от функции, принимающей диапазон итераторов, которая имела бы следующий вид (если считать, что DoubleContainer обеспечивает тип-член cons t_i t era tor): void DumpDoubles( DoubleContainer::const_iterator first , DoubleContainer::const_iterator last); Путаница возникает, когда const_iterator определяется как value_type const* - то есть double const* - поскольку в этом случае будет лишь одна функция DumpDoubles (). С этой несогласованностью вам, как пользователю Double- Container (), придется сосуществовать. Только не соблазняйтесь применять begin () (и end ()), когда концептуально вы имеете дело с указателями, а не с итераторами. Это прививает плохие привычки, которые будут проявляться в неподходящие моменты (см. раздел 14.2.2). Между прочим, использование метода end О не имеет тех же самых потенциальных проблем, которые характерны для &с [с. size () ], поскольку end() непосредственно предназначен для ссылки на элемент, следующий за последним элементом. 27.3. Возвращаемое значение Итак, мы пришли к выводу о необходимости применения разумной стратегии по проверке достоверности индекса, но все же остается вопрос о том, что нам в действительности следует возвращать оператором индексации. К счастью, это решается довольно просто при применении канонического &с [ 0 ]. Мы должны возвращать нечто
Глава 27. Операторы индексации 537 такое, что после применения оператора адресации (унарного оператора &) даст указатель на управляемую последовательность элементов. Поэтому DoubleContainer возврашает doubles и double const &, которые, и тот и другой, дадут указатель после применения оператора адресации. Я не знаю, как много тех, кто как и я в прошлом, создавал наивные до-БТЬ’овские контейнеры, которые возвращали значение элемента, как показано1 в следующем примере: class DoubleContainer { double operator [](size_t index) const; Это никуда не годится. Если вы возвращаете значение элемента, то нарушаете согласованность управляемой последовательности элементов и клиентского программного кода, который может специально применять оператор адресации для получения указателя на управляемую последовательность. Нельзя сказать, что вы обязаны возвращать ссылку. Альтернативной является стратегия возврата экземпляра, который действует как ссылка. Например, operator [] () класса DoubleContainer мог бы возвращать экземпляр Doublelndex- Ргоху: class DoublelndexProxy { double ‘operator &(); double const ‘operator &() const; Как вы, возможно, заметили, это противоречит приведенному в предыдущей главе совету воздерживаться от перегрузки operator & (). В данном случае все нормально, поскольку никто в здравом уме не будет пытаться разместить DoublelndexProxy в контейнере или действительно использовать его для каких-нибудь других целей. Фактически, это был бы закрытый вложенный класс, находящийся внутри Double- Container, поэтому компилятор никому не позволит спокойно осуществлять к нему Доступ, и придется следовать условиям управления доступом, навязанным вами. Этот подход кажется разумным, но он оказывается не совсем подходящим, когда Речь идет о многомерных массивах (см. гл. 33).
Глава 28 Операторы инкремента Встроенные префиксные и постфиксные операторы инкремента и декремента значительно сокращают запись. Вместо того, чтобы писать х = х + 1; мы можем просто написать ++х; ИЛИ Важным, особенно с момента появления STL, является то, что эти операторы могут перегружаться для типов классов. Префиксные операторы инкремента и декремента определяются для классов с помощью следующих функций: class X { X &operator ++(),- X &operator —() ; X operator ++(int); X operator —(int); 11 префиксный оператор инкремента 11 префиксный оператор декремента // постфиксный оператор инкремента U постфиксный оператор декремента Хотя ничто не заставляет вас поступать таким образом, суть в том, чтобы возвращаемые типы префиксной и постфиксной форм имели указанный выше вид, то есть для префиксных форм - неконстантная ссылка на текущий экземпляр, а для постфиксных форм - копия текущего экземпляра до изменения его значения. Другими словами, они работают так, как должны работать типы int [Меуе 1996]. Хотя некоторым это может показаться очевидным, я встречал много людей, испытывавших трудности при реализации постфиксной формы, поэтому я просто приведу ее канонический вид: X X::operator <-+(int) { X ret(*this); operator ++() ; return ret;
Глава 28. Операторы инкремента 539 Во всех библиотеках STLSoft 18 из 20 постфиксных операторов инкремента имеют точно такой вид, и если у вас нет особой причины делать что-то еще, такая реализация будет хорошо вам служить. 28.1 - Недостающие постфиксные операторы Поскольку C++ позволяет вам брать ответственность на себя (в отличие от некоторых раздражающе опекающих более «современных» языков), то разрешается определять префиксные методы в отсутствии постфиксных эквивалентов и наоборот. Проблема в данном случае заключается в том, что компиляторы по-разному ведут себя при отсутствии оператора. Например, если ваш класс определяет только префиксную форму оператора инкремента и ваш клиентский программный код размешает экземпляр вашего класса в выражении постфиксного оператора инкремента, некоторые компиляторы выдадут сообщение об ошибке, а другие компиляторы выдадут только предупреждение. Поведение ряда компиляторов сведено в табл. 28.1. По моему мнению, такое поведение, несомненно, ошибочное, и я не могу представить, чем может быть вызвана такая реализация, если только не потаканием неопытности прикладных программистов, которые слишком не профессиональны и не могут использовать соответствующую форму. С другой стороны - то есть когда речь идет о подстановке постфиксного оператора вместо вызова префиксной формы - все компиляторы поступают правильно и категорически отвергают ее. Пожалуй, это говорит в пользу моей позиции. Конечно, здесь существует опасность. Семантика этих двух операторов существенно отличается. Если ваш компилятор заменяет одну форму на другую, ваша программа становится некорректной. К счастью, большинство компиляторов будет категорически отвергать такие подстановки, а те, которые допускают ее, будут все же выдавать предупреждение, но если вы работаете только с предупреждающими компиляторами и ваши партнеры запутаются с уровнями предупреждений1, они могут проскользнуть незамеченными. Таблица 28.2. Компилятор Реакция Borland Предупреждает и использует префиксный оператор CodePlay Ошибка CodeWarrior Ошибка Comeau Ошибка Digital Mars Предупреждает и использует префиксный оператор GCC — Ошибка говорю о вашем партнере, т. к. знаю, что вы никогда бы так не поступили.
540 Часть 5. Операторы Таблица 28.2. Компилятор Реакция Intel Предупреждает и использует префиксный оператор Visual C++ Предупреждает и использует префиксный оператор Visual C++ (-Za) Ошибка Watcom Ошибка Дефект: некоторые компиляторы C++ будут подставлять префиксную форму перегруженных операторов инкремента/декремента вместо отсутствующих пост- фиксных эквивалентов. Это имеет довольно простое решение. Вы всего лишь объявляете нежелательную для использования перегрузку с закрытым типом доступа. Никакой компилятор не будет подставлять открытую префиксную форму вместо закрытого постфиксного оператора и наоборот. Во всяком случае, это действительно лучшее решение, поскольку делает очевидными ваши намерения; очень мало шансов, что сопровождающий программист не поймет вас, если будет указан спецификатор private, сопровождаемый комментариями о причинах недоступности оператора. 28.2 . Эффективность Поскольку постфиксные операторы должны сохранять предыдущее значение, чтобы вернуть его в контекст вызывающей программы, они часто менее эффективны по сравнению с префиксными аналогами. Я помню, как много лет назад один гуру1 по аппаратному обеспечению сказал мне, что микросхемы компании Motorola были способны более эффективно выполнять префиксные, а не постфиксные операции. Если операция постфиксного инкремента не поддерживается аппаратурой, ее применение обычно требует использования временного регистра за исключением тех случаев, когда компилятор может прийти к заключению, что первоначальное значение не нужно, и тогда он мог бы генерировать программный код, идентичный префиксной форме. Однако когда речь идет о типах классов, почти всегда вызов постфиксного оператора будет выполняться менее эффективно по сравнению с префиксной формой. К сожалению, очень многим обучающим заведениям не удается объяснить это отличие, т. к. по-прежнему существует много нового программного кода, написанного с применением постфиксных форм. Я встречал многих разработчиков, которые по привычке применяют постфиксную форму и, тем не менее, настаивают на том, что они используют префиксную форму для типов классов. Вот так-то! 1 Во всяком случае, он сам себя считал гуру.
Слава 28. Операторы инкремента 541 Абсурдность этого положения объясняется двумя причинам. Во-первых, для людей характерны свои привычки. Каждый раз, когда я обсуждал этот вопрос с разработчиками, и они готовы были предоставить мне для проверки их программный код, я обнаруживал примеры их забывчивости, из-за чего их машина выполняла лишнюю работу. Вторая причина - в пренебрежении принципом универсальности. Мы многократно используем шаблонные алгоритмы, которые работают с фундаментальными типами и с определенными пользователем типами, то есть с типами итераторов, не являющимися указателями. То, что не удается придерживаться правильной префиксной формы, может быть несущественно при применении таких алгоритмов к фундаментальным типам, но компиляторы могут быть не в состоянии исправить ваши погрешности, когда вы работаете с другими типами. 28.2.1. Обнаружение постфиксных операторов с неиспользуемыми возвращаемыми значениями Если вы, как и я, иногда устаете обсуждать корректность, устойчивость и эффективность с людьми, которые заставляют вас задуматься, правильно ли они выбрали свою сферу деятельности, то вы, возможно, заслуживаете того, чтобы иметь убедительное доказательство их недальновидности. Не правда ли, неплохо было бы иметь средство автоматического обнаружения неуместного применения постфиксных операторов? К счастью, у нас есть такая возможность. Я уверен, что вы не очень удивитесь, узнав, что существует решение на базе шаблона. Оно основано на применении класса unused_return_value_monitor. Листинг 28.1. template< typename V , typename М , typename R = V > class unused_retum_value_jnonitor { public: typedef unused_retum_value_jnonitor<V, M, R> class_type; public: explicit unused_return_value_jnonitor (R value, M monitor = MO) : m_value(value) , mjnonitor (monitor) , m_bUsed(false) {} unused_return_value_jnonitor (class_type const &rhs) : m_value(rhs.m_value) , m_monitorFn(rhs.mjnonitorFn) , m_bUsed(rhs.m_bUsed) { rhs.m_bUsed = false;
542 Часть 5. Операторы } ~unused_return_value_jnonitor() { if(!m_bUsed) { rnjnonitor (this. m_value); } } public: operator V() const { m_bUsed = true; return m_value; ) private: R m_value; M m_jnonitor; mutable bool m_bUsed; // Реализация не требуется private: unused_return_value_jnonitor ^operator =(class_type const &rhs); }; Это легко интегрируется в постфиксные операторы вашего класса, как можно видеть в следующем классе: Листинг 28.2. class X { private: struct usejnonitor { void operator ()(void const *instance, X const fcvalue) const { printf( 'Unused return value %s from object instance %p\n* , value.get_value().c_str(), instance); ) ); public: X ^operator ++() { . . . // Реализация операции инкремента return *this; } #ifdef ACMELIB_DEBUG unused_return_value_jnonitor<X, use_jnonitor> operator ++(int) #else /* ? ACMELIB_DEBUG */ X operator ++(int) #endif /* ACMELIB_DEBUG */
543 Слава 28. Операторы инкремента { X ret(*this); operator ++(); return ret; } private: string_t m_value; }; Поскольку это предназначено для обнаружения неэффективностей, пользоваться им следует только в отладочных версиях, поэтому я бы советовал вам применять условные операторы препроцессора для получения необработанного возвращаемого типа в рабочих версиях, как показано в этом примере. Что касается возможных действий вашей функции мониторинга, я полагаю, она должна направлять трассировку в файл отладчика или журнал регистрации событий, а не, скажем, выбрасывать исключение или вызывать abort О, поскольку вызов неуместной формы операторов представляет собой ошибку, приводящую к снижению эффективности (или, точнее, приведет к этому, если не произойдет чего-нибудь странного), а не семантическую ошибку. Но т. к. эти действия параметризуемые, вы можете делать все, что посчитаете необходимым.
Глава 29 Арифметические типы В разделе 13.3 мы рассматривали расширение целых типов, обеспечиваемое C++ в форме типов uinteger64 и sinteger64. В этой главе мы постараемся выяснить возможность создания типа при реальной и полной его интеграции в язык, позво- ляющей его использовать подобно тому, как мы это делаем со встроенными целыми типами. (Неидеальные результаты этих усилий включены в состав компакт-диска.) 29.1. Определение класса Прежде чем серьезно заняться деталями, давайте обратим свой взгляд на определе- ние одного класса. Чтобы избежать путаницы с двумя существующими переменными- членами lowerVal и upperVal, я решил использовать защищенное наследование и получить производный новый тип UInteger64. Теперь мы можем словчить и приме- нить объединение union типов uinteger64 и uint64_t для выполнения арифме- тических операций. Это рассчитано на правильное размещение в памяти двух членов структуры, как показано ниже:1 * Листинг 29.1. struct uinteger64 ( # if defined(ACMELIB_LITTLE_ENDIAN) uint32_t lowerVal; uint32_t upperVal; # elif defined(ACMELIB_BIG_ENDIAN) uint32_t upperVal; uint32_t lowerVal; #else # error Need to discriminate further . . . # endif /* endian */ ); Это позволяет нам создать следующего монстра, который, как я говорил, предна- значен для реализации только этого конкретного типа, и его нельзя рассматривать как общий метод, используемый для работы с большими целыми числами: 1 Естественно, это связано с особенностями преобразования такого типа в поток байтов и из него, что таю*6 характерно и для встроенных 64-битовых целых чисел.
545 Глава 29. Арифметические типы Листинг 29.2. class UInteger64 : protected uinteger64 { public: typedef union { uint64_t i; uinteger64 s; } ui64_union; ui64_union &get_union_() { uinteger64 *pl = this; ui64_union *p2 = reinterpret_cast<ui64_union*>(pl); return *p2; } ui64_union const &get_union_() const; Это не тот случай, когда вам приходится проектировать класс больших целых чисел с чистого листа и у вас нет возможности использовать более фундаментальный тип в типах, размер которых превышает 64 бита. Средство тестирования, используемое в этой главе, состоит из шаблонной функ- ции, в которой выполняются разнообразные операции с целыми числами. Мы все их разберем и рассмотрим варианты проектирования нашего класса. 29.2. Конструирование по умолчанию Это делается очень просто. Здесь я, как правило, стремлюсь к максимальному бы- стродействию и не инициализирую члены целого типа в соответствии с режимом работы встроенных типов. Однако это не так важно, и вы могли бы посчитать вполне разумной инициализацию членов нулевыми значениями. Одна интересная стратегия заключается в поддержке дополнительного члена, возможно, только в отладочных версиях, который обозначает неинициализированное Целое число во многом подобно тому, как используется специальное значение NaN (not а Graber - не число) для типов с плавающей точкой. Обращение к значениям неини- циализированных (то есть инициализированных по умолчанию) экземпляров приведет к НаРУшению утверждения предусловия. 29.3. Инициализация (конструирование значения) Следующее, что нам необходимо сделать с нашим целым типом - это обеспечить ®°зможность инициализации нашего типа другим типом. Учитывая отсутствие знака ^Типа, мы разрешим его конструировать с помощью как одного значения uint32_t, и двух значений uint32_t:
546 Оператор class UInteger64 { UInteger64(uint32_t i) // Нижние 32 бита { this->lowerVal = i; this->upperVal = 0; } UInteger64(uint32_t upper, uint32_t lower); В данном случае я не стал использовать спецификатор explicit, так что он в действи- тельности является конструктором преобразования (стандарт С++-98: 123.1), поскольку этот тип должен быть очень простым. Мы увидим, что это, казалось бы, безобидное реше- ние оказывает сильное влияние на остальную часть проекта класса. Теперь мы можем написать следующий оператор, который имеет обычный вид: UInteger64 i = 0; К сожалению, теперь нам потребуется немного схитрить. Поскольку это 64-бито- вый целый тип, очень вероятно, мы захотим иметь возможность его инициализации типом uint64_t при использовании компилятора, который поддерживает этот класс. class UInteger64 { UInteger64(uint32_t i); tifdef ACMELIB_UINT64_T_SUPPORT UInteger64(uint64_t i) { get_union_().i = i; ) tendif ACMELIB_UINT64_T_SUPPORT Однако теперь мы находимся в трудном положении. Указание в качестве аргумента любого типа без знака, размер которого меньше 64 бит - мы вновь здесь подразумева- ем, что целое число содержит 32 бита - приведет к выполнению перегрузки для uint32_t. Указание в качестве аргумента uint64_t приведет к использованию перегрузки для этого типа. Но целочисленные литералы, допускающие модифи цирующий суффикс (см. раздел 15.4.2), интерпретируются либо как int или long, либо как sint 64_t, когда этот тип поддерживается, хотя и не входит в стандарт. Одно решение состоит в добавлении третьего конструктора для int: class UInteger64 { UInteger64(uint32_t i); #ifdef ACMELIB_UINT64_T_SUPPORT
Глава 29. Арифметические типы 547 UInteger64(uint64_t i); #endif ACMELIB_UINT64_T_SUPPORT UInteger64(int i); С этим связаны две проблемы. Во-первых, теперь мы можем передавать тип int, имеюший отрицательное значение. Можно написать конструктор с простым его приве- дением в значение без знака, но это семантически бессмысленно - вы не можете пред- ставить отрицательное число в виде какого-то значения без знака, просто используя его как хранилище битов, которые будут в дальнейшем возвращены в переменную со зна- ком. Только один этот фактор делает неразумным применение этой стратегии. Существует также практическое возражение. Скажем, при желании выполнить преобразования типов unsigned long, unsigned int, unsigned short и un- signed char, что вполне логично (в предположении, что long содержит не более 64 бит), вы столкнетесь с неоднозначностью. Из-за того что uint32_t может быть реализован на нашем наборе компиляторов только как unsigned int, unsigned long или unsigned_____int32 (см. раздел 13.2), нам придется сильно дифференцировать действия препроцессора, чтобы получить переносимый класс. Вы, в конце концов, запутаетесь, как это иллюстрирует листинг 29.3. Листинг 29.3. class UInteger64 { UInteger64(uinte82_t i> ; #ifdef ACMELIB_UINT64_T_SUPPORT UInteger64(uint64_t i); ttendif ACMELIB_UINT64_T_SUPPORT UInteger64(int i); if defined (ACMELIB_COMPII>ER_IS_BORLAND) | | \ . . . CodeWarrior, Comeau, Digital Mars, GCC defined(ACMELIB_COMPILER_IS_WATCOM) UInteger64(unsigned int i); telif defined(ACMELIB_COMPILER_IS_INTEL) UInteger64(unsigned long i); tfelif defined(ACMELIB_COMPILER_IS_MSVC) UInteger64(unsigned long i); # ifdef ACMELIB_32BIT_INT_IS_DISTINCT_TYPE UInteger64(unsigned int i); # endif /* ACMELTB_32BTT_INT_IS_DISTINCT_TYPE */ tendif /* компилятор */ Решение заключается в использовании операторов условной компиляции для выбора лучшего конструктора, тип которого поддерживается текущим компилятором. решение вряд ли можно назвать красивым, но оно дает однозначный результат и значительно лучше выглядит, чем альтернативное:
548 ^S-Опереть class UInteger64 { «ifdef ACMELIB_UINT64_T_SUPPORT UInteger64(uint64_t i); «else /* 7 ACMELIB_UINT64_T_SUPPORT */ UInreger64(uint32_t i); «endif ACMELIB_UINT64_T_SUPPORT Фактически, вы должны обеспечить конструктор большого типа, поскольку очень легко столкнуться с усечением при использовании 32-битового конструктора, когда задается аргумент из 64 бит. Значительное меньшинство компиляторов не будет выда- вать предупреждение об этом, в частности, внутри программного кода шаблона. Следует отметить, что здесь потенциально имеется небольшое снижение эффек- тивности. Даже если внутри обоих конструкторов в итоге компилятором устанавли- ваются 64 бита и, следовательно, очень вероятно, что они одинаково затратные, в кли- ентском программном коде версия uint64_t будет вынуждена расширять любые 32 бита (или меньшее их количество) аргументов до 64 битов. Но даже если это бесспорно из-за выполняемой компилятором оптимизации, это небольшая цена, которую имеет смысл заплатить, чтобы избежать двусмысленных и бесчисленных перегрузок. 29.4. Копирующий конструктор Он очень простой. Класс UInteger64 - это простой тип значения, который не управляет никакими ресурсами (см. гл. 3 и 4), поэтому вы можете предоставить компи- лятору возможность обеспечить конструктор копирования по умолчанию или же вы можете определить его непосредственно, выполняя копирование каждого члена. Для использования второго способа нет особых причин, если не считать желание действо- вать в соответствии с вашими собственными стандартами кодирования. UInteger64 i = 0; UInteger64 i2 = i; // Синтаксис оператора присваивания UInteger64 i3(i); // Синтаксис конструктора 29.5. Присваивание В действительности это сделать достаточно легко, если мы станем полагаться на конструкторы преобразований. И снова, поскольку UInteger64 является типом значения, который не управляет ресурсами, мы можем предоставить компилятору в03' можность определения оператора присваивания. Это означает, что любое выражение, содержащее присваивание экземпляра UInteger64, выполнится удачно, если правая сторона выражения может быть преобразован в UInteger64, то есть она подходит для конструктора. Поэтому следующие операторы выполнятся удачно, т. к. конструк торы тоже выполнятся удачно:
549 рлава 29. Арифметические типы UInteger64 i; unsigned i2 = . uint64_t i3 = . UInteger64 i4; i = 0; i = i2; i = i3; i = i4; 29.6. Арифметические операторы Так же как и с присваиванием, мы можем с помощью конструктора преобразования очень просто решить нашу задачу. Во-первых, нам необходимо определить операторы присваивания как члены класса [Stro 1997]: Листинг 29.4. class UInteger64 { UInteger64 boperator +=(UInteger64 const brhs) { get_union_().i += rhs.get_union_().i; return *this; } _____________________________ UInteger64 boperator -=(UInteger64 const brhs); UInteger64 boperator *=(UInteger64 const brhs); UInteger64 boperator /=(UInteger64 const brhs); UInteger64 boperator %=(UInteger64 const brhs); UInteger64 boperator л=(UInteger64 const brhs); Затем обеспечиваются бинарные операторы в виде функций-не-членов, реализуе- мых с помощью открытого интерфейса класса: UInteger64 operator +( UInteger64 const blhs , UInteger64 const brhs) { return UInteger64(Ihs) += rhs; } 29.7. Операторы сравнения Операторы сравнения могут быть реализованы точно так же, как арифметические операторы с бинарными операторами-не-членами с помощью функций-членов: bool operator ==( UInteger64 const blhs , Ulnteger64 const brhs)
550 часть 5. Операторы return Ihs.IsEqual(rhs) ; ) bool operator <( UInteger64 const &lhs , UInteger64 const &rhs) { return Ihs.Compare(rhs) < 0; 29.8. Осуществление доступа к значению Нерешенным остался единственный вопрос, а именно, как нам извлекать значения из экземпляров Ulnteger64, когда надо их где-то сохранить, куда-то передать и т. п. Как вы помните, 64-битовые целые числа мы используем только в качестве при- мера, поэтому, хотя мы могли бы остановить свой выбор на методе доступа или опера- торе, обеспечивающем значение в виде встроенного 64-битового целого типа для ком- пиляторов, которые его поддерживают, в общем случае у нас нет такой возможности. Этот случай отлично подходил бы для явных приведений (см. раздел 19.5), если бы только они были надежно переносимыми и эффективными при работе с типами ссы- лок. В качестве альтернативного подхода я бы выбрал обеспечение константных и не- константных методов get_value, которые возвращают константные и неконстант- ные ссылки на базовую структуру, как показано ниже: class UInteger64 { uinteger64 &get_value(); uinteger64 const &get_value() const; Хотя неконстантная версия приведения или функции позволит вам манипулировать содержимым экземпляра за рамками открытого интерфейса, в общем случае, вероятно, вам потребуется обеспечить возможность такого доступа программным интерфейсам С. Поскольку Ulnteger64 - это простой тип значения, который не управляет никакими ресурсами, я бы не сказал, что с их помощью мы позволяем грабителям добраться до королевских сокровищ. Если вы предпочитаете обеспечивать их в виде операторов неявного преобразова- ния, тогда вы можете также просто получить производный класс от uinteger64 с от- крытым доступом к последнему и позволить компилятору выполнять преобразования там, где необходимо. Я предпочитаю более осторожный подход, характерный для явного вызова функции: он требует выполнения выбора непосредственно программи- стом и, кроме того, значительно легче поддается автоматическому поиску.
Г|0ва 29. Арифметические типы 29.9. sinteger64 551 Вопросы, которые мы исследовали для uinteger64 / UInteger64, в значитель- ной степени относятся и к версии со знаком, за исключением того, что нам больше нет необходимости обеспечивать сомнительную перегрузку для типа int, чтобы учесть применение литералов. Но это спорно, поскольку, как и с типом UInteger64, мы можем просто обеспечить единственный конструктор, представляющий самое боль- шое возможное значение для встроенного целого типа со знаком: class SInteger64 : protected sinteger64 ( #ifdef ACMELIB_SINT64_T_SUPPORT SInteger64(sint64_t i); «else /• ? ACMELIB_SINT64_T_SUPPORT */ SInteger64(sint32_t i); #endif ACMELIB_SINT64_T_SUPPORT Все остальное лишь плавно вытекает из этого. 29.10. Усечения, перевод в другие форматы и проверки Кажется, до сих пор наше обсуждение развивалось очень неплохо. Однако пока еще не все так здорово. Хотя мы и обсудили два возможных механизма получения нужных нам базовых значений больших целых типов, существует несколько операций встроен- ных целых типов, которые нам все же придется рассмотреть. 29.10.1. Усечения Обычно мы говорим негативно об усечении, но существуют случаи, когда оно нам как раз необходимо. Поскольку встроенные интегральные типы поддерживают усече- ние, мы должны стараться обеспечить его для наших больших целых типов. Для 64-би- тового типа нам естественно хотелось бы иметь возможность его усечения до 32-бито- Вог° типа и до типов меньшего размера. Единственный способ, позволяющий нам это Делать, используя естественный синтаксис, - это обеспечить оператор неявного преобразования: class SInteger64 { operator sint32_t () const
552___________________________________________________________Часть 5. Оператора Однако здесь имеется очень неприятная проблема. Если мы предоставляем этот оператор, то кроме обычных проблем, связанных с неявными преобразованиями мы будем способствовать выполнению усечения без предупреждений. Хотя это и опре делается реализацией, компиляторы в действительности обеспечивают выдачу преду преждений при возможности усечений фундаментальных типов, и нам, несомненно хочется, чтобы то же самое делалось для наших больших интегральных типов. Учитывая, что оба наших класса не являются шаблонами, усечение внутри этих операторов будет обнаружено на этапе компиляции функций, а не при их выполнении Если операторы являются встраиваемыми, любой применяющий эти типы клиентский программный код будет проинформирован, причем в каждой клиентской единице ком- пиляции, в которой обнаружен изъян этих классов, что приведет только к нежеланию ими пользоваться. Если операторы находятся в отдельных файлах реализации, то никто никогда не увидит предупреждения. Другой, немного лучший подход заключается в использовании явных приведений (см. раздел 19.5). Поскольку мы говорим о возврате именно значений фундаменталь- ных типов, явные приведения будут отлично работать: class SInteger64 { operator explicit—cast<sint32_t>() const 1; SInteger64 i64; sint32_t i32 = explicit_cast<sint32_t>(i64) ; Проблема здесь в том, что клиентский программный код вновь не будет получать предупреждение об усечении. Это немного лучше, чем при использовании оператора неявного преобразования, поскольку в исходном тексте, по крайней мере, существует нечто, бросающееся в глаза, но по-прежнему я не считаю, что этот подход достаточно хорош. Мне на ум приходит единственное решение, а именно, скопировать механизм явного приведения в новый шаблон, называемый truncation_cast (приведение с усечением), который будет работать точно так же, как явное приведение, но усечение становится более очевидным в силу его имени. Однако, на самом деле, я считаю, что это слишком сильный отход от здравого смыс- ла, поэтому мне самому было бы достаточно методов, выполняющих усечения: class UInteger64 { uint32_t truncate32() const;
Глава 29. Арифметические типы 553 Следует отметить, что в этом случае компилятор по-прежнему не будет выдавать предупреждение, но этого и не требуется, поскольку используемый нами синтаксис применения больших целых типов не будет совпадать с синтаксисом встроенных типов. 29.10.2. Перевод в другие форматы Кроме усечений значений иногда нам может потребоваться преобразование значе- ний в другие числовые типы, особенно в float, double и long double. Очевид- ный способ - обеспечить операторы неявного преобразования: class SInteger64 ( z operator float() const; operator double() const; operator long doublet) const; К сожалению, это выбивает из колеи все наши предыдущие арифметические опера- торы. Выражение, подобное следующему, теперь завершается неудачей из-за конфлик- та между оператором сложения, который не является членом, и встроенным арифме- тическим оператором. SInteger64 il; SInteger64 i2; il = i2 + 100; Это происходит из-за того, что данное выражение может интерпретироваться либо как преобразование 100 в тип SInteger64 и затем добавление его к i2, либо как преобразование i2 в тип float (или double, или long double) и его добавление к 100. Мы не можем обеспечивать неявное преобразование в какой-нибудь арифметиче- ский тип, т. к. иначе мы потеряем все наши арифметические операторы. Единственно решение - воспользоваться явными приведениями или методами преобразования. 29.10.3. Проверки Последний аспект встроенных целочисленных операций связан со способностью неявного участия в булевых условных подвыражениях. Хотя мне и не нравится пользо- ваться такими вещами для целых чисел (см. раздел 17.2.1), это все же является частью семантики встроенных интегральных типов, и поэтому мы должны рассмотреть этот аспект для наших больших целых типов. Мы как раз узнали, что любой интегральный тип нельзя использовать для нашего булева оператора, поэтому нам необходимо использовать либо void* (), Т * (), либо int т: : * (), рассмотренные нами в гл. 24. Естественно, лучше всего было бы исполь- зовать макрогенератор «правильных» булевых выражений.
554 Часть 5. Операторы Увы, дополнительный оператор, как мы только что видели, также привел бы к неод- нозначности. Выражение могло бы интерпретироваться либо как преобразование Юо к типу SInteger64 и затем его добавление к i2, либо как преобразование i2 к типу int operator_bool_generator< SInteger64>: : * и затем увеличение этого указателя на 100. Не правда ли, иногда просто хочется рыдать? Решение в данном случае заключается в том, чтобы обратиться за помощью к нашему верному другу - к прокладке атрибутов (см. раздел 20.2) и объявить проклад- ки is_true() и is_not (): inline bool is_true(SInteger64 const &i) { return i != 0; } inline bool is_not(SIntegers4 const &i) // или is_not_true() ( return i == 0; 1 Затем это может использоваться в совсем приемлемой форме: SInteger64 i = . . if(is_true(i)) { По моему мнению, однако, это всего лишь еще один гвоздь в гроб применения небу- левых выражений и их неявной интерпретации как булевых, поэтому если вы придержи- ваетесь совета из раздела 17.2.1. вам никогда не придется беспокоиться о таких вещах. if(0 != i) // Что может быть проще ( 29.11. Арифметические типы: заключение Я надеюсь, вы получили удовольствие от путешествия по теневой стороне того, что на поверхности кажется тривиальным. Мы только что видели, с какой легкостью опреде- лялись наши операции присваивания, сравнения я • арифметические операции при кон- струировании значений посредством конструкторов преобразований (без explicit). Увы, дальше этого мы не смогли пойти, пытаясь эмулировать синтаксис встроен- ных типов. Любая поддержка неявного усечения, перевода в другой формат и булевой проверки делает несостоятельными арифметические операции, и поэтому этого делать нельзя. Явные приведения, функции-члены доступа и прокладки обеспечивают разум- ный выход из положения, но они не безболезненны, не привлекательны, и мы едва ли сможем назвать их применение безоговорочной победой. Но они представляют собой определенный прорыв. Этот великий язык готов делать все возможное для поддержки наших намерений, но зайти так далеко он может только в том случае, если вы его убедите.
Глава 30 Быстрое вычисление Операторы && и | | обеспечивают выполнение булевой операции над двумя выра- жениями, как показано в следующем примере: if(x && у) // Проверяет истинность как х, так и у if (х || у) // Проверяет истинность /сотя бы одного из двух выражений, х или у Оба эти оператора обладают так называемой семантикой быстрого вычисления (short-circuit evaluation semantics) [Stro 1997]. Поскольку оператор && обеспечивает ло- гическую операцию AND над двумя аргументами, нет необходимости оценивать второй аргумент, если первый представляет собой «ложь». Напротив, для оператора | |, который обеспечивает логическую операцию OR над своими аргументам, нет необходимости оценивать второй аргумент, если оценка первого дает результат «исти- на». Поскольку все в С нацелено на обеспечение высокой эффективности, вторые аргу- менты оцениваются только по мере необходимости. Это очень удобно, поскольку позволяет нам писать лаконичные, но все же безопасные условные выражения: if( !str.empty() && str[0] == 1(') Однако семантика быстрого вычисления этих операторов не работает, когда они перегружаются и один или оба аргумента представляют собой тип класса. Следующий программный код может как выдать, так и не выдать свой болезненный «крик» в зави- симости от того, имеют ли аргументы перегруженного оператора operator && () типы bool и Y const& или типы bool и X const&: class X (}; class Y { public: operator X() const ( printffWhy am I called?\n"); 11 "Почему меня вызвали?" return X () ; } 1; Y У; if(false && y) {}
556_________________________________________________________________Часть 5. Операторы Это принципиальное изменение семантики почти невозможно обнаружить, просматри вая программный код. Дефект: перегрузка операторов && и || для типов классов приводит к незамет ному нарушению механизма быстрого вычисления. Безусловно, этого надо избегать. К счастью, необходимость в таких перегрузках возникает редко: за более чем десятилетнее программирование на C++ мне приходи- лось это делать только при проведении исследовательских работ и, вполне вероятно что вам также не придется это делать. В противном случае вы должны сознавать воз- можность побочного эффекта, приводящего к изменению семантики.
Часть 6 Расширение C++ Помимо всего прочего, самой сильной стороной C++ является его расширяемость. Лучшим и самым убедительным примером является стандартная библиотека шабло- нов (STL), которая изменила подход к использованию C++ многих из нас и стала важным компонентом стандартной библиотеки C++. Рассмотрение только возможно- стей расширения STL составило бы целую книгу. Однако STL - это всего лишь один пример. Его способность к расширению может проявляться бесчисленным числом способов, и в этой последней части мы рассмотрим только несколько самых интересных. Первая из пяти глав, гл. 31, «Продолжительность жизни возвращаемых значений», достаточно подробно рассматривает эту тему через реализацию концептуально простого компонента библиотеки. Решение этой задачи мы завершаем практической трактовкой некоторых вопросов, которые рассматривались ранее в книге, включая поточную организа- цию обработки, статические объекты и эффективность. В конце устанавливается реальная ценность систем сборки мусора, которую даже большие скептики (такие, как я) не могут отрицать. В главе 32, «Память», рассматриваются различные механизмы памяти в C/C++ и обращается внимание на традиционную и вызывающую часто раздражение пробле- му выбора между быстродействием и гибкостью. В ней также рассматриваются недос- татки и достоинства выбора распределителя памяти на этапе компиляции и на этапе выполнения, а также обсуждаются возможные способы эффективного сочетания раз- личных схем. В следующей главе 33, «Многомерныемассивы», демонстрируется несколько мето- дов использования ограниченных возможностей C++ для работы с динамическими многомерными массивами. Существуют недостатки, которые мы так часто находим, в обеспечении таких многомерных массивов, как типы классов, но мы знаем, что будучи смиренными программистами, аккуратно использующими индексацию, мы м°жем сделать все как надо и пользоваться этим. В главе 34, «Функторы и диапазоны», исследуются некоторые менее используемые аспекты STL и современных идиом C++, а также описываются некоторые ранние Эт®пы одного проекта - RangeLib - в котором я участвую с некоторыми другими Экспансионистами» C++. Представленная здесь концепция диапазона рассматривает
558 Часть 6. Расширение Сн его как неделимую сущность, а не как классическую асимметричную пару. Мы рас смотрим некий начальный этап работы по проекту RangeLib для того, чтобы просто убедиться в больших возможностях этой простой концепции1. В последней главе 35, «Свойства», описываются эффективные с точки зрения вре- мени и пространства способы поддержки свойств в C++. Мы увидим, как далеко можно раздвинуть этот язык в наших поисках реализаций концепций высокого уровня при низких (по времени и памяти) затратах. Эта одна из моих любимых глав книги2 в которой действительно показана мощь и приспособляемость этого языка. 1 Последние версии RangeLib возможно будут включены в компакт-диск; они также доступны на сайте RangeLib по адресу http://rangelib.ofg/. 2 Если вы думаете, я так говорю, потому что она самая большая, и я просто склонен к излишней многословности, ну... возможно, в этом есть некоторая польза.
Глава 31 Продолжительность жизни возвращаемых значений Данная глава содержит много материала из серии моих обзоров «Flexible C++» (Гибкий C++) в журнале «C/C++ User’s ^Journal» и в онлайновой конференции «Expert’s Forum» [Wils 2003d, Wils 2003f, Wilson 2004b]. Этот материал связан с каза- лось бы тривиальной проблемой эффективного преобразования целых чисел в строковые данные. Но когда начинаешь вникать в детали, оказывается, что эта проблема какая угодно, но только не тривиальная, и как иллюстрируют некоторые практические варианты реше- ний многих уже рассмотренных в книге вопросов, она остро высвечивает.проблему про- должительности жизни возвращаемых значений {Return Value Lifetime - RVL), которую мы обсуждали в разделе по прокладкам преобразований (см. разделы 16.2 и 20.5). Вероятно, самый распространенный способ преобразования целых чисел в строко- вые данные связан с использованием функции sprint f (), которая вызывается либо непосредственно в клиентском программный коде, либо косвенно, например, при вы- полнении операций вставок в lOStream [Lang 2000]. Однако эта очень мощная функция слишком избыточна для многих простых преобразований, и при ее использовании при- ходится расплачиваться снижением эффективности. Мы рассмотрим простой, очень эффективный, основанный на шаблоне метод и затем его усовершенствуем нескольки- ми различными путями, исследуя проблемы, возникающие в каждом из пяти неидеаль- ных решений. Каждое из этих решений по преобразованию целого числа в строку имеет тот или иной недостаток, и их исследование позволит отчетливо увидеть проблемы, имеющие- ся в C++, которые заставляют неосторожных (а иногда и осторожных) кусать локти. 31.1. Таксономия «странностей» продолжительности жизни возвращаемых значений Возникающие при работе с RVL трудности обусловлены тем фактом, что в C++ не все является типом значения. Иногда мы осуществляем доступ к сущности с помощью ссылки. Эта ссылка может (в C++) быть либо указателем (например, X*), либо собст-
560 Часть 6. Расширение C++ венно ссылкой C++ (например, Х&), но в обоих случаях это, фактически, просто некое значение, обозначающее место расположения сущности, на которую оно ссылается. Проблема RVL возникает из-за того, что такая ссылка может устаревать в силу изменения или прекращения существования сущности, к которой она относится Существует три основных проявления этой проблемы в C++, и в каждом случае она будет влиять на наши реализации преобразования целого числа в строку. 31.1.1. Локальные переменные Почти каждая книга по основным возможностям и особенностям C++ [Dewh 2003 Меуе 1998, Stro 1997] содержит предупреждение относительно возврата адреса локаль- ных переменных, и я буду исходить из того, что вы понимаете связанные с этим опасно- сти1. Мы будем называть это проблемой RVL-LV (RVL локальных значений). 31.1.2. Локальные статические объекты Как мы говорили ранее (см. гл. 11), применение локальных статических объектов может быть как опасным, так и очень удачным, в зависимости от обстоятельств, в которых они используются. Почти всегда обращение к локальным статическим объектам по ссылке несет в себе опасность. Мы будем называть это проблемой RVL- LS (RVL локальных статических объектов). 31.1.3. Указатели на уничтоженный экземпляр объекта Как мы видели в разделе 16.2 и при обсуждении прокладок преобразования и про- кладок доступа (см. гл. 20), в C++ всегда очень легко присвоить указатель или ссылку на что-нибудь, содержащееся в экземпляре объекта, что в последствии уничтожается. Мы будем называть это проблемой RVL-PDP (RVL указателей на уничтоженный экземпляр объекта). 31.2. Зачем возвращать ссылку? Вы можете поинтересоваться, почему вообще нужно возвращать ссылку, если это так опасно, как мы это уже видели (см. разделы 16.2, 20.6.1) и еще увидим далее в данной главе. Ну, причина в том (мы ей посвятили много времени при обсуждении прокладок), что это позволяет нам обеспечить высокую степень обобщенности про- граммного кода при применении фундаментального типа, например, char const*, в качестве нашего общего типа. Более того, поскольку весь C++ в некоторой степени является оболочкой, нам в какие-то моменты приходится добираться до внутреннего содержания - в конце концов, по этой причине std: : basic_string имеет метод c_str (), поэтому нет возможности каким-то образом полностью избежать этих про- блем. 1 Если вы не находитесь на этом уровне, то я, конечно, с одобрением отношусь к проявленной вами стойкости, позволившей вам дойти до этих страниц книги.
(-дэва 31 Продолжительность жизни возвращаемых значений 561 ---------------------------------------------------------- ------—— Другая причина, как мы увидим в данной главе, более высокая эффективность по сравнению с возвратом значения, причем иногда эта разница значительна. 31.3. Решение 1 - integer_to_string<> В основе всех пяти методов лежит набор шаблонных функций integer_to_s tring (), которые перегружаются для выбора соответствующей реализации шаблонных функций signed_integer_to_string () и unsigned_integer_to_string (): template «typename С> С const *integer_to_string(C *buf, size_t cchBuf, sint8_t i) { return signed_intagar_to_string(buf, cchBuf, i); . . . // и uint8_t, sintl6_t и так далее template «typename C> C const *integer_to_string(C *buf. size_t cchBuf, uint64_t i) { return unsigned_integer_to_atring(buf, cchBuf, i); } Предусмотрены отдельные реализации функций, т. к. при преобразовании целых чисел со знаком приходится учитывать необходимость обработки отрицательных чисел, поэтому оно выполняется немного менее эффективно по сравнению с преобра- зованием чисел без знака, когда не требуется обрабатывать отрицательные числа. Версия для чисел без знака показана в листинге 31.1. Листинг 31.1. template* typename С , typename I > const C *unsigned_integer_to_string(C *buf, size_t cchBuf, I i) { C *psz = buf + cchBuf - 1; // Установить psz на последний символ *psz =0; // Установить завершающий нуль do { unsigned Isd = i % 10; // Получить младшую цифру i /= 10; // Подготовить следующую по старшинству // цифру —psz; // Передвинуть указатель назад *psz = get_digit_character<C>()[Isd]; // Записать цифру } whiled != 0) ; return psz; )
562 Часть 6. Расширение C++ При работе функций выполняется запись цифр в обратном порядке в переданный вызывающей программой буфер символов и делается возврат указателя на находя щуюся в буфере преобразованную форму. Младшая цифра вычисляется и записывает ся в текущий конец буфера. Каждая цифра преобразуется в эквивалентный символ по таблице преобразования, содержащейся в функции get_digit_character ()1 показанной в листинге 31.2. Листинг 31.2. template «typename С> const С *get_digit_character() { static const C s_characters[19] = ( '9', ‘в*, Ч', ‘б’, '5', '4', ’З', '2', , 'О' , '2', 'З1, '4', *5', 'б*, Ч'. ’в’, '9* }; static const С *s_jnid = s_characters + 9; return sjnid; Преобразуемое значение делится на 10, конечная точка перемещается назад, и цикл повторяется, пока не будет достигнут 0, и текущая конечная точка возвращается в виде указателя на преобразованную строку. Следует отметить, что она может не совпадать с началом заданного буфера. После этого для чисел со знаком, имеющих отрицатель- ное значение, устанавливается знак минуса перед значением, и функция возвращает указатель на этот символ. Функция используется очень просто: uint64_t i = . . . wchar_t buf[21]; wchar_t const *s = integer_to_string(buf, dimensionof(buf), i); T. к. здесь не применяется внутренний буфер, этот метод является потокозащищен- ным (см. гл. 10). Он типобезопасен и работает с любым целым типом, для которого опре- делены перегрузки integer_to_string(), и с любым символьным типом. Ион очень быстрый - расходует 10% от времени выполнения sprintf () (см. раздел 31.8). Однако в его адрес можно сделать два критических замечания. Во-первых, он не очень лаконичный: в качестве аргументов приходится передавать длину буфера вместе с указателем на буфер и целое число. Во-вторых, что более серьезно, можно неверно указать значение длины буфера. Это значение, которое представляет длину в символах, а не размер в байтах, используется для определения конечной точки полученной строки, с которой начинается запись 1 Кроме того, что эта реализация обеспечивает не только десятичные цифры (поддерживая планируемь реализации с базой, отличной от 10), она будет также хорошо работать с любыми схемами кодирова в которых символы ‘О’, ‘ Г - ‘9’ располагаются непоследовательно.
Глава 31 • Продолжительность жизни возвращаемых значений 563 символов в обратном направлении. Поскольку эта реализация имеет только утвержде- ние для отладки на этапе выполнения, может возникнуть недогрузка буфера. В обязан- ности программиста входит обеспечение буфера достаточной длины; в частности, именно благодаря этому данный метод обеспечивает дополнительное быстродействие. Естественно, вы будете поступать как я, используя dimensionof () (см. раздел 14.3) или эквивалентный механизм, чтобы избежать любых проблем, но, тем не менее, суще- ствует это слабое место. Для различных типов требуются буферы небольшого и постоянного размера (см. табл. 31.1), и хотя разработчики, применяющие данный набор функций, без труда воспринимают эту идиому, все же имеется тревожное чувство хрупкости. Более того, несмотря на то, что никто из использующих эту функцию не сообщал об ошибке, имеется много жалоб на многословность клиентского программного кода. Существует несколько различных вариантов расширения этого метода в ответ на эту критику. Таблица 31.1. Необходимые размеры буферов для преобразования целых типов Тип Размер (в символах), включая завершающий нуль Пример 8-битовый со знака 5 "-127" 8-битовый без знака 4 "255" 16-битовый со знака 7 "-32768" 16-битовый без знака 6 "65535" 32-битовый со знака 12 "-2147483628" 32-битовый без знака 11 "4294967295" 64-битовый со знака 21 "-9223372036854775808" 64-битовый без знака 21 "18446744073709551615" 31.3.1 . RVL Для решения 1 совсем не характерны проблемы, связанные с RVL-LS или RVL-PDP. Оно чувствительно к RVL-LV, но лишь в той же мере, как и любая другая функция - например, memset (), s trcpy () и тому подобное - которая возвращает переданный ей Указатель. 31.4. Решение 2 - специальная память потока Два из трех параметров функций integer_to_string () предусмотрены для обес- печения потокозащищенности. Если бы мы могли каким-то образом организовать потоко- з^ищенный внутренний буфер, то нам потребовалось бы обеспечивать только преобра-
564 Часть 6. Расширение Сн зуемое целое число. Похоже на то, что здесь подойдет какая-нибудь специальная память потока {Thread-Specific Storage - TSS) (см. раздел 10.5). Применяя TSS, мы можем на основе первоначальной функции написать новую, скажем, int_to_string () *, которую можно было бы определить следующим образом: template* typename С , typename I > С const *int_to_string(I value) { const size_t CCH =21; // вмещает 64-Оитовое число * знак С ‘buffer = i2str_get_tss_buffer<C, CCH>(); return integer_to_string(buffer, CCH, value); ) Мы сделали CCH = 21 для обеспечения достаточного пространства любому целому числу, содержащему вплоть до 64 бит (со знаком и без знака). Эта реализация рассчитана на то, что функция i2str get tss buf fer () возвращает для каждого потока свой буфер (в статической памяти) заданного символьного типа С. 31.4.1. _declspec(thread) На платформе Win32 метод TSS доступен в двух формах, как мы видели в разделе 10.5.3. Для исполняемых модулей и динамических библиотек, которые явным образом загружаются при запуске приложения [Rich 1997], некоторые компиляторы платформы Win32 обеспечивают расширение компании Microsoft decl spec (thread), опреде- ляющее отдельную переменную для каждого потока. Применяя__decl spec (thread), мы можем предложить следующую реализацию i2str_get_tss_buf f er ()2: template* typename С , size_t ССН > С *i2str_get_tss_buffer() { __declspec(thread) static С s_buffer[CCH]; return s_buffer; } 1 Фактически, существует восемь функций, соответствуя восьми функциям integer_to_string, для каждого целочисленного типа (8-, 16-, 32- и 64-битовых целых чисел со знаком и без знака). Это тр6 > дополнительных усилий от бедных разработчиков библиотек (вздох) и иногда вызывает неудобства пользователя, но этот способ помогает гарантировать невозможность (неверного) использования ДРУГ интегральных типов, например, wchar_t и bool, с этими чисто числовыми С-функциями (см. раздел 19.4). 2 Следует отметить, что в фактической реализации WinSTL каждая из восьми функций >nt_to_string<:’ использует число 21 в качестве длины буфера. Это в действительности экономит пространство, занима। и программным кодом, и данными, поскольку количество инстанциирований i2str_get_tss_bune уменьшается с потенциально максимального значения 8 до всего лишь 2. Кроме того, это также сн» временные расходы там, где реализация функции содержит нетривиальную логику, как мы УВ1 в следующей реализации.
Глава 31 Продолжительность жизни возвращаемых значений 565 Эта реализация имеет очень хорошие показатели производительности, лишь чуть меньшие, чем показатели самой функции integer_to_string()(cM. раздел 31.3). Увы, ограничения применимости___decl spec (thread) (см. раздел 10.5.3) означают, чТо мы не можем ее серьезно рассматривать в качестве кандидата для применения в наших библиотечных функциях преобразования. 31.4.2. Локальная память потока в Win32 Другая форма TSS в системе Win32 - программный интерфейс локальной памяти потока (Thread-Local Storage - TLS), который мы обсуждали в разделе 10.5.2. Применяя TLS на платформе Win32, мы получаем следующую реализацию i2str get tss buf f er: Листинг 31.3. template* typename C , size_t CCH Z > C *i2str_get_tss_buffer() { static Key<C, CCH> s_key; Slot<C, CCH> *slot = s_key.GetSlot()i if(NULL == slot) ( slot = s_key.AllocSlot(); } return slot->buff; } Вся работа делается классами Slot (см. листинг 31.4) и Key (см. листинг 31.5), при написании которых нам пришлось проявить хорошее мастерство по работе с потоками. Листинг 31.4. template* typename С , size_t CCH > struct Slot { Slot(Slot *next) : next(next) {} -SlotO ( delete next; } C buff[CCH]; Slot ‘next; );
566 Часть 6. Расширение C++ Класс Key выделяет память для ключа TLS с помощью функции TlsAlloc () которая затем используется в его методах GetSlot () и AllocSlot (). GetSlot () просто возвращает должным образом приведенное значение, полученное от T1S- GetValue (). Метод AllocSlot () немного более сложный, потому что ему необхо- димо выделить память под экземпляр слота Slot и добавить его в список связанных экземпляров слотов ключа Key внутри потокозащищенного блока. Этот блок требуется только для обеспечения сохранности целостности связанного списка, управляемого членом m_top, и поэтому отсутствует вызов TlsSetValue (). (Все экземпляры Slot (слотов) уничтожаются в деструкторе Key, что происходит на этапе завершения выпол- нения модуля или процесса и не требует каких-либо мер по обеспечению потокозащи- щенности.) Листинг 31.5. template* typename С , size_t ССН > struct Key { typedef Slot<C, CCH> Slot; Key() : m_key(::TlsAlloc()) { if(TLS_OUT_OF_INDEXES == m_key) { . . . // выбросить исключение } } -Key() { // Пройти список слотов, освобождая память. Это может // выполняться медленно, // поскольку быстродействие здесь не играет важной роли, delete m_top; ::TlsFree(m_key); } Slot ‘GetSlotО { // ПРИМЕЧАНИЕ: здесь не надо обеспечивать потокозащищенность return reinterpret_cast<Slot*> (: :TlsGetValue (it\_key)) ; } Slot ‘AllocSlot() { Slot ‘next; { il Защитить манипуляции co связанным списком lock_scope<thread_mutex> lock(m_mx); m_top = next = new Slot(m_top);
Глава 31 • Продолжительность жизни возвращаемых значений 567 ::TlsSetValue(m_key, next); return next; } private; dword_t const m_key; Slot *m_top; threadjnutex mjnx; }; Это требует достаточно мало программного кода, но после первого конструирования Key этой функцией (в любом потоке) и первого распределения памяти под Slot для ка- ждого потока, дальнейшие затраты будут очень небольшими, как мы увидим в разделе 31.8. Самые наблюдательные из вас, возможно, заметили существование потенциально- го условия гонки1 из-за того, что в int_to_s tring () не видно защиты сериализации потоков для статически сконструированного экземпляра Key (см. гл. 11). В данном случае решение состоит в обеспечении потокозащищенности самого класса Key путем применения спин-мьютексов (чего еще?) а полную реализацию вы можете найти в составе компакт-диска. Итак, решен вопрос динамической загрузки DLL, но наша задача решена еще не полностью. Проблема в том, что количество ключей конечно в каждой конкретной системе Win32. В более поздних операционных системах это не играет большой роли, но в ранних версиях Windows предусматривается очень мало ключей TLS (см. раздел 10.5.2). Не трудно представить очень сложное программное обеспечение с большим количеством компонентов, использующих TLS, поэтому вполне реально, что их ока- жется недостаточно. Другой недостаток состоит в медлительности (хотя и незначительной; см. раздел 31.8) этой формы TSS по сравнению с declspec (thread). Как отмечалось ранее, установ- ка во всех перегрузках int_to_string () значения 21 переменной ССН позволяет эффек- тивно использовать пространство и время. Однако этот подход имеет еще одно достоинство. В свете теперь известной нам потенциальной нехватки ключей понятно, что можно исполь- зовать максимум два ключа TLS - для char и wchar_t7, а не до восьми, значительно уменьшая вероятность катастрофического отказа при выделении памяти для ключа. Тем не менее, остается открытым вопрос о том, что делать, когда выделяемая под ключ память оказывается недоступной. Если подойти к нему практически, то можно сделать так, что варианты функции преобразования для char и wchar_t будут вызы- ваться при инициализации приложения. Не защищая приложение от аварийного завершения, это, по крайней мере, ускорит его и поэтому существенно поможет прак- тическому тестированию его устойчивости. Естественно, это совершенно не гаран- ТиРУет, что не произойдет нехватки памяти. При необходимости обеспечения абсолют- ^2^У£ТО^ЧИВОСТИ мы должны использовать другой подход. Я Уверен в том, что те из вас, кто заметил это, подумают о том, насколько маловероятно возникновение этого ®ия гонки. Однако «маловероятно» еще не означает достижение нужной цели при разработке ^поточного программного обеспечения, поэтому необходимо признать отсутствие защищенности от УСл°вий гонки.
568 Часть 6. Расширение с++ 31.4.3. Платформо-независимые программные интерфейсы Предыдущие два решения представляли собой два варианта одних и тех же фуНк ций проекта WinSTL8. В моем коммерческом воплощении в качестве консультанта компании «Synesis Software» я реализовал платформо-независимые версии функций преобразования целых чисел в строку, которые недавно были обновлены и теперь используют функции STLSoft integer_to_string(). В одном из базовых DLL системы Synesis существует функция LongToStringA() - вместе с ее unsigned Unicode и 64-битовыми собратьями - определенная в пространстве имен SynesisStd (см. листинг 31.6). Она использует платформо-независимую библиотеку TSS, которую мы рассматри- вали в разделе 10.5.4. Эта реализация библиотеки включает объекты синхронизации для взаимного исключения внутрипроцессного доступа и упорядоченный список слотов с идентификаторами потоков, поэтому не должен особо удивлять недостаток этого подхода - он работает значительно хуже, чем два других подхода. Листинг 31.6. PCAChar LongToStringA(Long value) { const size_t I2S_LIMIT = 0x7f; TssValue value = Tss_GetSlotValue(sg_hkeyA); PAChar buffer; if(value == 0) { value = (TssValue)Mem_Alloc_JfoTrack( sizeof(AChar) * (1 + I2S_LIMIT))); Tss_SetSlotValue(sg_hkeyA, value, NULL); } buffer = SyCastRaw(PAChar, value); return integer_to_string(buffer, 1 + I2S_LIMIT, value); } 31.4.4. RVL Это решение защищено от проблем RVL-LV и RVL-PDP. На первый взгляд, может пока- заться, что нами решена также и проблема RVL-LS в силу обеспечения потокозащищенных буферов, хотя и за счет значительного увеличения сложности. Однако RVL - хитрая штука, и мы увидим в следующем разделе, что это решение все-таки имеет проблемы. 31.5. Решение 3 - расширение RVL Преимуществами подхода, построенного на базе TSS, являются его потокозашишен- ность, работа с любым типом символа и отсутствие буфера, предоставляемого вызы- вающей программой. Естественно, вызывающей программе не надо также предостав Лйть длину буфера, поэтому невозможно передать integer_to_s tring () буфер не достаточного размера.
Глава 31. Продолжительность жизни возвращаемых значений 569 Одно небольшое неудобство связано с тем, что теперь нет никакого символьного параметра, на основании которого компилятор мог бы определить тип символа, что означает необходимость явной параметризации шаблонной функции (стандарт С++- 98: 14.8.1): uint64_t i = . . . wchar_t const ‘result = int_to_string<wchar_t>(i); Однако существует другой, более серьезный недостаток, который мы собираемся исследовать и попытаемся устранить в решении 3. Рассмотрим следующий пример в свете реализации решения 2: printf(•%s %s•, int_to_string<char>(5) , int_to_string<char>(10)); При текущей реализации функции int_to_string () мы можем получить "5 5" или "10 10", но нет способа, позволяющего нам получить нужное значение "5 10". Это происходит из-за того, что два вызова int_to_s tring () возвращают одинаковое значение, то есть указатель на буфер определенного потока для конкретной комбинации символа и целого числа. Таковы неожиданные особенности проблемы RVL-LS, которая может проявляться и более тонко: int some_func(int, char **); printf("%s %d\n", int_to_string<char>(argc) , some_func(argc, argv); Если some_f unc () вызывает int_to_string<char> (int), непосредственно или косвенно, то мы вновь получаем непредсказуемый результат. Для обеспечения высокой эффективности функции преобразования возвращают С-строки, а не экземпляры std: :basic_string() и подобных типов. Проблема в том, что С-строка не является типом значения; это тип указателя, значение которо- го представляет собой адрес представления в памяти целого числа в виде строки. Эта проблема характерна не только для int_to_string (): любая функция, которая возвращает указатель на структуру, может обладать таким свойством вне зависимости от того, будет она или не будет, как и int_to_string (), потокозащищенной. 31.5.1. Решение внутрипоточной проблемы RVL-LS? Итак, что же нам делать с этим? Давайте предположим, что нам обязательно требует- ся возвращать указатели на С-строки. Конечно, нам желательно иметь возможность воз- врата различных буферов в результате параметризации i2str_get_tss_buf fer (), когда соответствующая параметризация int_to_ string () вызывается многократно внутри одного выражения. Я полагаю, что это, к сожалению, почти невозможно или, по крайней мере, потребовало бы больших затрат процессорного времени.
570 Часть 6. Расширение C++ Однако в действительности последовательность вызовов внутри одного выражения не является обязательным условием; просто достаточно сделать так, чтобы вероятность этого события была очень маленькой. Из-за характерных особенностей преобразования целого числа в строку (то есть существования фиксированных максимальных длин для преобразованной в строку формы) мы можем приблизить «невозможность» этого собы- тия, следующим образом изменяя реализацию i2str_get_tss_buf fer ()l: Листинг 31.7. template* typename C , size_t CCH > C *i2str_get_tss_buffer() { const size_t DEGREE = 32; ______declspec(thread) static C s_buffers[DEGREE][CCH]; ______declspec(thread) static size_t s_index; s_index = (s_index + 1) % DEGREE; return s_buffers[s_index]; } Выбирая достаточно большое с нашей точки зрения число, мы снижаем вероят- ность перезаписи. 32 буфера, каждый размером ССН (достаточного для размещения преобразованного целого числа), объявляются для каждого потока вместе с индексной переменной потока. При каждом вызове значение индексируемой переменной увеличивается на единицу и вновь устанавливается в 0, когда оно достигает числа 32. Таким образом, каждый из 32 буферов используется по очереди, и эта процедура осуще- ствляется независимо для каждого потока. Естественно, число 32 определяет предполагаемое максимальное количество пре- образований целых чисел в строку (следует иметь в виду, что это число определяет максимум в расчете на каждый целый тип, то есть существует 32 буфера для uint32_t, 32 - для intl6_t и т. д.) и представляет собой компромисс между дос- тижением желательной «безопасности» и размером стека. Вы можете установить свое собственное ограничение. 31.5.2. RVL Итак, мы увеличили защищенность от проблемы RVL-LS. К сожалению, мы не устранили ее, и, я надеюсь, вам ясно, что теоретически это невозможно сделать. Конечно, мы можем практически избавиться от нее путем использования достаточно большого буферного массива, но это совершенно дилетантское решение. Не поймите 1 Следует отметить, что это только реализация версии для_declspec(thread). Как описано в разделе 31-4, __declspec(thread) подходит только для ограниченного количества сценариев разработки, поэтому вам. вероятно, придется использовать другую форму TSS. С точки зрения производительности, как версия этого решения с_declspec(thread), так и версия, использующая библиотеку Tss Library (см. раздел 10.5.4), работают так же быстро, как и варианты решения с одним буфером, описанные в разделе 31.4.
Глава 31. Продолжительность жизни возвращаемых значений 571 —---------------------------------------------------------------------------- меня неправильно: существуют обстоятельства, при которых допустимо обеспечивать практическую, а не теоретическую корректность. Я просто не думаю, что это тот случай. Любопытно, что Бьерн Страуструп обсуждает аналогичное применение этого метода в сценарии, в котором неудача в обеспечении уникальности представляла бы собой серьезную проблему. В обычной для него мягкой форме он сказал, что вы будете в затруднительном положении, если попадете в ситуацию, возникшую из-за наруше- ния уникальности. Я бы сказал то же самое, но не в такой мягкой форме. По моему мнению, это решение в действительности еще менее желательно, чем решение 2, поскольку, по крайней мере, в этом случае отсутствует желание создать у пользователей функции ложное чувство безопасности; любое многократное использование возвращаемого значения обязательно приведет к получению ошибочного результата. Это более предпочти- тельно, чем применять библиотеку, которая обещает, что «маловероятно встретить ошибку». Данное решение должно быть полностью отвергнуто1. Если мы хотим возвращать указатель на буфер, это будет выглядеть, как будто мы собираемся передать в буфере самих себя. 31.6. Решение 4 - определение размера статического массива К этому времени вы, возможно, уже потеряли надежду из-за того, что с каждым новым решением делается шаг назад. К счастью, это не так, и я полагаю, что решение 4 - это оптимальное решение проблемы, учитывая текущее состояние языка и большинство под- держивающих его компиляторов. Возвращаясь к первоначальным функциям integer_to_string (), мы обнару- жим, что единственной их особенностью, заслуживающей серьезной критики, являет- ся возможность передачи вызывающей программой недостоверного значения длины буфера. Если длина слишком маленькая, то, по крайней мере, сработает утверждение в отладочных версиях. Если переданное значение длины достаточно большое, но оно не точно представляет реальную длину небольшого буфера, то возникает недогрузка буфера, которая приведет к нарушению работы стека или динамической памяти. Но как мы видели в разделе 14.3, большинство современных компиляторов может определить статический размер массивов. Поэтому мы можем определять перегрузки первоначальных функций integer_to_string(), которые принимают в качестве параметра массив, а не параметры указателя и размера: template< typename С , size_t N > С const *integer_to_string(C (&buf)[N], int8_t i) { return integer_to_string(buf, N, i); // Безопасный вызов версии // с указателем _______}_____________________ Фактически, подобный подход мог бы принести пользу, когда (редкие) повторные применения объекта п₽иводили бы к потерям в эффективности, а не к нарушению корректности, поэтому он не совсем бесполезен.
572 Часть 6. Расширение C++ Это исключает возможность передачи функции ошибочной длины буфера. Еще лучше, если мы используем проверку на этапе компиляции в форме статического утверждения (см. раздел 1.4.7) для гарантирования достаточности размера буфера1. template< typename С , size_t N > С const *integer_to_string(C (&buf)[N], int8_t i) { STATIC_ASSERT(!(N < printf_traits<int8_t>::size)); return integer_to_string(buf, N, i); } Теперь мы можем не волноваться о таи, что столкнемся при работе рабочей версии с ошибкой, которая не оказалась обнаруженной при тестировании в отладочном режиме. Если заданный параметр имеет недостаточно большое значение, программный код не будет откомпилирован. Совсем не плохо, разве не так? Это решение является потокозащищенным, оно невосприимчиво к заданию оши- бочной длины, работает с любыми символьными кодировками и не требует явного ин- станциирования. Используемые здесь функции без труда могут быть сделаны встраи- ваемыми, поэтому эффективность не страдает. Более того, оно отличается привлека- тельной простотой и не использует никакую платформо-зависимую функциональ- ность. И, наконец, оно ведет к получению более лаконичного программного кода, поскольку мы больше не должны указывать размер буфера. uint64_t i = . . . wchar_t buf f[12]; wchar_t const ‘result = integer_to_string(buff, i); Единственный недостаток в том, что все-таки приходится обеспечивать буфер. 31.6.1. RVL Здесь RVL обладает теми же свойствами, которые характерны для решения 1, то есть допускается RVL-LV. 31.7. Решение 5 - прокладки преобразований Последнее решение отличается от предыдущих четырех. Вместо возврата указателя на С-строку, содержащуюся в буфере, переданном функции или обеспечиваемого библиотекой для каждого потока, эта версия возвращает экземпляр объекта прокси. В этом случае полностью устраняются проблемы RVL-LV и RVL-LS. Класс прокси - int2str_proxy - содержит свой собственный буфер в качестве переменной-члена и обеспечивает неявное преобразование в указатель на С-строку. Преобразование целого 1 Шаблон printf_traits - это класс свойств библиотеки STLSoft, который вычисляет на этапе компиляи* максимальную длину сообщения функции printf для заданного интегрального типа.
Глава 31 Продолжительность жизни возвращаемых значений 573 числа в строку, конечно, реально выполняется функцией integer_to_string (), как и во всех других решениях. Реализации класса и вспомогательной функции int2str (), показаны в листинге 31.8. Листинг 31.8. template< typename С , typename I > class int2str_proxy { public: typedef C char_type; typedef I int_type; public: int2str_proxy(int_type i) : m_result(integer.to_string(m_sz, dimensionof(m_sz), i)) {} int2str_proxy(int2str_proxy const brhs) : m_result (xn_sz) { char_type *dest = m_sz; char_type const *src = rhs.m_result; for(; 0 != (*dest++ = *src++);) {} } operator char_type const *() const { return m_result; } private: char_type const * const m_result; char_type m_sz[21]; // Реализация не требуется private: int2str_proxy boperator =(int2str_proxy const brhs); }; template< typename C • typename I > int2str_proxy<C, I> int2str(I i) { return int2str_proxy<C, I>(i); } Для его использования просто вызывается int2str() с передачей соответст- вующего символьного типа, как, например, в int2str<wchar_t> (101). Функция
574 Часть 6. Расширение С++ int2str () возвращает экземпляр int2str_proxy и, следовательно, является про кладкой преобразования (см. раздел 20.5.) 31.7.1. RVL Преимущество этого метода в нечувствительности к проблемам продолжительно сти жизни возвращаемого значения, которые характерны для решений 2-4. Поэтому в результате вычисления выражения, подобного указанному ниже, будут получены верные результаты: void dump_2_ints(char const *sl, char const *s2); int i = . . ; int j = . . ; dump_2_ints(int2str<char>(i), int2str<char>(j)); Однако, как и для прокладок преобразований, для этого решения характерна чувст- вительность к RVL-PDP: int i = . . . ; char const *s = int2str<char>(i); puts(a); // Бац! ж указывает на гиперпространство Это плохой программный код. Хотя он может работать, это будет являться только артефактом вашего компилятора и конкретного размещения в памяти вашей программы. В принципе, поведение приведенного выше программного кода будет непредсказуемым и, следовательно, неустойчивым. Это является одной из особенностей прокладок преобразований: возвращаемые преобразованные значения не должны сохраняться, если они являются указателями, а должны сразу же использоваться, или должна выпол- няться копия содержимого экземпляра, на который ссылается возвращаемый указатель. Эффективность, за которую мы расплачиваемся, объясняется, в частности, тем фак- том, что сделана ставка на указатели, особенно С-строк. Я думаю, было достаточно хорошо продемонстрировано, что расплатой за обеспечиваемую указателями эффектив- ность является получение если не опасного, то, по крайней мере, программного кода с симптомами заболевания. Для решения этой проблемы нужно возвращать нечто такое, что является типом значения, например, экземпляр строки, как в следующем примере- std: zstring int_to_string_instance(int i) { char buffer[21]; return std: :string(integer_to_string(buf e stlsoft_num_elements(buffer), i)); } Естественно, безопасность можно обеспечить за счет потерь в эффективности.
Глава 31. Продолжительность жизни возвращаемых значений 575 31.8. Производительность Поскольку мы подвергли себя опасностям RVL, характерным для этих различных решений задачи преобразования, именно ради повышения эффективности потенци- альную выгоду следует представить в количественной форме, чтобы мы могли решить, оправдывает ли полученный результат все неудобства и всю сложность. В частности, очень интересно знать, будет ли повышение удобства и легкости использования после- дующих решений сопровождаться существенным снижением их эффективности по сравнению с решением 1. Поскольку решение 3 нельзя рассматривать всерьез, я не включаю его в тест произ- водительности. Решения 1, 2,4 и 5 сравниваются со стандартной библиотечной функ- цией sprintfO, с популярной функцией расширения библиотеки С itoa() и с функцией int_to_string_instance (), описанной в последнем разделе. Эти семь механизмов преобразований были протестированы на пяти популярных компиля- торов платформы Win32, причем всегда использовалась оптимизация по скорости (см. приложение А). Для каждого варианта 10 миллионов значений 32-битовых целых чисел со знаком преобразовываются в строку. Это делается два раза и используется время второй итерации1. Результаты показаны в табл. 31.2. Таблица 31.1. CodeWar- rior Digital Mars GCC Intel Visual C++ 7.1 Среднее sprintf 100.0% 100.0% 100.0% 100.0% 100.0% 100.0% itoa 67.8% 44.8% 27.5% 26.8% 28.1% 39.0% Решение 1 24.7% 18.8% 12.6% 12.0% 29.4% 19.5% Решение 2 26.8% 20.4% 14.1% 12.7% 29.0% 20.6% Решение 4 24.5% 19.6% 12.7% 11.8% 29.5% 19.6% Решение 5 28.5% IX.9% 12.6% 11.8% 29.5% 20.3% int_to_string_ instance 124.4% 37.4% 42.2% 28.1% 43.2% 55.1% Результаты ясно показывают, что четыре специально разработанных механизма пре- образований дают существенный выигрыш по производительности по сравнению с sprintf () и itoa () для всех компиляторов и их стандартных библиотек за исключе- нием Visual C++ 7.1, где они находятся почти на одном уровне с реализацией itoa (). Учитывая, что тестирование компилятора Intel проводилось с библиотекой этапа выполне- ния Visual C++ 7.1, очевидно, что специально разработанные решения дают заметное по- чтение производительности при их использовании на компиляторах, обеспечивающих Чсокоуровневую оптимизацию шаблонов. В среднем специально разработанные решения Работают в два раза быстрее itoa О и в пять раз быстрее sprintf (). **—._____________________________ Нее тесты выполнялись за один сеанс на машине, работающей на 2 Ггц, имеющей 512 Мб памяти и процессор ^tium IV при отсутствии других процессов.
576 Часть 6. Расширение С++ Четыре специально разработанных решения дают в сущности идентичную производи- тельность, поэтому выбор между ними может производиться, исходя из оценки легкости применения, удобства и различной степени их чувствительности к проблемам RVL. И последнее, что мне хотелось бы отметить, хотя это может в какой-то степени по- дорвать мою хорошую репутацию среди программистов C++. Производительность варианта с возвратом значения std: : string не очень впечатляет. В большинстве случаев он работает лучше, чем sprintf (), но несмотря на помощь, получаемую от факта его реализации на основе integer_to_string(), затраты выполнения одного распределения памяти1 и strcpy(), связанные с созданием возвращаемого значения, говорят о том, что этот вариант недостаточно хорош для специально разра- ботанных решений. 31.9. RVL: большая победа сборки мусора Как человек, которому нравится достаточно хорошо владеть несколькими языками и который может распознать достоинства большинства из них2, существует одна вешь, которая потрясает меня в дискуссиях по поводу сравнения языков, использующих сборку мусора, и тех, которые не используют ее; это утверждение, что сборка мусора освобождает программиста от очистки своей собственной памяти или предохраняет его от ошибки, которая возникает, когда забывают освобождать свою память. Вежли- вость (и мой редактор) не позволяют мне сказать то, что я на самом деле думаю о такой аргументации, поэтому я просто скажу, что, по-моему, это совершеннейшая чепуха. Во-первых, в C++ это почти никогда не создает проблем. Если вы разумны, то вы используете RAII (см. гл. 3,4 и 6), и поэтому данная проблема почти полностью исче- зает. Второе, что меня по-настоящему раздражает в сторонниках таких языков, как, скажем, Java и .NET, это постоянное брюзжание относительно утечки памяти, как будто память - единственный ресурс, с которым может происходить такое. По крайней мере, столь же серьезные проблемы создает утечка таких системных ресурсов, как дескрип- торы файлов, объекты синхронизации и т. п. Удивительно, что эти языки фактически не поддерживают автоматическую и защищенную от исключений обработку таких ре- сурсов. Здесь приходится иметь дело с неприятными, очень запутанными блоками try- f ina 11у. Ужасная штука! Ирония в том, что сторонники этих языков никогда не затрагивают реальную про- блему, где сборка мусора на самом деле дает неоспоримое преимущество. При исполь- зовании сборки мусора проблемы RVL, которые обсуждались в данной главе и в разде- лах 16.2 и 20.6.1, просто исчезают. Поскольку сборка мусора освобождает память только в том случае, когда не существуют ссылки на нее в активном программном коде, 1 Это подразумевает возможность применения оптимизации RVO в функции integer_to_string_instance<>()на всех проверяемых компиляторах, что мы видели по результатам тестирования этой пятерки в гл. 12. 2 Я не вижу достоинств у Visual Basic и никогда их не видел. Извините.
Глава 31. Продолжительность жизни возвращаемых значений 577 ------------------------------------------------------------------------- не надо доказывать, что указатель на выделанный в динамической памяти блок не яв- ляется недостоверным, пока он нужен. Нам потребовалось бы всего лишь возвратить новый блок динамической памяти, содержащий преобразованную форму. Если бы здесь возникали проблемы с эффективностью, мы могли бы поддерживать пул в дина- мической памяти из двадцатиодносимвольных1 блоков, которые бы эффективно разда- вались всем функциям преобразования. 31.10. Потенциальные применения Описанные здесь функции преобразования могли с пользой применяться при реализации библиотек сериализации, поскольку они выполняют преобразование значительно быстрее, чем sprintf () для одиночных переменных. Более того, они также полезны в любой ситуации, когда функции требуется указатель на символьный тип, содержащий символьную форму (в десятичном виде) целого числа (например, при задании текста окна в интерфейсе пользователя). Они не могут проводить никаких локально-чувствительных преобразований, как, например, при применении точки в качестве разделителя тысяч числа, но там, где требуется выполнить чистое преобра- зование, они дают большой выигрыш, который трудно не замечать. 31.11. Продолжительность жизни возвращаемых значений: заключение Я надеюсь, эта глава продемонстрировала вам различные способы выполнения пре- образований и дала пищу для размышлений. В целом, я полагаю, имеет смысл профи- лировать используемые вами библиотеки, даже стандартные библиотеки, а не просто принимать то, что вам дают. Интересно, что некоторые даже лучшие компиляторы используют посредственные библиотеки. Я также надеюсь, что у вас была возможность поразмышлять о поточной организа- ции вычислений и о реентерабельности, и вы получили удовольствие от практического применения некоторых методов поточной организации, рассмотренных в гл. 10. Но самое важное, я надеюсь, вы усовершенствовали или упрочили ваши знания ° проблемах продолжительности жизни возвращаемых значений и о компромиссных Решениях, которые мы, программисты-практики C++, принимаем при использовании мощного и эффективного языка. Максимальное количество символов в любом 64-битовом числе равно 20 (без разделителя тысяч и подобных особенностей локализации).
Глава 32 Память 32.1. Таксономия памяти Мы обсуждали некоторые детали (локальной и нелокальной) статической памяти в гл. 11, но не рассматривали подробно набор механизмов памяти, поддерживаемых в С и C++. 32.1.1. Стек и статическая память Переменные стека распределяются в памяти стека выполняемого потока в области види- мости функции, внутри которой они определяются путем настройки указателя стека при входе в область видимости их объявления. Статические переменные закреплены за глобальной памятью программы, то есть память для них выделяется путем резервирования пространства в области глобальной памяти. Для достижения целей данного раздела я основ- ное внимание уделяю применению памяти стека, хотя некоторые обсуждаемые вопросы также применимы для глобальной памяти. Т к. распределение глобальной памяти и переменных стека выполняется на этапе компиляции, оно имеет как преимущества, так и недостатки. Главное преимущество - в фактическом отсутствии «распределения» памяти; просто происходит некоторое манипулирование указателями и адресами; все соответствующие потребности удовле- творяются уже существующей памятью. Поэтому эта форма распределения памяти очень эффективна; фактически, это самая эффективная форма распределения памяти. Небольшое дополнительное преимущество дает возможность определения на этапе компиляции размера выделяемой памяти с помощью оператора sizeof. Недостаток в том, что эта память может иметь только фиксированный, заранее определенный размер. (Небольшое исключение представляет собой подход, основан- ный на применении функции alloca (), который мы рассматриваем в разделе 32.2.1) Это часто оказывается вполне приемлемо, например, при работе с именами объектов файловой системы, которые имеют фиксированный максимальный размер на многих платформах. При написании такого программного кода можно просто объявить буфер максимально возможного размера, будучи уверенным в том, что при его передаче любой функции не произойдет запись за пределами буфера. Однако при рассмотрении программных интерфейсов, функции которых могут использовать буферы любых раз- меров (например, программный интерфейс регистрации событий в Win32), никогда нельзя гарантировать то, что размер фиксированного буфера будет достаточен . 1 Я уверен, что несмотря на это большинство из нас писали программный код RegXxx(), передавая б)ФеРь размером _МАХ_РАТН!
Глава 32. Память 579 Как упоминалось в гл. 11, память статических переменных инициализируется нуля- ми. Переменные стека автоматически не инициализируются и будут содержать случай- ные значения, пока они не будут проинициализированы явно. 32.1.2. Расширение стека До этого я говорил, что память стека уже существует. Однако это верно лишь в отношении воображаемой среды выполнения программ C/C++. В действительности, память стека процесса может быть столь же эфемерной, как любая другая часть его виртуального адресного пространства. Операционная система отвечает за обеспечение существования памяти стека в тот момент, когда она вам нужна. В операционных системах платформы Win32 память стека используется постранично [Rich 1997]. Это означает, что если текущая область памяти стека еще не была зафиксиро- вана (committed)1, и какая-нибудь инструкция обращается к этой памяти, операционная система будет пытаться зафиксировать страницу и затем заново выполнить инструкцию, осуществляя теперь доступ к достоверной памяти. Следующая незафиксированная страни- ца называется охраняемой страницей и содержит специальный атрибут охранения, способ- ствующий расширению стека. Однако атрибут охраняемой страницы назначается только первой незафиксированной странице, так что обращение к любым другим незафиксиро- ванным страницам приведет к простому, завершающему, нарушению доступа. Другие операционные системы используют аналогичные механизмы. В реальных условиях проблема памяти стека (включая alloca (); см. раздел 32.2.1) возникает в том случае, когда общий размер всех переменных в локальной области види- мости превышает (или потенциально может превысить) системный порог для страниц. В этом случае компилятор требует включения программного кода, который позволяет убедиться в допустимости заданной памяти стека. Причина этого может быть продемон- стрирована на простом примере. Рассмотрим следующий программный код: void stack_func(size_t index) { char stack_buffer[4097]; buffer[index] = 'XO'; } Этот программный код демонстрирует возможность пропуска страницы по указан- ной причине. Если размер страницы равен 4096 и первый байт буфера стека stack_buffer попадает на первый байт охраняемой страницы, то можно при равен- стве индекса index числу 4096 пропустить охраняемую страницу и обратиться к сле- дующей незафиксированной странице. Поскольку эта страница не будет иметь атрибут охранения, возникнет ошибка доступа, и ваш процесс будет завершен Хотя этот Пример придуман, можно часто встретить сценарии, где объявляется несколько локаль- Этот термин используется для страницы виртуальной памяти, для которой выделена физическая память оперативная или дисковая). - Примеч. пер.
580 Часть 5. Операторы ных буферов, и их общий размер может превысить размер одной или нескольких стра ниц, что делает пропуск охраняемой страницы очень вероятным. Для обеспечения достоверности всех страниц между текущей последней страницей и любой (и всеми) требуемой в любом следующем блоке компиляторы должны вмеши- ваться и брать на себя некоторую часть этого бремени. Компилятор Visual C++ вставляет вызовы функции chkstk (), которая гарантирует, что обращения к любым страницам которые могут проскользнуть в это окно, выполнялись в правильном порядке, чтобы страницы загружались в память согласованно. Такая вставка имеет два связанных недос- татка: это вызывает привязку к библиотеке С этапа выполнения (что может быть нежела- тельным) и приводит к некоторому снижению производительности из-за вызова и вы- полнения функции chkstk (). 32.1.3. Динамическая память Динамическая память (heap memory - память кучи) отличается от памяти стека: она получается на этапе выполнения программы из специальной динамической памяти (иногда ее называют свободной памятью - free store [Stro 1994]) или из одного из на- боров «куч» динамической памяти. Во всяком программном интерфейсе динамической памяти требуется иметь функцию для выделения памяти (например, mallocO) и, исключая случаи сборки мусора, соответствующую функцию для освобождения памяти (например, free ()). Преимущество динамической памяти в том, что буфер может иметь любой удобный размер в рамках ограничений системы этапа выполнения (хотя некоторые старые программные интерфейсы памяти ограничивают максималь- ный размер отдельных буферов). Применение динамической памяти имеет несколько недостатков. Во-первых, распре- деление динамической памяти выполняется значительно медленнее, чем распределение памяти стека или глобальной памяти (из-за сложности реализации схем распределения памяти, связанных с восстановлением и дефрагментации свободной памяти). Во-вторых, может случиться так, что запрос в действительности не может быть удовлетворен на этапе выполнения программы, и эта возможность должна обрабатываться в клиентском программном коде (либо путем обработки исключений, либо путем проверки возвращае- мого значения на NULL). В-третьих, вы должны сами вернуть вашу память, когда она становится ненужной. Если вы забыли освободить выделенные куски памяти, ваш про- цесс, вероятно, станет медленно «умирать» из-за недостатка памяти. Кроме того, интенсивное применение любой динамической памяти может привес- ти к фрагментации, когда свободные участки динамической памяти будут разбросаны по многим выделенным секциям. Это может повысить вероятность отказа при распре- делении памяти, а также ухудшить производительность из-за необходимости поиска по списку свободной памяти областей подходящего размера.
Глава 32. Память 581 32.2. Лучший из двух миров Давайте на минуту отложим в сторону несущественные проблемы, характерные для этих схем памяти: расширение стека обеспечивается вашим компилятором и операционной системой; отказ при распределении динамической памяти часто может передаваться обработчику ошибок, покрывающему весь процесс; утечки памяти можно избежать, используя классы управления буферами памяти, приме- няющие RAII (см. раздел 3.5); фрагментация может быть минимизирована при помощи специальных пулов (см. раздел 32.3). Но существует два момента, которые не- возможно избежать при использовании памяти, обеспечиваемой в С и C++. Если вы будете использовать память стека, то получите очень высокую скорость, но вам потре- буется знать размер выделяемой памяти на этапе компиляции, и эту ситуацию нельзя изменить. Напротив, если вы используете динамическую память, вы можете опреде- лять размер и изменять его на этапе выполнения программы, но при этом пострадает производительность, и для некоторых менеджеров динамической памяти издержки будут значительными. Дефект: С и C++ заставляют делать выбор между производительностью и гиб- костью при распределении памяти. Естественно, такой конфликт интересов не может ни радовать, ни спокойно воспри- ниматься разработчиком. В данном разделе мы рассмотрим различные механизмы, разработанные для того, чтобы обхитрить систему. 32.2.1. alloca() Поскольку память переменных стека «распределяется» настройкой указателя стека, выполняемой компилятором, кажется вполне естественным вопрос о том, должна ли такая настройка обеспечивать фиксированный размер. Попыткой сочетания скорости памяти стека с гибкостью динамической памяти, хотя это возможно не во всех архитек- турах [Stev 1993], является функция а11оса()(и ее аналоги, например, функция Visual C++ _alloca ()). Эта функция выделяет память, размер которой определяется па этапе выполнения программы, из стека, а не из динамической памяти, просто путем настройки значения указателя стека. Эта память автоматически «освобождается» при выходе из охватывающей функцию области видимости. Это очень полезное средство, и (там, где она доступна и применима) в большинстве случаев обеспечивает наи- лучшее решение для автоматических буферов. К сожалению, механизм ее работы ос- нован на большом количестве ограничений и специфических особенностей, что суще- ственно уменьшает ее применимость. Существует несколько небольших недостатков. Во-первых, как и при распределе- нии динамической памяти, она не может задавать размер на этапе компиляции и ис- пользует локальную переменную для отслеживания размера памяти там, где это необ-
582 Часть 5. Операторы холимо. Во-вторых, она не может перераспределять память, как это делает функция realloc О (и ее платформо-зависимые аналоги), и поэтому не может напрямую использоваться взамен программных интерфейсов динамической памяти. В-третьих она не является стандартной функцией, поэтому не гарантируется ее доступность на всех платформах/компиляторах - использующий ее программный код не является переносимым. В-четвертых, в ней по-разному обрабатывается отказ: в системе Linux она возвращает NULL, если выделение запрошенной памяти завершается неудачей а на платформе Win32 генерируется машинно-зависимое исключение. В-пятых, как и для распределения стека, alloca () на этапе компиляции требует применения про- граммного кола по контролю стека (см. раздел 32.1.2). Наконец, ее применение имеет ограничения, зависящие от реализации. Например, вариант этой функции в библиоте- ке С этапа выполнения на платформе Win32 компании Microsoft, _alloca(), не может использоваться в определенных контекстах обработки исключений, поскольку это может привести к непредсказуемым последствиям. Два главных недостатка вызывают еще больше сомнений. Во-первых, в силу механизма реализации alloca () она не может быть использована для хранения памяти вне рамок текущего контекста выполнения, поэтому ее нельзя применять для распределения блоков переменной длины как внутри экземпляров объектов, так и в оболочках в виде шаблона или других функций, например, недопустимо писать local_strdup ()!. Во-вторых, что самое главное, из-за настройки указателей стека внутри текущего контекста функции легко вызвать нехватку памяти стека при ее использовании в функции, которая выполняет неко- торое количество циклов операций выделения и освобождения памяти. В самом деле, функции, используемые в данной главе для тестирования относительной производительно- сти (см. раздел 32.2.6), по этой причине быстро завершились аварийно на платформах Linux и Win32. Если невозможно или непрактично поместить вызов alloca () и обработ- ку возвращаемой ею памяти в отдельную функцию, вызываемую внутри цикла - что может сильно ухудшить производительность и не принесет никакой пользы, - тогда следует избе- гать ее применения в программном коде цикла. 32.2.2. Массивы переменной длины Улучшения спецификации языка С, предусматриваемые стандартом С99, включали новую концепцию массивов переменной длины (МПД) [Меуе 2001b], которая (по край- ней мере, синтаксически) решает проблему переменных стека, представляющих дина- мические массивы. МПД позволяют определять размерности массивов на этапе выполнения, как показано в следующем примере: void func(int х, int у) { int ar[x][y]; } To есть не используя макрос.
Глава 32. Память 583 В данный момент МПД не входят в стандарт C++, хотя некоторые компиляторы поддерживают их как расширение C++. МПД обеспечиваются компиляторами Digital Mars и GCC, а также и Comeau при его использовании совместно с постпроцессорным компилятором, который их поддерживает. Вероятно, МПД будет реализован при помощи либо allocaO (или подобной функции), либо динамической памяти. Digital Mars применяет alloca (). C++ компи- лятора Comeau в настоящее время использует любой обеспечиваемый постпроцес- сорным компилятором механизм МПД хотя разработчики этого компилятора и рас- сматривают вопрос непосредственной реализации МПД с помощью allocaO; это могло бы существенно расширить поддержку. GCC использует подход на основе памяти стека, но он, по-видимому, не связан с alloca О, в чем мы вскоре убедимся. Поэтому хотя синтаксис языка станет более понятным по сравнению с применением в настоящее время либо alloca (), либо динамической памяти, последствия для про- изводительности, устойчивости и доступности, как мы увидим, будут почти такими же. Поддержка типов, отличных от POD, зависит от реализации. GCC - единственный из наших компиляторов (приложение А), который это делает. Объекты МПД, по крайней мере, для типов POD не инициализируются. В стандарте С (С-99: 6.2.4; 6) говорится, что «начальное значение объектов (типов массива переменной длины) не определено». МПД имеют два недостатка. Во-первых, они (пока) не входят в стандарт C++, и их поддержка ограничена. Во-вторых, они имеют такие же параметры производительно- сти и устойчивости, какие характерны для их базовых механизмов, которые, как мы увидим, могут сильно отличаться для разных реализаций. Следует отметить, что sizeof () можно применять для МПД, но результат опре- деляется на этапе выполнения программы. 32.2.3. auto.bufferO В качестве решения, позволяющего получать эффективные автоматические буферы переменного размера предлагается показанный в листинге 32.1 шаблонный класс auto_buf f er5 - он так поименован, поскольку первоначальное его назначение было связано с автоматическими переменными. Листинг 32.1. template< typename Т , typename А , size_t SPACE = 256 > class auto_buffer : protected A { public: ...II Определения обычных типов-членов // Конструирование explicit auto_buffer(size_type cltems);
584 Часть 5. Операторы ~auto_buffer(); III Операции bool resize(size_type clterns); void swap(class_type &rhs); III Операторы operator pointer (); pointer data(); const_pointer data() const; III Итераторы // константные / неконстантные методы (г)beginО + (r)end() III Атрибуты size_type size() const; bool emptyО const; allocator_type get_allocator() const; III Члены private: value_type *m_buffer; // Указатель на используемый буфер size_type rn_cltems; // Количество элементов в буфере value_type m_internal[space]; // Внутренняя память // Реализация не требуется private: ...II Запретить конструктор копирования и оператор присваивания }; Этот класс предназначен для того, чтобы эмулировать синтаксис и семантику пере- менной стандартного массива для типов POD, а также обеспечить максимальную гиб- кость и производительность стека и динамической памяти. Для этого он, при возмож- ности, распределяет память из своего внутреннего буфера; в противном случае память выделяется из динамической области с помощью типа распределителя памяти. Для обеспечения максимальной совместимости с синтаксисом обычных, «сырых» массивов и возможности работы в тех случаях, когда преобладает вырождение массива в указатель (см. гл. 14), он предусматривает оператор неявного преобразования, но не операторы индексации (value_type ^operator [ ] () и его константный вари- ант). Это обеспечивает как преобразование в указатель, так и (неявную) индексацию массива в то время, как сам по себе подход с использованием оператора индексации допускает только (явную) индексацию массива1. Этот класс реализуется удивительно просто. Почти все действия выполняются в конструкторе (см. листинг 32.2). Он принимает один аргумент - запрошенное количе- ство элементов массива. Это значение сравнивается с размером внутреннего буфера и, если оно не превышает его, член m_buf f er устанавливается на значение указателя на внутренний массив m_internal. (Это очень напоминает оптимизацию малых строк [Меуе 2001].) Если запрошенный размер больше внутреннего буфера, то выполняется запрос распределителя памяти с установкой значения m_buf fer для размера выделяе- мого блока. (Все методы доступа в обоих случаях ссылаются на m_buf f er.) * Хотя операторы индексации и могли бы допустить возможность использования некоторой простой проверки достоверности индекса (например, с помощью утверждения или даже путем выбрасыва исключения), простота и удобство использования в данном случае победили.
Слава 32. Память Листинг 32.2. 585 explicit auto_buf fer (size_type cl terns) : m_buffer((space < cl terns) ? alloc_( cl terns) : &m_internal [ 0 ]) , m_cltems((NULL != m_buffer) ? cltems : 0) { STATIC_ASSERT(space != 0); STATIC_ASSERT( offsetof(class_type, n\_buffer) < offsetof(class_type, nj_cltems)); const rain t_jnust_be_pod (value_type); ) -auto_buffer() { if (space < m_clterns) { assert(NULL !=m_buffer); dealloc_(m_buffer, nj_cltems); ) В последнем случае распределение памяти может закончиться неудачно. Из-за важности обеспечения максимальной совместимости этого класса и библиотек STLSoft, конструктор написан так, что будет работать корректно как в ситуациях, когда при не- удачном распределении памяти выбрасывается исключение, так и в случаях возврата ме- тодом allocate () значения NULL. При выбрасывании исключения оно передается про- грамме, вызвавшей конструктор auto_buf fer, а экземпляр auto_buf f er не создает- ся. Некоторые распределители памяти не выбрасывают исключения, когда не удается вы- делить достаточно памяти при выполнении запроса на распределение памяти; вместо этого они возвращают NULL. Кроме того, при создании небольших программ может ока- заться нежелательной компиляция и компоновка механизмов обработки исключений; в этом случае может специально подключаться распределитель памяти, возвращающий NULL при неудаче. В таких случаях благоразумно обеспечить целостное состояние aut°_buffer, поэтому характер инициализации m_cItems зависит от ненулевого значения m_buffer. Когда возвращается NULL, при выполнении оставшихся действий 80 время конструирования экземпляра auto_buf fer член m_cltems инициализирует- ся в 0 и тем самым обеспечивает осмысленный и корректный режим работы, позво- ляющий применять этот пустой экземпляр, то есть begin () = end (), empty () воз- вращает true, a size () возвращает 0. Следует отметить, что этот механизм достаючно хрупок, поскольку учитывает вза- имное расположение (при инициализации) членов m_buffer и m_clterns. С этой Целью находящиеся в конструкторе утверждения (статические на этапе компиляции и Динамические на этапе выполнения) предохраняют от любого редактирования при с°Провождении, которое не учитывает это требование, и могут изменять их последова-
586 Часть 5. Операторы тельность, что приводит к обнаружению «мусора» при проверке значения m__buf fer и дает классический непредсказуемый результат (то есть крах). Расчет на такие зависи- мости от порядка расположения членов, в целом, не является хорошей идеей, но я выбираю в данном случае этот подход, поскольку он позволяет мне объявить m_cItems как константный член, а утверждения гарантируют, что все хорошо1. Важно отметить, что конструктор auto_buf fer лишь выделяет память и не соз- дает в ней никаких элементов, подобно встроенным массивам (типа POD) и МПД. Его деструктор также не удаляет элементы. Чтобы гарантировать невозможность его (неправильного) использования с типами, отличными от POD, мы используем огра- ничение constraint_must_be_pod() (см. раздел 1.2.4). В то время как член мас- сива m_internal сам по себе предотвратил бы компиляцию auto_buf f er при его параметризации классами, которые не содержат открыто доступные конструкторы по умолчанию, все-таки могли бы использоваться типы классов с конструкторами по умолчанию, и поэтому это ограничение необходимо. Следует отметить, что на этапе компиляции проверяется утверждение для предотвращения ненормальной параметри- зации его внутреннего размера нулевым значением. Конструктор имеет спецификатор explicit подобно хорошим примерам програм- мирования (хотя трудно представить сценарий применения неявного преобразования, для которого потребуется защищаться). Деструктор реализуется достаточно просто. Убедившись в превышении m_clterns размера внутреннего буфера, он проверяет, дей- ствительно ли m_buf f er указывает на m_internal, и если это так, освобождает дина- мическую память путем вызова метода распределителя памяти deallocate (). Этот класс также обеспечивает основные методы контейнеров STL: empty О, size (), begin () и end(). Методы begin () и end () добавлены из чисто прагма- тических соображений, и это не следует воспринимать, как свидетельство того, что auto_buf f er является полнофункциональным контейнером STL. Это не так, посколь- ку он не работает (в настоящее время) с экземплярами типов, отличных от POD. 32.2.4. Использование шаблона Применение шаблона требует задания в качестве параметров типа элемента, типа распределителя памяти и (необязательно) размера внутреннего массива (m_internal). Как ранее отмечалось, этот класс проектируется для того, чтобы не ограничиваться под- держкой семантики конкретной схемы распределения памяти, пока распределитель памяти поддерживает используемую в STL концепцию распределителя Allocator [Aust 1999, Muss 2001]. Может использоваться любой подходящий распределитель памяти, который обеспечивает максимальную гибкость клиентского программного кода 1 Здесь практика побеждает теорию. В принципе, результат выполнения макроса oflsetof() не определен ДЛЯ типов, отличных от POD, поэтому их применение в данном случае не совсем правомерно. Однако все компиляторы, поддерживающие STLSoft, дают требуемый результат, поэтому он используется. Если STL о переносится на компилятор, который так размещает классы в памяти, что данное условие не выполняется. auto_bufler<> или макрос будут переписаны соответствующим образом.
Глава 32. Память 587 в соответствии с практикой правильного применения STL. Размер m_internal, пред- ставленный количеством элементов, а не байтов, задается третьим параметром, который по умолчанию равен 256. В клиентском программном коде здесь можно задавать любой размер, чтобы максимально эффективно обрабатывать массивы самых распространен- ных размеров. int some_func(char *s, size_t len) { typedef auto_buffercchar, std::allocator<char> > buffer_t; buffer_t buf(l + len); strncpy (&buf [0], s, buf.sizeO); Следует отметить, что при применении buf в вызове strncpy () используется метод size () для обработки неудачного завершения распределения памяти, когда рас- пределитель памяти возвращает NULL, а не выбрасывает исключение. В реальных усло- виях вам пришлось бы все-таки что-то сделать в этом случае, если вы не хотите удивить клиентский программный код, используюя some_func (). 32.2.5. Оптимизация ЕВО, где же ты? Во всех компиляторах, кроме Borland, auto_buf fer является производным классом от распределителя памяти и в подходящих случаях использует преимущества оптимизации ЕВО (см. раздел 12.3). Однако при использовании компилятора Borland это вызывает столь существенное ухудшение производительности (фактически, она всегда хуже, чем в сце- нариях с применением функций malloc () /free ()), что распределитель памяти вместо этого используется совместно всеми экземплярами, как показано в листинге 32.3: Листинг 32.3. template< . > class auto_buffer #ifndef ACMELIB_COMPILER_IS_BORLAND : protected A #endif f* !ACMELIB_COMPILER_IS_BORLAND */ { #ifdef ACMELIB_COMPILER_IS_BORLAND static allocator.type &get_allocator() { static allocator_type s_allocator; return s_allocator; } #else allocator_type get_allocator() const { return ‘this;
588 _________________________________________________________4aCTbS. Опера,о№ К счастью, концепция STL распределителя памяти Allocator [Aust 1999, Muss 2001] предусматривает, что распределители памяти не должны действовать так, будто у них имеются отдельные данные на каждый экземпляр, и большинство так не делает но если это происходит, то существует очень небольшая вероятность возникновения здесь условий гонки при многопоточной обработке, что нежелательно. Если вы хотите обеспечить очень высокую надежность, то можете применить здесь спин-мьютекс (см. раздел 10.2.2). 32.2.6. Производительность Производительность auto_buf fer была протестирована для следующих типов распределителей памяти: переменных стека, динамической памяти с использованием malloc () /free (), динамической памяти с использованием оператора new/delete, динамического распределения памяти в стеке с использованием alloca()/ _alloca(), МПД, std: :vector. Для каждого распределителя памяти программа выделяет блок памяти, обращается к байту внутри него (для предотвращения устране- ния цикла компилятором в результате оптимизации) и затем освобождает его. Опера- ция повторяется заданное число раз, определяемое вторым параметром программы. Поскольку по умолчанию передаваемый программе в качестве параметра внутренний буфер имеет размер 256 байт, тестировались два размера - выше и ниже этого значе- ния, а именно, 100 и 1000 байт с повторением 10 миллионов раз1. Поскольку auto_buf fer выполняет проверку (сравнивая размер своего внутрен- него буфера с запрошенным размером буфера) перед выделением блока в динамиче- ской памяти, в тех случаях, когда он должен выделять блок именно из этой памяти, производительность будет меньше, чем непосредственное распределение динамиче- ской памяти. Поэтому целью этого теста производительности является получение количественных оценок предполагаемого превосходства использования им внутренне- го буфера и предполагаемого более худшего результата при выделении им буфера из динамической памяти. Этот тест выполнялся на наших десяти компиляторах платформы Win32 (см. прило- жение А). Полученные показатели времени были нормализованы относительно времени, затрачиваемого при применении функций malloc ()/free () каждым компилятором, и представлены в процентах2. Табл. 32.1 показывает производительность для внутренне- го распределения. Цифры ясно показывают, что можно получить значительное преиму- щество, когда размер распределяемой памяти меньше внутреннего буфера при дополни- тельных затратах от 1% до 54% по сравнению с malloc () /free (). Средние дополни- тельные затраты составляли 8,6%, а при исключении очень низкой производительности Borland они падают до 2,9% - очень близко к показателям памяти стека. * Тесты 10 и 100 байт с 64-байтовыми буферами дали фактически идентичные относительные результаты на всех компиляторах. Полные результаты тестирования и программа тестирования включены в состав компакт-диска.
Глава 32. Память 589 Таблица 32.1. Производительность (относительно mallocO) схем для распределений 100-байтных блоков памяти Память Самый медленный (Borland) Самый быстрый (Intel) GCC Среднее Среднее (Borland) Автоматическая 2.2% 0.2% 0.3% 0.9% 0.7% mallocO 100.0% 100.0% 100.0% 100.0% 100.0% Оператор new 178.3% 98.6% 105.9% 109.3% 100.7% vectoro 386.8% 335.3% 121.6% 153.2% 124.0% auto_bufierO 54.2% 1.2% 2.8% 8.6% 2.9% allocaO * * * * * МПД - - 1.7% - - Звездочка * говорит о том, что оказалось невозможным получение осмысленных значе- ний показателей производительности в результате краха программы из-за нехватки памяти стека на каком-то уровне цикла. Это происходило во всех реализациях с allocaO, а также с МПД при использовании компилятора Digital Mars (что вполне предсказуемо, учитывая, что он пользуется своей реализацией alloca ()). Любопытен тот факт, что для МПД при использовании GCC не наблюдается нехватки памяти стека (это происходило с alloca ()), что подразумевает применение другого подхода. Хотя этот подход работает медленнее, чем память стека, он почти в два раза быстрее, чем auto_buffer. Поэтому я подозреваю, что он восстанавливает указатель стека при выходе из области видимости, отличной от области видимости этой функции, избегая таким образом нехватки памяти. Табл. 32.2 показывает производительность, когда выделяемый блок памяти больше внутреннего буфера. В данном случае производительность находится в диапазоне от 101% до 275%. Среднее значение -123% или только 104%, если не учитывать показатели Borland. Конечно, умный программист сможет выбрать размер параметра шаблона для того, чтобы подавляющее большинство операций распределения памяти было в пределах внутреннего размера, что приведет в итоге к значительному выигрышу в производительности. Стоит отметить, что показатель производительности для вектора в двух сценариях Доходит до 370% и до 2500% (Intel со стандартной библиотекой Visual C++, здесь не показаны), так что нет никаких сомнений в существенно более эффективной работе auto_buffer по сравнению с vector. Но не забывайте, что auto_buffer под- держивает типы, отличные от POD, и он не инициализирует полученную память. Более того, он не обеспечивает никаких вставок, удалений и прочих сложных операций модели вектора Vector (стандарт С++-98: 23.2.4). Все это сделано намеренно - я хотел обеспечить только операцию распределения памяти1, сделать ее быстрой - и поэтому сравнение с vector уместно только при таких обстоятельствах. Класс auto_buf f er не заменяет vector — и такой цели не ставилось. Т. к. со временем повысилось доверие к auto_buflfer и он стал широко использоваться, к нему добавились методы resizeO и swapO (см. листинг 32.1), которые значительно повышают его полезность при написании •ciaccoB, защищенных от исключений (работающих по принципу «сконструировать и поменять местами» [Sutt 2000]). Эти улучшения не влияют на производительность и принципиальную простоту.
590 Часть 5. Оператору Таблица 32.1. Производительность (относительно mallocO) схем для распределений 1000-байтовых блоков памяти Память Самый медлен- ный (Borland) Самый быстрый (Digital Mars) GCC Среднее Среднее (Borland) Автоматическая 2.0% 0.5% 0.3% 0.8% 0.6% mallocO 100.0% 100.0% 100.0% 100.0% 100.0% Оператор new 184.7% 101.8% 105.6% 112.0% 102.9% vectoro 642.7% 614.8% 162.7% 608.5% 604.2% auto_bufier<> 275.2% 101.4% 107.1% 122.7% 103.7% alloca() * * * * * МПД - * 1.6% - - Стоит отметить, что большинство операционных систем имеет различные распреде- лители памяти, которые могут обеспечить более эффективное или дружественное к потокам распределение памяти, например, libumem в системе Solaris, поэтому вполне возможно, что вам удастся получить значительно лучшие характеристики производи- тельности по сравнению с malloc (). Я очень сомневаюсь в том, что они смогут обес- печить производительность, сравнимую с auto_buf f er и МПД, но реальные сведения о производительности можно получить только путем ее тестирования. 32.2.7. Сравнение динамической памяти, стека... Используя количественные результаты, мы можем составить таблицу (табл. 32.3) относительной полезности различных схем. Конечно, при разумном применении auto_buf f er имеет лучшее сочетание свойств из всех схем для автоматических мас- сивов фундаментальных типов и типов POD. Таблица 32.1. Свойства схем распределения памяти Стек Динами- ческая память allocaO МПД vectoro auto_ buffer<> Определение размера на этапе выполнения Нет Да Да Да Да Да Размер может изменяться Нет Да Нет Нет Да Да Может возникать не- хватка стековой памяти Да Нет Да Нет Нет Нет при коррект- ном при- менении
Глава 32. Память 591 Таблица 32.1. Свойства схем распределения памяти Стек Динами- ческая память allocaO МПД vectoro auto_ buffero Стек предварительно проверяется Да Нет Да Нет Нет Нет при коррект- ном при- менении Распределение может завершиться неудачно Да Да Да Да Да На всех платформах и компиляторах Да Да Нет Нет Да Да Эффективность Очень высокая Низкая Высокая Низкая Очень низкая Высокая Размер определяется на этапе компиляции Да Нет Нет Нет, но sizeof() применяется на этапе вы- полнения Her,Hosize()- встраиваемая функция Нет, но size() - встраивае- мая функ- ция Может поддерживать типы классов, отличных от POD Да Да Нет Нет Да Нет Допускает сочетание Да Да Нет Нет Да Да Кроме того, auto_buf fer может использоваться совместно с другими схемами, но необходимо проявлять осторожность в этих случаях. Хотя это может обеспечить аналогичный выигрыш в производительности, делать это следует только в тех случаях, когда наиболее распространенный размер распределяемой памяти равен или близок к размеру внутреннего буфера. Если типичный размер больше, чем внутренний буфер, то большинство экземпляров класса вообще не будут использовать свой внутренний буфер. Если типичный размер существенно меньше внутреннего буфера, тогда боль- шинство экземпляров класса не будут использовать большую часть своего буфера. В обоих случаях, если класс, в котором он содержится, размещается в динамической памяти, то auto_buf fer и его внутренний буфер будут также находиться в дина- мической памяти, потенциально растрачивая большое количество памяти. Лучше всего его использовать для автоматических переменных. Все-таки это auto- буфер! 32.2.8. podvectorO Учитывая высокую эффективность, но низкоуровневою природу auto_buffer, возникает вопрос1, можем ли мы применить этот подход в более общей форме? Ответ Утвердительный, мы можем это делать, и в этом случае он принимает форму шаблона Pod-vector (см. листинг 32.4). pod_vector обеспечивает реализацию модели Фактически Крис Ньюкомб (Chris Newcombe) поставил этот вопрос и способствовал разработке pod vector.
592 Часть 5. Операторы Vector (стандарт С++-98: 23.2.4), ограничиваясь типами POD. Подобно auto_buf f er этот шаблон использует внутренний буфер параметризуемого размера. Фактически, он реализуется с помощью auto_buffer, добавляя только один член щ_сItems для представления размера вектора, получаемого функцией size(); размер члена auto_buf f er представляет собой размер выделенной вектору памяти, который опре- деляется функцией capacity (). Листинг 32.4. template* typename Т , typename А , size_t SPACE = 64 > class pod_vector { III Имена, вводимые typedef private: typedef auto_buffer<T, A, SPACE> buffer_type; public: typedef typename buffer_type:;value_type value_type; typedef typename buffer_type::allocator_type allocator_type; typedef pod_vector<T, A. SPACE> class_type; III Конструирование explicit pod_vector(size_type cltems = 0); pod_vector (size_type cltems, value_type const &value) ,- III Операции void resize(size_type cltems); void resize(size_type cltems, value_type const &value); III Члены private: size_type m_cltems; buffer_type m_buffer; }; Для шаблона pod_vector можно указать две причины, по которым он потенциально имеет преимущество по производительности в сравнении с реализациями вектора стан- дартной библиотеки. Во-первых, он использует auto_buffer для реализации своей памяти. При условии соответствующей параметризации этого шаблона и надлежащего его применения, позволяющего воспользоваться обеспечиваемым auto_buf fer преимуще- ством в производительности, будет достигаться существенная оптимизация. Во-вторых, из-за того, что этот вектор содержит только типы POD, нет необходимости уничтожать элементы, когда они удаляются из вектора. Например, метод pop_back () просто уменьшает на единицу m_clterns.
Глава 32. Память 593 Несмотря на эти большие потенциальные возможности, данный класс будет показы- вать значительное улучшение производительности только при определенных обстоя- тельствах, т. к. неправильное применение auto_buf fer приводит к снижению произ- водительности. Табл. 32.4 показывает результаты выполнения набора тестов (которые я ни в коем случае не рассматриваю как исчерпывающие), написанные для того, чтобы помочь мне настроить работу реализации. Во всех операциях использовались pod_vector<int, . . ., 64> и std: :vector<int> с выполнением действий с 50 и 100 элементами для обеспечения внутреннего порога auto_buf f er. Как вы можете ви- деть, имеется существенный выигрыш в производительности даже для некоторых 100-эле- ментных вариантов. Однако совершенно очевидно, что относительная производительность двух контейнеров сильно зависит от типа операции, а также от используемого компиля- тора. Даже если можно считать не таким уж удивительным небольшое снижение показа- телей производительности для компилятора Borland, то эти показатели для CodeWarrior и GCC в некоторых случаях тоже вызывают озабоченность. Но также очевиден большой потенциал для получения высокой производительности. Когда вы имеете дело с таким классом, как pod_vector, я предлагаю вам напи- сать ваше приложение с применением std: :vector и затем подключить pod_vector для тестирования производительности. Поскольку он реализует исполь- зуемую в STL модель Vector [Muss 2001], как и std::vector, это сделать столь же просто, как изменить одну строку. В этом - одно из проявлений красоты STL. Таблица 32.1. Производительность podyectoro относительно vectoro Тесты Borland CodeWar- rior GCC Intel VC 7.1 insert(beginO) - 50 69.1% 93.6% 70.6% 73.4% 64.2% insert(beginO) — >00 78.9% 172.9% 92.5% 105.6% 89.5% push_back - 50 132.4% 34.1% 37.8% 23.8% 12.3% push_back- 100 188.4% 56.1% 72.2% 36.9% 22.7% push_back + reserve - 50 331.5% 90.5% 168.6% 50.5% 27.8% push_back + reserve - 100 374.2% 117.1% 266.2% 66.6% 38.4% erase(beginO)- 50 22.9% 24.9% 50.2% 49.1% 31.9% erase(beginO) -100 15.7% 25.1% 30.6% 38.2% 26.0% erase(&back0)-50 59.4% 30.3% 86.7% 38.0% 28.2% erase(&back0)-100 78.2% 67.5% 188.9% 76.5% 79.6% erase block-50 78.5% 62.4% 72.7% 40.4% 34.6% erase block - 100 85.5% 80.6% 88.0% 48.4% 44.9%
594 Часть 5. Операторы 32.3. Распределители памяти Теперь мы рассмотрим достоинства и недостатки другого выбора - между распре- делением памяти на этапе компиляции и на этапе выполнения. И снова это связано с выбором между эффективностью и гибкостью. Мы видели в гл. 9, что передача памяти между единицами компоновки имеет непривлекательную перспективу вне завсисмости от того, требуем ли мы, чтобы все единицы компоновки совместно использовали один и тот же базовый распределитель памяти, или чтобы клиентский программный код каждой единицы компоновки возвращал выделенный участок памяти туда, откуда он был взят. Первый подход ограничивает в действиях а второй может быть иногда трудно выполнимым. В обоих случаях решение об источнике получения памяти делается на этапе компиляции. Третий способ достижения этой цели - передача распределителя памяти библио- течной функции, - тем самым откладывается решение на этапе выполнения. Это связа- но с дополнительными затратами, фактически эквивалентными вызову vtable, хотя обычно (но не всегда) это несущественно по сравнению с затратами распределения памяти и использования этого блока. Потенциально это также дает большой выигрыш. Во-первых, это освобождает автора библиотеки от принятия решения по поводу того, какой библиотечный распределитель памяти использовать. Вы можете поинтере- соваться, почему вообще возникает такой вопрос. В этом случае вам, вероятно, просто следует посмотреть внутрь библиотеки С этапа выполнения вашего любимого компи- лятора. Большинство поставщиков предпринимают серьезные усилия для того, чтобы хорошо настроить распределение памяти, часто выделяя ее в отдельных пулах (в зави- симости от размера запрошенной памяти). Причина этого в том, что одна область ди- намической памяти, обслуживая большое количество запросов на получение памяти самых разнообразных размеров, становится сильно фрагментируемой, и это может очень вредно сказаться на производительности. Во-вторых, вы можете подключить распределитель памяти на этапе выполнения программы в ответ на возникновение в данный момент определенных условий. В-третьих, вам не надо повторно компилиро- вать библиотеку для изменения распределителей памяти. Наконец, вы можете освобо- дить или перераспределить в клиентском программный коде то, что распределялось внутри библиотеки, а это может помочь упростить многие ситуации. Существуют различные механизмы задания распределителей памяти на этапе выполнения, но в большинстве случаев все сводится к одному и тому же1 * * - установке в структуре указателей на функции распределения и освобождения памяти. 1 Стоит упомянуть еще один подход, называемый «внутренним позиционированием» (interpositioning [Lind 1994], когда подключается новая библиотека, содержащая специальные определения работают1'4 с памятью (или других) служебных программ, и вызовы этих библиотечных функций в рамках процесс^ переключаются на функции новой библиотеки. В комментариях автора говорится, что «за многие годы мы видели ни одного убедительного примера получения существенного эффекта, который нельзя было ь получить другим... способом», и «он подходит только для тех, кто любит ходить по непроторенной дорожке»- Не могу не согласиться, и хотя я иногда его использовал, полагаю, что показанные в данном разделе методь представляют собой более устойчивое и управляемое решение.
Глава 32. Память 595 32.3.1. Указатели функций Вездесущая библиотека zlib определяет посредством typedef две функции: typedef void ‘(*alloc_fn)(void ‘opaque, ulnt items, ulnt size); typedef void (*free_fn)(void ‘opaque, void ‘address); Объявления typedef этих функций используются внутри структуры z_stream, которая применяется клиентом для обмена информацией с функциями в программном интерфейсе zlib. typedef struct z_strean\_s ( alloc_func zalloc; /* применяется для распределения памяти под блок с параметрами внутреннего состояния */ free_func zfree; /* применяется для освоосииения памяти, выделенной под блок с параметрами внутреннего состояния */ voidpf opaque; /* закрытый объект данных, передаваемый функциям alloc и zfree */ } z_stream; Вам достаточно лишь установить члены zalloc и zfree так, чтобы они указыва- ли на ваши функции, и zlib будет их использовать. Если у вас нет каких-то специаль- ных требований, вы можете задать NULL, и zlib будет использовать свои внутренние, используемые по умолчанию функции распределения и освобождения памяти. 32.3.2. Интерфейсы распределителей памяти Альтернативным и более объектно-ориентированным подходом является привязка функций к интерфейсу. Этот подход использовался в проекте расширяемого синтак- сического анализатора, описанного мною в разделе 8.3. В базовых библиотеках Synesis определяется распределитель памяти IA1 locator вместе с переносимым интерфей- сом (см. раздел 8.2), который в версии C++ может выглядеть примерно так1: struct ZAllocatorVTable : public IRefCounter { virtual LPVoid Alloc(Size cb) ; virtual LPVoid Realloc(LPVoid pv. Size cb) ; virtual void Free(LPVoid pv) ; virtual Size GetSize(LPCVoid pv) const; virtual void Compact(); Он имеет тройное преимущество. Во-первых, он содержит все функции в одном ^есте, что снижает вероятность ошибочного задания неподходящей пары. Если вы 2^стесь действительно невезучим, такая комбинация может в действительности так Если он чем-то напоминает вам COM-интерфейс IMalloc, то это просто случайное совпадение...
596______________________________________________________________Часть5~ Операторы отработать на некоторых компиляторах и/или воперационных средах, что головолом всплывут при сопровождении, когда ваша память потускнела и вы испытываете дефи цит времени. Во-вторых, привязывая функции к интерфейсу, вы можете обеспечивать экземп ляры класса для ваших распределителей памяти; в этом случае манипулировать ими гораздо удобнее (см. гл. 8). В-третьих, используя интерфейс, вы можете совершенно свободно обеспечивать другие функции, не ограничиваясь лишь парой распределения и освобождения памя- ти. Функция перераспределения памяти является очевидным кандидатом, но сущест- вуют и другие, например те, которые содержатся в IA1 locator. Наконец, если вы предоставляете интерфейс, использующий подсчет ссылок, то вы можете позволить библиотеке подключиться к нему. Мы вскоре увидим, насколько это может быть полезно. 32.3.3. Отдельная инициализация для каждой библиотеки Передача работающих с памятью функций каждому методу, даже через структуру или интерфейс, может все же быть утомительным и не совсем эффективным делом, если требуется задавать дополнительный параметр функции. Один из вариантов - снабдить ими функции инициализации программного интерфейса библиотеки, которую вы собираетесь использовать. int AcmeLib_Init(IA1locator *ator); void AcmeLib—Uninit(); void *AcmeLib_GiveMeBlock(size_t cb); Вы можете легко это сочетать с установкой глобального состояния единицы компо- новки и с адаптером распределителя памяти, а также успешно использовать с любыми классами STL внутри библиотеки. Адаптер распределителя памяти мог бы выглядеть примерно так, как показано в листинге 32.5. Листинг 32.5. template* typename Т , lAllocator *А > struct IAllocator_adaptor { typedef T value_type; typedef value_type ‘pointer; typedef size_t size_type; typedef ptrdiff_t difference_type; static pointer allocate(size_type n, void Const*)
Глава 32. Память 597 return (pointer)(А->А11ос(sizeof(value_type) * n)); } static void deallocate(pointer p, size_type) { A->Free(p); } Он мог бы совместно использоваться с каким-нибудь классом STL, как показано в сле- дующем примере: lAllocator *s_ator; typedef IAllocator_adaptor<int, s_ator> Acme_allocator; void AcmeLib_DoOtherStuff(int i) ( std::vector<int, Acme_allocator» v(i); std::basic_string<char, Acme_allocator> s(. . .)’ Это удобный механизм, но он имеет некоторые серьезные изъяны. Во-первых, он должен быть реентерабельным, поэтому ему необходимо обеспечить потокозащищен- ное глобальное состояние единицы компоновки, включая указатель распределителя памяти, счетчик инициализации и объект блокировки для защиты указателя. Во-вторых, это не соответствует нашим представлениям об инициализации про- граммных интерфейсов (см. раздел 11.2) (то есть нам нравятся многократно инициали- зируемые программные интерфейсы), если два вызова хотят использовать различные распределители памяти. Оба варианта - когда побеждает первый и когда побеждает последний - приводят к проблемам. Если побеждает первый, то второй должен завершиться неудачей, в противном случае этот клиентский программный код будет использовать неверный распределитель памяти, который был указан в первом вызове. Напротив, если побеждает последний, будет нарушена связь между клиентским программный кодом, выполняющим первую инициализацию, и программным интерфейсом. Единственный способ обеспечить применимость такого программного интерфейса - это обеспечить однократность его инициализации. Из-за этих проблем лучше ограничиться применением этого механизма к библиотеке, которая инициализируется только внутри единицы компоновки и не доступна в клиентском программном коде, который является внешним по отношению к авторам этой библиотеки. В таких контекстах, однако, он может быть очень полезным, и я его использовал с положительным эффектом во многих случаях. 32.3.4. Спецификация при каждом вызове Мы можем избавиться от всех проблем инициализации, поточности и реентера- бельности поступая так, как это сделано в библиотеке zlib (см. приложение А), и пере- рвать распределитель памяти вместе с каждым вызовом программного интерфейса
598________________________________________________________Часть 5. Операт^ библиотеки. Недостаток такого подхода в том, что клиентский программный код отвечает за поддержку связи между блоком памяти и его распределителем памяти В большинстве случаев сам клиентский программный код будет использовать только один распределитель памяти, поэтому это делается очень просто, но вам необходимо отдавать себе отчет о такой возможности. Одной интересной особенностью данного подхода является возможность обеспечения отдельного распределителя памяти каждому потоку. Поскольку большинство распредели- телей памяти пишутся как потокозащищенные, они должны обеспечивать охрану своих внутренних структур с помощью объектов синхронизации (см. раздел 10.2). Такие синхро- низационные блокировки приводят к дополнительным затратам, и для часто используемо- го распределителя памяти они могут быть значительными. Один вариант возможной опти- мизации заключается в применении различных, неблокирующих распределителей для каждого потока, и допустимость их спецификации при каждом вызове способствует этому. 32.4. Память: заключение Данная глава представляет собой введение в полный набор типов памяти. В ней мы обсудили достоинства и недостатки различных типов относительно их эффективности и гибкости. Кроме того, мы видели, что выбор распределителей памяти также связан с компромиссом между эффективностью и гибкостью. Сочетание представленных здесь методов - реальный способ получения максимальной гибкости и эффективности. Другими словами, применение динамической спецификации распределителей памяти при каждом вызове в сочетании с auto_buffer может оказаться тем способом, который позволит получить всю необходимую гибкость при работе с динамической памятью в то время, как затраты потенциально могут оказаться большими только в меньшем количестве случаев.
Глава 33 Многомерные массивы Как мы видели в разделе 14.7, С и C++ не поддерживают многомерные массивь динамического размера, кроме тех случаев, когда только старшая размерность изме- няемая, а остальные - постоянные. Другими словами, вы можете делать следующее: void f(int х) { new byte_t[х]; } но не: void f(int х, int у, int z) { new byte_t[x][y][z]; // Ошибка } Многомерный массив может создаваться только следующим образом: const size_t Y = 10; const size_t Z = Y * 2; void f(int x) { new byte_t[x][Y][Z] ; ) Это может быть довольно серьезным ограничением. Например, пару лет назад я ра- ботал над интерфейсом пользователя мультимедийного программного продукта, в ко- тором при компоновке элементов интерфейса они динамически вызывались из цен- трального сервера по сети Интернет. Эта компоновка могла представлять собой любую возможную комбинации видеофрагментов, текстов, диалоговых форм, изображений и Других визуальных элементов управления внутри прямоугольных блоков, вложен- ных в другие прямоугольные блоки на произвольную глубину, которую позволяет эму- лировать неструктурированная компоновка. Напрашивается реализация этих уровней прямоугольников с помощью «массива прямоугольников», но поскольку размерность не была фиксированной ее нельзя было предугадать, поддержка встроенных массивов 3Десь не помогала.
600____________________________________________________ Дефект: C/C++ не поддерживает динамические многомерные массивы. ' * * Вы можете не рассматривать это как дефект, поскольку одним из принципов C++ является то, что он, когда речь идет об обеспечении функциональности, отдает пред почтение добавлению новых библиотек, а не добавлению в библиотеку новых возмож ностей, и такие библиотеки существуют, как мы позже увидим в данной главе. Однако как я продемонстрирую, невозможно полностью эмулировать синтаксис встроенных массивов, но можно довольно близко подойти к нему. Конечно, решение проблемы состоит в создании специальных контейнеров. Как мы узнали в разделе 14.7, язык поддерживает работу со срезами массивов, поэтому это вполне реально. Мы рассмотрим несколько механизмов обеспечения N-мерных масси- вов в данной главе1. 33.1. Обеспечение синтаксиса индексации Мы уже видели (см. гл. 14 и раздел 27.1), что можно осуществлять индексацию одномерных массивов, просто обеспечив неявное преобразование в указатель на тип элемента. struct IntArraylD { operator int *() { return nK_elemanta; } int m_elements[3]; }; IntArraylD ia; la[2] - la[l] + 1; Мы не можем это делать с массивами других размерностей, т. к. в данном случае невозможно вырождение массива в указатель (см. раздел 14.2). Следующий класс имеет неявное преобразование в указатель, который ссылается на указатель на int. это совсем не похоже на неявное преобразование в двумерный массив; в противном случае как бы компилятор смог вычислить смещение старшей размерности в дующем случае? ---------------------------------------- std'va,arra> 1 Один или два программиста предложили, чтобы такое исследование включало сравнение с Назовите меня высокомерным и избалованным, но я просто не могу заставить себя использ ^уорого который переопределяет operator =0 и возвращает не булево значение, а другой valarray, элемен представляют результат сравнения отдельных элементов двух его операндов!
601 гя^зЗЗ. Многомерные массивы struct IntArгay2D { operator int **() { return m_elements; // Недопустимо } int m_elements[3][3]; IntArray2D ia; ia[2][2] = ia[2][1] + 1; // Компилятор не может рассчитать смещения // размерностей! Для нас единственной возможностью поддержки естественного синтаксиса для многомерных массивов является перегрузка оператора индексации для последней раз- мерности с возвращением объекта, который может быть индексирован. В случае дву- мерного массива мы могли бы возвращать оператором индексации тип, который обес- печивает либо оператор индексации, либо оператор неявного преобразования в указа- тель. Для более старших размерностей возвращаемый тип сам должен возвращать тип с перегруженным оператором индексации. 33.2. Установка размеров на этапе выполнения программы В данной главе позднее мы увидим некоторые классы, которые обеспечивают много- мерные массивы со статичными размерами, но потребность в таких классах не очень большая, т. к. встроенные массивы «бесплатно» реализуют большинство возможностей таких классов. Наиболее желательны такие типы многомерных массивов, размерности которых задаются на этапе выполнения программы. В этом случае хорошо помогает модель размещения массивов C/C++, поскольку все промежуточные экземпляры могут быть реализованы как прокси срезов (slice proxies), которые довольно эффективны: нет операций распределения динамической памяти, члишь выполняется инициализация нескольких переменных-членов для каждой ПоДразмерности. Альтернативный подход заключается в распределении своей собст- кенной памяти каждому промежуточному экземпляру и копировании значений. Это не ’олько было бы очень неэффективно, но также не позволяло бы применять элементы Массивов в качестве значений lvalue. 33.2.1. Массивы переменной длины Как мы упоминали в разделе 32.2.2, массивы переменной длины (МПД) позволяют здавать динамические встроенные массивы. Однако мы узнали, что только три из с*ти наших компиляторов (см. приложение А) так или иначе поддерживают МПД, °лько один из них, GCC, поддерживает такие массивы для типов, отличных от POD.
602 __________________________________________________________^^ерагал До тех пор, пока МПД не станут частью стандарта C++, мы не можем рассчитывать что с их помощью можно удовлетворить наши требования. Даже когда они войдут в стандарт, еще достаточно продолжительное время мы не сможем на них полагаться из-за необходимости обеспечения обратной совместимости. 33.2.2. vector< ... vector<T>... > Одно из виденных мною решений заключается в использовании std: :vector для эмуляции многомерных массивов, как показано в следующем примере: typedef std::vector<std::vector<int > > intarray_2d_t; К сожалению, здесь не создается двумерный массив; в действительности создается массив массивов, что совсем нечто другое. Для создания массива 2 х 3 мы могли бы сделать следующее: int array_2d_t ar(2) ; ar[0].resize(3) ; ar[l].resize(3); Если мы забудем сделать два дополнительных вызова resize (), то любая попыт- ка обратиться к этим элементам приведет к непредсказуемому результату. Хуже то, что ничто не сможет остановить кого-нибудь от использования различных размеров и соз- дания «зубчатого» массива: intarray_2d_t аг(2); аг[0].resize(3) ; ar[l].resize(2); // лучше не обращаться к аг[1][2]1! Конечно, это плохое решение. Более того, должно быть совершено ясно, что здесь выполняется несколько операций распределения памяти: одна для самого массива и по одной для каждого его подмассива. Это сильно отражается на производительности, как мы увидим в разделе 33.4. «Последний гвоздь в его гроб» - это невозможность обеспечения в стиле STL прохода по всем его элементам, что делает очень трудным применение к нему всех наших «драгоценных» алгоритмов STL. Если бы нам захотелось просмотреть все содержимое, нам пришлось бы написать специальный шаблон for_each, например: template<typename Т, typename Al, typename A2, typename F> F for_each( std::vector<std::vector<T, Al>, A2>::iterator from , Std::vector<std::vector<T, Al>, A2>::iterator to , F fn) { for(; from != to; ++from) { for_each((* from).begin(), (* from).end(), fn); } return fn;
Слава 33. Многомерные массивы 603 Как вам это нравится? Откровенно говоря, это тот случай, когда интуиция математика сработает очень четко: это не красиво1, и мы можем уверено сделать вывод, что это плохо. Нам потре- буются некоторые типы, которые специально написаны для этой цели. 33.2.3. boost: :muffi_array Библиотеки Boost имеют класс динамического массива под названием multi_array3. Он использует некоторые сложные шаблонные механизмы для обес- печения многих размерностей в рамках одиночного шаблона. Применение multi_array требует от вас инстанниирования шаблона с элемен- том типа и количеством размерностей, а затем конструирования экземпляров вашей параметризации с указанием длин размерностей, как в следующем примере: boost::multi_array<int, 2> intarray_2d_t(boost::extents[2][3]); Как мы увидим в разделе 33.5, производительность multi_array не оптимальна, и, вероятно, этим неизбежно приходится платить за его сложность и универсальность. Массив multi_array сталкивается с той же самой проблемой при прохождении по всем своим элементам, которая характерна для решения vector<.„, vector<T> ...>. С моей точки зрения, это серьезный изъян, хотя я должен признать, что в принципе кому- нибудь может потребоваться работа сразу той или иной размерностью многомерного мас- сива. Тем не менее, я полагаю, лучше для этого иметь отдельный механизм, в котором методы begin () и end () возвращали бы итераторы для всего диапазона управляемых элементов (как они это делают во всех контейнерах стандартной библиотеки), а не просто их последовательность. Но на самом деле не все так плохо. С точки зрения клиентского программного кода приятно иметь возможность параметризации шаблона, когда задается тип элемента и количество размерностей. Спецификация размеров каждой размерности не так эле- гантна, т. к. требует применения глобального объекта extents, но поскольку он уточняется пространством имен, это не приведет к каким-либо условиям гонок в силу поддержки состояния в каждом экземпляре, и поэтому проблемы, обычно возникающие в таких случаях с глобальными переменными, нас не волнуют. Массив multi_array позволяет также изменять свои размеры. Более того, этот шаблон может поддерживать последовательность элементов, которая принята в языке Fortran. В С крайняя слева размерность имеет самый большой Шаг между элементами массива в памяти; в языке Fortran крайняя слева размерность имеет самый малый шаг между элементами массива в памяти [Lind 1994]. Это свойст- Во шаблона полезно, когда необходимо осуществлять связь с единицей компоновки Язь,ка Fortran. Данной возможностью не обладают специальные классы массивов, которые я вскоре введу, хотя такое дополнение довольно просто сделать. И ады даже не написали версию итератора const Jterator, которую также следовало бы обеспечить в явном виде!
604________________________________________________________^S-Onewroft, Я не проверял производительность используемого в языке Fortran формата, т к (в настоящее время) не с чем сравнивать, и я не думаю, что эти варианты будут суще ственно отличаться; затраты на изменение схемы расчета смещений в multi_array на обратную должны быть такими же, как для любого другого класса многомерного массива, если их авторы рационально подходят к решению этой задачи. 33.2.4. fixed_array_1/2/3/4d Неудивительно, что первоначальным мотивом, заставившим меня взяться за напи- сание моих собственных многомерных массивов в виде набора взаимодействующих шаблонных классов, каждый из которых со своей размерностью, является как раз мое несогласие с проектом multi_array библиотеки Boost. Это вовсе не значит, что я считаю используемый в multi_array подход неверным, просто он не совсем соот- ветствует моим требованиям к многомерным массивам и моему образу мышления. Несомненно, авторы multi_array могли бы с таким же недоверием отнестись к моему подходу. Существует четыре шаблона с поразительно «оригинальными» именами: f ixed_arгау_1 d, f ixed_ array_2 d, f ixed_array_3 d и f ixed_array_4 d. Их название начинается co слов fixed_array (фиксированный массив), т. к. каждый тип реализует массив конкретной, фиксированной размерности. Естественно, этот подход гораздо проще реализуется, чем написание одного шаблона, обеспечи- вающего любое число размерностей. (Уже не говоря о его более высокой эффективно- сти, как мы увидим в разделе 33.5.) Другая особенность multi_array, которая отличает его от f ixed_array, - это возможность изменять размеры. И снова, эту возможность не так уж сложно добавить в классы f ixed_array, и едва ли это повлияло бы на производительность операций, не связанных с изменениями размеров, но я этого не сделал по той причине, что мне просто не нужно было изменять размер многомерного массива на этапе выполнения программы. Я не хотел бы касаться концептуально затруднительных, хотя технически простых вопросов относительно того, что делать с новыми или отвергнутыми элемен- тами, и какие изменения размеров допустимы, а какие нет. Не предусмотрен шаблон для массива более высокого уровня, т. к. он никогда не был мне нужен. Честно говоря, я, к сожалению, никогда не пользовался шаблоном f ixed_array_4d, но думал, что разумно иметь шаблон более высокой размерности, чем мои потребности, на тот случай, если он мне все-таки понадобится; было бы не очень здорово включить эти «очень быстрые классы многомерных массивов» в кли ентский проект и затем расширять набор компонентов на месте только для того, чтобы внести в систему не протестированный программный код. Это не будет способствовать улучшению вашей репутации или улучшению отношения к программному обеспече нию с открытым исходным кодом.
Глава 33. Многомерные массивы 605 Существует несколько важных особенностей шаблонов f ixed_array. Во-первых, каждый шаблон поддерживает указатель на одномерный блок, в котором находится содержимое его N-ой размерности. Мы вскоре увидим, что здесь делается для обеспече- ния надежной и эффективной работы. Во-вторых, каждый класс имеет тип-член dimens ion_element_type. Для случая одномерного массива он совпадает с value_type. Однако для размерностей более высокого уровня этот тип определяется на основе шаблона размерности предыдущего уровня, как показано в листинге 33.1. Листинг 33.1. template< typename Т // Тип значения , typename А = . . . // Распределитель памяти , typename Р = . . // Стратегия конструирования , bool R = true // Обладает данными? > class fixed_array_3d : public A { public: typedef fixed_array_3d<T, A, P, R> class_type; typedef fixed_array_2d<T, A, P, £alse> dimension_element_type; typedef A allocator_type; private: fixed_array_3d( pointer data, index_type dO, index_type dl , index_type d2); public: fixed_array_3d(index_type dO, index_type dl, index_type d2); fixed_array_3d(index_type dO, index_type dl, index_type d2 , value_type const &t); -fixed_array_3d(); В-третьих, методы begin () и end () возвращают итераторы, которые охваты- вают весь набор управляемых элементов. Это означает, что вы можете применять один и тот же алгоритм, например, f or_each (), к любому классу массивов, вне зависимо- сти от его размерности. Если вы хотите рассматривать массивы блоками, относящими- ся к отдельным размерностям, это легко достигается путем получения среза массива и применения стандартных алгоритмов к этому срезу. В-четвертых, максимально повышается его устойчивость и облегчается его сопро- вождение, поскольку для срезов-подмассивов используются те же самые шаблоны, что и Для полных массивов. Это означает, что все вычисления индексов, создание новых сРезов-подмассивов, перечисление и все другие методы здесь также могут использо- ваться. Единственное отличие в том, как осуществляется распределение и управление Памятью. Если шаблонный параметр владельца - R в приведенном выше объявлении -
606 ^ь5-Оператор, имеет значение true, то стандартный конструктор выделяет память, а деструктор ее освобождает. Если он имеет значение false, то конструктор среза просто копирует переданный ему указатель. Следует отметить, что этот режим выбирается на этапе компиляции, поэтому не существуют потери в эффективности из-за проверки пара- метра владельца на этапе выполнения программы. Листинг 33.2 показывает два кон- структора и деструктор для f ixed_array_3d, а также как используются статические утверждения для предотвращения неверного применения двух режимов работы этих шаблонов. Листинг 33.2. template<typename Т, typename A, typename Р, bool R> fixed_array_3d<T, А, Р, R>::fixed_array_3d( pointer data , index_type dO , index_type dl , index_type d2) : m_data(data) , m_dO(dO). m_dl(dl), m_d2(d2) { STATIC_ASSERT(!R); ) template <typename T, typename A, typename P, bool R> fixed_array_3d<T, A, P, R>::fixed_array_3d( index_type dO , index_type dl , index_type d2 , value_type const &t) : m_data(allocator_type::allocate(dO * dl * d2, NULL)) . m_d0(d0), m_dl(dl), m_d2(d2) { STATIC_ASSERT(R); array_initialiser<T, A, P>::construct(‘this, m_data, sizeO); ) template <typename T, typename A, typename P, bool R> fixed_array_3d<T, A, P, R>::~fixed_array_3d() { if (R) { array_initialiser<T, A, P>: :destroy(*this, m_daca, sizeO); allocator_type::deallocate(m_daca, size()); } ) Дополнительную помощь в обеспечении высокой производительности оказывает параметр стратегии конструирования - Р в приведенном выше программном коде который отвечает за определение способа инициализации памяти. Это позволяет улучшать производительность для основных типов (то есть типов POD) двумя спосо бами, а именно, можно использовать memset () вместо std: : f ill_n () для выпол нения инициализации и не делать вызов «деструкторов» перед освобождением памяти-
Слава 33. Многомерные массивы 607 Оба решения принимаются внутри вспомогательного класса array_initialiser, который также используется в шаблонах static_array, с которыми мы познакоми- лись в разделе 33.3.21. Последнее улучшение эффективности обусловлено пониманием того, что хотя перегружаемые операторы индексации очень эффективны, все-таки с созданием объ- ектов прокси связаны ненулевые затраты. Поэтому каждый шаблон имеет методы at () и at_unchecked () с соответствующим количеством параметров для каждой размерности. template< . . . > class fixed_array_3d { public: reference at(index_type iO, index_type il, index_type i2); reference at_unchecked(index_type iO, index_type il , index_type i2); . . . // и их константные перегрузки Методы at () следуют подходу, применяемому в стандартной библиотеке, по кон- тролю достоверности индекса и выбрасыванию исключения std: :out_of_range, если индекс имеет недопустимое значение. Однако методы at_unchecked () обес- печивают возможность бесконтрольного доступа к элементу2. Вместо вычисления трех смещений и генерирования двух промежуточных экземпляров объектов для срезов эти методы выполняют одно вычисление смещения. Естественно, этот меха- низм оказывается очень эффективным, что мы увидим в разделе 33.5. Таким образом, все операторы во внутреннем цикле листинга 33.3 семантически эквивалентны, но имеют разную производительность. Листинг 33.3. fixed_array_2d<int, . .> аг(10, 20); // Сконструировать массив 10x20 int v = 0; for(size_t i = 0; i < ar.dimOO; ++i) { for(size_t j = 0; j < ar.dimlO; ++j, ++v) { ar[l][j] - v» ar.at(i, j) - v; ar.at—uncheckedd, j) v; ) } Исходные тексты всех классов fixed_array, вспомогательного класса и обсуждаемых в следующем разделе Бассов static_anay включены в состав компакт-диска. Подобно оператору [], он выполняет применяемую методикой «проектирования по соглашению» (см. раздел •3) проверку предусловий с помощью использования утверждений в отладочных версиях.
608 Часть5-0пВДгорь, 33.3. Установка размеров на этапе компиляции Обсуждая различные проектные решения по созданию динамических многомерных массивов, нам следует выяснить, делают ли целесообразным создание статических мно- гомерных массивов какие-нибудь характеристики различных вариантов. Несомненно, C++ (и С) обеспечивают очень хорошую поддержку встроенных много- мерных массивов фиксированного размера. Основная причина, я полагаю, по которой стоит рассматривать возможность создания какого-нибудь типа класса, заключается в обеспечении естественного итератора для прохода по всем элементам массива (то есть с помощью begin () / end ()). хотя не так уж трудно организовать проход по массиву с ссылкой на элемент, следующий за последним: void print_int(int const &); int ar[10][10]; std::for_each(&ar[0][0], &ar[10][10], print_int); 33.3.1. boost: :array Библиотека Boost имеет шаблон для одномерных фиксированных массивов, boost: : array, который обеспечивает возможность применения концепции последо- вательности STL [Aust 1999, Muss 2001] для встроенных массивов, включая методы begin() и end(), size() и операторы индексации. Применяя boost: :array, нетрудно сформировать многомерные массивы: boost::array<boost::array<int, 10>, 10> ar; Но это на самом деле никак нас не приближает к встроенным массивам, т. к. мы не можем использовать begin () и end () для перечисления всех элементов (см. раздел 33.2.2); они возвратят указатели на следующую младшую размерность - в данном случае boost: : array<int>. Аналогично, метод size () скажет нам ту же самую «неправду», как и для boost: :multi_array, а именно, возвращая только количе- ство элементов в старшей размерности экземпляра, для которого он вызван. Вероятно, лучше работать со встроенными массивами, чем пытаться облачить такие типы в одежды типов многомерных массивов1. 1 Примечание: эта критика не относится конкретно к boost::array, поскольку я никогда не видел никако> документа, где предполагалось бы его пригодность к применению (или были бы намерения такого примен в качестве составного типа, на основании которого строился бы многомерный массив. Я использую ее только в педагогических целях.
Глава 33. Многомерные массивы 609 33.3.2. static_array_1/2/3/4d Мое решение задачи получения статических многомерных массивов представляет собой набор шаблонов static_array, которые очень напоминают концепцию шаб- лонов fixed_array (см. раздел 33.2.4). Основное отличие в том, что экземпляры этих массивов содержат свои элементы массивов в виде массива-члена, а не как указа- тели на некий отдельный массив; срезы-подмассивы применяют тот же самый меха- низм использования указателя на соответствующую часть содержимого экземпляра массива, в котором они находятся. Я полагаю, несмотря на их схожесть, имеет смысл отметить отличия, т. к. они демонстрируют очень важные вещи, которые вы можете делать с шаблонами в C++. При работе static_array нет операций распределения памяти: его экземпляр либо является прокси и содержит только указатель, либо он действительно массив и содержит полноправный N-мерный встроенный массив элементов. template< typename Т // Тип значения , size_t NO // Старшая размерность , size_t N1 // Младшая размерность • typename ?=...// Стратегия конструирования , typename М = T[N0 * N1] > class static_array_2d; Но когда static_array_2d применяется в качестве среза-подмассива, он пара- метризуется типом т*, а не значением по умолчанию T[N0 * N1J. Это означает, что вместо определения своего члена m_data в виде внутреннего массива, он будет определен как указатель. Листинг 33.4. tempiatе< typename Т , size_t NO , size_t N1 , size_t N2 , typename P = . . . // Стратегия конструирования , typename M = T[N0 * N1 * N2] > class static_array_3d ; public null_allocator<T> { public: typedef static_array_3d<T, NO, Nl, N2, P, M> class_type; typedef atatic_array_2d<T, Nl, N2, ₽, T*> dimension_type; Осталось лишь завершить картину конструктором и деструктором, которые опреде- лится следующим образом:
610 Операторы Листинг 33.5. template ctypename Т, . . . > static_array_2d<. . .>::static_array_2d(T *data) : m^data(data) {} template ctypename T, . . . > static_array_2dc. . .>::static_array_2d(value_type const &t) { array_initialiser<. . .>:{construct(*thia, m^data, size(), t); } template ctypename T, . . . > static_array_2dc. . .>::~static_array_2d() { if (lis_pointer_type<M>: lvalue) // Вычисляется на этапе компиляции { array_initialiser<. . . >:{destroy(*this, m^data, size()); } ) Первый конструктор - это конструктор среза, и при инстанциировании этого метода его параметризация обеспечит использование в качестве m_data указателя, а не массива. Напротив, второй конструктор будет так параметризован, что m_data будет массивом, поэтому нет необходимости «распределения» или инициализации этого члена; конструктор просто инициализирует его, используя нашего старого друга array_initialiser. Деструктор применяет конструкцию метапрограммирования - is_pointer_type7 - для определения, будет ли тип члена (М в списке параметров шаблона) указателем. Если ответ утвердительный, то при инстанциировании создается прокси и нет необходимости выполнять уничтожение. Если ответ отрицательный, то при инстанциировании действи- тельно создается массив, и его элементы должны уничтожаться. Мы воспользовались способностью массивов и указателей иметь аналогичный син- таксис, поскольку доступ к содержимому расширенного массива - &m_data[0] и &m_data [size () ] - выполняется в одинаковой форме. В принципе, статические массивы не только просто удобны, их создание свидетель- ствует о мощи и эффективности шаблонов. Нельзя сказать, что они не имеют дополни- тельных полезных свойств по сравнению со встроенными массивами; очень приятно по- лучать осмысленные и удобные значения, возвращаемые широко используемыми и важ- ными методами size (), begin () и end(). Написание обобщенного программного кода сильно усложняется, когда эти методы не работают корректно со всеми типами. 33.4. Доступ ко всему массиву Встроенные массивы мы часто рассматриваем как единое целое и выполняем много операций над элементами массива посредством единственного оператора или неболь шим количеством операторов. Например, их легко можно инициализировать нулям»1’ используя функцию memset (), как показано в следующем примере:
Глава 33. Многомерные массивы 611 byte_t аг[10][10]; memset(&аг[0][0], 0, sizeof(аг)); или для ленивых: memset(ar, 0, sizeof(аг)); или для сомневающихся: memset(&ar, 0, sizeof(аг)); или для очень нерешительных: memset(&аг[0], 0, sizeof(аг)); К сожалению, очень легко делать то же самое с типами классов, обладающими семантикой массивов, как в следующем примере: fixed_array_2d<byte_t, . . . > fa2(10, 10); boost: :mul ti_array<byte_t, 3> btna3(boost::extents[10][10][10]); memset(&fa2[0][0], 0, sizeof(fa2)); // Неверный размер! memset (&bma3, 0, sizeof (ЬпаЗ)); // Неверный указатель; неверный размер! memset(&fa2[0], 0, sizeof(fa2)); // Неверный указатель; неверный размер! Табл. 33.1 показывает допустимость сочетания memset () и sizeof () для раз- личных типов одно- и двумерных массивов. Пустая ячейка таблицы означает, что ком- пиляция и выполнение были корректными. НК означает, что это сочетание не компи- лируется, что хорошо. О означает, что это сочетание компилируется и выполняется, ио с ошибками - либо записывая слишком много или слишком мало элементов, либо переписывая другие переменные-члены экземпляра массива; в любом случае О обо- значает очень плохую вещь. Здесь имеются две отдельные проблемы. С одной стороны, можно передать непра- вильные объекты функции memset (); поскольку она требует всего лишь void*, очень легко передать не то, что нужно. Это особенно опасно, поскольку передача &аг фактически работает корректно в случае встроенного массива, но приводит к переза- писи данных-членов для экземпляров типа класса. Не вызывает большого удивления тот факт, что классы динамических массивов - b°ost: : multi_array и f ixed_array - имеют проблемы на этапе выполнения при использовании memset () и sizeof (). Когда их адрес передается memset (), перемен- иые-члены оказываются переписанными - О(П). Когда передается адрес их первого элемента (&аг[0] - для одномерного массива, &аг [ 0 ] [ 0 ] - для двумерного массива), записывается неверное количество элементов, т. к. размер экземпляра не соответствует (в большинстве случаев) размеру управляемого диапазона элементов - О(Р). Если мы хотим написать обобщенный программный код, содержащий блочные операции с типами массивов, то нас, очевидно, ждет впереди определенная работа.
612 Часть 5. Операторы 33.4.1. Применение std::fill_n() Давайте сначала рассмотрим проблему неправильного применения указателя Мы можем ее избежать, используя вместо memset () более типобезопасный (и безо пасный относительно размеров) алгоритм стандартной библиотеки std:: f ill_n (j Перезапись нескольких наших примеров позволяет выявить многие проблематичные выражения. byte_t аг[10][10]; fixed_array_2d<byte_t, . . . > fa2(10, 10); boost::multi_array<byte_t, 3> ЬпаЗ(boost::extents[10][10][10]); fill_n(&ar[0][0], dimensionof(ar), 0); fill_n(ar, dimensionof(ar), 0); fill_n(&ar, dimensionof(ar), 0); fill_n(&ar[0], dimensionof(ar), 0); fill_n(&fa2[0][0], dimensionof(fa2), 0)); fill_n(&hma3, dimensionof(bma3), 0); fill_n(&fa2[0], dimensionof(fa2). 0); // Нормально 11 Ошибка компиляции! 11 Ошибка компиляции! 11 Ошибка компиляции! 11 Неверный размер! 11 Ошибка компиляции! // Ошибка компиляции! Таблица 33.1. Совместимость типов массивов с memset() и sizeof() Тип массива | Одномерные I Двумерные ar &ar &ar[0] ar &ar &ar[0] &ar[0][0] встроенные boost::array HK HK staticarray HK HK О(П) boost: :multi_array НК О(П) O(P) HK О(П) О(П) O(P) fixed_array HK О(П) O(P) HK О(П) О(П) O(P) Таблица 33.2 показывает допустимость сочетания std: и dimen- sionof () для различных типов одно- и двумерных массивов. Следует отметить, что мы используем dimensionof () (см. раздел 14.3), а не sizeof (), т. к. аргументом f ill_n () является количество изменяемых элементов, а не количество байтов. Не следует беспокоиться о снижении эффективности std: : f ill_n (), т. к. хоро- шая реализация стандартной библиотеки позволит использовать memset () только с однобайтовыми типами, и мы никак не сможем использовать memset () с типами большего размера (кроме случаев, когда можно устанавливать все байты в 0). При использовании std: :fill_n() мы почти все случаи ошибок перезаписи элементов, выдаваемых на этапе выполнения, перевели в ошибки этапа компиляции- Эти результаты убедительно показывают, почему нам, как правило, следует предпочи тать этот вариант функции memset ().
Слава 33. Многомерные массивы 6 *13 Однако мы по-прежнему задаем неверные размеры для наших динамических мас- сивов, boost: :multi_array и f ixed_array, что означает вероятную перезапись либо слишком малого количества байтов, либо слишком большого - ошибка в обоих случаях. 33.4.2. Прокладка array size Нам нужно иметь единый для всех типов массивов механизм определения количе- ства элементов. Решение состоит в применении прокладки атрибутов (см. раздел 20.2), «ловко» названной array_size (). Как и для большинства прокладок, существуют обшие определения, которые обрабатывают большинство случаев, и специальные определения для конкретных случаев. Общими являются следующие определения array_size (): template ctypename Т> size_t array_size(T const &) { return 1; 11 He массив, поэтому только один элемент } template ctypename Т, size_t N> size_t array_size(T (&ar)[N]) { return N * array_size(ar[0]); // N * (количество элементов 11 следующей размерности) ) Таблица 33.2. Совместимость типов массивов с fill_n() и dimensionof () Тип массива Одномерные | Двумерные ar &ar &ar[0] ar &ar &ar[0] &ar[0][0] встроенные HK HK HK HK boost: :array HK HK HK HK HK staticarray HK HK HK HK HK boost: :multi_array HK HK O(P) HK HK HK O(P) fixed_array HK HK O(P) HK HK HK O(P) Эти две перегрузки в принципе могут работать со сколь угодно большими раз- мерностями типов встроенных массивов и не-массивов. Поэтому применение array__size() к int ai[10] [30] [2] [5] [6] приведет к пяти вызовам второй Перегрузки и к одному вызову первой перегрузки в конце. Вы можете поинтересоваться, почему мы не используем методы, позволяющие рас- считать количество элементов на этапе компиляции. Дело в том, что нам также необхо- димо иметь возможность их применения к типам, размерность которых станет известна Только на этапе выполнения. Так или иначе, вам не стоит беспокоиться, только если вы
614^5. Оператор не собираетесь использовать значение на этапе компиляции, т. к. все приличные компи ляторы избавляются от этой прокладки при ее применении к встроенным массивам и просто переводят результат этапа выполнения в константу при оптимизации программного кода. Итак, давайте посмотрим, как мы можем расширить эту прокладку на другие типы Для семейства классов f ixed_array и static_array перегрузки этой прокладки определяются следующим образом: template* typename Т, typename A, typename Р, bool R> size array_size(fixed_array_4d«T, A, P, R> const bar) { return ar.sizeO; } template* typename T, size_t NO, typename P, typename M> size_t array_size(static_array_ld*T, NO, P, M> const bar) { return NO; } Мы можем обеспечивать подобные перегрузки для любых других необходимых нам классов, как, например, классов массивов библиотеки Boost: template «typename Т, size_t N> size_t array_size(boost::array*T, N> const &ar) { return N * array_size(ar[0]); } template «typename T, size_t N> size_t array_size(boost::multi_array«T, N> const bar) { // ПРИМЕЧАНИЕ: sizeO возвращает значение только для старшей // размерности return ar.num_elements(); } Теперь мы имеем простой и универсальный способ определения количества эле- ментов любого массива. Единственная неприятность возникает в том случае, если ока- зывается, что у вас нет определения этой прокладки для вашего типа массива. Но вы получите его в процессе досконального тестирования, не так ли? При использовании memset () вы избавляетесь от всех проблем, связанных с несо- ответствием размеров, хотя проблемы адресации остаются, как показано в табл. 33.3. Но, как показано в табл. 33.4, при использовании std: : f ill_n () мы получаем идеальный набор сочетаний. Все сомнительные преобразования указателей не компи- лируются, а все остальные компилируемые случаи дают корректные результаты.
Глава 33. Многомерные массивы 615 Таблица 33.3. Совместимость типов массивов с memset() и array_size () Тип массива Одномерные | Двумерные ar &ar &ar[0] ar &ar &ar[0] &ar[0][0] встроенные boost::array HK HK static_array HK HK О(П) boost::multi_array HK О(П) HK О(П) О(П) fixed_array HK О(П) HK О(П) О(П) Рекомендация: прокладка размера массива должна всегда использоваться при определении размеров массивов, т. к. все другие подходы не распространяются одно- временно на встроенные и пользовательские типы массивов. 33.5. Производительность Мы рассмотрели несколько различных вариантов применения многомерных масси- вов в C++, которые являются адекватными, элегантными или совершенно блестящими, в зависимости от того, как вы к ним относитесь. Но как и при рассмотрении многих ас- пектов разработки программного обеспечения, мы должны найти баланс между гибко- стью и производительностью, поэтому мы собираемся завершить эту главу анализом производительности различных типов массивов для определения нами этого баланса. В тестах, результаты которых приведены здесь, программный код выполняет N-крат- ные циклы, присваивая и вновь считывая в циклах элементы [i] [ j ] [k], где N - размерность тестируемого массива. Присваиваемые значения рассчитываются детерм и- нированно, так что они имеют одинаковые значения для всех типов массивов. Фактиче- ски, этот тест определяет затраты на операции чтения и записи элементов. Для типов с динамическими размерами затраты на создание и уничтожение массива учитываются при подсчете временных показателей. Таблица 33.4. Совместимость типов массивов с sld::fill_n() и array_size() Тип массива Одномерные Двумерные ar &ar &ar[0] ar &ar &ar[0] &ar[0][0] встроенные HK HK HK HK boost: :аггау HK HK HK HK HK static_array HK HK HK HK HK boost: :multi arra У HK HK HK HK HK fixed_array HK HK HK HK HK
616 Часяъб. Операторы При тестировании в качестве элементов массивов использовались типы std: :string, int и double, но поскольку во всех случаях получены фактически идентичные показатели относительной производительности, я привожу лишь резуль- таты для int. Показатели времени представлены в процентах относительно самого медленного типа каждой тестируемой размерности. Все программы тестирования включены в состав компакт-диска. 33.5.1. Массивы, размер которых устанавливается на этапе выполнения программы Табл. 33.5 показывает относительную производительность для вектора векторов, массива multi_array библиотеки Boost и f ixed_array_l/2/3d; во всех случаях использовался синтаксис оператора индексации. Таблица также показывает затраты на применение в f ixed_array методов прямого доступа at () и at_unchecked () (которые требуют использования промежуточных объектов при двух или более раз- мерностях). Следует отметить несколько интересных моментов. Во-первых, затраты контро- лируемого доступа, который обеспечивают методы at (), оказываются менее сущест- венными, чем затраты на промежуточные объекты, требуемые при выполнения опера- ций индексации высоких размерностей. При трех размерностях at () осуществляет доступ приблизительно в два раза быстрее, чем при доступе посредством неконтро- лируемой индексации. Это происходит из-за того, что не генерируются промежуточные объекты, и расчет всего смещения выполняется одной операцией, а не в три этапа. При анализе результатов метода at_unchecked () становится очевидным пре- имущество отсутствия промежуточных вычислений и промежуточных объектов. Этот метод имеет одинаковую производительность с индексным доступом типов vector и f ixed_array при одной размерности, с вектором при двух размерностях, и более высокую производительность по сравнению со всеми другими схемами. В равной степени также очевидно, что гибкость проекта multi_array библиоте- ки Boost оказывает влияние на производительность. При двух и трех размерностях он значительно менее эффективен, чем fixed_array - разность в быстродействии может доходить до четырех раз. Он обходится дороже vector’а, не считая трех раз- мерностей, где начинают сказываться затраты на большое количество отдельных операций распределения памяти, необходимых при создании vector’ом массива, состоящего из массивов, также состоящих из массивов. Серьезные «нюмористы» (seri- ous numerists)1 могут не захотеть платить такую цену. Я полагаю, мы с полным основанием можем сказать, что производительность fixed_array доказала его ценность. Не очень приятно иметь разные классы, которые в целом делают одно и то же2, но способность естественно выполнить проход То есть те, кто серьезно относится к численным расчетам. - Примеч пер. 2 Я не отказался от идеи вынести в отдельные блоки много повторяющейся функционально поддерживающей различную природу методов at(), at_unchecked(), begin(), end() и size(). Если у вас в03Н”ваМ,1 желание самому это сделать, вы сможете найти указанные классы в составе компакт-диска; полученные результаты, пожалуйста, пришлите мне по электронной почте.
Глава 33. Многомерные массивы 617 00 всем элементам с помощью методов begin (), end (), обеспечение методом size () осмысленного результата и обладание такой хорошей производительностью для меня, несомненно, является серьезным аргументом. Конечно, вы можете смотреть на это по-другому и будете совершенно правы; углубившись в один из недостатков C++, мы понимаем, что здесь не существует однозначного ответа. Таблица 33.5. Относительная производительность массивов, размер которых устанавливается на этапе выполнения int 1 2 3 vector< vector <...>> 39.1% 31.6% 100.0% boost: :multi_array 91.0% 100.0% 97.6% fixed_array - [i][j][k] 41.0% 62.8% 77.7% fixed_array - at 100.0% 53.8% 44.3% fixed_array - at_unchecked 42.0% 31.6% 27.0% Таблица 33.6. Относительная производительность массивов, размер которых устанавливается на этапе компиляции int 1 2 3 встроенный массив 50.9% 49.1% 35.6% array < array <...>> 52.4% 48.3% 36.7% static_array - [i ][j][k] 54.2% 83.9% 100.0% static_array-at 100.0% 100.0% 88.4% static_array - atunchecked 51.9% 49.0% 35.5% 33.5.2. Установка размера на этапе компиляции Табл. 33.6 показывает относительную производительность, полученную для встроенных массивов, массива array библиотеки Boost и static_array_l/2/3d; во всех случаях ис- пользовался синтаксис оператора индексации. Она также показывает затраты на примене- ние в static_array методов прямого доступа at () и at_unchecked () (которые требуют применения промежуточных объектов при двух или более размерностях). В этом случае картина совсем другая, чем при использовании динамических масси- вов. Просто очевидно, что индексный доступ к элементам встроенных массивов и мас- сивам boost:array, а также доступ посредством метода at_unchecked() к static_array достаточно одинаков по всем размерностям. Применение индекса- ции или метода at () для static_array обходится значительно дороже. Как было показано в разделе 33.3.1, тип boost: : array не подходит для построения многомерных массивов, т. к. методы begin (), end () и size () возвращают бессмыс- ленные значения для двух или более размерностей. Но по этим результатам производи- тельности видно, что stat ic_array (хотя этот тип массива и не имеет указанных недос- татков) связан с неприемлемыми затратами, не считая его метода at_unchecked () - только он имеет производительность на уровне встроенных массивов.
618 Часть 5. Оператора Я бы сказал, что static_array (в отличие от f ixed_array) не стоит вашего внимания. На мой взгляд, при использовании статических массивов лучше придержи- ваться встроенных массивов. Они очень быстрые, и хотя не существует прямой поддержки, которая позволяла бы их рассматривать как контейнеры STL, по крайней мере, это лучше, чем иметь дело с типами, которые дают неверные ответы на запросы методов STL. 33.6. Многомерные массивы: заключение Надо надеяться, вы не скучали при работе с данной главой и узнали некоторые неиз- вестные вам изъяны C++, а также много способов их исправления. Мы видели, что суще- ствуют опасности при трактовке любых типов массивов точно так же, как мы часто (неверно) используем встроенные массивы, но применяя std: : f ill_n () и прокладку array_size(), можем спокойно писать обобщенный программный код, который будет работать со всеми типами массивов. Мы также видели, как сложная природа типов с динамически устанавливаемыми раз- мерами проявляется в ухудшении производительности. Как для f ixed_array, так и для boost: :multi_array характерно значительное ухудшение производительности для больших размерностей, сопровождаемое применением непривлекательных методов досту- па, имеющих неоператорную форму. Именно таков мир, в котором мы живем; гибкость и попытки эмуляции синтаксиса встроенных типов массивов могут приводить к ухудше- нию производительности. Обеспечение таких средств, как at_unchecked (), дает про- граммистам возможность создавать более быстрый программный код, но за счет того, что синтаксис становится менее естественным. Такова жизнь!
Глава 34 Функторы и диапазоны 34.1. Синтаксическая неразбериха Многие алгоритмы стандартной библиотеки работают с диапазонами, определяе- мыми парой итераторов [Aust 1999]. Это очень мощная абстракция эксплуатируется до такой степени, что от нее зависит большая часть STL и, следовательно, большая часть современного C++. Примером может служить простая программа по считыванию целых чисел из файла с их размещением в векторе: std::fstream f(*integers.dat*, std::ios::in | std::ios::out); std::copy( std::istream_iterator<int>(f) , std::istream_iterator<int>() , std::back_inserter(v2)); В данном случае второй аргумент - это конструируемый по умолчанию итератор, который выполняет роль индикатора конца диапазона. Эти два итератора не связаны в физическом смысле; итератор istream_iterator реализован так, что сконструиро- ванный по умолчанию экземпляр может интерпретироваться как логическая конечная точка диапазона. Мы многократно используем алгоритмы, работающие с диапазонами значений, получаемых из контейнера или объекта, подобного контейнеру, как в следующем примере: struct dump_string { void operator ()(std::string const &) const; }; std::vector<std::string» > strings = . . .; std::for_each(strings.begin(), strings.end(), dump_string()); Такой подход может стать утомительным, поскольку мы раз за разом повторяем одни и те же вызовы begin () и end (). Это вовсе не дефект - возникает всего лишь небольшая досада - но существуют обстоятельства, при которых это может оказаться болезненным. Рассмотрим пример использования таких (псевдо) контейнеров, Как glob_sequence (см. раздел 20.6.3), которые созданы с целью выделения
620_____________________________________________________________Часть50пераГОЦ| из последовательностей диапазонов.1 Допустим, нам требуется определять, какое количество заголовочных файлов книги «C++: практический подход к решению проблем программирования» имеет размер, превышающий 1024 байта, т. к. мы хотим по-настоящему окунуться в магию. struct is_large : public std::unary_function<char const *, bool> { bool operator ()(char const *file) const { ...II Возвратить значение «истина», если «file» > 1024 байта } }; glob_sequence gs(*/usr/include/", "impcpp*"); size_t n = std: :count_if (gs.begin(), gs.endO, is_large()); Ни для чего другого gs не предназначен, и его использование для других целей только бы засоряло локальное пространство имен. Аналогичная ситуация возникает, когда мы хотим выполнить проход по двум или более диапазонам в одной области видимости. В конце концов мы можем ввести в теку- щую область видимости несколько переменных для различных условий начала и конца каждого диапазона, как это было в разделе 17.3.2, или использовать трюк со сдвоенной областью видимости из раздела 17.3.1. 34.2. forallQ ? Используя обычную функцию перебора элементов в заданном диапазоне, легко создать эквивалент стандартных алгоритмов для всего диапазона. Мы могли бы соз- дать совместимую с контейнерами версию std: :for_each(), которую мы пока будем называть for_all (): template* typename С , typename F > inline F for_all(C &c, F f) { return std: :for_each(c.begin()c.endO , f); } Такая реализация вполне подходит для подобных алгоритмов, поскольку больший ство контейнеров - стандартных и прочих - обеспечивают методы begin () и end () • 1 Следует отметить, что в этом конкретном случае я отчасти создаю проблему «под себя», т. к. я скЛ0”"^г к расширению STL в этом направлении. Во многих случаях можно следовать примеру 'streanq-'He по и инкапсулировать начало перебора в конструкторе итератора, принимающего некоторое значение умолчанию; так работает компонент перебора элементов в файловой системе библиотеки Boost нарушается логика, и мне трудно следовать этому примеру - вполне возможно, что у вас другая точка зре
Глава 34. Функторы и диапазоны 621 Кроме избавления от надоедливых вызовов двух методов итераторов, это может также снизить зрительное напряжение при работе с (псевдо) контейнерами. Наш glob_sequence может объявляться как безымянный временный объект, и в резуль- тате мы получаем привлекательную краткую форму: n = std::count_if( glob_sequence(*/usr/include/*, "impcpp**) , is_large()); Когда речь идет о промышленном программном коде, такое наведение порядка в синтаксической неразберихе может реально улучшить читаемость. Некоторые критики могут возразить, что большое количество программного кода в данном операторе представляет собой еще один пример того, что C++ не следует духу С [Como-SOC]; конечно, трудно спорить с тем, что «ничего не скроешь». Я бы согласился с этой точкой зрения, но только в отношении функтора. Полагаю, довод о недопустимости «сокрытия» природы доступа к диапазону и перебора его элементов неверен; с таким же успехом можно убеждать в том, что нам следует отказаться от при- менения библиотечных функций и все писать на ассемблере. В STL концепция итера- тора [Aust 1999, Muss 2001] введена для того, чтобы способствовать максимальной эффективности операции перебора элементов - обычно единственная возможность «неправильного» применения последовательностей заключается в использовании концеп- ции высокруровнего итератора [Aust 1999, Muss 2001], но для f or_each () требуется только входные итераторы. 34.2.1. Массивы Как мы видели в гл. 14, возможность определения размера массива в двух местах является потенциальным источником ошибок. Даже когда используется константное значение, все-таки существует два определения. int ari[10] = { . . . std: : for_each (&ari [ 0] , &ari [ 10 ], print_int) Мы также видели, что надежным способом работы с массивами является определе- ние размера статических массивов с помощью макроса dimensionof () или подоб- ной конструкции. std::for_each(&ari[0], &ari[dimensionof(ari)], print_int); Однако наши определения, подобные f or_all (), мы можем легко специализиро- вать для массивов, используя аналогичные возможности, как показано в следующем примере: template* typename Т , size_t N , typename F inline F for_all(T (bar)[N], F f) { return std::for_each(bar[0] , &ar[N], f);
622 Часть 5. Операторы 34.2.2. Проблемы именования алгоритмов Давайте теперь рассмотрим вопрос именования таких алгоритмов. Я намеренно выбрал неподходящее имя, чтобы вам было не совсем удобно им пользоваться Проблема с именем for_all () (для всех) совершенно очевидна: нельзя подобным образом именовать другие алгоритмы. Хотели бы вы иметь алгоритмы с именами fill_all() (заполнить все), accumulate_all() (накопить все) и т. д.? Этот вариант не назовешь привлекательным. Отличным названием данного алгоритма было бы for_each() (для каждого), так почему бы его не использовать? К сожалению, все дело в том, что нам приходится выбирать из вариантов, которые одинаково неприятны. Для тех новых типов нам разрешено специализировать только те шаблоны, которые уже существуют в стандартном пространстве имен; мы не можем добавлять новые шаблонные (и нешаблонные) функции и типы. Я бы сказал, что форму алгоритма про- смотра массива for_all(), названную нами for_each(), нельзя рассматривать как специализацию, основанную на новых типах, хотя я признаю, что здесь имеется едва заметная двусмысленность. Можно поступить по другому и определить for_each() в рамках собственного пространства имен. В этом случае мы должны не забывать использовать объявление using всякий раз, когда мы хотим его применять за рамками нашего пространства имен. К сожалению, нам также пришлось бы использовать std: : for_each() во всех случаях, когда именно он нужен, поскольку любое объявление using для нашего for_each() перекроет объявление пространства имен std. Если этого не сделать, можно получить очень озадачивающие сообщения об ошибках. Однако проблемы, возникающие при записи обобщенного программного кода, когда алгорит- мы имеют различные имена, столь существенны, что, в целом, необходимо серьезно рассматривать1 возможность применения этого подхода, несмотря на докучающие объявления пространств имен. Мы рассмотрим только один пример проблем, связанных с пространствами имен. Допустим, вы используете свои собственные компоненты библиотеки совестно с директивой using namespace acmelib. Вы считаете, что все нормально, т. к. в этом клиентском программном коде вы будете применять много объектов из пространства имен acmelib. В частности, будет использоваться ваша версия для массива алгоритма перебора всех элементов, f or_each (). Какое-то время все у вас идет гладко, и затем вам требуется применить std:: f or_each () в некоторой части файла реализации, поэтому вы для ис- пользования этой функции применяете объявление using: using namespace acmelib; // Директива using using std::for_each; // Объявление using int ai[10]; for_each(ai, print_int); 1 Этот подход использовался Джоном в его совместимой с Boost реализации RangeLib, хотя он полагается на непосредственное указания пространства имен в алгоритмах RangeLib - boost: :rtl::rng::f°r-ea
Глава 34. Функторы и диапазоны 623 Теперь ваш программный код не будет компилироваться, т. к. введение уточнения std: : for_each() с помощью объявления using делает его более приоритетным по отношению к директиве using, определяющей общее пространство имен. Что же делать, ввести s td: : f or_each () с помощью директивы using? Что случится, если в двух пространствах имен существуют конфликтующие определения данной функции или типа, которые используются в нашем программном коде? Нам приходится делать дополнительное объявление using для acmelib:: f or_each (). Учитывая это, почему бы с самого начала просто не использовать объявления using? Возможно, в начале будет больше работы, но в конце концов сильно сэкономим, и все мы знаем о том, что стоимость кодирования на первоначальном этапе разработки программного обеспечения достаточно несущественна по сравнению с затратами на кодирование при его сопровождении [Gias 2003]. Это только одна из причин, почему я отказываюсь использовать директивы using почти в любых условиях1. В данном случае наши новые функции f or_each () и s td: : f or_each () имеют разное количество аргументов, поэтому мы не можем писать обобщенный программ- ный код, который так или иначе мог бы работать с обоими. Поэтому мы могли бы просто назвать алгоритмы for_each_c (), f ill_c () и т. д. Мы вернемся к проблеме именования в конце этой главы. 34.3. Локальные функторы До сих пор озабоченность у нас вызывал в основном синтаксис. Но при использо- вании алгоритмов STL существует вторая, более существенная трудность, которая представляет собой более серьезный дефект. Когда в вашем распоряжении имеется подходящая функция или функтор, то вы можете получать очень лаконичный про- граммный код, который одновременно будет и очень эффективным: std::for_each(c.begin(), c.end(), fn()); Однако как только вам необходимо выполнить какие-нибудь более сложные или спе- цифические действия с элементами в неком диапазоне, у вас оказывается два варианта возможных действия, и ни один из них не отличается особой привлекательностью. Либо вы разворачиваете цикл и сами обеспечиваете его функциональность, либо вы уклады- ваете эту функциональность в специальную функцию или функтор. Большой, с трудом полученный опыт применения директив import х.* языка Java также внес свой вклад Вл<ое крайне отрицательное отношение к неразборчивому вводу имен в пространства имен. Герб Саттер и“ придерживаемся разных точек зрения по этому поводу. К счастью для Герба, он значительно более сведут и эрудирован в C++, чем я. К счастью для меня, это моя книга и я могу писать обо всем, что мне нравится.
624 4anb50neparow, --------------------------------------------------------------------------__ 34.3.1. Развертывание циклов Обычный вариант действий - развертывание цикла алгоритма с кодированием всех необходимых действий, как показано в следующем программном коде, взятом из ранней версии мультиплексора компиляторов Arturius (см. приложение В): Листинг 34.1 void CoalesceOptions(. . .) { { OptsUsed_jnap_t: :const_iterator b = usedOptions.beginO ; OptsUsed_jnap_t: :const_iterator e = usedOptions.end(); for(; b != e; ++b) { OptsUsed_jnap_t: :value_type const &v = *b; if( !v.second && v.first->bUseByDefault) { arcc_option option; option.name = v.first->fullName; option.value = v.first->defaultValue; option.bCompilerOption = v. first->type == compiler; arguments.push_back(option); ) }} Естественно, в подобных случаях программный код может получиться довольно многословным; я мог бы взять другой пример из того же файла, который имел бы еще больший размер. 34.3.2. Специальные функторы Альтернативой развертыванию циклов является написание специальных функ- торов для обеспечения нужного вам режима работы. Если такой режим работы может использоваться в различных местах, то это отлично, но часто вам приходится писать новый класс только для одного конкретного случая или для небольшого их количества. Т. к. это отдельный класс, он будет физически определяться «далеко» от того места, где он используется, что приводит к созданию трудно понимаемого и трудно сопрово- ждаемого программного кода. Лучше всего вам в этой ситуации определить класс функтора в той же единице компиляции, где он будет использоваться, предпочтительно перед той функцией, которая его применяет. Листинг 34.2. struct argument_saver { public: argument_saver(ArgumentsList &args) : m_args(args)
Глава 34. Функторы и диапазоны 625 О void operator ()(OptsUsed_jnap_t::value_type const &o) const { if( !o.second && о.first->bUseByDefault) { arcc_option option; m_args.push_back(option); } } private: ArgumentsList &m_args; }; void CoalesceOptions(. . .) { std::for_each( usedOptions.begin(), usedOptions.end() , argument_saver(arguments)); Однако относящийся к конкретной предметной области программный код функ- тора физически отделен от одного или нескольких единственных мест своего примене- ния, только в которых проявляется его значимость, и эту ситуацию нельзя назвать иде- альной. Такое разделение усложняет сопровождение и, хуже того, провоцирует слиш- ком энергичных разработчиков на попытки использования этого функтора в других местах или его реорганизации. 34.3.3. Вложенные функторы Для уменьшения трудностей, возникающих из-за того, что специальный функтор определяется в одном месте, а применяется в другом, можно было бы позволить специ- альным функторам определяться в функции, в которой они используются. Например, мы могли бы определить argument_saver внутри f п (), как в следующем примере: void CoalesceOptions(. . .) { struct argument_saver { }; std::for_each( usedOptions.begin(), usedOptions.end() , argument_saver(arguments)); Увы, локальные классы функторов не допустимы в C++. Это происходит из-за того, что аргумент шаблона должен ссылаться на сущность, которая компонуется при помощи внешнего связывания (стандарт С++-98: 14.3.2)1. "Это относится ко всем шаблонам, не только к шаблонным алгоритмам и функторам
626 Чзсть 5. Операторы Дефект: C++ не поддерживает локальные классы функторов (используемых с шаб- лонными алгоритмами). Несмотря на незаконность таких классов, некоторые компиляторы разрешают их использовать: CodeWarrior, Digital Mars и Watcom. Более того, можно сделать так, что Borland и Visual C++ станут их поддерживать за счет двойной вложенности классов, как показано в листинге 34.3: Листинг 34.3. void CoalesceQptions( { struct X { struct argument_saver { std::for_each( usedQptions.begin(), usedQptions.end() , X::argument_saver(arguments)); Поддержка обоих форм несколькими популярными компиляторами сведена в табл. 34.1. Comeau, GCC и Intel вообще не поддерживают такие вложенные функции1. Если вы используете один или несколько компиляторов из группы Borland, CodeWarrior, Digital Mars, Visual C++ и Watcom, то вы можете применять этот подход. Но это незаконно, и вы нарушите переносимость вашего программного продукта, если будете так делать. Таблица 34.1. Компилятор Локальные классы Вложенные локальные классы Borland Нет Да CodeWarrior Да Да Comeau Нет Нет Digital Mars Да Да 1 Если эти три компилятора утверждают, что ваш программный код неверен, большая вероятность того, что вы действительно что-то делаете неправильно.
(лава 34. Функторы и диапазоны 627 Таблица 34.1. Компилятор Локальные классы Вложенные локальные классы GCC Нет Нет Intel Нет Нет Visual C++ Нет Да Watcom Да Да 34.3.4. Будем законопослушны Мне известен единственный законный способ заставить все это работать, причем вызов алгоритма перебора оказывается настолько «раздутым», что становится неприем- лемым. Поскольку тип параметризации шаблона должен быть внешним типом, мы опре- делим тип функции за пределами функции, в которой она применяется. Т. к. мы хотим задать нужный режим работы в локальном классе, мы должны связать внутренние и внешние классы. Поскольку нельзя использовать шаблоны, мы можем обратиться за помощью к старой рабочей лошадке C++, то есть к полиморфизму. Локальный класс argument_saver наследует внешний класс argument-processor и переопределя- ет его оператор operator () {) const, как показано в следующем примере: Листинге 34.4. struct argument_processor { public: virtual void operator () (OptsUsed_jnap_t: :value_type const &o) const = 0 }; void CoalesceOptions(. . .) { struct argument_saver : argument_processor { virtual void operator () (OptsUsed_jnap_t: :value_type const &o) const { } b По-моему, это выглядит не так уж плохо. Однако для его применения в шаблонном алгоритме необходимо для параметризации использовать внешний (родительский) Класс. А поскольку родительский класс является абстрактным классом, функтор должен передаваться как (константная) ссылка, а не по значению.
628___________________________________________________________И^5-Операм Более того, s td: : f or_each () принимает тип функтора в качестве второго пара метра шаблона, поэтому необходимо явно уточнить также тип итератора. Таким обра зом, применение «удобного» for_each() выглядит не очень лаконично и, в целом не очень привлекательно: for_each< OptsUsed_jnap_t:: const_iterator , argument-processor const &>( &ari[0], &ari[10] , argument_saver()); Вам придется согласиться, что выполненный вручную перебор элементов выглядел бы предпочтительнее. Чтобы сделать его привлекательнее, можно нанести последний штрих, то есть определить вариант f or_each (), который принимает параметры шаб- лона в обратном порядке, так что вывод о типе функции может быть получен косвенно: template* typename F , typename I > inline F for_each_l(I first, I last, F fn) { return std::for_each<I, F>(first, last, fn); } Что приводит нас к окончательной форме, понять которую не так-то просто: for_each_l<argument_processor const &> ( &ari[0], &ari[10] , argument_saver()); Но по моему убеждению, это решение все-таки далеко от приемлемого. Представьте состояние бедного сопровождающего программиста, пытающегося разобраться во всем этом’1 34.3.5 . Обобщенные функторы: тоннелирование типов Если мы не можем сделать функторы более локальными, возможно, нам удастся улучшить ситуацию, сделав их более обобщенными? Давайте рассмотрим, например, как можно сделать функтор is_large (см. раздел 34.1) более универсальным. Мы можем его использовать в последовательности, например, glob_sequence, чей тип value_type представляет собой char const* (или может быть неявно преобразован в этот тип). К сожалению, она может использоваться только с такими типами. Если мы хотим исполь зовать эту же самую функцию с типом, применяющим кодировку Unicode и тип wchar_ , она не будет работать. Решение заключается в реализации is_large в виде шаблона, параметризуемого своим типом символа, как показано в следующем примере: template «typename С> : public std::unary_function<C const *, bool> Прошло несколько недель между началом этой главы и окончательным вариантом За этот коротк"'1 промежуток времени я забыл, как это работает, хотя именно я это написал!
34. Функторы и диапазоны 629 --------------------------------------------------------------------------- struct is_large { bool operator ()(C const ‘file) const; }; Теперь это будет работать с последовательностями, использующими как char, так и wchar_t (применяя фиктивную последовательность globw_sequence, обес- печивающую кодировку Unicode) при условии обеспечения нами соответствующего инстанциирования: glob_sequence gs (’ /usr/include/ ’, * impcpp* *) ; n - std: :count_if (gs.beginO , gs.sndO, is_largs<char>()); globw_sequence gsw(L*/usr/include/", L"impcpp**); n - std::count_if(gsw.begin(), gsw.and(), is_largs<wchar_t>()) ; Этот вариант значительно полезнее, но пока еще не все сделано. Возвращаясь к раз- делу 20.6.3, мы увидим еще одну последовательность readdir_sequence, осущест- вляющую перебор элементов файловой системы, чей тип value_type - struct dirent const* - не может быть неявно преобразован в тип char const*. Реше- ние проблемы, описываемой в том разделе, состояло в использовании прокладок дос- тупа (см. раздел 20.6.1), и здесь мы можем их применить с той же целью. Однако теперь это сделать немного сложнее, поскольку мы имеем дело с шаблонами, как пока- зано в листинге 34.5. Листинг 34.5. templatec typename С , typename А = С const * > struct is_large : public std::unary_function<A, bool> { template ctypename S> bool operator ()(S const &file) const { return is_large_(c_str_ptr(file)); // применить прокладку c_str_ptr ) private: static bool is_large_(C const ‘file) { . . . // определяет, является ли экземпляр большим или нет ) ); Оператор вызова функции - operator () () const - теперь является шаблонной Акцией-членом, которая пытается при помощи прокладки c_str_ptr () преобразовать я,°бой переданный ей тип в С const*, который затем передается статической реализации
630 Часть5- Оператор метода is_large_(). Теперь мы можем использовать функтор с любым типом, для которого существует подходящее определение типа c_str_ptr (), и оно находится в об- ласти видимости, отсюда: readdir_sequence rs(*/usr/include/"); n = std::count_if(rs.begin(), rs.end(), is_large<char>()); Я называю этот механизм тоннелированием типов (Type Tunneling). Определение: тоннелирование типов-это механизм обеспечения взаимодействия через прокладки доступа двух логически связанных, но физически несвязанных типов Такая прокладка позволяет внешнему типу пройти сквозь интерфейс как по тоннель- ному переходу и предстать перед внутренним типом в понятной и совместимой форме. За последние несколько лет я с большим успехом пользовался этим механизмом в своей работе. Кроме помощи в обеспечении через формы С-строк взаимодействия большого спектра физически несвязанных типов, существуют также обобщенные мани- пуляции дескрипторами, указателями и даже объектами синхронизации. Тоннелирова- ние типов (и, вообще говоря, любые прокладки) нужны вам для того, что сделать компи- лятор вашим ординарцем. Мы видели еще один пример тоннелирования типов в разделе 21.2, когда фактически любой совместимый с СОМ тип может с помощью тоннелирова- ния проникнуть в программный интерфейс регистрации событий, используя комбина- цию обобщенных шаблонных конструкторов и перегрузок InitialiseVariantО, которые выполняют роль прокладки доступа. 34.3.6 . Шаг далеко вперед с последующим уверенным шагом еще дальше Вы можете поинтересоваться, можем ли мы продвинуться еще на один шаг дальше и устранить необходимость указания типа символа. Ответ утвердительный, мы это можем сделать с легкостью, как показано в листинге 34.6. Листинг 34.6. struct is_large : public std::unary_function<. . . , bool> { template -ctypename S> bool operator ()(S const &£ile) const { return is_large_(c_str_ptr(file)); } private: static bool is_large_(char const *file);
Глава 34. Функторы и диапазоны 631 static bool is_large_ (wchar_t const *file); }; Его теперь проше использовать, например: n = std: :count_if (rs.beginO , rs.endO, is_large()); n = std::count_if(gs.begin(), gs.end(), is_large()); n = std:;count_if(gsw.begin(), gsw.end(), is_large()); Однако существуют веские причины так не делать. Этот функтор является предика- том, то есть это функтор, при вызове которого возвращается булево значение, отра- жающее некоторое свойство его аргумента (одного или нескольких). Одной из важных особенностей предикатов является возможность их сочетания с адаптерами [Muss 2001], как показывает следующий оператор, который подсчитывает количество небольших файлов: n = std::count_if ( gs.beginO, gs.endf) , std::not1(is_large<char>())); Для работы адаптеров с предикатами они должны уметь извлекать из предикатного класса типы-члены argument_type и resul t_type (тип аргумента и тип результа- та). Обычно это делается путем их выведения из std: :unary_operator. Теперь нам понятно, почему нельзя осуществить последнее уточнение, показанное в листинге 34.6. Не существует способа задания типа аргумента, кроме определения предикатного класса как шаблона с одним шаблонным параметром для предиката. Но его придется предоставлять при каждом применении, поскольку нет осмысленного значения по умолчанию, из-за чего его трудно будет использовать и воспринимать при чтении. По этой причине реальное определение функтора представляет собой шаблон с двумя параметрами, где первый параметр С - тип символа, а второй параметр А (который имеет значение по умолчанию С const*) - тип аргумента предиката, argument_type. template< typename С , typename А = С const * > struct is_large : public std::unary_function<A, bool> { Теперь, когда мы хотим использовать это с адаптером и последовательностью, тип ко- торых value_type не является типом С const*, мы поступаем, как показано ниже: n = std: :count_if ( rs.beginO, rs.endO // rs: readdir_sequence , std::notl(is_large<char, struct dirent const*» О)); Это не обладает красотой, от которой замирает сердце, но вполне сносно, учитывая тот факт, что комбинация последовательности и обязательного адаптера встречается Редко, а его универсальность и последующее повторное использование дают большие преимущества. Это способствует достижению высокой степени обобщения, поскольку
632____________________________________________________________Операторы мы можем написать шаблонный алгоритм, который был бы полностью совместим с любой последовательностью и который поддерживал бы тоннелирование типов как показано в следующем примере: template* typename С // тип символа , typename S // тип последовательности > void do_stuff(. . .) { S s = . . . ; size_t n = std: :count_if ( s.beginO, s.end() , std::notl (is_large<C, typename S:: value..type» ())),- Однако перед тем, как вы подумаете, что я совсем сошел с ума, я признаю, что это едва ли является нечто таким, что мне взбрело в голову благодаря малейшему, быстро проходящему помутнению рассудка. Это нечто такое, о чем необходимо специально думать. Но в некоторых случаях нам просто приходится иметь сложные куски про- граммного кода; проверьте некоторые библиотеки C++ ваших дружески настроенных соседей, если вы мне не верите. Дело в том, что мы имеем механизм создания сильно обобщенных - другими словами, повторно используемых - компонентов, которые очень хорошо подходят (то есть они имеют естественный вид) в большинстве случаев их применения. Цена обеспечения этой универсальности, на мой взгляд, приемлема - указание типа value_type для заданной последовательности при использовании адаптеров. 34.3.7 . Локальные функторы и программные интерфейсы обратного вызова Только то, что локальные функторы недопустимо применять в алгоритмах STL, вовсе не значит, что им вообще нельзя найти применение в алгоритмах перебора элементов. Фактически, использование локальных классов при работе с программны- ми интерфейсами алгоритмов перебора, использующих функции обратного вызова, является очевидным решением. Рассмотрим реализацию функции FindChild Byld () (см. листинг 34.7), которая обеспечивает глубоко нисходящий вариант широко известной функции платформы Win32 GetDlgItem(). GetDlgltemO возвращает дескриптор непосредственного дочернего окна по заданному идентифика- тору. FindChiIdById () обеспечивает ту же самую функциональность, но при этом она способна определить по идентификатору местоположение любого окна-потомка, а не только непосредственного дочернего окна. Листинг 34.7. HWND FindChildById(HWND hwndParent, int id) { if(::GetDlgCtrlID(hwndParent) == id)
Глава 34. Функторы и диапазоны 633 { return hwndParent; // Найдено собственное окно } else { struct ChildFind { ChildFind(HWND hwndParent, int id) : nChwndChild(NULL) , m_id(id) { // Выполнить перебор, передавая ’this* в качестве // идентифицирующей структуры ::EnumChiIdWindows( hwndParent, FindProc, reinterpret_cast<LPARAM>(this)) ,- } static BOOL CALLBACK FindProc(HWND hwnd, LPARAM IParam) { ChildFind &find = *reinterpret_cast<Child- Find*>(IParam); return (::GetDlgCtrlID(hwnd) == find.m_id) ? (find.m_hwndChild = hwnd, FALSE) : TRUE; } HWND m_hwndChild; int const m_id; } find(hwndParent, id); return find.m_hwndChild; } ) Объявление класса ChildFind внутри функции максимально увеличивает инкапсуля- цию. В экземпляре find для поиска передается дескриптор родительского окна и идентификатор. Конструктор записывает идентификатор в член m_id и устанавливает член результата поиска m_hwndChild в значение NULL. Он затем вызывает функцию Win32 перебора с обратным вызовом, EnumChiIdWindows (), которая принимает Дескриптор родительского окна, функцию обратного вызова для поиска и переданный вы- зывающей программой параметр. Этот экземпляр передает статический метод Find- ₽г°с () и себя как параметр. FindProc () затем реагирует на каждый свой вызов, пыта- ”сь найти окно по заданному идентификатору, и, если окно найдено, записывает его де- скриптор и завершает поиск. После завершения конструирования экземпляра find он будет содержать в члене m-hwndChild либо дескриптор запрошенного окна, либо NULL. В обоих случаях Управление возвращается в программу, вызвавшую FindChildByld (). Полный
634____________________________________________________^SOnw^, перебор осуществляется в конструкторе локального класса, определение которого не доступно для любого внешнего контекста. FindchildBy!d() полностью инкапсу лирует класс ChildFind. 34.4. Диапазоны Мы видели, что существует две проблемы при прохождении диапазонов и примене- нии функторов. Во-первых, нам желательно избегать постоянного использования общепринятых, но не универсальных для типов последовательностей методов be- gin () и end (). Во-вторых, нам необходимо избегать писать излишне специфичные функторы, которые «пишешь на один случай и больше нигде не используешь». В дискуссиях с моим другом Джоном Торьо (John Toijo)1 мы обнаружили, что эти вопросы вызывают у нас аналогичное разочарование, и мы оба хотели бы иметь лучшее решение. Нами выдвинута концепция диапазона {Range). Естественно, мы смотрим на эту проблему немного по-разному2; приводимое здесь определение этой концепции и реализации компонентов прежде всего представляют мою точку зрения. 34.4.1. Концепция диапазона Концепция диапазона очень простая. Определение: диапазон представляет собой коллекцию связанных элементов, доступ к которым может осуществляться по очереди. Он включает в себя логиче- ский диапазон - то есть точки начала и конца вместе с правилами прохода по нему (перемещения от начальной до конечной точки) - и представляет собой одну сущ- ность, при использовании которой клиентский программный код может получать доступ к значениям из этого диапазона. Выглядит даже слишком просто для содержательной концепции, не так ли? Ну, в этом определении бросается в глаза то, что диапазон - одна сущность. Каноническая форма его применения следующая: for(R г = { f(*Г); Джон был также одним из рецензентов данной книги. 2 Больше шансов разместить на головке булавки еще одного ангела, чем найти проектировш' * программного обеспечения, которые не спорили бы по самым простым вещам. Несмотря на это, пришли здесь к разумному компромиссу. Вы можете увидеть отличие наших интерпретаций данной кон и понять различные принципы проектирования и механизмов их реализации, т. к. всеобъемл ^^стаВ впечатляющая, совместимая с библиотекой Boost реализация Джона концепции диапазона включена компакт диска вместе с моей собственной версией в STLSoft.
Глава 34. Функторы и диапазоны 635 } или, если вам не нравятся все эти перегрузки операторов: for(R г = . . r.is_open(); r.advanceO) { f(г.current() ); } Заметна краткость записи, в чем, частично, проявляется смысл существования диа- пазонов. Характеристики диапазона показаны в табл. 34.2. Давайте рассмотрим пару примеров работы с диапазонами: // Диапазон последовательности glob_sequence gs(*/usr/include/•, •impcpp* *); for(sequence_range<glob_sequence> r(gs); r; ++r) { puts(*r); // Вывести подходящий элемент файла в стандартный поток stdout ) // "Воображаемый диапазон* for(integral_range<int> r(10, 20, 2); г; ++r) { cout « *r « endl; // Вывести значение текущего целого числа ) Таблица 34.2. Характеристики воображаемого диапазона Название Выражение Семантика Предисловие Постусловие Разыменова- ние или r.currentO Возвращает значение, пред- ставляемое текущей позицией не достигло конца диапазона не изменилось Продвиже- ние вперед или r.advance() Продвинет вперед текущее положение г не достигло конца диапазона не может быть разы- меновано или достиг- нут конец диапазона Состояние или r.is_openO Возвращает значение «исти- на», если г достигло конца; «ложь» в противном случае не изменилось Нет способа, позволяющего подогнать какой-нибудь указатель под синтаксис диа- пазонов1, поэтому не надо беспокоиться об удовлетворении любых фундаментальных типов; это позволяет нам более гибко осуществить реализацию. Другими словами, мы можем исходить из того, что все экземпляры диапазонов будут типами классов. Если не считать возможности ссылки указателя на верхнюю точку памяти и затем в цикле перемещаться вниз, пока не будет достигнут 0, то это в результате привело бы к нарушению доступа на многих платформах, поэтому приемлемость этого подхода спорна.
636 Часть5-Операторы ---------------------------------------------------------------------------- и, следовательно, мы можем рассчитывать на наличие или отсутствие определенных характеристик типа класса для уточнения свойств алгоритмов, работающих с диапазо- нами (см. раздел 34.4.4). Работа с ними напоминает реализацию алгоритмов манипулирования итераторами [Aust 1999], только все делается проще. Моя реализация диапазонов рассчитана на получе- ние специальных типов диапазонов просто как производных от соответствующей струк- туры тегов типов диапазонов: struct simple_range_tag In- struct iterable_range_tag : public simple_range_tag (}; В следующих разделах мы увидим, как ими пользоваться. 34.4.2. Воображаемый диапазон Иногда у вас отсутствует конкретный диапазон, то есть определенный двумя итера- торами; вместо этого вы имеете воображаемый диапазон. Это могли бы быть нечетные числа между 1 и 99. В этом простом случае вы знаете, что количество нечетных чисел равно 49 (от 1 до 97 включительно), и поэтому вы могли бы его реализовать в клас- сическом стиле STL, как показано в листинге 34.8: Листинг 34.8. struct next_odd_number { next_odd_number(int first) : m_current(first - 2) {} int operator () () ( return ++++m_current; 11 Выглядит неприятно, но помещается в один оператор } private: int m_current; ); std::vector<int> ints(49); std: :generate(ints.beginO , ints.endO, next_odd_number (1)) ; В книгах no STL имеется тенденция в преподнесении таких вещей как вполне разумных примеров применения STL. Может быть, это и так, но этот пример вызывает у меня много сомнений. Во-первых, мы создаем функтор, который, вероятно, нигде больше не потребуется. Во-вторых, что более существенно, здесь функтор рассматривается как пассивнь поставщик значений, чьи действия направляются алгоритмом стандартной библиотеки generate (), возможности которого, в свою очередь, ограничены функционально
Глава 34. Функторы и диапазоны 637 стью вектора. Это очень важно, т. к. означает, что нам необходимо заранее знать количество получаемых элементов для того, чтобы обеспечить для них пространство в векторе. Поэтому нам необходимо очень хорошо понимать, как работает поставщик элементов - функтор. В этом простом случае все делается относительно просто - хотя я должен признаться, что сделал ошибку при подготовке программы тестирования! Но представим, что нам приходится рассчитывать все члены рядов Фибоначчи до указан- ного пользователем числа. Невозможно здесь заранее предсказать количество шагов, кроме как с помощью перебора всех этих чисел с начала до самого конца диапазона, что делается не так уж просто, не говоря уже о неэффективности такого решения. Поэтому мы хотим, чтобы в таких случаях поставщик управлял процессом в соот- ветствии с критерием, предоставляемым автором программного кода. Настало время представить мой любимый пример диапазона - шаблон integral_range, упрощен- ное определение которого показано в листинге 34.9: Листинг 34.9. template «typename Т> class integral_range : public simple_range_tag { public: typedef simple_range_tag range_tag_type; typedef T value_type; // Конструирование public: integral_range( value_type first, value_type last , value_type increment = +1) -integral—range() { // Обеспечить интегральный тип для типа параметризации STATIC—ASSERT(О != is_integral_type<T>::value); } // Перебор элементов public: operator "надежный булев оператор*О const; // См. гл. 24 value_type operator *() const; class_type &operator ++(); // Члены }; Диапазон integral_range выполняет перебор элементов по своему логическому Диапазону переменных-членов заданного интегрального типа, Т. Мы можем теперь переписать заполнение вектора нечетными числами следующим образом: Std::vector<int> intS; ints.reserve(49); // He страшно, если это число не верно Г-copy(integral_range<int>(1, 99, 2), std::back_inserter(ints));
638 Часть 5. Операторы Следует отметить, что вызов reserve () сделан ради оптимизации и может быть опущен без каких-либо последствий относительно корректности программного кода г_сору () - это алгоритм диапазона (см. раздел 34.4.3), который имеет ту же семан- тику, как и алгоритм стандартной библиотеки сору () [Muss 2001]. Теперь поставщик элементов, экземпляр integral_range<int>, управляет процессом соответст- вующим образом. Мы могли бы легко здесь подставить Fibonacci_range, и про- граммный код работал бы корректно и эффективно, что нельзя сказать о версии STL. 34.4.3. Итерабельный диапазон Итерабельный (iterable) диапазон представляет собой расширение воображаемого диапазона с добавлением методов begin (), end () и range (), позволяющих обес- печивать полную совместимость со стандартными алгоритмами STL и использование с ними (см. раздел 34.4.4). Итерабельные диапазоны обычно поддерживают внутренние итераторы, отра- жающие условия для текущей позиции и для позиции, следующей за последним элементом, и возвращающие их с помощью методов begin () и end (). Метод range () позволяет клонировать заданный диапазон в текущем состоянии перебора элементов, хотя это зависит от ограничений копирования итераторов, если базовые итераторы диапазона соответствуют концепции входного (Input) или выходного (Output) итератора [Aust 1999, Muss 2001]: только форвардные (Forward) и более «высокие» модели итераторов [Aust 1999, Muss 2001] поддерживают возможность прохода по задан- ному диапазону несколько раз. Можно написать классы итерабельных диапазонов, но общий подход заключается в использовании адаптера. В своей реализации концепции диапазона я использую два адаптера - шаблонные классы sequence_range и iterator_range. Очевидно sequence_range приспосабливает последовательности STL, a iterator_range - итераторы STL. Имея тип последовательности, мы можем приспособить ее к диапазону, передавая экземпляр последовательности конструктору соответствующей конкретизации sequence_range, как в следующем примере: std::deque<std::string» d = . - . ; sequence_range< std::deque<std::string» » r(d); for(; r; ++r) { • . . // Использовать *r } Аналогично, iterator_range конструируется из пары итераторов, как показано в следующем примере: vectorcint» v = . . . ; for (iterator_range<vector<int»: :iterator» r(v.beginO , v.endO ) ;
Глава 34. Функторы и диапазоны 639 { . . . // Использовать *г } На первый взгляд кажется, что итерабельные диапазоны придуманы только ради более удобного синтаксиса, позволяющего уменьшить путаницу с лишними перемен- ными, когда обеспечивающий обработку итератор используется в неразвернутом цикле. Я должен признаться, что это, само по себе, очень привлекательно для меня, но данная концепция заключает в себе гораздо большее, как мы увидим в следующих двух разделах. 34.4.4. Алгоритмы диапазонов и теги В показанных до сих пор примерах диапазоны использовались в неразвернутых циклах. Диапазоны могут также применяться в алгоритмах. Здесь проявляется отличие концепций воображаемых и итерабелъных диапазонов. Рассмотрим алгоритм стандартной библиотеки distance (). template «typename I> size_t distanced first, I last); Этот алгоритм возвращает количество элементов в диапазоне (first, last). Для всех типов итераторов, кроме итератора произвольного доступа {Random Access) [Aust 1999, Muss 2001], это количество рассчитывается путем прохождения в цикле всех элементов, начиная с first, пока не будет достигнут last. Но данный шаблон предназначен для простого и эффективного расчета расстояния (last - first) для итераторов произвольного доступа. Мы, конечно, не желаем, чтобы эффективность снизилась при использовании алгоритмов с диапазонами. Решение очень простое: мы реализуем алгоритмы диапазонов с помощью алгорит- мов стандартной библиотеки там, где возможно. Алгоритм диапазона r_distance () определяется, как показано в листинге 34.10: Листинг 34.10 template «typename R> ptrdiff_t r_distance_l(R r, iterable_range_tag const &) ( return std::distance(r.begin(), r.end()); } template «typename R> ptrdiff_t r_distance_l(R r, simple_range_tag const &) ( ptrdiff_t d = 0; for(; г; ++Г, ++d) {} return d; ) template «typename R> inline ptrdiff_t r_distance(R r)
640^^Опеигоры { return r_distance_l(г, г); } r_distance () реализуется на основе функции r_distance_l ()!, которая имеет два определения: одно для итерабельных диапазонов, которые отсылают к алгоритму стан дартной библиотеки, и другое для воображаемых диапазонов, в котором проход по элемен там осуществляется самостоятельно. Две перегрузки r_distance_l () отличаются вторым параметром, определяющим тип диапазона: простой или итерабельный. Мы используем полиморфизм этапа выполнения (наследование) для выбора поли- морфизма этапа компиляции (разрешение типа шаблона), поэтому, чтобы сигнатуры перегрузок отличались, нам необходимо передать диапазон функции r_distance_l () в двух параметрах с сохранением реального типа в первом пара- метре и с указанием вида этого диапазона во втором параметре. Поскольку компиля- торы легко могут справиться с такими простыми вещами, не стоит беспокоиться о неэффективности - этого механизма вполне достаточно. Мы видели в разделе 34.4.2, что шаблон integral_range является наследником simple_range_tag. Итера- бельные диапазоны являются наследниками iterable_range_tag. Поэтому при реализации всех алгоритмов диапазонов всегда выбирается наиболее подходящее решение. Теперь мы можем вернуться к проблеме именования алгоритмов (см. раздел 34.2.2). Поскольку мы четко отличаем диапазоны с парой итераторов и экземпляры классов диапазонов, нам никогда не потребуется иметь одинаковое имя для того, чтобы содей- ствовать обобщенному программированию, т. к. первые диапазоны задаются двумя параметрами, а последние только одним. Следовательно, мы можем избежать всех не- приятностей, связанных с пространствами имен, и просто использовать для алгорит- мов префикс г_. 34.4.5. Фильтры Фильтры являются еще одной сильной стороной абстракции концепции диапазона. Этот аспект диапазонов лишь только разрабатывается, но уже обещает принести боль- шие выгоды. Я проиллюстрирую их работу на примере простого фильтра divisible (делимый нацело), который мы применим к нашему диапазону integral_range. Листинг 34.11. template «typename R> struct divisible : public R::range_tag_type 1 Эта стратегия именования реализаций носит общий характер и помогает избегать конфликтов, к0^ и могут возникать, когда «внешняя» функция и реализация «внутренней» функции имеют одинаковое существует несколько перегрузок «внешней» функции. Различные компиляторы по-разному относятся вещам, поэтому самое простое решение заключается в том, чтобы избегать этого и именовать реали «внутренней» функции так, чтобы не возникало двусмысленности.
Глава 34. Функторы и диапазоны 641 ( public: typedef R filtered_range_type; typedef typename R::value_type value_type; public: divisible(filtered_range_type r, value_type div) : m_r(r) , m_div(div) ( assert(div > 0); for(; m_r && 0 != (*m_r % m_div); ++m_r) (} } public: operator "надежный булев оператор"() const; // См. гл. 24 ( . // реализуется как надежный булев оператор для m_r } value_type operator *() const { return *m_r; } class_type boperator ++() ( for(; m_r && 0 != (*++m_r % m_div); ) {} return *this; } private: filtered_range_type m_r; value_type m_div; }; При его сочетании c integral_range мы можем отфильтровать все нечетные числа заданного диапазона, получая только те, которые делятся нацело на три, как показано в следующем примере: std::vector<int> ints; integral_range<int> ir(l, 99, 2); r_copy( divisible*integral_range<int> >(ir, 3) . std::back_inserter(ints)); Естественно, в реальных условиях фильтры диапазонов могут быть значительно изощреннее.
642^Опера^ 34.4.6. Лукавство? Я, как всякий, кто выступает против злоупотреблений перегрузками операторов1 несомненно, открыт для обвинений в нечестности, когда это касается операторов под* держивающих диапазоны. Конечно, можно оспаривать (при желании выглядеть очень строгим) неправильность употребления неестественной комбинации операторов, обес- печивающих диапазоны. На самом деле я не могу защитить концепцию в каком-то чистом смысле, поэтому йогу отступить на позиции философии неидеального практика и утверждать, что столь хорошая и простая ее работа стоит укоров совести. Если вам нравится концепция диапазона, но вы не хотите иметь дело с перегрузка- ми операторов, просто достаточно заменить некоторые или все операторы методами. Семантика и эффективность останутся прежними; только немного тяжелее станет син- таксис: for(R г = . . .; r.is_open(); г.advance()) { . . . = г.current(); } 34.5. Функторы и диапазоны: заключение В данной главе было предпринято отчасти извилистое «путешествие» по проблеме, которая в лучшем случае проявляется в мягком, часто незамеченном, ворчании по поводу слишком большого количества ввода символов, а в худшем случае может на- нести серьезный вред при сопровождении. Мы рассмотрели достоинства и недостатки неразвернутых циклов и функторов, а также некоторые из дефектов языка, затрудняющих применение локальных функторов. Мы также видели, как можно максимально повысить универсальность функторов, делая их совместимыми с самыми различными типами за счет параметризации типа сим- вола, тоннелирования типов и параметризации типа аргумента адаптера. Сочетание этих методов в значительной мере решает вопросы, поднятые в начале данной главы. Наконец, мы рассмотрели концепцию диапазона. Диапазоны не только обеспечи- вают более удобный синтаксис, но они также способствуют унификации обработки диапазонов, ограниченных итераторами, и других диапазонов, которых мы можем назвать чисто логическими диапазонами. Более того, применение фильтров к диапазо нам обоих типов представляет собой мощный, но простой в использовании механизм манипулирования диапазонами, управляемыми критериями. Хотя вы могли заметить в гл. 25, что я могу преодолевать свою щепетильность.
Глава 34. Функторы и диапазоны 643 Дополнение: последние работы по библиотеке диапазонов включили в нее про- граммные интерфейсы прохождения диапазонов с функцией обратного вызова (в форме косвенного диапазона - Indirect Range), что нельзя обеспечить с помощью итераторов STL. См. http://www.rangelib.org/, где приводятся последние результаты.
Глава 35 Свойства Переменные-члены (также называемые полями) представляют собой данные которые являются частью объекта или (в том случае, если они статические перемен- ные-члены) принадлежат классу. Методы являются функциями, которые принадлежат экземплярам объектов или (в том случае, если они статические) классу. Несколько современных языков (С#, D, Delphi) поддерживают концепцию свойства. Хотя синтак- сически доступ к свойствам осуществляется так же, как и к переменным-членам, от по- следних они отличаются тем, что их применение в действительности может приводить к вызову функции. Например, определения свойств могли бы выглядеть так1, как это сделано в классе Date, который приводится в листинге 35.1. Листинг 35.1. class Date { public: enum WeekDay { Sunday, Monday, ... }; public: property WeekDay get_DayOfWeek() const; property int get_DayOfMonth() const; property void set_DayOfMonth(int day); property int get_Year() const; static property Date get_Now(); private: time_t m_time; }; Существует несколько приятных качеств, которыми свойства выгодно отличаются от переменных-членов. Во-первых, они могут быть только считываемыми, только записываемыми и позволяющими делать и то и другое. Это обеспечивает более тонкое управление, чем то, которое возможно в C++, когда открытая переменная-член может быть изменена любым клиентским программным кодом, если она не константная: Date date = Date::Now; // вызывает get_Now(); Date::WeekDay weekday = date.DayOfWeek; // вызывает get_DayOfWeek() date.DayOfMonth =31; // вызывает set_DayOfMonth(); 1 Следует отметить, что ключевое слово property (свойство) и префиксы get_/set_ (получить/установить) придуманы-мною просто для того, чтобы проиллюстрировать, как это может выглядеть; они не явля расширениями какого-нибудь компилятора и их не следует рассматривать как предложения усовершенствованию языка.
Глава 35. Свойства 645 Во-вторых, они могут использоваться для открытых, очень понятных преобразова- ний между внутренними и внешними типами. Наш класс Date содержит поле типа time_t, но его открытым интерфейсом пользоваться просто и удобно. В-третьих, свойства могут обеспечивать проверку достоверности. Вы могли бы определить тип Date, который содержит свойство Day с доступом по чтению и запи- си. Легко реализовать базовый метод свойства set_DayOfMonth (int day) с тем, чтобы обеспечить проверку достоверности диапазона, например, позволяя значению находиться только в пределах от единицы до максимального количества дней месяца и выбрасывая исключение в противном случае. Листинг 35.2. void Date::set_DayOfMonth(int day) { struct tm tm = ‘localtime(&m_time); i f(!ValidateDay(tm, day)) { throw std::out_of_range("The given day is not valid for the current month"); } tm.tm_mday = day; time_t t = mktime(&tm); if(static_cast<time_t>(-l) == t) { throw std::out_of_range("The given day is not valid for the current month"); } m_time = t; } В-четвертых, они отделяют интерфейс от реализации. Может случиться так, что один класс имеет свойство, которое просто представляет какую-нибудь переменную- член. Другой класс мог бы обеспечивать свойство, которое фактически обращается за пределы экземпляра для получения какого-нибудь совместно используемого системно- го состояния. Вызывающая программа не знает и не беспокоится об этих деталях; Для использования свойств существенными для нее являются только открытые ин- терфейсы типов. Статическое свойство Now представляет собой экземпляр Date, отражающий теку- щее время. При желании реализация метода get_Now () могла бы в действительности связываться по протоколу NTP (Network Time Protocol - протокол сетевого времени) с сервером времени для синхронизации локального системного времени перед возвра- щением экземпляра Date. В-пятых, благодаря свойствам повышается простота и удобство применения («успеш- ность» восприятия) ваших классов, поскольку приходится меньше набирать на клавиатуре текста и реже использовать сбивающие с толку префиксы. Нам не надо писать Gate.get_Month(), просто date.Month. Поэтому свойства помогают отличать
646 Часть 5. Операторы методы доступа от операционных методов, что может существенно способствовать пони- манию открытых интерфейсов нетривиальных классов. Конечно, можно и нужно (обычно именно так и делается) в существующих определениях классов указывать в начале ваших методов префикс get_ или Get, но по этому вынужденному «безобразию», я надеюсь никто, кроме мазохистов, не будет скучать. В-шестых, они позволяют навязывать инварианты (см. раздел 1.3), поддерживая синтаксическую совместимость - структурное соответствие (см. раздел 20.9) - с суще- ствующими типами и клиентским программным кодом. Наконец, они отчасти способствуют повышению обобщенности программного кода (см. раздел 35.7.1), поскольку сложные типы классов могут выглядеть как простые структуры. За последний год или два я имел дело со многими различными языками програм- мирования и действительно полюбил свойства и считаю, что они являются очень полезной абстракцией. Поэтому я собираюсь предложить следующее: Дефект: C++ не обеспечивает свойства. 35.1. Расширения компилятора Хотя свойства в данный момент не являются частью языка, некоторые компиляторы обеспечивают их в качестве расширений (например,_property в компиляторе Bor- land C++ и______________________________________decl spec (property) в компиляторах Intel / Visual C++), как показа- но в следующем примере: Листинг 35.3. class Date { public: WeekDay get_DayOfWeek() const; # if defined(ACMELIB_COMPILER_IS_BORLAND) __property WeekDay DayOfWeek = {read = get_DayOfWeek}; # elif defined(ACMELIB_COMPILER_IS_INTEL) || \ defined(ACMELIB_COMPILER_IS_MSVC) _____declspec(property(get = get_DayOfWeek)) WeekDay DayOfWeek; «else # error Property extensions not supported by your compiler # endif // компилятор };
Глава 35. Свойства 647 Очевидно, главная проблема заключается в нестандартности расширений свойств и их доступности только на некоторых компиляторах. Даже там, где они доступны, поддерживаются только свойства экземпляров, но не свойства классов (статические свойства)1. Нам необходимо иметь нечто более эффективное и более переносимое. 35.2. Варианты реализации Синтаксически нам нужно сделать так, чтобы обращение к переменной-члену переводилось в вызов метода. К счастью, C++ (как мы уже много раз доказывали в данной книге) имеет необходимую нам вспомогательную инфраструктуру в форме операторов неявного преобразования, определяемых пользователем операторов при- сваивания, ссылок и шаблонов. (Мы используем также некоторые менее «благород- ные» аспекты языка, но я не хочу сразу же портить впечатление.) 35.2.1. Таксономия вариантов реализации свойств Прежде всего мне хотелось бы выделить возможные подходы к реализации свойств. Во-первых, чтобы обеспечить синтаксис переменной-члена, нам необходимо иметь какую- нибудь переменную-член. Тем не менее, запрещается перегружать operator . () [Stro 1994]. Кроме этого, однако, существует несколько других возможностей. В некоторых случаях воображаемое значение свойства поддерживается внутри класса реальной переменной-членом (полем), в которой они находятся: такие свойства называются свойствами-полями (см. раздел 35.3), они обеспечивают доступ либо только для чтения, либо только для записи. В других случаях значение может рассчитываться при помощи произвольно сложных методов, обеспечивая контроль достоверности и режим прокси; такие свойства называются свойствами-методами (см. раздел 35.4). Естественно, эти два варианта могут сочетаться (см. раздел 35.4). В тех случаях, когда свойства поддерживаются реальными переменными-членами, существует два варианта их реализации. Переменная-член может содержаться в самом свойстве-члене; такие свойства называются внутренними. В других случаях перемен- ная-член может уже существовать как часть охватывающего класса и поэтому находит- ся вне переменной свойства; такие свойства называются внешними. В последующих разделах мы будем исследовать сочетания свойств-полей и/или свойств-методов, внутренних или внешних, допускающих чтение и/или запись. Они имеют свои достоинства и недостатки в отношении переносимости, эффективности (использования пространства и времени) и даже законности. Visual C++ услужливо подсказывает, что «методы свойств могут связываться только с нестатическими ванными-членами». Компилятор Borland C++ (5.6) выдает сообщение о внутренней ошибке при компиляции.
648^Оператор, 35.2.2. Оптимизация пустых членов? Прежде чем мы серьезно начнем детально рассматривать различные реализации свойств, я хочу обсудить вопрос, который раскрывает причины несовершенства неко торых из этих решений. Мы видели в разделе 12.3 официально признанную оптимизацию пустой базы и неофициальную, но обеспечиваемую многими компиляторами оптимизацию пустых производных классов. Рассмотрим следующий пример, где в качестве переменных-членов используются пустые классы: struct empty {}; struct emptyish { empty el; empty e2; empty e3; int il; }; Поскольку el, e2 и еЗ - пустые, было бы неплохо, если бы компилятор мог распре- делять память для экземпляров emptyish, учитывая только непустые поля, так что sizeof (emptyish) = = sizeof (int). В этом случае выполнялась бы оптимиза- ция пустых членов (Empty Member Optimization - EMO). На первый взгляд кажется, что здесь все нормально. В конце концов, т. к. они пус- тые, им не требуется никакого пространства. Более того, поскольку структура empty не имеет переменных-членов, было бы все равно, если бы empty-члены имели одина- ковый адрес. Однако посмотрим, что произойдет в этом случае в следующем примере: struct emptyish2 { empty ае[3]; int il; }; Здесь три (пустых) члена empty содержатся в массиве. Очевидно, выделение для них памяти нулевого размера приведет к противоречию: &ае[0] == &ае[1] &ае [ 2 ], что бессмысленно. Поэтому мы придерживаемся правила, согласно которому члены любого агрегата занимают ненулевую память. 35.3. Свойства-поля Существует четыре вида свойств-полей, обеспечивающих доступ по чтению или записи и внутреннюю или внешнюю реализацию. Они представляются четырьмя классами field property get, field_property_get_extemal и f ield_pi‘0Perty" set_external, field_property_set, которые мы сейчас рассмотрим. ОчевиДН0’
Глава 35. Свойства 649 не имеет смыла сочетать в свойстве-поле доступ по чтению и записи, например, в классах field__property_getset и field_property_getset_external, поскольку это семантически эквивалентно отрытой переменной и приводит к большим затратам. 35.3.1. field_property_get Давайте рассмотрим возможные способы реализации свойства-поля, используе- мого только для чтения. Допустим, у нас имеется класс связанного списка, и мы хотим обеспечить свойство Count, показывающее количество элементов в списке. В любой разумной реализации списка1 подсчет элементов поддерживается с помо- щью переменной-члена, а не рассчитывается путем прохода по списку. Поскольку нет смысла задавать списку количество, Count будет обеспечивать доступ только для чтения закрытой целочисленной переменной-члена. Это мы могли бы реализовать, как показано в листинге 35.4. Листинг 35.4. class ListElement; class LinkedList; class LinkedListCountProp { public: operator int() const { return m_value; } private: friend class LinkedList; // Разрешить LinkedList доступ к m_value int m_value; }; class LinkedList { // Свойства public: LinkedListCountProp Count; // Операции public: void Add(ListElement *e) { ++Count.m_value; // Обновить счетчик } }; Свойство Count имеет тип LinkedListCountProp и является открытой пере- менной-членом класса LinkedList. Это выглядит немного странно, поскольку она не имеет префикса ш_2 и начинается с большой буквы. Я решил именно так обозначать «Неразумные» реализации вы можете найти в приложении Б. 2 Что не является большой потерей для большинства из вас. Такая система обозначений не так уж популярна, х°тя, я подозреваю, это происходит по политическим, а не по техническим причинам (см. гл. 17).
650_________________________________________________________И30*5-Оператор свойства, чтобы отличать их от открытых переменных-членов; вы можете использо вать свою систему обозначений. Например, если вы хотите, чтобы они выглядели так же как открытые переменные-члены, то их необходимо определять в соответствии с прави* лами обозначения эмулируемых вами типов. Клиентский программный код может ссылаться на член Count экземпляров списка LinkedList и тем самым осуществлять доступ к значению (типа int) с помощью оператора неявного преобразования. Клиентский программный код не может изменить значение LinkedListCountProp: :m_value, т. к. этот член закрыт, но объявление friend означает, что это значение может изменяться в Rectangle. Действительно просто, не так ли? Недостаток также вполне очевиден. LinkedListCountProp написан для конкрет- ного случая, и то же самое пришлось бы делать для каждого охватывающего класса и ка- ждого типа значения. Если мы не хотим вводить много текста, допускать разбухания программного кода и иметь проблемы при сопровождении, то нам следует найти обоб- щенное решение. Естественно, шаблоны дают ответ в форме field—property_get, которая показана в листинге 35.5. Листинг 35.5. template< typename V /* Тип реального значения свойства */ , typename R /* Тип ссылки */ , typename С /* Охватывающий класс */ > class field_property_get { public: typedef field_property_get<V, R, C> class_type; private: // He инициализирует m_value field_property_get() {} // Инициализировать m_value заданным значением explicit field property qet(R value) : m_value(value) {} DECLARE_TEMPLATE_PARAM_AS_FRIEND(C); public: /// Обеспечивает доступ к свойству только для чтения operator R () const { return m_value; ) private: V m_value; // Реализация не требуется private:
Глава 35. Свойства 651 field_property_get(class_type const &); class_type boperator =(class_type const &); ); Шаблон принимает три параметра: тип внутренней переменной-члена, тип ссылки для доступа к значению и тип охватывающего класса. Разделение типа значения и типа ссылки повышает гибкость и эффективность. Например, если бы вы использовали std: : string как тип значения, вы, вероятно, стали бы применять std: : string const& в качестве типа ссылки. В конкретном случае в классе LinkedListCountProp доступ только для чтения обеспечивается за счет объявления закрытыми переменной-члена m_value и кон- структоров, но открытым оператора неявного преобразования в тип ссылки. Следует отметить, что конструктор по умолчанию непосредственно не инициали- зирует эту переменную-член. Это отражает мою личную склонность отдавать высокий приоритет эффективности и считать необязательную инициализацию лишней тратой времени. Инициализация значения обеспечивается конструктором, принимающим значение. Вы можете иметь другую точку зрения, и поэтому реализовать конструктор по умолчанию, который будет сам конструировать эту переменную-член. field_property_get() : m_value (V ()) {} Как и раньше, охватывающему типу необходимо иметь доступ для записи в пере- менную-член, что обеспечивается его дружественной связью с шаблоном (используя метод, который мы рассматривали в разделе 16.3). Применяя этот общий шаблон класса, мы теперь можем определить LinkedList, например, так: class LinkedList { // Конструирование public: // Свойства public: field_property_jget<int, int. LinkedList> Count; }; Такое определение свойства Count имеет также дополнительное преимущество - оно самодокументируемое: из определения видно, что это свойство-поле обеспечивает Доступ только для чтения (get), а в качестве его типов как значения, так и ссылки ис- пользуется int. Выражения в клиентском программном коде тоже легко читаемы и недвусмысленны: LinkedList Hist (...); for(. . .) {
...II Добавить элементы в список } int count = Hist.Count; // Дает количество элементов Hist.Count = count +1; // Ошибка! Доступ для записи отвергается Конструктор копирования и оператор копирующего присваивания скрываются чтобы не допустить бессмысленного использования, подобного следующему: field property ere t< int. int, Count> SomeProp(Hist.Count) ; // Конструктор копирования Hist.Count = Hist.Count; // Конструктор присваивания Надо надеяться, вы согласитесь, что мы очень просто получили доступ только для чте- ния. На всех тестируемых компиляторах (приложение А) шаблон f ield_property_get не приводил к какому-то дополнительному расходу времени и пространства, поэтому он стопроцентно эффективен. Единственный недостаток в том, что в настоящее время используется нестандартный, хотя и широко поддерживаемый, механизм обеспечения дру- жественности типа параметризации его шаблону1. Но мы - неидеальные практики, и нам необходимо иметь нечто работающее (и переносимое). А данное решение работает (и пере- носится). 35.3.2. field_property_set Должен сказать, что я не могу себе представить, как может каким-то образом ис- пользоваться свойство-поле, обеспечивающее доступ только для записи, но мы со- бираемся рассмотреть этот механизм из педагогических целей, т. к. это поможет нам реализовать свойства-методы (см. раздел 35.4) с доступом только для записи, которые вполне имеют право на жизнь. Свойства с доступом только для записи семантически противоположны свойствам с доступом для чтения, поэтому шаблон f ield_property_set заменяет открытый оператор неявного преобразования на оператор присваивания, как в следующем примере: Листинг 35.6. template< typename V /* Тип реального значения свойства */ , typename R /* Тип ссылки */ , typename С /* Охватывающий класс */ > class field_property_set { public: typedef field_property_set<V, R, C> class_type; 1 Другим недостатком (для эстетов программирования) является применение макроса. Хотя на самом де^ мне не нравится так делать, в данном случае этот вариант более предпочтительный, поскольку по определению класса быть лаконичным и читаемым, а следовательно, понятным и легко сопровождаемым-
Глава 35. Свойства 653 private: field_property_set () {) explicit field_property_set(R value) : m_value(value) {) DECLARE_TEMPLATE_PARAM_AS_FRIEND(C); public: III Обеспечивает доступ только для записи этого свойства class_type ^operator =(R value) { m_value = value; return ‘this; } private: V m_value; // Реализация не требуется private: field_property_set(class_type const &); class_type ^operator =(class_type const &); }; Этот шаблон имеет ту же самую эффективность, переносимость и законность, как и его собрат, обеспечивающий доступ только для чтения. Все имеет стройный вид, и программный код соответствует стандарту, если не брать в расчет (очень хорошо переносимую) дружественную связь. 35.3.3. Внутренние свойства-поля: заключение Итак, мы познакомились с основными аспектами технологии создания и примене- ния свойств. Доступ только для чтения обеспечивается открытым оператором неявно- го преобразования в классе свойства и сокрытием конструктора копирования и опера- торов копирующего присваивания, чтобы предотвратить ненадлежащее их использо- вание. Охватывающий тип объявлен другом свойства, так что он может устанавливать значение, которое в противном случае окажется недоступным, т. к. оно объявлено закрытым. Доступ только для записи обеспечивается применением ключевого слова public Для оператора присваивания и отсутствием оператора неявного преобразования. Охва- тывающий тип вновь объявлен другом на этот раз для того, чтобы он мог осуществлять Доступ к значению свойства. У очень осторожных (включая меня самого) возникает соблазн объявить, но не определять закрытый оператор неявного преобразования. Вскоре мы увидим, почему так поступать неразумно. Будем считать, что эти аспекты «считаны» (извините за каламбур!) и их не надо повторять для оставшихся вариантов сочетаний, и теперь мы основное внимание Уделим отличительным особенностям механизмов их реализации.
654 Часть5, Операторы -------------------------------------------------------------------------—_ 35.3.4. field_property_get_extemal Внутренние свойства-поля являются наилучшим вариантом, если мы можем полно стью управлять реализацией охватывающего класса, к которому мы добавляем свойст- ва. Однако иногда это не так, и нам приходится искать возможность дополнения класса свойствами, построенными на базе существующих переменных-членов. Это достига- ется применением ссылки. Отсюда f ield_property_get_external определяет- ся следующим образом: Листинг 35.7. template< typename V /* Тип реального значения свойства */ , typename R /* Тип ссылки */ > class field_property_get_external { public: field_property_get_external(V &value) // Принимает ссылку : m_value(value) {} // Операторы доступа public: III Обеспечивает доступ только для чтения этого свойства operator R() const { return m_value; } 11 Члены private: V &m_value; }; Это свойство выглядит проще, чем внутренняя версия, f ield_property_get_ external параметризуется типом значения и типом ссылки на переменную, а также содержит ссылку на тип значения, который передается своему конструктору. Поскольку объект этого свойства содержит прямую ссылку на переменную-член, для которого оно действует, нет необходимости знать охватывающий класс или устанав- ливать с ним дружественную связь. Поэтому этот шаблон на 100% совместим с языком. Более того, его не трудно откомпилировать даже тем компиляторам, которые обрабаты- вают шаблоны с большими недостатками; он откомпилируется даже Visual C++ 4.2! Что касается эффективности по быстродействию, то этот шаблон поддается даже осторожной оптимизации, так что воображаемая косвенная ссылка может быть опти- мизирована в прямую ссылку. Недостаток в том, что в результате применения этой ссылки неэффективно используется пространство: на 32-битовых машинах ссылки занимают 4 байта на каждое свойство. Более того, это свойство немного сложнее
Глава 35. Свойства 655 использовать, поскольку оно должно быть инициализировано ссылкой на переменную, для которой оно будет действовать. Листинг 35.8. class LinkedList { // Конструирование public: Rectangle(. . . : Count(m_count) // Свойства public: field_property_get_external<int, int, LinkedList> Count; И Члены private: int m_count; }; На первый взгляд, это напоминает проблему упорядочивания списка инициализа- ции членов (см. раздел 2.3), но на самом деле это не так. Причина заключается в том, что Count передается члену m_count в виде ссылки, а не значения. Поэтому пробле- ма упорядочивания могла бы возникнуть только в том случае, если бы Count исполь- зовался в другом списке инициализации, который сам по себе имел бы проблему упорядочивания, не имеющую никакого отношения к свойству. Тем не менее, стоит отметить, что т. к. в принципе плохо, если результат зависит от порядка расположения членов, вам следует остерегаться ловушек, когда вы (по вашему мнению) сталкивае- тесь с этим. 35.3.5. field_property_set_extemal В шаблоне свойства f ield_property_set_external обеспечивается семан- тика field_property_set с использованием того же самого механизма ссылок, который применяется в f ield_property_get_external. 35.3.6. Дополнительные возможности При обсуждении нами свойств-методов в следующем разделе мы увидим, что про- блему неэффективного расхода памяти можно решить при использовании достаточно специфических методов. То же самое может быть с внешними свойствами-полями, но я не стал это описывать здесь просто потому, что они очень редко применяются. Конечно, может оказаться, что они так редко используются из-за большого расхода памя- ти, но это вопрос философский, поэтому пусть каждый считает, что он прав, и мы пойдем дальше.
656 Часть 5. Операторы 35.4. Свойства-методы Картина сильно усложняется при рассмотрении свойств-методов, поэтому, возможно вы захотите немного передохнуть или выпить чашечку любимого напитка, содержащего кофеин, перед тем, как продолжить. Свойства-методы могут быть внутренними или внешними, могут обеспечивать доступ для чтения и/или для записи, и все эти сочетания образуют шесть типов свойств-методов. Первые три рассматриваемые нами внутренние типы, обеспечи- вающие чтение, запись и чтение-запись, фактически представляют собой гибрид свой- ства-поля и свойства-метода, поскольку они внутри себя содержат поле, которое под- держивает значение свойства. Я начинаю наше обсуждение свойств-методов именно с них, поскольку они проще реализуются и чаще используются. Последние три являются чистыми методами; это внешние типы, обеспечивающие чтение, запись и чтение-запись. Как и для внешних свойств-полей, здесь расходуется много памяти, и, пытаясь довести ее затраты до минимума, мы немного развлечемся, затрагивая «темные» стороны языка. 35.4.1. methodj)roperty_get Внутреннее свойство-метод, используемое только для чтения, обладает следующи- ми особенностями: • имеет внутреннее поле, поддерживающее значение свойства; • обеспечивает доступ к закрытым внутренним свойствам для охватывающего класса через объявление дружественности; • имеет оператор неявного преобразования; • имеет указатель на экземпляр (нестатического) метода охватывающего класса, которому делегируется право доступа к значению через оператор неявного пре- образования; • имеет указатель (или ссылку) на экземпляр охватывающего класса, к которому при- меняется делегированная функция. Обеспечение внутреннего поля и дружественности теперь для нас не проблема. Однако другие три особенности представляют для нас несколько более сложную задачу. В простой реализации можно было бы передавать указатели экземпляру охватывающего класса и функции-члену в конструкторе, а затем вызывать их опера- тором преобразования, как показано в следующем примере: template < . . . > class method_property_get { operator R() const
Глава 35. Свойства 657 return (m_object->*m_pfnGet)(); ) }; Однако быстро проведенный в уме арифметический подсчет подскажет нам, что это утроит (по крайней мере) размер свойства. В действительности, некоторые компиля- торы представляют указатель на функцию-член с помощью нескольких машинных слов, поэтому это приведет к значительному расходу памяти. Конечно, это плохо, но такая реализация была бы простой,! а нам нужно нечто совсем другое. Однако по- добная реализация обладает высокой переносимостью и высоким быстродействием, поэтому продолжаем читать дальше. Давайте очень внимательно посмотрим на две избыточные веши, которые мы хотим исключить: указатель на охватывающий класс и указатель на функцию-член. Посколь- ку поле свойства будет располагаться внутри охватывающего класса, почему бы просто не извлечь указатель класса из указателя this этого свойства? Однако пере- дача смещения экземпляру свойства просто добавила бы переменную-член, что проти- воречит цели устранения неэффективностей, связанных с использованием памяти. Один способ решения этой проблемы мог бы заключаться в применении статического члена класса свойства. Но это означало бы существование каждого свойства как отдельного класса. Если бы мы не захотели вводить много повторяющегося кода, нам пришлось бы использовать макросы. И даже при применении макросов нам пришлось бы определить статический член смещения в файле реализации, что только усложняет использование свойств. Насколько было бы лучше, если бы мы могли сделать само смещение частью типа? Ну, поскольку мы используем шаблоны, разве плохо, если вместо этого мы используем смещение в качестве одного из параметров шаблона, как показано в следующем примере: Листинг 35.9. class Date { public: WeekDay get_DayOfWeek() const { return WeekDay.m_value; } public: method_property_get<WeekDay , offsetof(Date, DayOfWeek) > DayofWeek;
658 Частьб. Операторы Параметризующий свойство метод get_DayOfWeek () должен объявляться открытым, но это вполне приемлемо. Нет причин, заставляющих запретить клиентско- му программному коду использовать метод, для которого свойство синтаксически просто выполняет роль прокси, поэтому нам не стоит торопиться объявлять его закры- тым. Все выглядит четко и ясно. Увы, реальный мир здесь ставит нам подножку. Минутное игнорирование вопросов переносимости и строгой законности макроса offsetof () (см. раздел 2.3.3) приводит к невозможности получения смещения на основе незавершенного типа, содержащегося в охватывающем типе. В момент инстанциирования THnamethod_property_get<Week- Day, .. ., offsetof (Date, DayOfWeek) > построение этого типа не завершено. Следовательно, его смешение нельзя определить. Следовательно, его нельзя использовать для инстанциирования шаблона. Следовательно... ну, я полагаю, вы меня поняли. Поэтому наша элегантная - во всяком случае, я считаю ее такой - идея оказывается несостоятельной. К счастью, существует очень простая альтернатива: статические методы. Теперь мы можем разбить инстанциирование и расчет смещения на два этапа: Листинг 35.10. class Date { public: WeekDay get_DayOfWeek() const; private: static ptrdiff_t WeekDay_offset() { return offsetof(Date, DayOfWeek); } public: method_property_get<WeekDay , &WeekDay_of fset > Dayo fWeek; Поскольку теперь инстанциирование выполняется для указателя функции, нет не- обходимости пытаться вычислять смещение во время инстанциирования, поэтому все работает отлично. Функция вызывается на этапе выполнения программы (по крайней мере, теоретически; см. ниже) для получения смещения, используемого затем для на- стройки указателя this на значение, ссылающееся на охватывающий класс, функция- член которого вызывается. Это приводит нас к последней части головоломки. Поскольку мы параметризовали шаблон методом, рассчитывающим смешение, почему бы нам просто не сделать то же самое с функцией, которая реально осуществ- ляет доступ к свойству? Ну, мы это действительно можем сделать, по крайней мере, на современных компиляторах. Рассчитывающий смещение метод является (статическим) методом класса, и очень многими компиляторами допускается его использование в качестве параметра шаблона. Для нас в данном случае методы класса эквивалентны свободным функциям. (Неста
Глава 35. Свойства 659 тические) методы экземпляра - совсем другое дело [Lipp 1996]. Хотя невиртуальные методы экземпляров трактуются компоновщиком точно так же, как методы класса и сво- бодные функции, в клиентском программном коде на C++ указатель экземпляра this, для которого вызывается метод, передается неявно. По-видимому, эта дополнительная сложность является препятствием правильной интерпретации шаблона некоторыми ком- пиляторами. Borland (5.5, 5.6), CodeWarrior 7, Visual C++ 7.0 и его более ранние версии, а также Watcom - всем им не удается откомпилировать такие конструкции. Однако CodeWarrior 8, Comeau, Digital Mars, GCC, Intel, VectorC и Visual C++ 7.1 уверенно их компилируют. Хватит рассуждать; давайте посмотрим на класс (показанный в листинге 35.11). Листинг 35.11. tenplatec typename v /* Тип реального значения свойства */ , typename R /* Тип ссылки */ , typename С /* Охватывающий класс */ , ptrdiff_t (*PFnOff) () /* Указатель на функцию, обеспечивающую смещение свойства в контейнере */ , R (С::*PFnGet)О const /* указатель на константную функцию-член, возвращающую R */ class inethocLproperty_get { public: typedef method_property_get<. . • > class_type; private: method_prqperty_get() {} explicit method_property_get (R value) : n\_value (value) {} DECU^E-TEMPIATELPARAJ-LAS-FRIENDtC) ; // Операторы доступа public: III Обеспечивает доступ к свойству только для чтения operator R() const { ptrdiff_t offset = (*PFnOff)(); С *pC = (C*)((byte_t*)this - offset); return (pC->*PFnGet)(); } // Члены private: V m_value; // Реализация не требуется private: // Этот метод скрыт, чтобы не позволить пользователям этого класса // применять оператор = для экземпляров свойства, находясь в охватывающем
660 Часть 5. Операторы // классе, поскольку выполнение этого оператора для // inethod_property_getset<> приводило бы к бесконечному циклу. class_type ^operator =(R value); }; Этот шаблон имеет пять параметров. V, R и С представляют тип значения, тип ссылки и тип охватывающего класса. Четвертым параметром является функция которая обеспечивает смещение свойства. Она объявляется просто как функция без параметров, возвращающее значение типа ptrdif f_t. Следует отметить, что здесь мы не используем size_t, хотя результат выполнения of f setof () имеет именно этот тип, поскольку мы смещение затем используем для восстановления адреса охва- тывающего класса в свойстве-члене. Применение унарного минуса к переменной без знака дает результат без знака, что указывало бы куда-то на верхние участки памяти, а это, как минимум, даст не тот результат, какой нам нужен! Я заранее защищаюсь от такой ситуации, заставляя функцию возвращать тип ptrdif f_t, чтобы не приходи- лось соответствующим образом приводить тип в реализациях свойства. Пятый параметр, который на первый взгляд очень похож на белиберду, которую часто можно видеть в используемых в C++ именах, в действительности вполне очеви- ден. Он просто объявляет, что PFnGet является указателем на константную С-функ- цию-член, которая не принимает аргументов и возвращает значение типа R. Мы можем теперь снова вернуться к нашему воображаемому типу Date, описан- ному в начале данной главы, чтобы начать подключать к нему некоторые реальные вещи. Для этого давайте представим, что Date кеширует значения различных компо- нент даты и времени в каждом своем свойстве. Мы познакомимся с версией, имеющей один член и несколько свойств, когда будем рассматривать внешние свойства (см. раз- делы 35.4.5-35.4.7). Листинг 35.12. class Date { public: WeekDay get_DayOfWeek() const { return DayofWeek.m_value; } private: static ptrdiff_t DayOfWeek_offset() ( return offsetof(Date, DayOfWeek); } public: method_property_get<WeekDay, WeekDay , Date , &DayOfWeek_offset, &get_DayOfWeek > DayofWeek; };
Глава 35. Свойства 661 Я определил как private static метод DayOfWeek_of f set, который рассчи- тывает необходимое смещение. Свойство DayOfWeek определяется как шаблон method_property_get, который принимает значение и тип ссылки WeekDay, ис- пользует метод, рассчитывающий смещение, и реализует доступ для чтения с помо- щью метода get_DayOfWeek. Хотя это теперь достаточно переносимое решение, и все необходимые части определяются рядом, желательно все-таки как-то уменьшить путаницу, связанную с определением свойства. Но не совершите ошибку. Это потребует дополнительных усилий от разработчика, и пострадает читаемость. Оправданием этого подхода является обеспечиваемая им высокая эффективность. Он и в самом деле работает очень хорошо. На всех тес- тируемых компиляторах дополнительные расходы на память равны нулю. Что касается быстродействия, то все компиляторы также оказались способны оптимизировать как вызов метода, рассчитывающего смещения, так и невиртуальную функцию-член, поэтому быстродействие также не ухудшается. То есть эффективность стопроцентная. Большинство компиляторов нормально воспринимают этот синтаксис, но Digital Mars и GCC в некоторых случаях действуют неадекватно. Digital Mars не может исполь- зовать закрытый метод для параметризации шаблона свойства, a GCC должен иметь пол- ностью квалифицированные имена, так что вам потребуется применить следующую форму, если вы хотите обеспечить переносимость: Листинг 35.13. class Date ( public: WeekDay get_DayOfWeek() const; public: static ptrdiff_t DayOfWeek_offset() { return offsetof(Date. DayOfWeek); } public: method_property_get<WeekDay , WeekDay , Date , &Date::DayOfWeek_offset , &Date::get_DayOfWeek > DayofWeek; Даже предыдущую версию едва ли можно назвать лаконичной, а эта и того хуже. Учитывая, что определение метода, рассчитывающего смещение, лишь только отвле- кает внимание пользователей, одно решение всех этих проблем заключается в приме- нении макросоа (на что мы идем, конечно, очень неохотно).
662 Часть 5. Операторы Листинг 35.14. «define METHOD_PROPERTY_DEFINE_OFFSET(C, Р) \ \ static ptrdiff_t P##_offset##C{) \ ( \ return offsetof(C, P); \ } «define METHOD_PROPERTY_GET(V, R, C, GM, P) \ \ METHOD_PROPERTY_DEFINE_OFFSET(C, P) \ \ method_property_get< V \ , R \ , C \ , &C::P##_offset \ , &C::GM \ > P Теперь форма применения свойства имеет вполне приемлемый вид: class Date { public: WeekDay get_DayOfWeek() const; public: METHOD_PROPERTY_GET(WeekDay, WeekDay, Date, getJDayOfWeek, DayofWeek); Следует отметить, что этот макрос не влияет на доступ к методу, рассчитывающему смещение. В наши дни, по-видимому, очень популярно так делать - без указания имен, вы понимаете почему - но мне не нравится помещать спецификаторы управления дос- тупом в макросы. Макросы нельзя считать хорошим местом для сокрытия чего-либо; маскировка управления доступом кажется совсем неудачным решением. Возможно, хотя и маловероятно, что вы захотите сделать свойство защищенным, поэтому исполь- зование в макросе ключевого слова public было бы ошибкой. В любом случае, изме- нение макроса с целью сокрытия уровня доступа нарушает принципы, изложенные в разделе 17.1. Небольшой недостаток заключается в том, что рассчитывающий смещение метод будет иметь открытый доступ, и в некоторых средах разработки, чув- ствительных к программному коду, это внесет неразбериху в «интерфейс» охваты- вающего класса, но данного довода недостаточно для того, чтобы скрывать в макросе уровень управления доступом. Макрос можно было бы сделать немного проще, если бы мы воспользовались опи- санной в разделе 18.5.3 идиомой class_type вместо параметра макроса С, т. к. это позволило бы не указывать в нем тип класса:
Глава 35. Свойства 663 class Date { public: typedef Date class_type; METHOD_PROPERTY_GET(WeekDay, WeekDay, get_DayOfWeek, DayOfWeek); Несмотря на это улучшение, полное определение макроса соответствует приведен- ному ранее. Хотя я во всех без исключения случаях определяю тип class_type, это практикуется далеко не всеми, и макрос METHOD_PROPERTY_GET будет менее навязчив, если он не накладывает дополнительное требование на его пользователей относительно изменения других частей их классов. 35.4.2. method_property_set Очень немного нового можно сказать о версии, обеспечивающей доступ только для записи, с учетом виденного нами в вариантах field_property_set и method property get, поэтому я просто покажу вам определение шаблона method__property_set: Листинг 35.15. tenplate< typename V /* Тип реального значения свойства */ , typename R /* Тип ссылки */ , typename С /* Охватывающий класс */ , ptrdiff_t (*PFnOff) () /* Указатель на функцию, обеспечивающую смешение свойства в контейнере */ , void (С: :*PFnSet) (R ) /* Указатель на функцию-член, принимающую R */ > class method_property_set { public: Ш Обеспечивает доступ к свойству только для записи class_type boperator =(R value) ( ptrdiff_t offset = (*PFnOff)(); C wpC = (C*)((byte_tw) this - offset); (pC->*PFnSet)(value); return *this; ) // Члены private: V m_value; }; Он применяет тот же самый механизм расчета смещения в статическом методе и метод присваивания экземпляра.
664 Часть 5. Операторы 35.4.3. methodj)roperty_getset Мы знаем, что реализация свойств-полей с возможностью комбинированного доступа - и для чтения и для записи - просто означает трату времени, но нельзя то же самое сказать о свойствах-методах, когда методы чтения и/или записи могут обеспечи- вать преобразование и проверку достоверности. Поэтому следующим логическим шагом будет объединение определений шаблонов method_property_get и method_property_set в шаблон method_property_getset, как показано в листинге 35.16. Листинг 35.16. template< typename V /* Тип реального значения свойства */ , typename RG /* Тип ссылки получаемого значения */ , typename RS /* Тип ссылки устанавливаемого значения */ , typename С /* Охватывающий класс */ , ptrdiff_t (*PFnOff)() /* Указатель на функцию, обеспечивающую /* смещение */ свойства в контейнере */ , RG (С::*PFnGet)() const /* Указатель на константную функцию- /* член, возвращающую R */ , void (С::*PFnSet)(RS) /* Указатель на функцию-член, /* /* принимающую R */ > class method property qetset ( // Операторы доступа public: III Обеспечивает доступ к свойству только для чтения operator RG () const { ptrdiff_t offset = (*PFnOff)(); С *pC = (C*)((byte_t*)this - offset); return (pC-> * PFnGet)(); ) III Обеспечивает доступ к свойству только для записи class_type ^operator =(RS value) { ptrdiff_t offset = (*PFnOff)(); С *pC = (C*)((byte_t*)this - offset); (pC->*PFnSet)(value); return *this; ) // Члены private: V m_value;
Глава 35. Свойства 665 Кроме того, что к одному указателю на метод расчета смещения добавляется два указателя на экземпляры методов, другое заметное отличие связано с двумя пара- метрами типов ссылок, одна из которых для записи, а другая для чтения. В данном случае присваивается значение одного типа, а получается значение другого типа, как упоминалось при обсуждении нами method_property_get. Для удобства определяется соответствующий макрос: «define METHOD_PROPERTY_GETSET(V, RG, RS, C, GM, SM, P) \ \ METHOD_PROPERTY_DEFINE_OFFSET(C, P) \ method_property_getset< V , RG , RS , C , &C::P##_offset , &C::GM , &C::SM > P Давайте рассмотрим другое свойство нашего класса Date чтение и запись: Листинг 35.17. class Date ( public: int get_DayOfMonth() const ( return DayOf Month. xn_value; } void set_DayOfMonth(int day) ( if(!ValidateDay(. . ., day)) // Выполнить некоторую проверку // достоверности } METHOD_PROPERTY_GETSET(int, int, int, Date, get_DayOfMonth, set_DayOfMonth, DayOfMonth); ); Я надеюсь, к этому времени вы начинаете понимать, насколько лаконично это может выглядеть. , которое обеспечивает
Часть 5. Операторы 666 35.4.4. Бесконечный цикл Следует обратить внимание на то, что в первоначальных версиях шаблонов f ield_property_set и method_property_set, я определял закрытый опера- тор неявного преобразования, чтобы в реализации охватывающего класса можно было воспользоваться возможностью доступа к внутреннему значению свойства, как пока- зано в следующем примере: Листинг 35.18. template <. . . > class method_property_set ( private: operator R () const { return m_value; } }; class Container { public: method_property_set<int, . . .> Prop; public: void SomeMethodO ( int pr_val = Prop; // Использует оператор неявного преобразования } }; Я уверен, вы догадались, в чем здесь фокус. При использовании method_property_getset такой вызов приводит вновь к вызову охватывающего класса для получения значения прокси. Если бы неявное преобразование выполнялось внутри этого метода, мы оказались бы в бесконечном цикле. Естественно, я посчитал логичным убрать эту возможность из всех других классов, где это могло бы принести вред. То же самое могло бы произойти в зеркальном варианте, поэтому оператор присваивания также был удален из шаблонов, получающих значения свойств. 35.4.5. method_property_get_extemal Внешние свойства-методы используются в тех случаях, когда охватывающему классу необходимо представить свои внутренние данные в другом виде для открытого доступа. Наш первоначальный класс Date хорошо это демонстрирует. Реализации внешних свойств-методов заимствуют у своих внутренних аналогов механизмы по расчету смещения и определения функции-члена, однако они более простые, посколь- ку сами не имеют полей. Позже мы воспользуемся этим фактом.
Глава 35. Свойства 667 Шаблон свойства method property get external определяется следующим образом: Листинг 35.19. tarplate< typename R /* Тип ссылки */ , typename С /* Охватьвапций класс */ , ptrdiff_t (*PFnOff)() /* Указатель на функцию, обеспечивакщую смещение свойства в контейнере */ , R (С:: *PFnGet) () const /* Указатель на константную функдию-член, возврацакщ/ю R */ > class metho4_property_get_extemal { public: typedef method_property_get_extemal<R, C, PFnOff, PFnGet> class_type; public: Ш Обеспечивает доступ к свойству только для чтения operator R О const ( ptrdiff_t offset = (*PFnOff)(); С *pC = (С*) ((byte_t*)this - offset); return (pC->*PEnGet) (); } // Реализация не требуется private: И Этот метод скрыт, чтобы не позволить пользователям этого класса И применять оператор = для экземпляров свойства, находясь в охватывающем И классе, поскольку вьполнение этого оператора для И method_property_getset<> приводило бы к бесконечному циклу, classjtype boperator = (R value) ; }; Это может быть подключено к первоначальной версии класса Date, используя соответствующий макрос, как показано ниже: Листинг 35.20. class Date ( public: WeekDay getJDayOfWeek() const ( return static_cast<WeekDay> (local time (&n\_time) ->tn\_wday) ; ) И Свойства public: METHOD_PROPERTY_GET_EXTERNAL (WeekDay, Date, get_DayOfWeek, DayOfWeek) ; private: time_t m_time; );
668 Часть 5. Операторы Это свойство не имеет собственных полей и передает право доступа методу класса Date get_DayOfWeek (). Метод get_DayOfWeek () использует член m_time для преобразования дня недели. (Следует помнить, этот класс приведен здесь для ил- люстративных целей; я бы не сказал, что он представляет собой эффективный способ реализации класса для даты и времени.) Внешние свойства-методы очень гибкие в силу того, что они разъединяют поля охватывающего класса и открытого интерфейса (свойств) этого класса. По скорости они эффективны, как и внутренние свойства-методы, поскольку используется фактиче- ски идентичный механизм. Имеется один недостаток (который мы обсуждали ранее в данной главе) - они не содержат переменных-членов, но, тем не менее, имеют нену- левой размер, т. к. нельзя использовать оптимизацию ЕМО (см. раздел 35.2.2). Мини- мальный размер, который может быть им обеспечен компилятором, равен, конечно, одному байту. Поэтому наш класс Date увеличивается на 1 байт на каждое свойство. Это не так уж приятно. Однако помните, мы видели в разделе 1.2.4, как union может использоваться для ограничения типов POD-типами (см. «Введение») Здесь мы можем поступить ана- логично. Поскольку наши параметризованные шаблоны внешних методов не имеют членов и не являются производными от других типов (которые могли бы иметь различные атрибуты, не являющиеся POD-типами), ничто не мешает нам поместить их в объединение. Отсюда: class Date { // Свойства public: union ( METHOD_PROPERTY_GET_EXTERNAL(WeekDay, Date, get_DayOfWeek, DayofWeek); METHOD_PROPERTY_GET_EXTERNAL(int, Date, get_Year, Year); }; }; Платить некоторую цену все-таки приходится, т. к. само объединение не может иметь нулевой размер. Но иена минимальна - 1 байт, конечно, и она платится лишь один раз, вне зависимости от количества свойств класса. Не идеально, но очень близко к тому1. Существует одно небольшое осложнение (разве такое происходит не всегда?), связанное с тем, что приведенное выше объединение макросов в конструкции union не будет работать. Как вы помните, эти макросы содержат внутри себя функцию расче- та смещения. Конечно, нельзя определять функцию расчета смещения внутри union, 1 Фактически, вы могли бы включить в объединение один из членов обрамляюшего класса, если он является типом POD (см. «Введение») и, следовательно, свести к нулю издержки памяти.
Глава 35. Свойства 669 т. к. она станет членом union, а не охватывающего класса. Поскольку этот union бе- зымянный, нам пришлось бы потратить уйму времени на поиск подходящего способа обращения к ней из других частей макроса, не нарушая при этом переносимость. Эту проблему можно решить путем разделения всего определения на два макроса: один для определения функции расчета смещения и другой для определения свойства. Мы видели макрос METHOD_PROPERTY_DEFINE_OFFSET () в разделе 35.4.1, а другой макрос определяется следующим образом: #define METHOD_PROPERTY_GET_EXTERNAL_PROP(R, С, GM, Р) \ \ method_property_get_external< R \ , С \ , &С:: P##_offset##C() \ , &C::GM \ > Р Вооружившись этими макросами, класс Date можно переписать следующим образом: Листинг 35.21. class Date { // Свойства public: METHOD_PROPERTY_DEFINE_OFFSET(Date, DayOfWeek) METHOD_PROPERTY_DEFINE_OFFSET(Date, Year) union { METHOD_PROPERTY_GET_EXTERNAL_PROP(WeekDay. Date, get_DayOfWeek, DayOfWeek); METHOD_PROPERTY_GET_EXTERNAL_PROP(int, Date, get_Year, Year); }; }; Вот так! Этот вариант эффективен по скорости и приводит к расходу только 1 байта в любом охватывающем классе, независимо от количества свойств. 35.4.6. method_property_set_extemal Можно снова констатировать, что при обсуждении предыдущих типов свойств мы получили достаточно информации, чтобы понять, как реализуется method_property_set_external, поэтому я просто представлю вам эту реали- зацию:
670 Часть 5. Оператор ----------------------------------------------------------------------------------- Листинг 35.22. tesnplate< typename R /* Тип ссылки */ , typename С /* Охватывающий класс */ , ptrdiff_t (*PFnOff)() /* Указатель на функцию, обеспечивающ/ю смещение свойства в контейнере •/ , void (С: :*PFnSet) (R ) /* Указатель на функцию-член, принимающую r *, > class method_property_set_extemal { public: typedef method_property_set_extemal<R, C, PFnOff, PFnSet> class_type,- public: III Обеспечивает доступ к свойству только для записи method_property_set_external ^operator =(R value) ( ptrdiff_t offset = (*PFnOff)(); С *pC = (C*)((byte_t*)this - offset); (pC->*PFnSet)(value); return *this; ) ); Нам очень трудно было представить, каким может быть реальное применение преды- дущих типов свойств с доступом только для записи, но данный тип имеет убедительный пример своего использования. При рассмотрении типов контейнеров часто поддержива- ется две отдельные концепции, связанные с хранением элементов в контейнере. Размер контейнера показывает количество содержащихся в нем элементов в то время, как емкость отражает реальный объем выделенной памяти и потенциально возможное количество элементов в контейнере. Хотя большинство современных библиотек контей- неров, обеспечивающих открытый доступ к емкости, позволяет обращаться к ней и для чтения, и для записи (например, методы reserve О и capacity О вектора std:: vector), существует направление, которое утверждает, что разрешение клиент- скому программному коду осуществлять доступ к выделенной контейнеру памяти нару- шает инкапсуляцию контейнера и привязывает клиентский программный код к внутрен- ней реализации контейнера. Один способ (надо признаться, немного замысловатый) заключается в предоставлении клиентскому программному коду возможности информирования контейнера о требованиях своего контекста и не зависеть при этом от результатов такого информирования; это можно было бы сделать путем реализации обеспечивающего доступ только для записи свойства Capacity (емкость), используя шаблон method_property_set_external, как показано в следующем примере. Листинг 35.23. class Container { public:
Глава 35. Свойства 671 size_t getjSizeO const; void set_Capacity(size_t minElenents); // Свойства public: union ME7THOD_PROPERTY_GET_RXTERNAL_PROP(size_t, Container, get_Size, Size); METHOD_PROPERTY_SET_gXTERNAL_PROP(size_t, Container, set_Capacity, Capacity); Существуют другие, диагностические, применения свойств с доступом только для записи, которые мы рассмотрим в конце данной главы. 35.4.7. method_property_getset_external Как и method_property_getset, method_property_getset_external является комбинацией его собратьев, получающих и устанавливающих свойства, и имеет соответствующий макрос METHOD_PROPERTY_GETSET_EXTERNAL(), который определяет данное свойство и его функцию расчета смещения, и макрос METHOD_PROPERTY_GETSET_EXTERNAL_PROP (), определяющий только собствен- ное свойство. Листинг 35.24. template< typename RG /* Тип ссыпки получаемого значения */ , typename RS /* Тип ссылки устанавливаемого значения */ , typename С /* Охватывающий класс */ , ptrdiff_t (*PFnOff)() /* Указатель на функцию, /* обеспечивающую смещение */ свойства в контейнере */ , RG (С::*PFnGet)() const /* Указатель на константную /* функцию-член, возвращающую R */ , void (С::*PFnSet)(RS) /* Указатель на функцию-член, /* принимающую R */ > class method property qetset external ( public: III Обеспечивает доступ к свойству только для чтения operator RG () const ( С *рС = (С*)((byte_t*)this - offset); return (pC->*PFnGet)(); } III Обеспечивает доступ к свойству только для записи class_type boperator =(RS value)
672 Часть 5. Операторы ( ptrdiff_t offset = (*PFnOff)(); С *pC = (С*)((byte_t*)this - offset); (pC->*PFnSet)(value); return *this; } }; Мы могли бы использовать это в нашем классе Date для считываемых и записы- ваемых свойств: Листинг 35.25. class Date { // Свойства public: METHOD_PROPERTY_DEFINE_OFFSET(Date. DayOfWeek) METHOD_PROPERTY_DEFINE_OFFSET(Date, DayOfMonth) METHOD_PROPERTY_DEFINE_OFFSET(Date, Year) union ( METHOD_PROPERTY_GET_EXTERNAL_PROP(WeekDay, Date, get_DayOfWeek, DayOfWeek); METHOD_PROPERTY_GET_EXTERNAL_PROP(int, Date, get_Year, Year); METHOD_PROPERTY_GETSET_EXTERNAL_PROP(int, int, Date, get_DayOfMonth , set_DayOfMonth, DayOfMonth); }; }; Вы можете убедиться, что мы обеспечили почти все свойства нашего мнимого класса Date, которые присутствовали в его первоначальном воображаемом определении. Оста- лось реализовать единственную вещь - свойства классов (статические свойства). 35.5. Статические свойства Как отмечалось в начале этой главы, даже те компиляторы, которые поддерживают свойства как расширение языка, не обеспечивают свойства классов (статические свой- ства). Поэтому даже если вам не надо применять шаблоны, рассмотренные в предыду- щих разделах, вам все же могут потребоваться методы, обсуждаемые в последующих разделах. Статические свойства так же, как свойства экземпляров, могут представлять собой свойство-поле и/или свойство-метод.
Глава 35. Свойства 673 35.5.1. Статические свойства-поля Статические свойства-поля могут быть реализованы точно так же, как, например, свойства-поля, применяющие шаблоны field_property_get/set, как показано в следующем примере: Листинг 35.26. // SocketBuffer.h class SocketBuffer { // Конструирование public: SocketBuffer(. . . ) ( ++Numlnstances.m_value; // Примечание: потокозащищенность //не обеспечивается } // Свойства public: static field_property_get<int, int> Numinstances; }; // SocketBuffer.cpp /* static */ field_property_get<int, int> SocketBuffer::Numlnstances (0) ; Оно может использоваться, как любое другое статическое поле: void monitor_proc(. . .) ( int buffercount = SocketBuffer::Numinstances; } 35.5.2. Внутренние статические свойства-методы Механизмы, применяемые свойствами-методами экземпляров, не могут перено- ситься на статические свойства-методы, поскольку очевидно, что статическое свойст- во-метод не может работать с экземпляром. К счастью, в результате получаются значи- тельно более простые определения. Я приведу только варианты для чтения и записи, поскольку в предыдущих разделах эти вопросы были достаточно хорошо раскрыты. Версия для внутреннего свойства- метода выглядит следующим образом: Листинг 35.27. template* typename V , typename RG , typename RS , typename C
674 Часть 5. Операторы , RG (*PFnGet)(void) , void (*PFnSet)(RS ) > class static method property qetset ( // Операторы доступа public: operator RGO const { return (* PFnGet)(); ) static method property qetset &operator =(RS value) { (•PFnSet)(value); return *this; ) // Члены private: V m_value; ); Мы могли бы использовать внутреннее статическое свойство-метод в классе Date для подсчета количества созданных экземпляров, как показано в следующем примере: Листинг 35.28. class Date ( public: Date(Date const &rhs) : m_t ime (rhs. m_t ime) ( ++InstanceCount.m_value; // Примечание: потокозашишенность // не обеспечивается ) public: static int get_InstanceCount() ( return InstanceCount.m_value; ) public: static static_method_property_get<int, int, Date, &get_InstanceCount> InstanceCount;
Глава 35. Свойства 675 Поскольку это свойство статическое, его необходимо определить: static method property qet< int , int , Date , &Date::get_InstanceCount > Date::InstanceCount; Конструкторы имеют открытый доступ, т. к. они должны использоваться в области видимости файла. Поскольку только вы будете вызывать конструктор для свойств вашего охватывающего класса, безопасность здесь никак не нарушается. Обязательно нужно уточнять имя метода квалификатором, т. к. оно используется вне контекста определения класса. Поскольку нас не волнует размер статического свойства, единственным преимуще- ством применения внутренних свойств-методов над свойствами-полями является возможность проверки достоверности и преобразования реального поля при взаимо- действии с клиентским программным кодом. 35.5.3. Внешние статические свойства-методы Когда нам не нужны внутренние поля, мы используем самые простые свойства- методы. И вновь для краткости я привожу только версию для чтения и записи: Листинг 35.29. template* typename RG , typename RS , RG (*PFnGet)(void) , void (*PFnSet)(RS ) > class static_xnethod_property_getset_external ( // Операторы доступа public: operator RG() const ( return (*PFnGet)() ; } static method property qetset external boperator =(RS value) ( (*PFnSet)(value); return *this; } }; Свойства static_method_property_get_extemal И static_method_property_ set_extemal реализуются как половина реализации этого типа, относящаяся к чтению и записи, соответственно. Наконец, теперь мы можем определить свойство Now для Date:
676 Часть 5. Операторы Листинг 35.30. class Date ( public: static Date get_Now() { return Date(time(NULL)); ) public: static static_method_property_get_external<Date, &get_Now> Now; }; static method property get external<Date. &get_Now> Date::Now; Оно может использоваться следующим образом: Date date = Date::Now; В обоих показанных статических свойствах-методах я не использовал макросы, поскольку не понадобилось никаких функций по расчету смещений. Однако вы, возможно, захотите их использовать ради обеспечения единообразного подхода. 35.6. Виртуальные свойства Этот простой механизм не позволяет нам непосредственно иметь виртуальные свойства, но их очень легко можно обеспечить. Как мы видели при рассмотрении метода Container: :set_Capacity, можно определить синтаксически коррект- ный невиртуальный метод, в котором будет затем вызываться виртуальный метод. Любые производные классы переопределяли бы этот виртуальный метод, и данное свойство действовало бы виртуально. Листинг 35.31. class Super { public: int get_Thing() const ( return get_Thingvalue(); ) public: method_property_get_external<int. Super, . . > Thing; protected: virtual int get_ThingValue() const = 0; };
Глава 35. Свойства 677 class Sub : public Super { protected: virtual int get_ThingValue() const ( } }; Sub sub; Super bsuper = sub; int i = super.Thing; // Использует Sub::get_ThingValue() Этот подход может также использоваться для адаптации существующих невирту- альных методов чтения и записи, делающих их совместимыми с требованиями сигна- тур функций шаблонов свойств-методов, поэтому методы доступа ваших свойств просто действуют как прокладки (см. гл. 20). 35.7. Применение свойств В данный момент, возможно, вы думаете: подход привлекательный, но кроме син- таксических улучшений, за которые иногда приходится платить 1 байт, дает ли он что- нибудь еще? Ну, и да и нет. В основном это позволяет получать более изящный синтак- сис, что нельзя недооценивать, но существуют другие применения свойств, которые более весомы. На самом деле, именно то, что они превращают семантическую слож- ность в синтаксическую простоту, может сделать их очень мощным инструментом. 35.7.1. Обобщенность Допустим, вы работаете с двумя вариантами простой структуры, определяющей прямоугольник: struct intRect ( int left, right, top, bottom; }; struct FloatRect ( double left, right, top, bottom; }; Вы можете уже использовать несколько сложных шаблонов для работы с этими структурами, реализация которых рассчитана на непосредственную доступность полей left, right, top и bottom. Теперь вам необходимо определить класс, который логически представляет прямоугольник, но координаты которого опреде- ляются в другой форме, возможно, с помощью двух точек верхнего левого и нижнего
678 Часть 5. Операторы правого угла, или значения которого получаются с помощью вызовов каких-нибудь методов. В данном случае вам потребовалось бы переписывать ваши алгоритмы с ис- пользованием либо свойств аргументов шаблона (traits), либо прокладок (см. гл. 20). Но они могут не быть вашими шаблонами. Существует слишком большой риск внесе- ния в них ошибки на вашем этапе проекта. В обоих случаях это может потребовать слишком больших усилий, а вам необходимо получить решение достаточно быстро. Один из возможных подходов заключается в использовании свойств. Достаточно просто улучшить ваш новый тип прямоугольника, скажем, DynRect, добавив в него свойства left, right, top и bottom. class DynRect ( union ( METHOD_PROPERTY_GET_EXTERNAL_PROP(int, DynRect, get_left, left); METHOD_PROPERTY_GET_EXTERNAL_PROP( . . . // right, top, bottom }; }; Даже если вам приходится использовать внешние свойства и однобайтовую плату за это нельзя считать оправданной в долгосрочной перспективе, у вас есть устойчивое и эффективное (не считая возможного дополнительного расхода памяти) кратко- срочное решение, позволяющее вам удачно пройти очередную веху. (Конечно, вы нахо- дитесь в реальном мире и поэтому рискуете, что это решение никогда так и не будет оптимизировано, но, возможно, успешный, частично оптимальный программный продукт представляет собой лучшую из альтернатив, которые имеются у вашей компа- нии в данный момент.) 35.7.2. Подстановка типов для диагностики Рассмотрим относящийся к диагностике случай, когда имеется много унаследован- ного программного кода, использующего struct tm, и при тестировании время от времени обнаруживаются ошибки при работе с датой и временем. Было бы не так сложно создать свой собственный тип, подобный tm, в котором «поля» будут заме- няться свойствами, проверяющими их достоверность (см. листинг 35.32), и затем под- ключить его к существующему программному коду. Листинг 35.32. class tm_spy ( method_property_getset_external<int, int , tm_spy , &sec_offset
Глава 35. Свойства 679 , &get_sec , &set_sec > tm_sec; method property qetset externalcint, yday . tm_spy , &yday_offset , &get_yday , &set_yday > tm yday; }; Если это ваш исходный текст, то вам достаточно всего лишь изменить в корневом заго- ловочном файле единственный typedef, не правда ли? Однако даже если этот программный код написан кем-то другим и типом struct tm усеяно все вокруг, у вас имеется несколько возможностей, хотя и не очень привлекательных. Вы могли бы выпол- нить глобальный поиск и заменить его на одно общее имя, вводимое typedef. Вероятно, это все же неплохая идея, поскольку это не привело бы к никаким нарушениям и упрости- ло бы проведение любых изменений в будущем. Можно поступить по-другому и опреде- лить ваш класс как struct, а затем использовать оператор #define tm tm_spy. Не очень здорово, конечно, но мы говорим о диагностике, а не о промышленном про- граммном коде. Как бы то ни было, я отвлекаюсь. Дело в том, что после подключения вашего нового типа вы можете прогнать некорректно работающее приложение, и ваш «умный» тип struct tm сможет обнаружить все ошибочные манипуляции с датами. Что делать при их обнаружении: регистрировать в файле, выводить в поток ошибок, выбрасывать исключение, использовать утверждение assert() - решать вам. Таким образом, даже в тех случаях, когда внешние свойства не могут применяться для рабочей версии из-за неэффективного использования памяти, они представляют собой полезное диагно- стическое средство. В других случаях вы можете иметь более мягкие требования, когда просто нужно отслеживать модификации вашей структуры. Снова можно использовать свойства, для чего достаточно просто реализовать методы записи для регистрации действий. 35.8. Свойства: заключение Эта глава получилась большой, самой большой во всей книге, и я уверен, многие из вас будут с чем-нибудь не согласны. Я помню, что при создании этих шаблонов моей голове пришлось хорошо потрудиться, а когда настало время вернуться к ним, чтобы описать их здесь, я тоже испытывал некоторое смущение. Несколько раз я спрашивал себя: «Почему я так сделал?» и восклицал: «Я не могу вспомнить, зачем я это написал!»
680 Часть 5. Операторы Если вы не понимаете всей сложности реализаций свойств-методов, я уверен по крайней мере, вы оцените простоту применения этих шаблонов и поймете, насколь- ко полезными могут быть свойства. Я призываю вас поэкспериментировать с этими шаблонами и, полностью поняв их силу и учитывая предостережения, пользоваться ими в вашей собственной работе Вы должны были отметить, что реализация таких шаблонов рассчитана на примене- ние механизма, обеспечивающего дружественные связи шаблонов (см. раздел 16.3), и который не на все 100% соответствует стандарту. Я не преуменьшаю значение этой нестандартной особенности, поскольку нарушая стандарт, вы рискуете (в будущем). Но все компиляторы (см. приложение А) поддерживают его, если не брать в расчет очень уважаемый и «самый стандартный» компилятор Comeau, когда он работает в жестком режиме. Однако и Comeau недавно добавил опцию компилятора (— f riendT), специ- ально предназначенную для поддержки этого механизма в жестком режиме, поэтому я считаю, что мы не совершим ошибки, если «присоединимся к поезду, который уже не остановить»1. Поэтому добавьте свойства в свой несовершенный набор инструментальных средств и сложите ваши пальцы крест-накрест, чтобы комитет по стандартизации добавил их как законное средство языка, благодаря чему вся эта лишняя суета вокруг шаблонов станет по существу беспредметной. * Это тот случай, когда действует принцип caveat emptor (покупатель действует на свой риск). Я не ДаЮ никаких гарантий и мне ничего не известно о планах комитета по стандартам о легализации дружественных шаблонов в С-н--0х, всего лишь имеется сильное подозрение.
Приложение А Компиляторы и библиотеки А.1. Компиляторы При проведении исследований в ходе написания книги я использовал следующие компиляторы, поставляемые девятью организациями: • Borland C/C++ версии 5.5(1), 5.6 и 5.64; • VectorC компании «CodePlay» версии 2.06; • Comeau C++ версии 4.3.0.1 и 4.3.3; • Digital Mars C/C++ версии 8.30-8.38; • GCC версии 2.95 и 3.2; • Intel C/C++ версии 6.0, 7.0 и 7.1; • CodeWarrior компании «Metrowerks» версии 7 и 8; • Visual C++ компании Microsoft версии 6.0, 7.0 и 7.1; • Watcom версия 12.0 (также известен под именем Open Watcom 1.0). Из них следующие компиляторы доступны совершенно свободно: • Borland (версия 5.5(1)) - можно найти на сайте http://yvwyv.borland.com-, • GCC (все версии) - имеется несколько источников; см. http://gcc.gnu.org/install/ bi- naries. html; * Watcom - можно найти на сайте http://www.openwatcom.org/. Следующие компиляторы свободно доступны в той или иной форме: • Digital Mars (все версии) - только консольный режим; на сайте http://www.digital- mars.com/-, • Intel (недавние версии) - только версии для Linux; http://www.intel.com/', • Visual C++ (версия 7.1) - только консольный режим; http://www.microsoft.com/. Следующие компиляторы доступны на коммерческой основе: • VectorC компании CodePlay - по адресу http://www.codeplay.com/', • Comeau - по адресу http://www.comeaucomputing.com/', • CodeWarrior компании «Metrowerks» — по адресу http://www.metrowerks.com/.
682 Приложение А. Компиляторы и библиотеки Даже если нет никакой свободно доступной версии, большинство этих поставщиков также предлагает бесплатно пробные версии. Я не собираюсь здесь давать их сравни- тельный анализ, т. к. существует много статей по сравнению, которые нетрудно найти в сети даже при не очень настойчивом поиске. Вероятно, вы уже поняли мое к ним отношение по их обсуждению во многих частях книги. В любом случае я большой сторонник применения нескольких компиляторов в своей работе вместо того, чтобы ограничиваться только одним (см. приложение Г); каждый их этих компиляторов выдает полезные предупреждения, которых нет у всех остальных. Мне бы хотелось поблагодарить всех этих поставщиков за предоставленную мне возможность пользоваться их инструментарием при подготовке книги. Великолепное ощущение создает дух поддержки, демонстрируемый всеми командами поставщиков компиляторов, от самой маленькой (самая небольшая команда состоит из одного чело- века - Уолтера Брайа, разработчика Digital Mars) до одной из самых больших в мире компаний по информационным технологиям. Это очень ценно. А.1.1. Выполняемая компилятором оптимизация Когда проводились измерения производительности программного кода (а не только лишь проверялась его корректность), все компиляции выполнялись в режиме макси- мальной оптимизации по скорости и с ориентацией (в той мере, насколько это было возможно) на Pentium IV как целевого процессора базовой системы. Настройки компи- ляторов для работы на платформе Win32 были следующие: Компилятор Оптимизационные настройки Borland 5.6 -02-6 VectorC 2.06 компании «CodePlay» -nodebug -optimize 10 -max -target p4 Comeau 4.3.0.1 (Visual C++ 6.0 backend) /O2/Ox/Ob2/G6 Digital Mars 8.38 -o+speed -6 GCC 3.2 -07 -mcpu=i686 Intel 7.0 -Ox -02 -Ob2 -GS -G7 CodeWarrior 8 компании «Metrowerks» -opt full -inline all,deferred -proc generic Visual C++ 6.0 компании Microsoft -Ox -02 -Ob2 -G6 Visual C++ 7.1 компании Microsoft -02 -Ox -Ob2 -GL -G7 Watcom 12.0 (Open Watcom 1.0) -oxtean -ei -6r -fp6 А.2. Библиотеки По мере возможности я старался использовать примеры, которые не зависят ни от какой конкретной библиотеки. Однако как основной автор библиотеки STLSoft я естественно брал много примеров из этого источника. В большинстве таких случаев я пытался относиться спокойно к тому, что с ними будет, не по причине моей неискренней
Приложение А. Компиляторы и библиотеки 683 скромности, а больше потому, что мне не нравится читать книги, которые используются почти открыто как средство маркетинга собственной работы автора. Это особенно раздражает, когда вам приходится «проглатывать» большое количество существенных и взаимосвязанных компонентов для понимания каждой новой обсуждаемой темы. Я не скрывал реальные возможности любой библиотеки при сравнении реализаций с точки зрения их производительности, т. к. должен содействовать любой независимой проверке полученных результатов, если вы, читатели, захотите ее провести. В против- ном случае они не заслуживали бы большого доверия. Другие примеры взяты из библиотек моего работодателя, компании «Synesis Soft- ware» (http://synesis.com.au/)1, или из библиотек Boost (http://boost.org!), которые имеют отрытый исходный код и объединяют вокруг себя много имен, занимающих ведущие позиции в C++. Остальная часть примеров, имеющих ошибки, свидетелем которых я был при работе с различными клиентами, была переработана (чтобы не вызывать недо- вольство) или взята из сетевых конференций, когда возникала необходимость проил- люстрировать какие-то моменты. А.2.1. Boost Система Boost превосходит все другие библиотеки C++, созданные за последнее время, и в ряду ее разработчиков находится много известных в мире C++ личностей. Она содержит некоторые очень мощные компоненты, громадный список тех, кто уча- ствует в ее разработке, и фактически уже рассматривается как стандарт. Вероятно, многие новые возможности, включенные в следующее издание стандартной библиоте- ки, будут взяты из библиотек Boost. Библиотеки Boost включены в состав компакт-диска и также свободно доступны в сети на веб-сайте Boost http://wwyv.boost.org/. А.2.2. STLSoft Библиотеки STLSoft появились несколько лет назад в виде пары STL-подобных кон- тейнеров последовательностей, обеспечивающих перебор элементов файловой систе- мы и работу с программными интерфейсами реестра Win32. Эти библиотеки постоян- но развивались в течение последних нескольких лет и теперь покрывают ряд техноло- гий и операционных систем. Многие части библиотеки получены на основе программ- ного кода, являющегося собственностью компании «Synesis Software», поэтому прошло уже много лет со времени его создания. Основное внимание в библиотеках STLSoft уделяется эффективности, устойчивости и переносимости, причем при усло- вии их реализации с помощью одних только заголовочных файлов. Они содержат меньше элементов, чем библиотеки Boost. Эта база программного кода строится с того времени, когда я работал над докторской диссертацией в 1992-95 <т. Поэтому она содержит много неприятных и «темных» вешей. Если вы имеете серьезные критические замечания к какому-нибудь программному коду системы «Synesis». приведенному в книге или включенному в состав компакт-диска, пожалуйста, сдержите свое стремление триумфально сообщить о какой-нибудь ошибке с пояснением, что я, вероятно, уже зная об этом, поместил ее в список будущих изменений и не стал ее воспроизводить в новом программном обеспечении (таком, как STLSoft). Я из тех, кто любит поговорить, и, конечно, буду рад получить от вас сообщение, но, возможно, вы не захотите тратить зря свои усилия
684 Приложение А. Компиляторы и библиотеки Я не знаю, войдет ли что-нибудь из STLSoft в следующую версию стандартной библиотеки, но я не придавал этому особого значения. Один из рецензентов книги «C++: практический подход к решению проблем программирования» входит в состав комитета по стандартизации и пригласил меня представить свои предложения, так что кто знает? Библиотеки STLSoft включены в состав компакт-диска и также свободно доступ- ные в сети на веб-сайте STLSoft http://www.stlsoft.org/. А.2.3. Другие библиотеки Существует несколько других полезных библиотек, о которых, я полагаю, стоит упомянуть. Open-RJ {http://www.openrj.org/) является небольшой простой библиотекой, обес- печивающей чтение структурированных файлов в формате Record-JAR [Raym 2003], написанной Грегом Питом (Greg Peet) и мною. Она реализована на С, но обеспечивает отображение в базовую систему, включая C++, D, Ruby и STL. Это очень простой формат - в откомпилированном виде библиотека занимает менее 5 Кб - но удивитель- но полезный1. PThreads-win32 {http://sources.redhat.com/pthreads-win32/) является GNU-библиоте- кой, которая обеспечивает почти все возможности PTHREADS для платформы Win32. Она очень полезна в тех случаях, когда вы создаете программный код для системы UNIX, а вынуждены работать на машине с Windows. Конечно, если ваш ноутбук загру- жает также Linux, тогда вам нет необходимости так поступать, но эта библиотека может быть все-таки полезна для написания одного комплекта программного кода, работающего на обеих платформах. RangeLib {http://www.rangelib.org/) - это место, где реализована концепция диапазо- нов, разработанная Джоном Торьо и мною. Разработка этой концепции еще не закончена, и она, как и реализации библиотек Boost и STLSoft, может перейти на другие языки. Но нам придется подождать какое-то время, и мы увидим как пойдут дела у этой новой концепции (см. «Эпилог»). reels (http://www.recls.org/) - то есть рекурсивный 1s - является платформо-незави- симой библиотекой рекурсивного поиска элементов файловой системы, за разработку которой я первоначально взялся из-за постоянного разочарования от бесконечного ко- дирования одного и того же, когда мне требовалось выполнять рекурсивные поиски. Она послужила примером для моей статьи «Positive Integration» («Положительная ин- теграция») в журнале «C/C++ Users Journal», в которой описываются методы интегра- ции С и C++ с другими языками и технологиями. Эта библиотека реализуется (исполь- зуя библиотеки STLSoft) на C++, но обеспечивает С-интерфейс с внешним миром (см. гл. 7 и 8). Она продолжает развиваться, переходя в другие области, где требуется 1 Веб-сайт «Ореп-RJ» и файлы HTML на компакт-диске книги «C++: практический подход к решению проблем программирования» CD были сгенерированы с помощью написанных на языке Ruby сценариев на основе файлов формата «Ореп-RJ». Эта библиотека также использовалась для получения конфигурационных данных, применяемых в мультиплексоре компиляторов «Arturius» (см. приложение В).
Приложение А. Компиляторы и библиотеки 685 рекурсивный поиск, и имеет версии для COM, D, Java, .NET, Ruby и STL (это лишь часть из постоянно растущего списка). zlib (http://www.zlib.org/) - это, вероятно, самая знаменитая из всех свободно дос- тупных библиотек по сжатию данных. Ее очень легко использовать, и она, к счастью, может применять внешние распределители памяти (см. раздел 32.3), что я охотно под- тверждаю. А.З. Другие источники Все приводимые ниже ресурсы могут быть полезны для программистов C++ и/или оказались полезны для меня при проведении исследований и написании данной книги. А.3.1. Журналы «BYTE» (http://www.byte.com/); «C/C++ Users Journal» (http://www.cuj.com/); «CVu» (http://www.accu.org/); «Dr. Dobb’s Journal» (http://www.ddj.com/); ' «Overload» (http://www.accu.org/); «The C++ Source» (http://www.artima.com/cppsource/); «Windows Developer Network» (http://www.windevnet.com/). A.3.2. Другие языки D (http://www.digitalmars.eom/d); Java (http://java.sun.com/); .NET (http://microsoft.com/net/); Perl (http://cpan.org/; http://perl.org/); Python (http://python.org/); Ruby (http://ruby-lang.org/). A.3.3. Сетевые конференции comp. lang.c++.moderated; comp.std.c++.
Приложение Б Остерегайтесь самомнения! После того, как я, уклоняясь от реальной работы (чтобы можно было больше време- ни кататься на велосипеде, именно так!), наконец, собрался и закончил мою док- торскую диссертацию, я окунулся в «реальный мир». В тот момент я, помню, был сбит с толку потенциальными работодателями - их совсем не впечатлял мой квалификаци- онный список, и они не очень-то стремились предложить мне тот уровень вознаграж- дения (то, что называют «компенсацией» мои друзья в Соединенных Штатах), который, мне казалось, я должен получить1. Проблема, оказалось, была в том, что они называли «опытом». Я был потрясен их близорукостью и резко реагировал на все несправедливости в течение нескольких недель поиска работы. Почему они просто не могут увидеть, как чертовски я умен, и не дадут мне кучу денег? Конечно, после того, как прошли трудные годы накопления опыта, я понимаю его несомненную ценность. Я работал с очень квалифицированными людьми, у которых не было ни капли практических задатков, и с мало квалифицированными или совсем не квалифицированными, которые были блестящими инженерами. Иногда мне приходи- лась работать с людьми, которых совсем не заботило качество работы и у которых не было ни малейшего интереса изучать что-то новое и лучшее. Эти парни - «ложка дегтя» в нашей профессии. Судьбу уже пообтрепавшейся индустрии разработки про- граммного обеспечения нельзя отдавать полностью в руки невежественных менед- жеров, хитрых специалистов по маркетингу и нетерпеливых пользователей. Большин- ству из них лучше бы считать скрепки для бумаг и смотреть в окно, занимаясь какой- нибудь другой деятельностью, где несерьезное отношение к свой профессии приносит меньше вреда - но не там, где воплощаются в жизнь самые сложные человеческие фантазии. Гм! Как много раз говорила мне моя мама: «Будь скромнее!» Также должны посту- пать и мы. Следуя этому совету, и просто, чтобы удивить вас, я собираюсь раскрыть детали самых неприятных «катастроф», произошедших в ходе моего развития от не- опытного новичка до человека, который кое в чем немного разбирается. Если вы узнае- те в моем опыте какое-нибудь из своих собственных «преступлений», молчите! Итак, сохраняйте спокойствие и позвольте мне приступить к делу. 1 Я получил хороший урок по переговорам при приеме на выбранную мной работу. Менеджер спросил. «На какую сумму вы рассчитываете?» Я призвал на помощь всю свою сообразительность и ответил: «Что-то между 20000 и 25000 фунтов стерлингов», и не успело мое сердце сделать следующий удар, как я услышал последние слова менеджера: «Мы можем вам предложить работу за 20000». Больше я так не поступал.
Приложение Б. Остерегайтесь самомнения! 687 Б.1. Перегрузка операторов Одна из первых вещей, с которыми сталкиваются новички-программисты C++, - это перегрузка операторов, и в этом проявляется ирония судьбы, т. к. этот аспект языка, вероятно, проще всего извратить. Мой первый класс строки имел очень большой открытый интерфейс, и ниже приво- дятся только самые неудачные его фрагменты: class DLL_CLASS String // Экспортирует все члены без разбора : public BaseObject // Полиморфная строка . . . ? ( И Создает строку длиной nlnitLen и заполняет ее символом cDef String bSet(int nlnitLen, char cDef = '\0'); Bool SetLowerAt(int nAt); // Возвращает значение «истина», если был верхний регистр Bool SetUpperAt(int nAt); И Возвращает значение «истина», если был нижний регистр String boperator =(int nChopAt); // Устанавливает длину на nChopAt String boperator +=(int nAdd); // Увеличивает длину на nAdd String boperator -=(int nReduce); // Уменьшает длину на nReduce 11 Уменьшает длину до последнего встретившегося соответствующего символа String boperator -=(char cChopBackTo); // Выполнить смещение (с переносом) содержимого строки влево/вправо String boperator »=(int nRotateBy); String boperator <<=(int nRotateBy); String boperator —(); // Все символы перевести в нижний регистр String boperator — (int); // Все символы перевести в нижний регистр String boperator ++(); // Все символы перевести в верхний регистр String boperator ++(int); // Все символы перевести в верхний регистр // Возвращает локальную статическую строку с преобразованным значением static String NumString(long IVal); operator const char *() const; // Неявное преобразование в с-строку operator int{) const; // Неявное преобразование с возвращением длины // Возвращает значение элемента const char operator (Hint nlndex) const; String boperator Л={сЬаг cPrep); // Добавляет в начало символ
688 Приложение Б. Остерегайтесь самомнения! String ^operator Л=(const char *pcszPrep); // Добавляет в начало строку }; Трудно представить более неприятную работу, но что возможно, то обязательно произойдет. Мне будет очень жаль, если это не вызовет у вас сильного отвращения. Удивительно, но мне удалось правильно написать операторы сложения, определяемые как свободные функции и реализуемые как operator += () (см. раздел 25.1.1). String operator +(const String bStringl,const String &String2); String operator +(const String &Stringl,const char *pcszAdd); String operator +(const char *pcszAdd,const String &String2); String operator +(const String fcStringl,char cAdd); String operator +(char cAdd,const String bStringl); Ужасно то, что в недавних сообщениях в одной из сетевых конференций, за которой я слежу, двое участников, суждения которых обычно глубоко содержательны и разум- ны, говорили, что предложение использовать operator ++ () для перевода всех сим- волов в верхний регистр было совсем неплохой идеей! Надо надеяться, они просто все- таки не все до конца продумали. Б.2. Когда-то я пожалел о том, что следовал принципу DRY При создании класса строки я также написал на C++ мой первый связанный список. Мне очень понравилась идея инкапсуляции. Увы, я наивно злоупотребил в этом классе моей сильной любовью к приципу не повторяй себя (Don’t Repeat Yourself - DRY) [Hunt 2000], согласно которому никакой элемент информации не должен храниться в нескольких местах, т. к. реализовал метод GetCount (), подсчитывающий количест- во элементов в списке, при каждом вызове проходя по нему с начала до конца. Я бы не сказал, что получается 0(1). Рассказывать вам о сбалансированном дереве у меня просто не хватает духу! Б.З. Параноидальное программирование Одной из постоянных параной в разработке программного обеспечения является защита интеллектуальной собственности, и я в прошлом попал под ее влияние. Вот не- большая часть того «ужаса». Программисты, которым придется пользоваться графическим интерфейсом Win32, хорошо узнают возможности и недостатки обычного элемента управления «графиче- ский список» (ListView). Моя первая попытка по его улучшению была выполнена с по- мощью MFC, множественного наследования и некоторых действительно порочных уловок. Расширенные возможности: цветной шрифт текста и его фон; пользователь- ские данные и изображения в каждом элементе; переходящие с места на место поля ре- дактирования и т. д., - все это обеспечивалось классом-примесью CColouredList2.
Приложение Б. Остерегайтесь самомнения! 689 class DLL_CLASS CColouredListView : public CListView , public CColouredList class DLL_CLASS CColouredListCtrl : public CWnd , public CColouredList Даже не задумываясь о том, насколько этот компонент может быть полезен потен- циальным клиентам, я потратил очень много сил для сокрытия реализации «неверо- ятно изящного» улучшения управления благодаря применению «восхитительного» трюка: class _CLCInfoBlock; 11 Предварительное объявление #define _CLC_RESBLK (208) Class DLL_CLASS CColouredList : public COwnerDrawCtrl ...II Много, много методов // Члены protected: HWND &m_hWnd; // Ссылка на HWND. _CLCInfoBlock &m_block; // Информационный блок, private: BYTE m_at[_CLC_RESBLK]; // зарезервировано. Я уверен, об остальном вы догадались: класс _CLCInf ©Block определяется в рамках файла реализации класса CColouredList. В конструкторе класса CColouredList создается экземпляр _CLCInf©Block, размещаясь в памяти m_at: CColouredList::CColouredList(HWND &hWnd) : m_hWnd(hWnd) , m_block{*new{m_at) _CLCInfoBlock) С какой стати 208, не говоря уж о том, что не гарантируется правильное выравнива- ние массива байтов для конструирования непосредственно в нем определенного поль- зователем типа? Все это выглядит просто ужасно. По любой разумной оценке эти дей- ствия должны рассматриваться как вредительские, и я не могу себя представить, чтобы кто-нибудь купил нечто подобное CColouredList.
690 Приложение Б. Остерегайтесь самомнения! Б.4. Настоящее безумие! Несколько лет назад передо мной была поставлена задача написания библиотеки общего назначения, которая заменяла бы lOStreams, строки и контейнеры стандартной библиотеки в существующей специальной подсистеме базы данных, чтобы можно было затем переписать текущий вариант с применением новых классов, ни на йоту не изменяя проект или открытый интерфейс подсистемы (применение которой всегда ограничивалось только командой разработчиков). К моменту завершения работы над проектом мы настолько запутались, что пришли к совершенно ненормальной ситуации, когда имелся отдельный включаемый файл для каждой функции стандартной библиотеки, например, fprintf .h, и контекстные объявления typedef (см. раздел 18.2.2) для всех возможных сочетаний компонент стандартной и специальной библиотек. Я сделал простую, но решающую ошибку. Мне следовало бы сказать, что проект был плохо выполнен, и идея переделки громадного количества стандартных компонен- тов при работе в условиях предъявляемых к производительности требований была неудачной, и в лучшем случае на ее реализацию ушло бы несколько месяцев. Напро- тив, благодаря своим рабочим связям и дружескими отношениями с первоначальным разработчиком, который также являлся инициатором выполнения этой разработки для клиента, я позволил сбить себя с толку и просто стал решать поставленную задачу. Я сделал то, на что мы, программисты, как правило, идем очень охотно, то есть с ув- лечением занялся решением сложной задачи, не выяснив, а стоило ли вообще за это браться. Это похоже на проведение пяти часов кряду в гимнастическом зале, когда в действительности следовало бы выполнить восхождение к трем седловинам Фран- цузских Альп или остаться дома и поесть пиццу вместе с детьми! Я уверен, ситуация вам очень знакома. Работа, которая, как предполагалось, займет не более трех недель, оказалась наполовину незаконченной по прошествии почти трех месяцев, и к этому времени наши рабочие связи ослабли, сам я был очень обеспокоен ходом работы, серьезно снизилась мотивация, а мой коллега совсем разрушил свою и без того не очень высокую репутацию в компании. Подошло время для оценки моего кон- тракта, и я почувствовал облегчение от того, что больше не понадоблюсь, т. к. проект был прекращен. Мой прежний коллега через несколько лет оставил своего работодателя и в течение многих месяцев был без работы. Урок простой: если король голый, то, ради Бога, скажите об этом! Нельзя молчать и с упоением работать, т. к. в любом случае это кончится плохо.
Приложение В Arturius Несколько лет назад, когда я стал обращать больше внимания на переносимости программного кода, мне в гололву пришла идея создать инструмент, работающий кан компилятор, но в действительности распределяющий компилируемый программный код по группе реальных компиляторов, чтобы охват всех предупреждений и ошибок был более полным. 1 И вот появился на свет Arturius. Так в Старой Англии писали имя короля Артура] известного своим знаменитым «Круглым столом». Идея заключается в том, чтобь! набор установленных компиляторов представлял собой как бы круглый стол умудрен- ных рыцарей, которые коллективно вынесут мудрое решение, которое едва ли может быть получено с помощью любого одного компилятора1. Проект Arturius к тому времени завершен был только частично. Еще при зарожде- нии идеи написания книги «C++; практический подход к решению проблем программирования» я намеривался сделать доступным вместе с этой книгой и проект Arturius, и поэтому после того, как было принято решении о включении в состав книги компакт-диска, я посчитал, что в него должен войти прежде всего Arturius. Ирония в том, что в процессе написания книги у меня была возможность пере- смотреть многие мои работы, а сам Arturius остался в прежнем, мягко говоря, не совсем убедительном виде. Поскольку книгу я должен был кончить за два или три месяца до того, как будет составлен компакт-диск, я планировал посвятить большую часть этого времени переделке проекта Arturius для того, чтобы он стал образцом при- менения на практике методов, о которых я во всеуслышание заявил. В результате вари- ант, который вы получите в составе компакт-диска, не будет отражать его текущее состояние, и поэтому я не собираюсь путать нас обоих, описывая возможности, не со- ответствующие тому, что вы найдете на компакт-диске. Тем не менее, в этой ситуации вас можно познакомить с моим обширным планом развития этого проекта. В настоящее время я планирую следующее: • консольный вариант, который работает как мультиплексор конфигурируемого набора компиляторов; Я признаю, что это выглядит немного высокопарно, но (обратите внимание!) имя домена было доступно.
692 Приложение В. Arturius • настраиваемый фильтр входных данных, который позволит переводить стандарт- ный формат опций консольных команд в эквивалентные опции соответствующих компиляторов; • настраиваемый фильтр выходных данных, который позволит переводить форматы сообщений об ошибках и предупреждениях конкретных компиляторов в стандарт- ный формат; • фильтр выходных данных, который позволит объединять семантически эквивалент- ные сообщения, чтобы конечный пользователь не получал избыточную информацию; • подключение популярных интегрированных сред разработки и отладки, что позво- лит применять консольную версию и/или компиляторы других поставщиков. Если в находящейся на компакт-диске версии не содержится реализация этих пла- нов, то, вполне возможно, достаточно скоро вы сможете их получить из сети Интернет (по адресу http://arturius.org/ или через http://www.impeifectcplusplus.com/), вероятно, еще до фактического выхода книги.
Приложение Г Компакт-диск Когда издатель предложил мне вместе с книгой выпустить и компакт-диск, я погру- зился в раздумья. Теперь я смогу раскрыть в полном объеме все подходы, которые я только фрагментарно описал в данной книге. Люди смогут увидеть все великолепие (или его отсутствие) моего программного кода, и здесь нельзя сказать, что «ради крат- кости я не привожу остальную часть класса» или нечто подобное. Однако, подумав немного, я пришел к выводу, что возникла фактически очень благоприятная ситуация. Она выгодна по нескольким причинам и для меня, автора, и для вас, читателя. Компакт-диск позволяет мне включить библиотеки, примеры про- ектов, полный комплект программ тестирования, которые использовались в исследова- ниях для книги, некоторые свободно доступные компиляторы C/C++, проект Arturius (см. приложение В) и несколько опубликованных мною статей, в которых подробно обсуждаются вопросы, лишь слегка рассмотренные в книге. Принимая во внимание то, что компакт-диск должен быть подготовлен через месяц или два после выхода окончательной версии книги, я решил включить в его состав сле- дующее: • мультиплексор компиляторов Arturius - см. приложение В; • компиляторы - если вы в настоящий момент имеете доступ только к одному компи- лятору, вы будете чувствовать себя немного неловко при тестировании Arturius’a. К счастью, несколько поставщиков согласились предоставить свободно распро- страняемые и/или ограниченные по времени версии своих компиляторов, поэтому вы можете получать коллективную оценку своего программного кода. Я также указал ссылки на другие полезные свободно доступные компиляторы, которые вы можете скачать из сети; • библиотеки - многие описанные в книге компоненты взяты из существующих биб- лиотек (в частности, из библиотек Boost и STLSoft), поэтому сюда включены их по- следние версии и также дистрибутивы некоторых других библиотек, с которыми мне приходилось иметь дело (см. приложение А). Я также указал ссылки на другие библиотеки, которые, по моему мнению, достойны вашего внимания; • статьи - издательством «СМР» любезно предоставлена подборка статей, допол- няющая материал данной книги;
694 Приложение Г. Компакт-диск • примеры и программы тестирования - полный комплект программ тестирования и файлов makefile, использованных при проведении исследования для книги. Этот программный код имеет не очень привлекательный вид, но иллюстрирует раз- личные темы данной книги; • инструментальные средства и сценарии - разнообразные системы/средства разра- ботки и сценарии обработки программного кода. Пожалуйста, посмотрите находящийся в корневом каталоге компакт-диска файл index.html, в котором вы найдете окончательный вариант описания содержимого ком- пакт-диска.
Эпилог Вот и все! Наше путешествие окончено, по крайней мере, на данный момент. Если оно оказалось для вас легким, то, я надеюсь, это заслуга моих редакторов и рецензен- тов, которые превратили мою бесконечные и небрежные рассуждения в нечто читае- мое. Если вы считаете это путешествие трудным, то, я надеюсь, по крайней мере, вы получили удовольствие от него. В обоих случаях вас может немного утешить приложение Б, где я исповедуюсь и показываю вам некоторые из моих самых неприят- ных промахов. Я надеюсь, что вы сделаете три вывода по прочтении данной книги: 1. Что C++ - не идеальный, но очень мощный язык. Произвольно выбрав несколько его элементов - переносимые виртуальные таблицы, свойства, диапазоны, «настоящие» объявления typedef, тоннелирование типов - мы можем ясно видеть внутренне ему присущую огромную силу. Несмотря на его дефекты, совершенно ясно, что еще долго мы не сможем исчерпать возможности этого замечательного языка. 2. Что почти для любой проблемы существует решение, обладающее простотой, убедительностью, эффективностью, гибкостью или устойчивостью, либо сочетани- ем каких-то из этих качеств. Задача неидеального практика - выбрать правильное их сочетание. 3. Что вы можете при определенной дисциплине и, используя соответствующие библиотеки и методы, действительно сделать компилятор вашим ординарцем и из- бежать столкновения с большинством дефектов C++. Когда это не удастся сделать, вам просто придется больше читать (книг), больше писать (программного кода) и накапливать свой опыт. Что мы и делаем в таких случаях. И для тех некоторых безумцев, которым этого может показаться мало... Маттью Уилсон вернется с книгой «Расширенный.STL» (Extended STL)
Библиография Естественно, при написании книги, которая занимает столь решительную позицию по различным вопросам, было бы глупо не опереться на плечи титанов. Приводимые здесь книги, статьи и сетевые ресурсы представляют собой частичную, однако репре- зентативную выборку многих авторитетных источников в данной сфере деятельности. Книги Прочти от корки до корки Как я упоминал (насколько я помню) иногда в книге, я больше делатель, чем чита- тель. Я также больше стремлюсь мыслить образами, чем словами; можно сказать, кинетика для меня значит больше, чем визуальное или слуховое восприятие. В школе я был ребенком, который вызывал раздражение; я хорошо сдавал экзамены, но всегда казался тем классным тупицей, который постоянно задает учителю вопросы «а что, если?» на каждом уроке. Одни слова мало что оставляют в моей голове. Поэтому я считаю, что книги данного раздела - а здесь указаны только те книги, которые мне удалось прочитать полностью, - были написаны авторами, которые не только знают очень много по своей тематике, но также очень хорошо излагают свои мысли. И не случайно, что большинство из них являются одними из самых знаменитых мыслителей в своей области. [Asim 1972] Isaac Asimov, The Gods Themselves, Granada. Эта книга мастера научной фантастики представляет собой первоклассный, стимулирующий мышление образец научной фантастики; приятно то, что автора не смутили его собственные слегка абсурдные концепции. Поистине бездонные «колодцы времени»! В книге также дается убедительный анализ причинно-следственных связей при путешествиях во вре- мени. [Вгоо 1995] Frederick Р. Brooks, The Mythical Man-Month, Addison-Wesley. Для раз- нообразия не плохо прочитать того, кто говорит по делу. (Я просто люблю реалистов.) [Dewh 2003] Steve Dewhurst, C++ Gotchas, Addison-Wesley. Я был рецензентом этой отличной книги издательства «Addison-Wesley» во время начальных этапов планирования книги «C++: практический подход к решению проблей программирования». К счастью, мы со Стивом имеем различные точки зрения по многим вещам, чтобы можно было меня заподозрить в заимствовании каких-нибудь его идей. Фактически, по нескольким вопросам я выступил с противоположных пози- ций. Несомненно, истина где-то посередине. [Gias 2003] Robert L. Glass, Facts and Fallacies of Software Engineering, Addison- Wesley. Эта была первая прочитанная мною книга, которая говорила о том, что все нормально, и я не сумасшедший. Автор вскрывает ту бессмыслицу, которая существу-
Библиография 697 ет в нашей сфере деятельности и против которой мы часто ничего не предпринимаем, инстинктивно избегая ее1. Я бы советовал любому, кто собирается получить какую- либо должность в разработке программного обеспечения, спросить своих потенциаль- ных менеджеров, читали ли они эту книгу, и принимать в соответствии с их ответом свое решение. [Hunt 2000] Andrew Hunt and David Thomas, The Pragmatic Programmer, Addison- Wesley. Много здравого смысла. (Я также люблю прагматиков.) [Kern 1999] Kemighan and Pike, The Practice of Programming, Addison-Wesley. Руко- водство, эффективное и без претензий, по программированию, эффективному и без претензий - блестящая книга! [Krug 1995] David J. Kruglinski, Inside Visual C++, Volume 4, Microsoft Press. Кажет- ся странным включение этой книги в данный список, но она просто хорошо написана и помогла мне совладать с такой громадиной, как MFC, в те дни, когда еще считалось полезным ее иметь в своем резюме. [Lind 1994] Peter van der Linden, Deep CSecrets, Prentice Hall. Отличная и к тому же занимательная книга. Эта книга - единственная, в которой мне действительно удалось найти объяснение тому, что происходит с указателями и массивами. Высказываемые в ней точки зрения относительно C++ безнадежно устарели (если вообще они были когда-то правомерны), но эта книга по С и в этом качестве она лучшая из всех мною прочитанных. Она также очень занимательна и содержит ряд очень забавных отступ- лений, отдающих должное пиву «Theakstone’s Old Peculiar». Несмотря на то, что она вышла достаточно давно, я бы рекомендовал вам ее приобрести. [Lipp 1996] Stanley Lippman, Inside the C++ Object Model. Именно по этой книге следует изучать множественное наследование. И сделав это, впредь обязательно обхо- дить его стороной. [Меуе 1996] Scott Meyers, More Effective C++, Addison-Wesley; [Meye 1998] Скотт Майерс, Effective C++, 2nd edition, Addison-Wesley. Эти книги обязательно должны быть прочитаны профессиональными программистами C++, и это действительно так. В них могут быть пропущены или бегло рассмотрены «неудобные» вопросы C++ (например, потоки вычислений, динамические библиотеки и т. д.), но в них раскрыва- ется много других фундаментальных вопросов, и поэтому первое простительно. Вы не получите работу в большинстве команд разработчиков, если не прочитаете эти две книги, и по понятным причинам. [Stro 1994] Bjame Stroustrup, The Design and Evolution of C++, Addison-Wesley. Я купил эту книгу в одном из походов по магазинам с моей женой, и к моменту возвра- щения домой третья ее часть уже была прочитана. При выборе покупок жене не прихо- дилось выслушивать мое мнение, поэтому все закончилось благополучно. Если у вас когда-либо возникали вопросы, почему в C++ сделано именно так-то, в этой книге, 1 Разве вам не хотелось бы отправиться в путешествие по железной дороге в одном вагоне с Робертом Л Глассом (Robert L. Glass), и Фрэдом П. Бруксом (Fred Р. Brooks)?
698 Библиография вероятно, вы найдете ответы. Надеюсь, автор планирует выпустить скоро второе изда- ние, и тогда мы сможем узнать, что случилось с vector<bool>! [Sutt 2000] Herb Sutter, Exceptional C++, Addison-Wesley. Это моя самая любимая книга по C++! Она объясняет очень запутанные вещи простыми словами и имеет дей- ствительно небольшой объем; именно такими должны быть все хорошие книги. Если бы я был столь же лаконичен, как ее автор, книга «C++; практический подход к решению проблем программирования», вероятно, имела бы формат карманного спра- вочника. Прояви великодушие - помоги себе сам Книги этой категории, возможно, не были мною прочитаны от корки до корки, но была прочитана их большая часть, и это все-таки хорошие книги как с технической точки зрения, так и с точки зрения способности авторов понятно излагать свои концепции. [Aust 1999] Matthew Ausrem, Generic Programming and the STL, Addison-Wesley. Эта книга была одной из первых, посвященных STL, и она по-прежнему остается одной из лучших. Она объясняет все на простом и доступном языке; ее приятно читать. Она могла бы быть более солидной, и, вероятно, ее необходимо обновить в свете изменений, про- изошедших за последние пять лет, но свою цену она по-прежнему оправдывает. [Beck 2000] Kent Beck, Extreme Programming Explained. Данная книга содержит много интересных идей. В прошлом я несколько раз участвовал в парном програм- мировании, и при правильном выборе партнера этот подход может оказаться чрез- вычайно продуктивным. Я не убежден в плодотворности некоторых других аспектов экстремального программирования, но ее все-таки можно прочесть при наличии побу- дительных причин. [Box 1998] Don Box, Essential COM, Addison-Wesley. Эта книга является полным руководством по объектам СОМ во всем их «голом» великолепии (без всех этих сби- вающих с толку фреймворков-оболочек). [Вгос 1995] Kraig Brockschmidt, Inside OLE, 2nd edition, Microsoft Press. Вероятно, никакая другая книга не требовала от меня большего напряжения. Программируя только два года на C++, я «проглотил этого зверя» почти целиком и едва восстановился после этого. Полезная книга, хотя ее и невероятно трудно читать. [Bute 1997] David R. Butenhof, Programming with POSIX Threads, Addison-Wesley. Прекрасный источник информации no PTHREADS и, что удивительно, читается доста- точно легко. [Gamm 1995] Gamma, Helm, Johnson, Vlissides, Design Patterns, Addison-Wesley. Если вы когда-нибудь захотите сурово обойтись с практикантом, дайте ему эту книгу и скажите, что в конце недели вы проверите, как она усвоена. Несмотря на то, что мне самому она далась не просто, могу искренне порекомендовать эту книгу вам. [Gerb 20021 Richard Gerber, The Software Optimization Cookbook, Intel Press. Если вас когда-нибудь интересовали непонятные и удивительные вещи, происходящие
Библиография 699 внутри процессоров, кэш-памяти, каналов и т. д., эта книга дает великолепную возмож- ность проникнуть внутрь архитекторы Intel. [Lako 1996] John Lakos, Large Scale C++ Software Design, Addison Wesley. Несмотря на то, что эта книга была написана еще до того, как динамическим библио- текам, шаблонам и поточной организации вычислений стали придавать столь большое значение, какое они имеют в настоящее время, книга содержит много по-прежнему ценной информации относительно физического соединения компонентов, а также про- ектирования и изготовления больших систем. [Larm 2000] Larman and Guthrie, Java 2 Performance and Idiom Guide, Prentice Hall. Чем бы вы ни думали по поводу языка или технологии Java - эта хорошая книга содержит много информации при небольшом ее размере. Если бы то же самое можно было сказать о... (фраза обрывается). (Lian 1999] Sheng Liang, The Java Native Interface, Addison-Wesley. Отличная маленькая книжка, описывающая механизм соединения Java с С и, следовательно, с внешним миром. Однако в ней мало той Java, которая мне нравится, хотя только «мама» может любить ее синтаксис и показатели производительности. [Меуе 1997] Bertrand Meyer, Object-Oriented Software Construction, Prentice Hall. В книге автор впервые предлагает методику контрактного проектирования (DbC) и многое другое. Один из моих друзей может неодобрительно к ней относиться, но я бы все же рекомендовал ее вам. Мне трудно представить, что эту книгу так же, как и «The C++ Programming Language» (Язык программирования C++), можно «пере- варить» полностью1, но она является великолепным справочником. [Raym 2003] Eric Raymod, The Art of UNIX Programming, Addison-Wesley. С помо- щью тринадцати первопроходцев UNIX вы совершите грандиозное «турне» по фило- софским и практическим вопросам программирования в системе UNIX. Каждый должен прочитать эту книгу, независимо от того, к какой операционной системе он привык. [Rect 1999] Brent Rector and Chris Sells, A TL Internals, Addison-Wesley. Полное руко- водство no ATL. Несомненно, в ней не удается реально отнестись к этой технологии, имеющей многочисленные изъяны, но она хорошо показывает полезные идеи ATL, которых также много. [Rich 1997] Jeffrey Richter, Advanced Windows, Microsoft Press. Отличный источник информации по программированию в системе Win32. Очевидна нехватка информации относительно системы безопасности, но зато рассматриваются почти все другие облас- ти базовой системы Win32, которые могут вам понадобиться. [Stev 1993] W. Richard Stevens, Advanced Programming in the UNIX Environment, Addison-Wesley. Этот отличный справочник содержит массу полезной информации, которая излагается в очень доступной форме. 1 Если вы это сделаете, дайте мне знать, и я сниму перед вами шляпу (но не при спуске с Французских Альп во время отпуска, который я собираюсь оплатить моим первым в году гонораром...).
700 Библиография [Stev 1998] W. Richard Stevens, UNIX Network Programming, Volume 1, 2nd edition, Addison-Wesley. Еще одна книга эксперта по системе UNIX, которую необходимо иметь у себя; она также хорошо воспринимается и столь же полезна, как и предыдущая книга. (Stro 1997] Bjame Stroustrup, The C++ Programming Language, Special Edition, Add- ison-Wesley. Как я упоминал в гл. 25, в этой книге рассмотрено огромное количество важных вопросов, охватывающих обширные области языка. Купите ее, держите побли- зости, пусть она регулярно вдохновляет вас на новые проекты. [Sutt 2002] Herb Sutter, More Exceptional C++, Addison-Wesley. Может быть, она читается не с таким же удовольствием, как ее предшественница, но, тем не менее, ее обязательно надо иметь и прочитать. Используй время от времени Эти книги стимулируют мышление и/или имеют ясный и доступный справочный материал. Если вы даже согласитесь со мной, что они воспринимаются не совсем про- сто, я считаю, что они все же вполне оправдывают свою стоимость и содержат бесцен- ную информацию для того, кто собирается стать неидеальным практикам. Убедите вашего менеджера купить их; вы можете сказать, что я их рекомендовал. |А1ех 2001] Andrei Alexandrescu, Modern C++ Design, Addison-Wesley. [Bulk 1999] Dov Bulka and David Mayhew, Efficient C++, Addison-Wesley. [Eddo 1998] Guy Eddon and Henry Eddon, Inside Distributed COM, Microsoft Press. [Hans 1997] David R. Hanson, C Interfaces and Implementations, Addison-Wesley. [Knut 1997] Donald E. Knuth, The Art of Computer Programming, Volume I: Funda- mental Algorithms, Addison-Wesley. [Lang 2000] Angelika Langer and Klaus Kreft, Standard C++ lOStreams and Locales, Addison-Wesley. [Lipp 1998] Stanley Lippman (ed.), C++ Gems, Cambridge University Press. [Joss 1999] Nicolai Josuttis, The C++ Standard Library, Addison-Wesley. [Kern 1988] Brian Kemighan and Dennis Ritchie, The C Programming Language, Pren- tice Hall. [Meye 2001] Scott Meyers, Effective STL, Addison-Wesley. [Muss 2001] David R. Musser, Gillmer J. Derge, Atul Saini, STL Tutorial and Reference Guide, 2nd edition, Addison-Wesley. [Rich 2002] Jeffrey Richter, Applied Microsoft .NET Framework Programming, Microsoft Press. [Robb 2003] John Robbins, Debugging Applications for .NET and Windows, Microsoft Press.
Библиография 701 [Rubi 2001] Allessandro Rubini and Jonathon Corbet, Linux Device Drivers, 2nd edition, O’Reilly. [Schm 2000] Douglas Schmidt, Michael Stal, Hans Rohnert and Frank Buschmann, Pat- tern-Oriented Software Architecture, Volume 2, Wiley. [Sedg 1998a] Robert Sedgewick, Algorithms in C, Parts 1-4, 3rd edition, Addison-Wesley. [Sedg 1998b] Robert Sedgewick, Algorithms in C++, Parts 1-4, 3rd edition, Addison- Wesley. [Sedg 2002] Robert Sedgewick, Algorithms in C++, Part 5, 3rd edition, Addison-Wesley. [Vand 2003] Daveed Vandevoorde and Nicolai Josuttis, C++ Templates: The Compre- hensive Guide, Addison-Wesley. Статьи Статьи из журналов Эти статьи взяты из журналов, издаваемых в печатной форме, а некоторые из них можно найти в сети Интернет по указанным адресам URL. [Alli 1993] Chuck Allison, Bit Handling in C++, Part 1, C/C++ Users Journal, Volume 11 Number 12, December 1993; http://www.freshsources.com/19930352.HTM. [Alli 1994] Chuck Allison, Bit Handling in C++, Part 2, C/C++ Users Journal, Volume 12 Number 5, May 1994; http://www.fteshsources.com/19930352.HTM. [Brig 2002] Walter Bright, The D Programming Language, Dr Dobb’s Journal, #332, February 2002; http://www.ddj.com/documents/s=2287/ddj0202c/. [Meye 2001b] Randy Meyers, The New C: Why Variable Length Arrays?, C/C++ Users Journal, Volume 19 Number 10, October 2001. [Henn 2002] Kevlin Henney, String Things Along, Application Development Advisor, Volume 6 Number 6, July/August 2002; http://www.two-sdg.demon.co.uk/curbralan/papers/ StringingThingsAlong.pdf. [Jagg 1999] Jon Jagger, Compile Time Assertions in C, CVu, Volume 11 Number 3, March 1999; http://www.jaggersoft.com/pubs/CVul 1 _3.html. [Lang 2002] Angelika Langer and Klaus Kreft, Secrets of Equals, Java Solution, C/C++ Users Journal Supplement, April 2002. [Same 2003] Miro Samek, An Exception or a Bug?, C/C++ Users Journal, Volume 21 Number 8, August 2003; http://www.quantum-leaps.com/writings.cuj/samek0308.pdf. [Saks 1996] Dan Saks, C+ + Theory and Practice: Mixing const with Type Names, C/C++ Users Journal, Volume 14 Number 12, December 1996. [Saks 1999] Dan Saks, Programming Pointers: const Tvs. T const, Embedded Systems Programming, Volume 12 Number 2, February 1999.
702 Библиография [Меуе 2000] Scott Meyers, How Non-member Functions Improve Encapsulation, C/C++ Users Journal, Volume 18 Number 2, February 2000; http://www.cuj.eom/documents/s=8042/ cuj0002meyers/. [Wils 2001] Matthew Wilson, Generating Out-of-Memory Exceptions, Windows Devel- oper’s Journal, Volume 12 Number 5, May 2001. [Wils 2003a] Matthew Wilson, Win32 Performance Measurement Options, Windows Developer Network, Volume 2 Number 5, May 2003; http://www.windevnet.com/documents/ win0305a/. [Wils 2003b] Matthew Wilson, Open-source Flexibility via Namespace Aliasing, C/C++ Users Journal, Volume 21 Number 7, July 2003. [Wils 2003c] Matthew Wilson, Generalized String Manipulation: Access Shims and Type Tunnelling, C/C++ Users Journal, Volume 21 Number 8, July 2004; http://www.cuj.com/doc- uments/ s=8681/cuj0308wilson/. [Wils 2003g] Matthew Wilson, Inserter Function Objects for Windows Controls, Win- dows Developer Network, Volume 2 Number 11, November 2003; http://www.windev- net.com/ wdn/issues/. [Wils 2004a] Matthew Wilson, C/C++ Compiler Optimization, Dr Dobb’s Journal, #360, May 2004. Онлайновые статьи и другие материалы Приводимые ниже статьи и спецификации доступны в сети Интернет по указанным адресам URL. [Como-POD] http://www.comeaucomputing.com/techtalk/Hpod. [Como-SOC] http://www.comeaucomputing.com/faqs/genfaq.htmlHbetterCgeneral. [Como-SOP] http://www.comeaucomputing.com/faqs/genfaq.htmlHwhatcando. [Itan-ABI] Itanium C++ ABI; http://www.codesourcery.com/cxx-abi. [Kaha 1998] Kahan and Darcy, How Java’s Floating-point Hurts Everybody Every- where, http://http. cs. berkeley. edu/~wkahan/JA VA hurt.pdf. [Otto 2004] Thorsten Ottosen, Proposal to Add Design by Contract to C++, 2004; http:/ /anubis. dkuug.dk/jtc l/sc22/wg21/docs/papers/2004/n 1613.pdf [Schm 1997] Schmidt, Harrison and Pryce, Thread Specific Storage: An Object Behavioral Pattern for Accessing per-Thread State Efficiently, 1997, http://www.cs.wustl.edu/~schmidt/ PDF/TSS-pattem.pdf. [Stro 2003] Bill Verniers, The C++ Style Sweet Spot: A Conversation with Bjarne Strous- trup, Part 1, Artima Developer; http://www.artima.com/intv/goldilocks.html. [Stro-Web] Bjarne Stroustrup’s FAQ; http://www.research.att.com/~bs/bsJaq.htmlHreally- say-that.
Библиография 703 [Torj 2003] John Togo and Andrei Alexandrescu, Enhancing Assertions, C/C++ Users Journal Experts Forum, August 2003; http://www.cuj.com/documents/s=8464/cujcexpO308 alexandr/. [WB-Email] Walter Bright, private email communication, 2003. (Wils 2003d] Matthew Wilson, Flexible C++ #7; Efficient Integer To String Conver- sions, Part 2, C/C++ Users Journal Experts Forum, September 2003; http://www.cuj.com/ documents/ s=8840/cujexp0309wilson/. [Wils 2003e] Matthew Wilson, C# Performance: Comparison with C, C++, D and Java, Parts 1 & 2, Windows Developer Network, Special Online Supplement, Fall 2003; http:// www.windevnet.com/wdn/webextra/2003/0313/. [Wils 2003f] Matthew Wilson, Flexible C++ #2: Efficient Integer To String Conversions, Part 3, C/C++ Users Journal Experts Forum, November 2003; http://www.cuj.com/docu- ments/ s-8906/cujexp031Iwilson/. [Wils 2004b] Matthew Wilson, Flexible C++ #3: Efficient Integer To String Conver- sions, Part 4, C/C++ Users Journal Experts Forum, January 2004; http://www.cuj.com/docu- ments/ s=8943/cujexp0312wilson/.
Предметный указатель А ABI. См. двоичный интерфейс приложения Alpha компании «Dec», 59 APIs. См. программные интерфейсы Arturius, мультиплексор компиляторов, 624 assert(), макрос, 59-62 ATL, 480, 523. См. также библиотека активных шаблонов компании Microsoft atomic, ключевое слово, 208,209 auto_bufter, 79, 95 В bool, 61, 260, 267-268, 274-277, 311, 312, 313, 355-356, 400, 493, 555 Boost, 193, 265, 604,616-617 библиотеки, 409,488, 523, 603 классы массивов, 614 компоненты неделимых операций, 194 Borland 5.6,41, 251, 339 Borland, 41,45, 145, 160,167,223, 246, 251, 253, 320, 322, 325,403^104, 500-501, 513, 513-515, 587, 588-590, 626, 646-647, 659 Bridge (мост), шаблон, 159 С char, 260-261, 261-262, 262-263, 267-268, 268-269, 269-270,287, 303-304, 310, 311, 317-318, 319, 334-335, 364-365, 377,400,413, 443-444, 445, 560, 628, 629-630 CodeWarrior, 140,223, 246,255,256 273-274, 275, 320, 322, 325, 339, 340 350,400,403-404,427,486, 513, 513-515,536, 593, 626, 659 COM, 164, 177, 332, 414,415-416, 417,424,459, 523-524. См. также модель многокомпонентных объектов IDL, 156 заголовки, 331 интерфейсы, 331-332,470-471 компоненты, 162,417,479 мета-данные, 523 программирование, 162 совместимость, 630 указатель интерфейса, 415-416 функции программного интерфейса, 523, 524 Comeau, 246, 246, 273-274, 325, 339, 342, 399,427, 477,486, 583, 626, 659 компилятор, 680 const, 67-68, 74-75,303-304,306-307, 346-347, 356, 382, 398, 398-399, 486-487, 501-502, 549, 644 D DEBUG, 55, 61-62 DbC, 37,48-49, 52, 52-53, 54-55. См. также проектирование по соглашению default, блок, 61 delete, 69, 71, 187-188,293,293 объектно-ориентированное, 453 оператор, 174 по соглашению, 35, 37
Предметный указатель 705 проектирование этап, 37-38 DeviceCloser, 87-88 Digital Mars, 41,43,45,160,223,225, 246,269, 272, 274, 320, 325, 339, 340, 399,403-404,427, 513, 513, 583, 589-590, 626, 659 dimensionof(), 279,284-286,292,462, 526, 562-563, 573, 612-613, 621 DRY-принцип, 278, 298 E ЕВО, 251-252, 479, 479, 587. См. также оптимизация пустой базы EDO, 252-253, 461. См. также оптимизация пустых производных классов explicit, 70, 70-71, 121, 122,197, 245-246, 287-288, 296, 303-304, 380, 390,400,418,422,430,441,443, 454,466,481-487,489, 546, 554, 583, 584-585, 586, 592, 650, 653,659 explicit_cast, 111, 342-346,401-407, 428, 552 F Fortran, 603-604 free(), 36 friend, 72, 338-342,453-454, 509, 650, 653, 659 G GCC, 77,143, 160, 164-165, 223,225, 246, 270, 274, 320, 322, 325, 339, 340, 350,403-404,427,486, 513, 583, 589-590,593, 626, 659 goto, оператор, 93 grep, 60,209,411,488, 528 H Has base, 39 I IDDE (integrated development & debug environment - интегрированная среда разработки и отладки), 62,169, 244 IDE (integrated development environment - интегрированная среда разработки), 120 inline, ключевое слово, 243-244, 346-347, 347-348 int, 59, 61,63,64-65,264-266, 266-267,268,277, 280, 303, 304, 309, 314, 340,410,494, 546-547 Intel, 45, 63, 139-140, 143, 160, 188, 201, 223, 254, 263,273-274, 320, 322, 325, 339,403-404,427,486, 513, 513, 514-516, 575, 589, 626, 646, 659 архитектура, 370 ассемблер, 201 процессор, 190-191, 191-192, 199-200 interface, 331-332, 332-333,416-417 IO Completion Port (порт завершения ввода-вывода), 453 lObject, 166-167, 169-170, 171-172 lOStreams, 493, 494, 559 IP-адрес, 453 is_pointer_type, 47 is_valid(), 52-53, 53 Itanium проблемы онтологические, 34 проект, 140 стандартизация, 143 IsDerivedFrom, 39 ISE Eiffel 4.5, 56 J Java, 503, 576 45 - 225
706 Предметный указатель L Linux, 143,176,179,179,184,192, 203, 436, 582 программная база ядра, 357 long, 63 М malloc(), 36 MFC, 366, 439, 516. См. также Microsoft’s Foundation Classes Microsoft, компания, 139,423 расширения, 564 Microsoft’s Foundation Classes (MFC), 59 MIL. См. список инициализации членов must_be_pod, 42,42-43,47 must_be_pod_or_void, 43,44 must_be_same_size(), 44 must_have_base, 39 N .NET, 576 NDEBUG, 55-56, 62 new, 66-67, 69, 293, 293 NRVO, 247, 504. См. также оптимизация именованного возвращаемого значения NULL, 51, 56-57, 213, 258, 355, 416-417, 417, 427-428,465, 523, 580, 582, 585, 587, 595 указатель, 54 О Open Watcom, 325 Р POD-значения, открытые типы, 103 POD-типы, 22, 79, 81, 81-82, 83, 96, 153, 153, 220,406-407,412, 460,460-461, 523, 583, 584, 586, 589-590, 606 PTHREADS, 210-211,211-212, 217, 217 R RAII, 34-35, 35, 50, 53, 81, 86, 88, 119, 120, 127, 129, 157, 211, 213, 214, 215,460,463, 490, 522, 523, 576, 581 RangeLib, проект, 558 realloc(), 36 ResourceManager, 90-91, 91-92 RRID, 34, 92-96,463 См. также освобождение ресурса при уничтожении типы, 81-82 RTTI, 177, 188. См. также идентификации типов во время выполнения rvm, 51 RVO, 245, 246, 246-247. См. также оптимизация возвращаемых значений S sequence_container_veneer, 86, 90, 91-92 Serializer, 270, 383-384 Service, 81-82, 82-83, 83 short, 64 Solaris, 144, 145, 176 static, 76-77, 225-226 ключевое слово, 227 метод вспомогательной функции, 77 функция, 39 STL, 538, 586-587, 593, 619, 636, 636 STL, концепции итераторов, 107, 283, 621
Предметный указатель 70 STL, концепция последовательности, 608 STL, концепция распределителя памяти, 586 STLSoft, 448,457-458, 512-513, 513-515,516, 568 STLSoft, библиотеки, 60, 72,239, 265,311, 350,438,457-458, 539 STLSoft, заголовочные файлы, 308 STL-алгоритмы, 632 STL-вектор, 593 STL-классы, 596-597 STL-контейнеры, 482, 521-522, 534, 535-536, 537, 586 string, 119, 344 String, 70, 76, 107, 245-246, 247, 303-304, 320, 334-335, 505-506 struct, 331, 333-334, 368, 678-679 switch, оператор, 61 switch-форма, 65 synchronized, ключевое слово, 205 Synesis, Atomic_API, 201-202, 203 библиотечные функции, 202 «Synesis Software», компания, 568 Synesis, база программного кода, 385 Synesis, библиотеки Win32, 216 Synesis, библиотеки, 60, 134, 134, 188, 276, 277, 371,473, 568 Synesis, класс BufferStore, 157 Т Tisnotsubscriptable, 40,41 TLS, 210, 566. См. также специальная память потока ТМР, 47, 47-48. См. также методы шаблонного мета-программирования TSD, 210. См. также специальные данные потока TSS, 209,210,211-212,215,217-218 См. также специальная память потока typename, 349-350, 350 и UInteger64, 107, 108-109 UNIX, 141, 142, 145, 176, 184, 192, 217, 332,444, 454 библиотеки потоков стандарта POSIX, 196 UNIXSTL, 457 V VectorC, 659 verify(), 59-60 VERIFY(), 59-60 Visual C++ компании Microsoft, 145 Visual C++, 41, 140, 188, 223,246, 254,255, 256, 263-264, 273-274,275, 320, 322, 339, 340, 350,403-404,427, 486, 500, 513, 513, 514-516, 524, 575, 580, 589, 626, 646-647, 659 библиотека С этапа выполнения, 63,187-188 библиотеки, 262-263, 589 совместимые компиляторы и компоновщики, 151 VMS, 176,332 void, 44,44—45,281-282 volatile, ключевое слово, 236,236, 255-256,256-257 W Watcom, 43,273-274, 322, 339, 340, 403-404,427, 659 Win32 + Intel, 62 платформа х86, 139 Win32, 164-165, 176-177, 188, 192, 195, 196, 204-205,210,211-212,216, 217, 238, 240, 355, 411, 448, 453-454, 528, 530
708 Предметный указатель Win32, DLL, 185 Win32, Interlocked-*, 202 системные библиотечные функции, 203 Win32, LoadLibrary(), 179 Win32, библиотека С этапа выполнения компании Microsoft, 582 Win32, инфраструктура многопоточной обработки, 210 Win32, компиляторы, 141, 143-144, 145, 149, 164, 200, 243, 564 Win32, конфигурация по умолчанию, 339 Win32, машина, 200 Win32, объекты синхронизации, 195 Win32, операционные системы, 141, 201,579 Win32, платформы, 125, 143, 144, 149-150, 564, 582 Win32, поставщики компиляторов, 149 Win32, программный интерфейс системы безопасности, 442 Win32, программный интерфейс, 62, 528 Win32, системные библиотеки, 139 Win32, системы, 144 Win32, системы, 203 Win32, соглашения, 145 Win32, функция перебора с обратным вызовом, 633 Win32, элемент управления «список», 439 Windows (16-битовый), 370 Windows, 370 платформы, 142-143 программный интерфейс, 411 разработка в среде, 139 WinSTL, 202, 457 встраиваемые функции, 202, 203 неделимые функции, 201-202 проект, 568 Z zlib, 265,595, 597 А абстрактные типы данных, 349 абстракция режима построения программы, независящей от компилятора, 62 агрегатные типы, 22, 101 «ад DLL», 145, 149, 184-185,212, 216,217 Азимов, Айзек (Asimov, Isaac), 312 «активный тупик», 190 Александреску, Андрей (Alexandrescu, Andrei), 65, 269 Б библиотека активных шаблонов компании Microsoft (Active Template Library - ATL), 193,470, 477, 481, 521. См. также ATL библиотека С этапа выполнения компании Microsoft, 132 библиотеки этапа выполнения, 58- 59, 140, 188, 216, 231, 335, 336, 417, 580, 594 библиотеки, 143-144, 144-145, 148, 149-150, 153, 153-154, 157, 173, 175, 193, 204-205, 205, 209, 217, 231-232, 233-234, 244-245, 265, 283, 294, 442, 452, 453,457, 516, 519, 568, 572, 594-595, 600. См. также динамические библиотеки C++, 632 fsearch, 376 lOStreams, 230 Tss, 216-217, 217-218, 232, 233-234, 234
Предметный указатель 709 конструкции стандартных биб- лиотек, 450 не сохраняющие состояние, 232 неделимые целочисленные операции, 194 независимых поставщиков, 374, 407,517 общего назначения, 428 распределители памяти, 252 с открытым исходным кодом, 428,430 сериализации, 577 системных функций, 204 сохраняющие состояние, 232 стандартные контейнеры, 374,434,435, 489, 520, 535-536, 603 стандартные, 294, 327,430, 439,442,450, 452,466,467, 508,512,516, 523, 534, 534-535, 575, 577, 592, 612 строки, 303-304, 514-516 функция расширения, 575 библиотечные вызовы, 217 библиотечные классы, 238 библиотечные функции, 141,621 библиотечный программный код, 309, 350 битовые поля, 65 блокировка шины, 200 блокировка, 201-202 блокировки монопольного доступа, 126 блочные тесты, 385 Брайт, Уолтер (Bright, Walter), 272 булев оператор неявного преобразования, 420 булев оператор, 553 булев результат, 631 булева логика, 312 булева природа условного оператора, 357-358 булева проверка, 500, 554 булевы выражения, вычисление, 555 булевы значения, 258, 275-276 булевы преобразования, 61,275 булевы псевдотипы, 312-313 булевы типы, 276,277,495 булевы условные (под)выражения, 57, 59,313, 355-357 буферы, 76, 186, 364, 562, 562-563, 568, 570-571, 571-572, 578-579, 580-581, 584-585, 586-587, 587-588,591-592 автоматические переменного размера, 583-591 массива, 570 символов, 561-562 совместно используемые, 158 содержимое, 76 указатель, 76, 115 быстрая, неагрессивная конкатенация, 503-519 быстрые вычисления, 492 В ван дер Линден, Питер (van der Linden, Peter), 287 взаимные блокировки, 189-190 взаимодействие компонент, 137 виртуальное наследование, 146 виртуальные таблицы vtable, 147, 162-171,182,235, 333-334,478, 479, 594 в программном коде, 172-173 методика переносимых, 175 полиморфизме наследования на базе, 451 возвращаемое значение, 444, 455, 511,536-537
710 Предметный указатель возвращаемые значения, 55,171, 275-276 встраивание, 243-244 встроенные типы, 193 встроенный, 244-245, 268,424 ассемблер, 63 программный код, 182, 244-245 выделение лексем, 209 выравнивание при упаковке структур, 142 выражения этапа выполнения, 63 выражения, 56-57, 57,63-64,284, 305, 313, 327,444,498, 504, 532, 553 зеркальные, 305 константные, 326-327 не булевы, 554 сравнений, 101-102 условные, 61 выходные параметры, 53, 55 Г генерация программного кода, 54, 391-392 Гершник, Юджин (Gershnik, Eugene), 99 гибкость этапа выполнения, 478 глобальное пространство имен препроцессора, 308 глобальные объекты, 133,134-135, 222-223 глобальные объекты, пространства имен и статические классы, 135 д двоичный библиотека, 138-139, 140 имя, 148, 159 компоненты, 136-137, 142 модули, 175 поставка только двоичных модулей, 138 стандарт, 136-137 уровень совместимости, 138 форма, 139 функция, 153-154 двоичный интерфейс приложения (ABI), 162, 163, 173, 175, 176-177, 184,188 деструкторы, 50-51, 53, 66, 70, 71, 72, 87, 88, 89-90, 90-91, 92-93, 94, 96, 114, 119, 123, 134, 168, 174, 174-175,215,290, 323, 332, 333, 348, 353, 354,461, 463, 475, 586, 606, 610 диапазон, задаваемый итераторами, 439 диапазоны, 634-643 Димов, Питер (Dimov, Peter), 497, 520, 527 динамическая инициализация, 220 динамическая компоновка, 144-145, 150-151,178, 182,184-185, 242 динамическая/свободная память, 66 динамические библиотеки, 149-150, 163, 211-212, 213, 231, 242, 321, 324, 328 независимые от компилятора, 152 директивы pragma, 160-161 для упаковки, 167 документация, 186 документация, 36 Дьюхерст, Стив (Dewhurst, Steve), 75, 77, 273, 304 Е единицы компоновки, 154, 171, 174, 181-182, 183, 184-185, 186-187, 187-188, 188, 192, 219, 318-319, 324, 328, 347-348,417
Предметный указатель 711 3 заголовки, 134-135,156,239, 307-308,424,450-451,464,516 заголовочные файлы, 72, 111-112,139, 155,158,172, 381,392,411 класса, 230 количественных ограничений в системе Synesis Software, 316 корневые, 134 заголовочные файлы библиотек, 358 «закрытость», 76 запись смещение[указатель], 41-42 запись указатель[смещение], 41-42 затраты этапа выполнения, 285,323 затраты, 73 инициализации, 96 захват ресурса при инициализация, 34, 35,50, 80, 92,92,119. См. также RAII защита копирующего присваивания, 67-68 значение по умолчанию, 62 опций генерации кода, 143 значения lvalue, 358,601 значения rvalue, 358 значения, 119, 198,210-211, 214, 215,220,239-240,289-290, 319, 321, 347,483, 545, 559-560, 579, 615,617, 655-656, 664-665 в слотах, 214,215,217 возврат, 50-51,51-52,245,417. См. также возвращаемые значения динамические, 220-221 изменение, 123 константные, 44, 285, 326-327 на этапе компиляции, 78-79 ненулевые, 202 нулевые, 87,428 типы, 34, 68-69, 72-73,97-98, 98-102,104-106, 106-108, 116,272-273, 330, 344-345, 443-444,445, 550-551, 654 частоты, 239-240 И идентификаторы (IID), 414,423 идентификаторы интерфейсов 328. См. также интерфейсы, идентификаторы константа, 415 идентификация типов во время выполнения (RTTI - run time type information), 146-147,174. См. также RTTI изменяемость, 95 имена констант, 48 переменных, 48 инварианты класса, 96. См. также инварианты класса инварианты, 49-50, 50, 55, 60, 96 класса, 49-50, 52-54, 55, 104 нарушения, 53 функции, 53 инициализация, 73, 75, 76, 79, 87, 88, 89,95-96,171,198,219, 220-221, 232,233,235-236,464,484 внешняя, 94 внутренняя, 94 динамическая, 220-221 затраты, 329 константная, 220-221 многократная, 240 нулевая, 219,220,222,237 порядок, 146, 231 синтаксис, 384 статическая, 220-221 флажок, 235, 237
712 Предметный указатель инкапсулированные типы, 101, 103-105,108-112 инкапсуляция данных, 35 инкапсуляция, 80-81, 81, 86,97-98, 272,453 инстанциирование, 139-140 инструментарий синтаксического анализа Java-программ, 391 интегральные типы, 160,270, 325-326, 395, 551-552 Интернет, 426 интерфейсы, 167-168, 169-170, 172-173, 173-174,174-175,332-333, 415-416,418-419,423,424-425,430, 478, 595-596, 599, 645-646 информация этапа выполнения, 140, 141 исполняемые модули, 144,148,149, 163, 178-179,181-182,187,243 исходный код, 138 деревья, 391 исходный файл, 397 К каталог, 128-129 утилита, 375 Керниган (Kemighan) и Пайк (Pike), 418 класс данных и времени, 109 класс, 69-70, 72,73-74,74-75,76-77, 78,93, 95, 96,121, 122,128-129, 147, 154, 163, 164, 244, 275, 293, 323-324, 329-330, 340, 342-343,351-352, 368, 385-386, 391-392, 399,418,418-419, 421-422,429,441,450-451,453-454, 458-459,467,470-471,475,477-478, 478-479,481,493-494, 524-525, 533, 537, 538, 542, 551-552, 566, 572-573, 584, 600, 607-608, 611, 616-617, 624, 626, 656, 656-657, 665-666, 671-672, 674, 677-678 C++, 187 DecrementScope, 121 абстрактный, 172,471, 627 автор, 71 базовый, 70, 249,250-251, 292-293, 337, 385-386, 471, 533 базовый, нулевой длины, 251 вариант, 464 виртуальный базовый, 73 виртуальный, 169 вложенный, 626 внешний, 159 внутренний, 158-159 вспомогательный, 607 выбрасывающий исключение при контроле диапазона дейст- вия ресурсов, 132 инициализатор, 230 интерфейс, 332-333,662 итератор, 72 канала ввода-вывода, 453 класс-оболочка последователь- ности, 444 класс-оболочка, 157, 292,464, 465, 523-524, 524-525, 526 класс-примесь, выделяющий память, 250 контейнера, 89-90 контролирующий состояние каталога, 128-129 методы, 69, 104,180,244, 658-659 многомерного массива, 72, 608 неполиморфный, 162 обрабатывающий, 466 объявление, 328, 329, 352-353 определение, 352-353, 369, 391-392,476, 508, 675 параметризующий, 473 переносимый, 547 полиморфный, 471
Предметный указатель 713 последовательности, 72 проектирование, 111 производный, 168, 180 прокси, 443,443-444,450,487, 572-573 реализация, 78 родительский, 73,418, 472-473, 522 с подсчетом ссылок, 92, 117-118 связь классов, 397 синхронизация, 126,476 составной, 474 строки, 430-431 счетчика производительности, 240 счетчика, 241 тип, 660 управляющий диапазоном действия ресурсов, 95,124, 130-131, 134,214 управляющий диапазоном действия состояний, 128 управляющий жизненным циклом объектов, 89 функтор, 418 шаблонный, 249 экспорт, 157 клиентский программный код, 56,67, 73,88,97,103-104, ИЗ, 114,115-116, 118, 132,139,146,155,157,163, 168, 170,172,173,174-175,185-186,215, 227,244-245,245, 302, 304,309,310, 311, 332, 335, 346, 375, 376, 388,407, 416,430,432,436,448,470,488, 488^89,502, 519, 533, 537, 548, 552, 559, 563, 586-587, 594, 597-598, 650, 651, 670, 675 ключевое слово, связанное с оптимизацией, 243-244 ключевые слова, 67, 70, 72,256 кодировка Unicode, 628 Комо, Грег (Comeau, Greg), 339 компилятор и компоновщик, 66,178, 244 компилятор, 37, 55,61, 62,63,64,67, 68-69, 70, 74, 76, 93, 101-102, ИЗ, 137,138,139,140-142,143-144,145, 146-147,148, 149-150, 153, 156, 160, 161,162,163,164,165, 166, 167-168, 169,170,171,172,174, 175,176-177, 180,182,188,207, 208, 211-212, 220, 222,223-224,243,244, 245, 246-247, 248,250,251-252,253-255, 256-257, 262,263-264, 268, 269-270, 271, 272, 273,274,275-276,281,284, 285,291, 300-301, 304, 305-306, 313, 314, 316, 318, 319,321, 324, 325, 326-327, 328, 335-336, 340, 341, 342, 345, 346, 347, 349, 350,358-359, 365,371, 378, 383, 389-390, 395-396, 399,404,406-407, 408,411,412,419,424-425,426,427, 431-432,437,447,448,479,483-484, 486,488-489,497-498, 500, 504, 510, 512, 514-516, 517, 519, 523, 525, 532, 539, 540-541, 547, 548, 550, 553, 571, 574, 575-577, 579-580, 583, 593,600, 601, 614, 626, 646-647, 652, 654, 657, 658-659,661 C++ компании «Sun», 165 MIDL, 156 Win32, 588 компоновщики, 223-224 опция, 142-143, 680 поставщики, 136,139-140,142, 148,176,270, 274, 313,490, 535 различие, 340 разработчики, 275 сгенерированные классы, 165 специальные возможности, 112 компиляция, 62, 64, 155, 180, 500, 547,611
714 Предметный указатель единица, 134,152,166,167, 221-222,222,224,230, 230-231,234,254, 328-329, 333, 347, 347-348,624 компоновка, 220 компоненты библиотек, 622 компоновка, 146 компоновщики, 222-223,223-224, 225, 347 последовательность компоновки, 231 константы, 57,67,186-187,278,279, 314-315, 321-330,407,407-408 литеральные, 76,407 конструирование преобразований, 343-344 конструирование, 74-75, 92-93, 146, 221,245,247, 306, 343, 353, 353-354,468, 585 конструкторы, 53,66,68,69-70,70-71, 72-73,74,76-77,78,88,93,94,96,119, 123,133-134,168,215,237,243,245, 245-246,247,297, 323,337, 339, 343, 352-353, 354, 365,402,406,418-419, 423,431,441,457,464,475, 518, 522, 546-547, 547-548, 548, 551, 567, 585-586, 586, 609-610, 654, 675 вызовы, 394 глобальных объектов, 231 копирование, 67-68,68-69,94, 171,245-246,290, 307, 522, 548, 652, 653 не по умолчанию, 73,651 объявления и определения, 369 по умолчанию, 68, 171,290, 293, 307, 522, 651 строки, 508 тело, 74 контейнеры, 67, 89-90, 114, 115-116, 275, 283, 435,437^438,444, 600, 670 библиотека, 436-437, 670 последовательности, 283 реализации, 250 стандартной библиотеки, 92, 115 тип, 90-91 контекстуальные typedef, 369, 373, 375-377, 379, 388-389, 392 контролируемые объекты, 51 концептуальные typedef, 369, 371-373, 377-380, 383, 388-389 концепции контейнеров ассоциативных, 436 последовательности, 436 концепция итератора, 374 копирующее присваивание, 69 оператор, 228, 306-307 копирующий конструктор, 69,228,390 Коплиен, Джеймс (Coplien, James), 477 Л Лангер (Lange) и Крефт (Kreft), 98 определение типа значения, 105 локальная память потока (TLS - thread-local storage), 210, 567. См. также TLS м Майерс, Скотт (Meyers, Scott), 227, 304,307, 309, 381 макросы, 46-47, 55, 56,60-61,206, 206,207-208,280,284, 284-285, 302, 308,311, 316-317, 341, 342, 350, 381, 392, 397-398,424, 451, 501, 501, 553, 657,661, 662, 665, 667, 668-669,671. См. также макросы утверждений; assert(),макрос; библиотечный макрос offsetof, 78, 78 формы, 54
Предметный указатель 71! массивы, 41,44,64,181,321,456-457, 461,463,492, 525, 526-527, 532-533, 604-605, 608-609, 648 выражение, 63-64 классы, 533 многомерные, 615 размерность, 64 методы виртуальные, 171-172 вход, 53 вызов, 97 класса. См. класс, методы выход, 53, 53 доступа, 172 не виртуальные, 333-334 освобождения памяти, 174 методы шаблонного мета- программирования, 47,138. См. также ТМР механизм распределения памяти в ограниченной области, 58 механизм реализации виртуальных функций, 139 механизмы вывода сообщений об ошибках, 55 механизмы этапа выполнения, 37, 140 механизмы, 349, 359, 396,415, 438-439,455,472-473,499, 502, 557, 578, 579, 597, 607, 613, 663, 673, 676 виртуальных функций, 146-147 деструктора, 467-468 завершения работы, 228 перехвата всех уведомлений, 217 поиска, 464 преобразования, 575, 575 приведения типов, 552 разрешения типов и наследования, 472 разрешения шаблонов, 475 синхронизации, 189, 198 шаблонных свойств, 368 многобайтовый набор символов (Multibyte Character Set - MCS), 262 многозадачные системы, 189 многомодульные системы, 137 многопоточность, 137, 189, 192-193, 204-206,242, 255 многопроцессорные машины, 199,201 многопроцессорные системы, 191,203 модели, 115-116 вектора, 520, 589-590, 591-592, 592-593 доступа с регламентированным временем жизни, 115 доступа, 115,117 класса счетчика производи- тельности, 457 размещения объектов, 153 регламентированного времени жизни объектов, 114-115, 115-116 с регистрацией доступа, 114 моделируемая динамическая связь, 477 модель многокомпонентных объектов, 331. См. СОМ модули, динамически компонуемые, 175 модули, построенные в рабочем режиме, 60 мьютексы, 189,196-198, 200,203, 204,208,217 классы, 478-479 потока, 479 спин-мьютексы, 237-238, 242, 330
716 Предметный указатель н нарушение, 49 наследование, 175-176,176,251,490, 522, 533, 544 иерархия, 386 методика, 342 отношение, 397 «настоящие» typedef, 264,270,271, 369, 381-382, 382-383 не-NULL, 51,423 указатель, 416 неделимые типы, 193 неделимые целочисленные операции, 198 декремента, 191 инкремента, 191-192 независимость от компилятора, 151, 172, 271 неизбежные потери, 74 некостантные объекты, 73, 73, 382, 550 ссылки, 403-404 ненулевые, 355 затраты, 55, 248, 607 размеры, 250 указатели, 54 непрозрачный тип, 179 нестатические объекты, 77 новый обработчик, 133-134 нулевой символ завершения, 442, 442-443 О область видимости, 360-361 облицовочные классы, 89, 393-394, 469,481,507 обнаружение на этапе выполнения, 410 обобщенный, 349, 366 алгоритмы, 522 механизм преобразования, 407 программирование, 368 программный код, 393,407, 450-451,532-533,610 оболочки, 235, 560 обработка ошибок, 133 объекты, 165-166, 181, 181-182, 183,205-206,223-224, 226-227, 278,482-483 глобальные, 66,146, 221-222,226-227,227, 231,237,242, 323 динамической памяти, 66 инициализатора, 231 менеджер статических ресурсов, 187 модель, 163, 177, 349 объектные файлы, 181 порядок компоновки объектных файлов, 223 применяющие подсчет ссылок, 434 синглетонов, 228 синхронизации, 124, 126, 195, 195-196, 474, 630 сконструированные, 168 составные, 66 состояния, 80 статические, 137,146,181-182. См. также статические объекты стека, 66 объекты, переносимые через границы, метод, 173-174, 177 объявления typedef, 411-412,414, 500-501, 595, 679 ограничения, 38—40,40—41, 41-42, 42-43,45,46-48, 55, 71, 94, 95, 114, 137, 176, 176, 295,406-407, 411-412, 412-413, 430,456,457, 458,461,469, 586 класса, 43-44 методов, 71
Предметный указатель 7 нешаблонные, 46 однопроцессорные машины, 201-202, 203 оператор инициализации, 360, 360-361, 362 операторы копирования, 243 операторы преобразований, 406,409, 419,420,427,429,431,487, 507, 511, 521,533, 533-534 операторы, 69-70, 100-101,101, 105, 105-106, 106-107, 130-131,243, 355-356, 399,401,402,406-407, 418,453-454, 505-506, 508, 516-517, 520,523-524, 525-526, 526-527, 529, 529-530, 542-543, 549-550, 551-552, 555, 556, 628-629, 641-642,647 delete, 66 new, 66-67 operator [], 284 индексации, 281-282,601, 607 инкремента, постфиксный, 373-374 копирующего присваивания, 67-68, 69, 69, 94,290, 354, 365,482, 652 неявного преобразования, 283 преобразования, 306, 311, 342, 343-344, 344-345, 348,443, 550,551-552, 553,584,647, 650, 653, 656 присваивания, 548-549, 647 свободных функций, 72 сравнения «меньше, чем», 106 сравнения на (не)равенство, 107 сравнения на равенство, 358 операции, 111, 190-191, 191, 283, 353 Methodl(), 195 Method2(), 195 арифметические, 529 неделимые целочисленные, 191-192, 201-202, 208 неделимые, 192-194,202, 203-204,237 связанные с конструктором, ’ операционная среда, 49, 136, 148,1 операционные системы, 137, 138, 139,144, 144, 148, 153, 159-160, 176,179,179, 181, 183, 188,201, 212,219, 271,371 поставщики, 186 определение, 169, 333 определения типов, 377-378 опрос, 197 оптимизация возвращаемых значений (return value optimization - RVO), 245. См. также RVO оптимизация именованного возвращаемого значения (named return value optimization - NRVO), 247. См. также NRVO оптимизация пустой базы (empty base optimization - EBO), 24S См. также EBO оптимизация пустых производных классов (empty derived optimization • EDO), 252-253. См. также EDO оптимизация, 137, 220, 243-244, 244-245, 248-249, 249, 251, 251-253 253, 254-255, 256, 256-257, 548 освобождение ресурса при уничтожении, 86-92. См. также RRI1 отдельная ячейка памяти, 190 ' отказ на этапе выполнения, 58-59, 428,428 ; открытые методы, 53 открытые типы, 101, 102-103 отладка, 55-56, 527 комплексное тестирование, 38 отладочная или тестовая версии, 54-56, 62, 244 программный код режима отладки, 60
718 Предметный указатель путей программного кода, 244-245 системы, 38 функциональность, 62 ошибка компиляции, 227 ошибки этапа выполнения, 58-59 П память, 58, 165 нехватка, 135 освобождение, 187 распределение, 187-188 условие нехватки, 133 параметризация, 39, 89,251,295, 407,411,440,461,468,479,489, 603, 610, 610, 627, 642, 657. См. также шаблоны параметры, 50-51,68, 90,263, 381, 461-462,486-487, 503, 569, 571-572, 607, 633 значение по умолчанию, 155 значения не по умолчанию, 458 отдельные выходные, 51 Паттерсон, Скотт (Patterson, Scott), 86 перегрузка, 146-147, 148, 155 переменная возвращаемого значения ret Vai, 51 переменные инициализации, 360- 362 переменные, 40, 51, 119,195,210, 265, 362, 363-364, 365-366, 371, 373- 374, 391,497,578-579, 591 автоматические, 92-93 внешние, 215 глобальные потоковые, 209 глобальные статические, 219 глобальные, 366, 578 константные переменные- члены, 73, 78 константные, 73-74 локальные потоковые, 209 нелокальные статические, 220-221 нессылочные переменные- члены, 73 нессылочные скалярные переменные-члены, 73 объявления, 262 открытые переменные-члены, 49-50 переменные-итераторы, 373-374 переменные-члены массивов, 73 переменные-члены, 74-75,77, 77-78,103-104,252 системные, 122 состояний, 189-190 спина, 198 среды PATH, 179 среды, 374,439 ссылок, 395 ссылочные переменные-члены, 73 статические guard, 237 статические переменные- члены, 219 статические, 366, 578-579 стека, 578-579, 581-582 счетчика-часового, 120 указателей, 395 фреймовые и глобальные, 71 переносимые, 172-173 Перри, Ли и Скотт (Perry, Leigh, and Scott), 470 поддержка на этапе выполнения, 328 подсистемы регистрации событий, 463 подсчет количества вызовов, 129 поиск Кенига, 125, 447-448,448,449 поиск, зависимый от аргументов. См. поиск Кенига
Предметный указатель 719 полиморфизм на этапе выполнения, 147,162, 163,470,473, 640 полиморфизм, 349,476-477, 627 моделируемый на этапе компиляции, 476-477 посредник запроса типового объекта (Common Object Request Broker Architecture - CORBA), 331 постоянная проверка состояния, 197 постусловия, 48-49, 50, 52, 55. См. также функции, постусловия объекты-мониторы, 51 подтверждение, 53 предупреждения об усечениях, 59 предусловия, 48-50, 50, 52-53, 55-56 тестирование, 55-56 преобразования, 70-71,275,284, 343-344, 345-346, 396-397,398, 410-411,413,426,431-432,438,450, 455,468,493,495,495, 501,530, 531, 533-534, 553, 554, 559-560, 561, 569-570, 577, 584, 600, 666 библиотечные функции, 565 неявные, 395-396 операторы, 666 прокладки, 574 препроцессор, 52, 53-54, 55, 61,207, 271,284, 393,434, 436,437 разграничение действий, 193, 204,270,474,486, 543, 547 символы, 307-308, 350 условные операторы, 170,449 приведение в С-стиле, 315-316 приведения типов, 393-394, 395-428 прикладной программный код, 430, 448 прикрепляемые классы, 393-394, 468, 481, 482, 486-487, 490 принудительные соглашения этапа выполнения, 48 проблема «мертвых» ссылок, 228 проблема упорядочения статических объектов, 183 проверка возвращаемых значений, 53 проверка инвариантов отладочной версии, 58 проверка ошибок этапа выполнения, 58 программные интерфейсы языка С, 108-109,128-130, 157-158, 213, 302, 336-337,453,458-459, 550 программные интерфейсы, 210-211, 565 программные интерфейсы, 58, 80, 82-83, 84, 86,102, 111, 119, 129, 130-131, 132, 152, 158, 184, 185, 210-211,213,230, 231-233, 234-235, 238-239,262-263,276, 302-303, 310, 378,444,453, 461,463,464, 465, 578, 582, 595, 597 С-совместимые, 148 TSS, 217-218 библиотек, состояния, 80 неделимых целочисленных операций, 193, 198, 204 операционной системы, 148 подход на основе, 162 решение с применением счетчиков, 227 с подсчетом ссылок, 232 синхронизация, 478-479 системы Synesis, BufferStore, 114 собственные ресурсы, 129 типы, 380-381 уровни стабильности, 184 функции, 48-50, 82, 96, 98, 100-101, 238-239, 275-276 функциональная модель, 82 функция инициализации, 129, 130 <программный интерфейс>_<имя функции>, формат имен функций, 154
720 Предметный указатель программный код сервера, 174 программный код, обеспечивающий надлежащую обработку в рабочей версии, 59 продолжительность жизни возвращаемых значений, 444 продолжительность жизни, 113-115, 132,168, 183-184,224,226-227,273, 332, 433-434,444. См. также продолжительность жизни возвращаемых значений продолжительность жизни, 421 прокладки, 346-348, 357, 357, 393-394, 394, 554, 560, 613, 614, 677-678 атрибутов, 502, 554, 613 логические, 417 преобразования, 465, 574 прокси-оболочки, 81, 83-84, 85, 86 пространства имен, 46,125-126,148, 154, 208, 219, 224, 225, 265, 315, 366, 368, 375, 376,440,447-448, 448-449, 449-450,450-451,455,475, 516-517, 517, 603, 622-623, 640 глобальные, 219, 388, 448,449 пространство компоновки, 181-182 протокол сетевого времени (NTP - Network Time Protocol), 645 процессоры, 190-191, 191-192 однопроцессорные машины, 200 Р рабочие версии, 56, 57 размещение в памяти, 164-165, 300 С-структуры, 163 модель, 164 наследование, 251 проблемы, 146-147 схема, 299 распределение памяти в ограниченной области, 58 распределитель памяти на этапе выполнения, 557 расширение имен, 139, 146-147 расширение имен, 147-148, 175, 185 схемы, 149,180-181, 188 расширенные имена, 149, 150,179 реализации библиотек, 612 реализации, 169-170, 170,214,229, 235, 242, 244-245, 255-256, 261, 332-333, 353,401,413,423,425, 439,442,450,463,464, 470-471, 505, 505-506, 508, 510, 512, 516, 523, 535, 538, 552, 565, 568, 619, 635-636, 640-641, 645, 648-649, 654,656-657, 666, 675, 677 TLS платформы Win32, 217 класса строки, 502 класса, 172-173, 392 метода, 365, 630 на основе шаблонов, 369 оператора, 174 сервера, 168 файла, 225 регистры процессора, 190-191 ресурсы, 68, 80-81, 83, 86, 89-90, 91-92, 114, 186-188 выделенные, 68 ссылки, 81 ресурсы, 92-93, 119-120,124 захват, 94 инкапсуляция, 93, 94, 97-98 обработчик, 88 управление, 157 управляемые, 94 утечка, 129 ретрансляция аргументов. См. ретранслирующие функции родительские классы, 52
Предметный указатель 721 родной интерфейс Java (Java Native Interface - JNI), 148, 331, 348 c с подсчетом ссылок, 596 Саттер, Герб (Sutter, Herb), 47, 117 свободная память, 66 свойства, 50, 644-645, 679-680 связывание, 450 на этапе компиляции, 111-112 на этапе компоновки, 111 физическое, 453 связь через сокет, 378 семантическое соответствие, 452 семафоры, 189 сериализация компонент, 383 программный код, 384 символы, 142-143 имя, 148, 154 символьные буферы, 443,444 строки, 364 типы, 160 символьные строки, 262-263 синглетон Александреску, 228 синглетон Майерса, 227 синглетоны, 227-228,231,233-234, 234-235 Local, 238 синдромом NIH (Not Invented Here - «сюда нельзя вмешиваться»), 176 синхронизация, 190,204-205,208 блокировка объекта, 206 механизм, 198 примитивы, 204-205 требования, 195 система управления версиями, 352 системная библиотека, каталоги, 179 системная библиотека, кэш, 179 системные библиотеки, 181-182 системные пути, 179 системные счетчики, 122 системный вызов, 256 скалярные типы, 21 соглашения о форматах вызова, 112 139, 142, 147-148, 159, 160 соглашения этапа выполнения, 55~ 56 соглашения, 37-38 синтаксис, 37 соответствие, 451,451-452 семантическое, 451-452 структурное, 451-452 составные типы, 21 состояние программы, 124 состояния, 119-120 специализация, 43 полная, 44 частичная, 44 специализированные, 425 специальная память потока (TSS - thread-specific storage), 198, 209, 564, 564-565, 566, 567, 568. См. также TSS специальная память потока, 183 специальные данные потока (TSD - thread-specific data), 210. См. также TSD спецификаторы, 371 списки инициализации, 74-75, 77, 77, 79 список инициализации членов (member initializer list - MIL), 73, 75. См. также списки инициализации среда функционирования, 265, 266-267, 270, 350, 596 целевая, 371
722 Предметный указатель ссылки, 67, 151,403-404,404-405, 406,425-426, 482,484-485,486-487, 530, 537, 559-560, 654, 654-655 в программном коде, 183 висячие, 419 и указатели/значения, 487 неконстантные, 484-485, 487-488, 538 подсчет, 191,331,332-333, 434,473 подсчет, ненадлежащее применение, 424 счетчики, 92, 117, 129, 235 типы, 397-398, 550 стандартная библиотека шаблонов, 373,436, 557. См. также STL статическая инициализация, 220 статическая компоновка, 149, 150-151 статическая область видимости, 322 статические библиотеки, 135, 143, 144,181-182 статические и динамические библиотеки, 111 статические методы, 171-172, 658 статические объекты, 171-172, 181-182,219,220-221,222,228, 230,230-231,235, 330 статические утверждения, 47, 63, 64, 65,409, 571-572. См. также этап компиляции, утверждения switch-форма, 64-65 форма с битовым полем, 65 форма с недопустимой размерностью массива, 44, 63-65 статические члены, 366 статические экземпляры, 135 статическое конструирование, 567 странно-рекуррентная модель шаблона (CRTP), 477 стратегии «примесные», 474 начальные, подсчета ссылок, 474 синхронизации, 474,475 Страу струп, Бьерн (Stroustrup, Bjarne) (Босс!), 39, 54,98-99, 154,410,425,504, 571 строки, 76, 97,99-101, 107, 114, 209,247,317-321,400,430-431, 465-466,483-484, 559-560 С-стиля, 430-431,442-443, 447, 506, 511. См. также С-стиль класс, выделяющий лексемы из, 465-466 класса, 303-304, 334-335, 502, 505, 506-507, 508-509, 512,512-513,515,516-517, 517-518 конкатенация, 391,491 константа, 76 литеральные пустые, 76 модель, 320,439,442,466,467 представления, 569 преобразования, 559-560, 570 содержимое, 443-444 фиксированного размера, 376 функции, 231-232 структурное соответствие, 451-452 счетчик Шварца, 230,230, 234,234, 237,476 Т тестирование, 79 бета тестирование системы, 38 блочное, 38 система отладки, 38 тесты этапа выполнения, 55-56 тип для представления валюты, 103, 107
Предметный указатель типы арифметических значений, 101, 106-108,273 типы классов, 21,98,412 типы объектов, 21 типы с плавающей точкой, 160, 395 типы сущностей, 97,98-99 типы, участвующие в преобразовании, 411 Томас, Дэвид (Thomas, David), 278 «тоннелирование» типов, 630 Торьо, Джон (Torjo, John), 634 трасса вызовов, 92 У указание квалификатора, 46 указатели, 41-42, 59,68, 71, 76, 88, 89, 107, 114, 147, 164, 171, 226-227, 262-263,271,275,280-281,282-283, 283-284,287-288,290,300,303-304, 311, 319, 334-335,369-370,370-371, 404,410,412-413,415,425-426,429, 431,431-432,432-433,433-434,444, 456,482,487,492,493,495,496-497, 497,519, 523, 525, 531-532,532-533, 536, 537, 554, 559, 569, 571,574, 577, 578, 584, 600,606,609-610,612,635, 656-657 буфера. См. буферы, указатель виртуальной функции, 476 значение, 307 класса, 292 константные, 76,94 метода класса, 664-665 не нулевые, 355 нулевые, 133, 302-303, 427-428 подсчитывающие ссылки, 71, 117 преобразование, 614 стека, 66, 581-582, 588-589 типы, 303, 357-358, 370, 395-396,402,425-426 управляемые, 94 функции, 367, 656-657 члены,297 уникальный тип, 381, 381 уничтожение, 66-67,92-93, 94, 146 221,228-229, 332,464,467-468 управление доступом, 70 условия гонок, 189, 193, 201,209, 231,236,241 усовершенствования, 144, 186 утверждение этапа выполнения, 38, 63,65,295,510, 563 утверждения, 50, 53, 54, 55, 56, 57-59, 59-61, 62, 65, 78, 359,413, 585-586. См. также этап компиляции, утверждения; этап выполнения, утверждения; статические выражение, 59-60, 61 макросы, 59-61 место нарушения, 54 механизмы, 53 нарушение, 545 сообщение об ошибке, 61 статические, 79,214 утверждения Ф файлы, 155,161, 167-168,271. См. также заголовочные файлы базовой системы, 374-375 двоичные, 464 дескриптор, 453-454 инструментальные средства обработки, 128 исходные, 347, 347 класс, 453-454 множество исходных, 136
724 Предметный указатель область видимости, 675 объектные, 222-223 реализации, 231, 387 система, 117-118 Фибоначчи, ряды, 637 флажки, 186,253, 378 значения не по умолчанию, 251 форматы этапа выполнения, 140 фундаментальные типы, 21,116,379, 382,402,404,406 функтор, 20, 90,418,423,433,434, 439,461,621 функции, 51,65,66,141,144,146-147, 148,150-151,151-152,153,153-154, 156-157, 159-160, 163,166,172-173, 178-179, 179, 181-182, 184-185, 185-187, 187-188, 191-192,193, 211-212,212-213, 216-217,224, 229-230,231,231-232,238-239, 244-245, 254, 255, 267, 267-268, 268-269,274,283,284-285,286-287, 287,289,290,295,297-298,298,300, 306, 307,314, 319, 320, 327,327, 333, 345, 375, 378, 378, 385,401,408,419, 444,447,449-450,450-451,455,457, 457,475,476,519, 523,524-525, 525, 526-527, 538, 543, 550, 552, 554, 559, 565, 567-568,568-569, 571-572, 578, 595-596,624-625,632, 644,658-659, 660. См. также инварианты функции; функция-член atomic*, 209 С, 450-451 destroy_Object(), 175 административные, 92 виртуальные, 163,471 внешней системы, 256 возврат из, 48 вспомогательная закрытая статическая, 76 вспомогательные, 573 встраиваемые, 255, 397 вызовы, 48—49, 195, 234-235, 314,437 выход, 50 глобальные и программного интерфейса, 431 де-инициализации, 129 доступа, ПО или алгоритмы, 389 имена, 142-143 инициализации, 232, 233, 234-235,463 конструирования, 461 набор, 72 не виртуальные, 471-472 не члены, 105, 107, 505 неделимые, 199-200, 201-202 неделимых целочисленных операций, 194 обработчика, 133 обратного вызова, 213, 633 оператор преобразования, член, 399 оператор, 463 очистки, 210,211, 217 пары, 595-596 перегрузки функций преобразований, 463 переносимые, 153-154 поиска, 179, 179 постусловия, 51 преобразования, 569-570, 577 расчета смещения, 668-669, 671,676 расширений библиотеки, 575 реализация, 111, 561-562, 569-570 ретранслирующие, 51, 151, 157, 288, 339,481^490 свободные шаблонные, 381 свободные, 133,462 синхронизации, 240
Предметный указатель 725 типы, 460-461 указатель, 658 уничтожения, 461 фабрика, 163, 175 члены, 105, 164 функционально-локальные статические объекты, 182 функциональность, защищающая от ошибок, 58 «функция создания», 96 X Хант, Эндрю (Hunt, Andrew), 278 характерные методы, 421 Хенни, Кевлин (Henney, Kevlin), 410 ц целые числа, 102, 193-194,264-266, 267-268,269-270,271-272,272-273, 286,311, 314-316, 321,326,372,400, 544-545, 545, 551,559-560, 562-563, 570, 619 32-битовые, 575 64-битовые, 492,528, 546, 550 как индекс, 302 класс, 545-546 операции, 553 преобразование, 383, 572-573 Ч члены, 350, 352-353, 353. См. также переменные const, 68-69, 73-74, 74-75, 76-77, 95 виртуальные, 252 имена, 366 классов, 338, 499-500 константные, 407-408 конструирования, 353-354 нефункциональные, 477 переменные, 113-114,353, 364-365,402,477, 572, 637, 644, 649, 654-655, 657, 668 перечислений, 477 порядок инициализации, 78 состояние, 77 ссылки, 69, 75, 77, 95 статические, 72, 181-182, 240-241, 324 типы iterator, 374 типы, 391, 610-611 указатели, 297 функции, 55-56, 96, 385, 656-657,661 чтение-модификация-запись (read-modify-write - RMW), 191 Ш шаблоны, 41,44-45,65, 71, 90, 120-121,123, 124-125, 125, 141, 146,205,245,288, 290, 321, 339, 344,346,349-350, 350,406-407, 412-413,418,421,423, 425, 430-431, 450-451,460,461,465-466,467, 472-473,476-477,479,481,488-489, 494,499,499-500, 501, 521-522, 524-525, 552, 575, 589, 602-603, 603-604,604-605,606-607, 608-609, 622, 627, 629, 647, 650, 653, 654, 656-657,658,658-659, 660,661, 663-664, 666, 666-667, 668, 672-673, 675, 677-678, 679-680 auto-buffer, 78 must_be_pod_or_void, 43. См. также must_be_pod_or_void pod veneer, 89 truetype, 107 TssSlotScope, 218 алгоритмы, 375, 389, 541 дочерних классов, 252 инстанциирование, 39,41, 139-140, 394,610,610
726 Предметный указатель классов, 71,206,252-253, 295-296, 380, 394,408, 486-487, 516-517, 591-592, 603-604 конструкторы, 297,418-419, 423,454, 630 метод дружественных шаблонов, 342 методы, 249 механизмы, 603 облицовочные, 489 обобщенные, 490 определение, 381,450-451 параметризации, 156 параметры, 39,285,420, 438-439, 500, 506-507, 627-628,631 программный код, 244-245, 346, 396-397,430 структура, 284-285 трансформация, 295 формы, 54 функции, 284-285, 307,408, 450-451, 568-569 Шварц, Джерри (Schwarz, Jerry), 230 Э эквивалентные, 376 Эллисон, Чак (Allison, Chuck), 275 этап выполнения, 34-35, 38, 63, 79, 149, 165, 187, 327, 397,428,428,476, 580-581,581-582, 594,604,606-607, 613-614 этап кодирования, 38 этап компиляции, 34, 38, 63, 67,187, 410,413,428,476-477,478, 594. См. также значение, вычисляемое на этапе компиляции вычисление, 326 и этап компоновки, 276 константа, 326-327 методы, 613 ограничения, 38-40. См. также ограничения отказ, 40,428 ошибка, 407-408 полиморфизм, 470, 640 принудительные соглашения, 37-38,48. См. также ограничения этапа компиляции проверка, 315, 572 распределение памяти, 557 сообщение об ошибке, 44-45, 295 установка размера на, 581 утверждения, 37, 63-64, 78, 295, 586 функциональность, 44 этап, 109. См. также этап компиляции; этап выполнения Я явная загрузка, 178-179,183 языки, 119,453
Содержание Пролог.................................................... Введение: философия неидеального практика.................14 Дефекты, ограничения, определения и рекомендации .........24 Часть 1. Базовые концепции................................34 Глава 1. Принудительное проектирование: ограничения, соглашения и утверждения ....................36 1.1. «Яичница с ветчиной» ..............................37 1.2. Соглашения времени компиляции: ограничения.........38 1.3. Соглашения времени выполнения: предусловия, постусловия и инварианты...................48 1.4. Утверждения .......................................55 Глава 2. Проблемы жизненного цикла объекта............... 66 2.1. Жизненный цикл объекта............................66 2.2. Контроль ваших клиентов ......................... 67 2.3. Списки инициализации членов и их достоинства..... 72 Глава 3. Инкапсуляция ресурсов........................... 80 3.1. Таксономия инкапсуляции ресурсов................. 80 3.2. Типы POD......................................... 81 3.3. Прокси-оболочки.................................. 83 3.4. Типы RRID ....................................... 86
728 Содержание 3.5. Типы RAII ............................................92 3.6. RAII: заключение.....................................96 Глава 4. Инкапсуляция данных и типы значений.................97 4.1. Таксономия инкапсуляции данных.......................98 4.2. Типы значений и типы сущностей.......................98 4.3. Таксономия типов значений............................99 4.4. Открытые типы...................................... 101 4.5. Инкапсулированные типы............................. 103 4.6. Типы значений...................................... 104 4.7. Арифметические типы значений ...................... 106 4.8. Типы значений: заключение........................... 107 4.9. Инкапсуляция: заключение .......................... 108 Глава 5. Модели доступа к объектам......................... 113 5.1. Ограниченное время жизни объектов .................. 113 5.2. Копирование объектов................................ 115 5.3. Непосредственный доступ............................. 116 5.4. Совместно используемые объекты...................... 117 Глава 6. Классы, контролирующие диапазон действия ресурсов................................. 119 6.1. Значение ........................................... 119 6.2. Состояние........................................... 124 6.3. Программные интерфейсы и службы..................... 129 6.4. Специальные возможности языка...................... 133 Часть 2. Выживание в условиях реального мира .............. 136 Глава 7. Двоичный интерфейс приложения..................... 138 7.1. Совместно используемый программный код.............. 138 7.2. Требования для двоичного интерфейса языка С......... 141 7.3. Требования C++ для двоичного интерфейса приложения. 146 7.4. Теперь мне ничто не мешает программировать в стиле С. 151
Содержание 729 Глава 8. Переносимые через границы........................ 162 8.1. Как сделать таблицы vtable максимально переносимыми?.......................................... 162 8.2. Переносимые таблицы vtable ....................... 166 8.3. Двоичный интерфейс и объекты, переносимые через границы: заключение ................. 175 Глава 9. Динамические библиотеки.......................... 178 9.1. Явный вызов функций .............................. 178 9.2. Идентичность объектов: единицы компоновки и пространство компоновки ............................. 181 9.3. Продолжительность жизни........................... 183 9.4. Контроль версий................................... 184 9.5. Владение ресурсами................................ 187 9.6. Динамические библиотеки: заключение............... 188 Глава 10. Поточная организация вычислений ................ 189 10.1. Синхронизация доступа к целым числам............. 190 10.2. Синхронизация доступа к блокам кода: критические области ................................... 195 10.3. Эффективность неделимых целочисленных операций .. 198 10.4. Многопоточные расширения..........................205 10.5. Специальная память потока ........................209 Глава 11. Статические объекты..............................219 11.1. Нелокальные статические объекты: глобальные объекты......................................221 11.2. Синглетоны .......................................227 11.3. Функционально-локальные статические объекты ......235 11.4. Статические члены.................................238 11.5. Статические объекты: заключение ..................242 Глава 12. Оптимизация..................................... 243 12.1. Встроенные функции ...............................243 12.2. Оптимизация возвращаемых значений ................245 12.3. Оптимизация пустой базы ..........................249
730 Содержание 12.4. Оптимизация пустых производных классов...............252 12.5. Предотвращение оптимизации...........................254 Часть 3 Языковые проблемы.................................... 258 Глава 13. Фундаментальные типы................................260 13.1. Могу ли я получить байт?.............................260 13.2. Целые типы фиксированного размера....................264 13.3. Целые типы большого размера..........................271 13.4. Опасные типы.........................................273 Глава 14. Массивы и указатели ................................278 14.1. Не повторяйте себя...................................278 14.2. Вырождение массивов в указатели......................280 14.3. dimensionof() .......................................284 14.4. Нельзя передавать массивы функциям...................286 14.5. Массивы всегда передаются с помощью адреса ..........289 14.6. Массивы унаследованных типов ........................290 14.7. Нельзя иметь многомерные массивы ....................298 Глава 15. Значения ...........................................302 15.1. NULL - ключевое слово, которого не было .............302 15.2. Перейдем к ZERO......................................309 15.3. Изгибы «истины» .....................................312 15.4. Литералы ............................................313 15.5. Константы ...........................................321 Глава 16. Ключевые слова .....................................331 16.1. interface............................................331 16.2. temporary............................................334 16.3. owner................................................337 16.4. explicit(_cast) .....................................342 16.5. unique ..............................................347 16.6. final................................................348 16.7. Неподдерживаемые ключевые слова......................349
Содержание 731 Глава 17. Синтаксис...........................................351 17.1. Компоновка класса....................................351 17.2. Условные выражения...................................355 17.3. for .................................................360 17.4. Обозначение переменных...............................363 Глава 18. Имена, вводимые typedef.............................367 18.1. Использование typedef для указателей.................369 18.2. Что содержит определение? ...........................371 18.3. Алиасы...............................................377 18.4. «Настоящие» typedef..................................379 18.5. Хороший, плохой и ужасный ...........................384 Часть 4. Осознанные преобразования........................... 393 Глава 19. Приведение типов....................................395 19.1. Неявное преобразование...............................395 19.3. Пример приведений в стиле С..........................397 19.4. «Стероидные» приведения .............................399 19.5. explicit_cast........................................401 19.6. literal_cast.........................................407 19.7. union_cast...........................................410 19.8. comstl::interface_cast...............................414 19.9. boost: :polymorphic_cast.............................425 19.10. Приведение типов: заключение .......................428 Глава 20. Прокладки...........................................429 20.1. Всеохватность изменений и усиливающаяся гибкость ....430 20.2. Прокладки атрибутов..................................432 20.3. Логические прокладки ................................434 20.4. Управляющие прокладки................................435 20.5. Прокладки преобразований.............................438 20.6. Концепции составных прокладок........................440 20.7. Пространства имен и поиск Кенига.....................447
732 Содержание 20.8. Почему не шаблоны свойств? ........................450 20.9. Структурное соответствие...........................451 20.10. Разрушение монолита...............................453 20.11. Прокладки: заключение.............................454 Глава 21. Облицовочные классы...............................456 21.1. Облегченный RAII...................................457 21.2. Связывание данных и операций.......................458 21.3. Уточнение концепций................................465 21.4. Облицовочные классы: заключение....................468 Глава 22. Прикрепляемые классы .............................469 22.1. Добавление функциональности .......................470 22.2. Выбор оболочки.....................................470 22.3. Переопределение не виртуальных методов.............471 22.4. Увеличение возможностей ...........................473 22.5. Моделирование полиморфизма на этапе компиляции: реверсивные прикрепляемые классы..........................476 22.6. Параметризованная полиморфная упаковка.............477 22.7. Прикрепляемые классы: заключение ..................479 Глава 23. Шаблонные конструкторы ...........................481 23.1. Скрытые недостатки ................................483 23.2. Висячие ссылки ....................................483 23.3. Специализация шаблонных конструкторов..............485 23.4. Прокси аргументов..................................487 23.5. Ориентация аргументов на определенные типы ........489 23.6. Шаблонные конструкторы: заключение.................489 Часть 5. Операторы .........................................491 Глава 24. operator boolO....................................493 24.1. operator intO const................................493 24.2. operator void *() const............................494 24.3. operator bool() const..............................495
Содержание 733 24.4. operator !() - нет!...................................496 24.5. operator boolean const *() const......................496 24.6. operator int boolean::♦() const.......................497 24.7. Применение операторов в реальных условиях.............497 24.8. operator! ............................................501 Глава 25. Быстрая, неагрессивная конкатенация строк.............................................503 25.1. fast_string_concatenatoro .............................504 25.2. Производительность....................................512 25.4. Метод посева конкатенации.............................517 25.5. Патологическое применение скобок .....................518 25.6. Стандартизация........................................519 Глава 26. Какой ваш адрес?.....................................520 26.1. Можно не получить реальный адрес .....................520 26.2. Что происходит во время преобразования?...............523 26.3. Что мы возвращаем? ...................................525 26.4. Какой ваш адрес?: заключение .........................527 Глава 27. Операторы индексации ................................531 27.1. Операторы преобразования в указатели и операторы индексации ....................... 531 27.2. Обработка ошибок......................................534 27.3. Возвращаемое значение.................................536 Глава 28. Операторы инкремента ................................538 28.1. Недостающие постфиксные операторы ....................539 28.2. Эффективность ....................................... 540 Глава 29. Арифметические типы..................................544 29.1. Определение класса....................................544 29.2. Конструирование по умолчанию .........................545 29.3. Инициализация (конструирование значения)..............545 29.4. Копирующий конструктор ...............................548
734 Содержание 29.5. Присваивание.........................................548 29.6. Арифметические операторы.............................549 29.7. Операторы сравнения..................................549 29.8. Осуществление доступа к значению ....................550 29.9. sinteger64 ......................................... 551 29.10. Усечения, перевод в другие форматы и проверки ......551 29.11. Арифметические типы: заключение.....................554 Глава 30. Быстрое вычисление..................................555 Часть 6. Расширение C++.......................................557 Глава 31. Продолжительность жизни возвращаемых значений.........................................559 31.1. Таксономия «странностей» продолжительности жизни возвращаемых значений................................559 31.2. Зачем возвращать ссылку? ............................560 31.3. Решение 1 - integer_to_stringo ......................561 31.4. Решение 2 - специальная память потока ...............563 31.5. Решение 3 - расширение RVL...........................568 31.6. Решение 4 - определение размера статического массива.571 31.7. Решение 5 - прокладки преобразований.................572 31.8. Производительность...................................575 31.9. RVL: большая победа сборки мусора....................576 31.10. Потенциальные применения ...........................577 31.11. Продолжительность жизни возвращаемых значений: заключение..........................577 Глава 32. Память..............................................578 32.1. Таксономия памяти ...................................578 32.2. Лучший из двух миров.................................581 32.3. Распределители памяти................................594 32.4. Память: заключение...................................598
Содержание 735 Глава 33. Многомерные массивы............................. 599 33.1. Обеспечение синтаксиса индексации..................600 33.2. Установка размеров на этапе выполнения программы ..601 33.3. Установка размеров на этапе компиляции.............608 33.4. Доступ ко всему массиву............................610 33.5. Производительность.................................615 33.6. Многомерные массивы: заключение....................618 Глава 34. Функторы и диапазоны .............................619 34.1. Синтаксическая неразбериха.........................619 34.2. for_all() ?........................................620 34.3. Локальные функторы.................................623 34.4. Диапазоны..........................................634 34.5. Функторы и диапазоны: заключение...................642 Глава 35. Свойства .........................................644 35.1. Расширения компилятора.............................646 35.2. Варианты реализации................................647 35.3. Свойства-поля......................................648 35.4. Свойства-методы....................................656 35.6. Виртуальные свойства...............................676 35.7. Применение свойств.................................677 35.8. Свойства: заключение...............................679 Приложение А. Компиляторы и библиотеки .....................681 А. 1. Компиляторы ........................................681 А.2. Библиотеки...........................................682 А.З. Другие источники.....................................685 Приложение Б. Остерегайтесь самомнения! ....................686 Б.1. Перегрузка операторов................................687 Б.2. Когда-то я пожалел о том, что следовал принципу DRY..688 Б.З. Параноидальное программирование......................688 Б.4. Настоящее безумие! ..................................690
736 Содержание Приложение В. Arturius.............................691 Приложение Г. Компакт-диск.........................693 Эпилог.............................................695 Библиография.......................................696 Предметный указатель...............................704