Принадлежность и наследование




Учитывая сложность важность отношений наследования, нет ничегоудивительного в том, что часто их неправильно понимают и используютсверх меры. Если класс D описан как общий производный откласса B, то часто говорят, что D есть B: class B { /*... */; class D: public B /*... */ }; // D сорта B Иначе это можно сформулировать так: наследование - этоотношение "есть", или, более точно для классов D и B, наследование- это отношение D сорта B. В отличие от этого, если класс Dсодержит в качестве члена другой класс B, то говорят, чтоD "имеет" B: class D { // D имеет B //... public: B b; //... }; Иными словами, принадлежность - это отношение "иметь" илидля классов D и B просто: D содержит B. Имея два класса B и D, как выбирать между наследованием ипринадлежностью? Рассмотрим классы самолет и мотор.Новички обычноспрашивают: будет ли хорошим решением сделать класс самолетпроизводным от класса мотор. Это плохое решение, посколькусамолет не "есть" мотор, самолет "имеет" мотор. Следует подойтик этому вопросу, рассмотрев, может ли самолет "иметь" два илибольше моторов. Поскольку это представляется вполне возможным(даже если мы имеем дело с программой, в которой все самолетыбудут с одним мотором), следует использовать принадлежность, ане наследование. Вопрос "Может ли он иметь два..?" оказываетсяудивительно полезным во многих сомнительных случаях. Как всегда,наше изложение затрагивает неуловимую сущность программирования.Если бы все классы было так же легко представить, как самолет имотор, то было бы просто избежать и тривиальных ошибок типа той,когда самолет определяется как производное от класса мотор. Однако,такие ошибки достаточно часты, особенно у тех, ктосчитает наследование еще одним механизмом для сочетанияконструкций языка программирования. Несмотря на удобство илаконичность записи, которую предоставляет наследование, егонадо использовать только для выражения тех отношений,которые четко определены в проекте. Рассмотрим определения: class B { public: virtual void f(); void g(); }; class D1 { // D1 содержит B public: B b; void f(); // не переопределяет b.f() }; void h1(D1* pd) { B* pb = pd; // ошибка: невозможно преобразование D1* в B* pb = &pd->b; pb->q(); // вызов B::q pd->q(); // ошибка: D1 не имеет член q() pd->b.q(); pb->f(); // вызов B::f (здесь D1::f не переопределяет) pd->f(); // вызов D1::f } Обратите внимание, что в этом примере нет неявного преобразованиякласса к одному из его элементов, и что класс, содержащий вкачестве члена другой класс, не переопределяет виртуальныефункции этого члена. Здесь явное отличие от примера, приведенногониже: class D2: public B { // D2 есть B public: void f(); // переопределение B::f() }; void h2(D2* pd) { B* pb = pd; // нормально: D2* неявно преобразуется в B* pb->q(); // вызов B::q pd->q(); // вызов B::q pb->f(); // вызов виртуальной функции: обращение к D2::f pd->f(); // вызов D2::f } Удобство записи, продемонстрированное в примере с классом D2, посравнению с записью в примере с классом D1, является причиной, покоторой таким наследованием злоупотребляют. Но следует помнить,что существует определенная плата за удобство записи в видевозросшей зависимости между B и D2 (см. $$12.2.3). В частности,легко забыть о неявном преобразовании D2 в B. Если только такиепреобразования не относятся к семантике ваших классов, следуетизбегать описания производного класса в общей части. Если класспредставляет определенное понятие, а наследование используетсякак отношение "есть", то такие преобразования обычно как раз то,что нужно. Однако, бывают такие ситуации, когда желательноиметь наследование, но нельзя допускать преобразования. Рассмотримзадание класса cfield (controled field - управляемое поле), который,помимо всего прочего, дает возможность контролировать на стадиивыполнения доступ к другому классу field. На первый взгляд кажетсясовершенно правильным определить класс cfield как производный откласса field: class cfield: public field { //... }; Это выражает тот факт, что cfield, действительно, есть сорта field,упрощает запись функции, которая использует член части field классаcfield, и, что самое главное, позволяет в классе cfieldпереопределять виртуальные функции из field. Загвоздка здесь в том,что преобразование cfield* к field*, встречающееся в определениикласса cfield, позволяет обойти любой контроль доступа к field: void q(cfield* p) { *p = "asdf"; // обращение к field контролируется // функцией присваивания cfield: // p->cfield::operator=("asdf") field* q = p; // неявное преобразование cfield* в field* *q = "asdf"; // приехали! контроль обойден } Можно было бы определить класс cfield так, чтобы field был его членом,но тогда cfield не может переопределять виртуальные функции field.Лучшим решением здесь будет использование наследования со спецификациейprivate (частное наследование): class cfield: private field { /*... */ } С позиции проектирования, если не учитывать (иногда важные) вопросыпереопределения, частное наследование эквивалентно принадлежности.В этом случае применяется метод, при котором класс определяетсяв общей части как производный от абстрактного базового класса заданиемего интерфейса, а также определяется с помощью частного наследования отконкретного класса, задающего реализацию ($$13.3). Посколькунаследование, используемое как частное, являетсяспецификой реализации, и оно не отражается в типе производного класса,то его иногда называют "наследованием по реализации", и оноявляется контрастом для наследования в общей части, когда наследуетсяинтерфейс базового класса и допустимы неявные преобразования кбазовому типу. Последнее наследование иногда называют определениемподтипа или "интерфейсным наследованием". Для дальнейшего обсуждения возможности выбора наследованияили принадлежности рассмотрим, как представить в диалоговойграфической системе свиток (область для прокручивания в нейинформации), и как привязать свиток к окну на экране. Потребуютсясвитки двух видов: горизонтальные и вертикальные. Это можнопредставить с помощью двух типов horizontal_scrollbar иvertical_scrollbar или с помощью одного типа scrollbar, которыйимеет аргумент, определяющий, является расположение вертикальнымили горизонтальным. Первое решение предполагает, что есть ещетретий тип, задающий просто свиток - scrollbar, и этот типявляется базовым классом для двух определенных свитков. Второерешение предполагает дополнительный аргумент у типа scrollbar иналичие значений, задающих вид свитка. Например, так: enum orientation { horizontal, vertical }; Как только мы остановимся на одном из решений, определитсяобъем изменений, которые придется внести в систему. Допустим, вэтом примере нам потребуется ввести свитки третьего вида. Вначалепредполагалось, что могут быть свитки только двух видов (ведьвсякое окно имеет только два измерения), но в этом примере,как и во многих других, возможны расширения, которые возникаюткак вопросы перепроектирования. Например, может появитьсяжелание использовать "управляющую кнопку" (типа мыши) вместо свитковдвух видов. Такая кнопка задавала бы прокрутку в различныхнаправлениях в зависимости от того, в какой части окна нажалее пользователь. Нажатие в середине верхней строчки должновызывать "прокручивание вверх", нажатие в середине левого столбца -"прокручивание влево", нажатие в левом верхнем углу -"прокручивание вверх и влево". Такая кнопка не являетсячем-то необычным, и ее можно рассматривать как уточнение понятиясвитка, которое особенно подходит для тех областей приложения,которые связаны не с обычными текстами, а с более сложнойинформацией. Для добавления управляющей кнопки к программе, использующейиерархию из трех свитков, требуется добавить еще один класс, ноне нужно менять программу, работающую со старыми свитками: свитокгоризонтальный_свиток вертикальный_свиток управляющая_кнопкаЭто положительная сторона "иерархического решения". Задание ориентации свитка в качестве параметра приводит кзаданию полей типа в объектах свитка и использованию переключателейв теле функций-членов свитка. Иными словами, перед нами обычнаядилемма: выразить данный аспект структуры системы с помощьюопределений или реализовать его в операторной части программы.Первое решение увеличивает объем статических проверок и объеминформации, над которой могут работать разные вспомогательныесредства. Второе решение откладывает проверки на стадию выполненияи разрешает менять тела отдельных функций, не изменяя общуюструктуру системы, какой она представляется с точки зрениястатического контроля или вспомогательных средств. В большинствеслучаев, предпочтительнее первое решение. Положительной стороной решения с единым типом свитка является то,что легко передавать информацию о виде нужного нам свитка другойфункции: void helper(orientation oo) { //... p = new scrollbar(oo); //... } void me() { helper(horizontal); } Такой подход позволяет на стадии выполнения легко перенастроить свитокна другую ориентацию. Вряд ли это очень важно в примере со свитками,но это может оказаться существенным в похожих примерах. Суть в том,что всегда надо делать определенный выбор, а это часто непросто. Теперь рассмотрим как привязать свиток к окну. Если считатьwindow_with_scrollbar (окно_со_свитком) как нечто, что являетсяwindow и scrollbar, мы получим подобное: class window_with_scrollbar: public window, public scrollbar { //... }; Это позволяет любому объекту типа window_with_scrollbar выступатьи как window, и как scrollbar, но от нас требуется решениеиспользовать только единственный тип scrollbar. Если, с другой стороны, считать window_with_scrollbar объектомтипа window, который имеет scrollbar, мы получим такое определение: class window_with_scrollbar: public window { //... scrollbar* sb; public: window_with_scrollbar(scrollbar* p, /*... */): window(/*... */), sb(p) { //... } //... }; Здесь мы можем использовать решение со свитками трех типов. Передачасамого свитка в качестве параметра позволяет окну (window) незапоминать тип его свитка. Если потребуется, чтобы объект типаwindow_with_scrollbar действовал как scrollbar, можно добавитьоперацию преобразования: window_with_scrollbar:: operator scrollbar&() { return *sb; }

Отношения использования

Для составления и понимания проекта часто необходимо знать,какие классы и каким способом использует данный класс.Такие отношения классовна С++ выражаются неявно. Класс может использовать только теимена, которые где-то определены, но нет такой части в программена С++, которая содержала бы список всех используемых имен.Для получения такого списка необходимывспомогательные средства (или, при их отсутствии, внимательноечтение). Можно следующим образом классифицировать те способы,с помощью которых класс X может использовать класс Y: - X использует имя Y - X использует Y - X вызывает функцию-член Y - X читает член Y - X пишет в член Y - X создает Y - X размещает auto или static переменную из Y - X создает Y с помощью new - X использует размер YМы отнесли использование размера объекта к его созданию, посколькудля этого требуется знание полного определения класса. С другойстороны, мы выделили в отдельное отношение использование имени Y,поскольку, указывая его в описании Y* или в описаниивнешней функции, мы вовсе не нуждаемся в доступе к определению Y: class Y; // Y - имя класса Y* p; extern Y f(const Y&); Мы отделили создание Y с помощью new от случая описанияпеременной, поскольку возможна такая реализация С++, при которойдля создания Y с помощью new необязательно знатьразмер Y. Это может быть существенно для ограничения всех зависимостейв проекте и сведения к минимуму перетрансляции после внесения изменений. Язык С++ не требует, чтобы создатель классов точно определял,какие классы и как он будет использовать. Одна из причин этогозаключена в том, что самые важные классы зависят от столь большогоколичества других классов, что для придания лучшего вида программенужна сокращенная форма записи списка используемых классов, например,с помощью команды #include. Другая причина в том, что классификацияэтих зависимостей и, в частности, обЪединение некоторых зависимостейне является обязанностью языка программирования. Наоборот, целиразработчика, программиста или вспомогательного средства определяют то,как именно следует рассматривать отношения использования. Наконец, то,какие зависимости представляют больший интерес, может зависеть отспецифики реализации языка.

Отношения внутри класса

До сих пор мы обсуждали только классы, и хотя операции упоминались,если не считать обсуждения шагов процесса развития программногообеспечения ($$11.3.3.2), то они были на втором плане, объекты жепрактически вообще не упоминались. Понять это просто: в С++класс, а не функция или объект, является основным понятиеморганизации системы. Класс может скрывать в себе всякую специфику реализации,наравне с "грязными" приемами программирования, а иногда онвынужден это делать. В то же время объекты большинства классовсами образуют регулярную структуру и используются такими способами,что их достаточно просто описать. Объект класса может бытьсовокупностью других вложенных объектов (их часто называют членами),многие из которых, в свою очередь, являются указателями или ссылкамина другие объекты. Поэтому отдельный объект можно рассматривать каккорень дерева объектов, а все входящие в него объекты как "иерархиюобъектов", которая дополняет иерархию классов, рассмотренную в $$12.2.4.Рассмотрим в качестве примера класс строк из $$7.6: class String { int sz; char* p; public: String(const char* q); ~String(); //... }; Объект типа String можно изобразить так:

12.2.7.1 Инварианты

Значение членов или объектов, доступных с помощью членов класса,называется состоянием объекта (или просто значением объекта).Главное при построении класса - это: привести объект в полностьюопределенное состояние (инициализация), сохранять полностью определенноесостояние обЪекта в процессе выполнения над ним различных операций,и в конце работы уничтожить объект без всяких последствий. Свойство,которое делает состояние объекта полностью определенным, называетсяинвариантом. Поэтому назначение инициализации - задать конкретные значения,при которых выполняется инвариант объекта. Для каждой операции классапредполагается, что инвариант должен иметь место перед выполнениемоперации и должен сохраниться после операции. В конце работыдеструктор нарушает инвариант, уничтожая объект. Например,конструктор String::String(const char*) гарантирует,что p указывает на массив из, по крайней мере, sz элементов, причемsz имеет осмысленное значение и v[sz-1]==0. Любая строковая операцияне должна нарушать это утверждение. При проектировании класса требуется большое искусство, чтобысделать реализацию класса достаточно простой и допускающейналичие полезных инвариантов, которые несложно задать. Легкотребовать, чтобы класс имел инвариант, труднее предложить полезныйинвариант, который понятен и не накладывает жестких ограниченийна действия разработчика класса или на эффективность реализации.Здесь "инвариант" понимается как программный фрагмент,выполнив который, можно проверить состояние объекта. Вполне возможнодать более строгое и даже математическое определение инварианта, и внекоторых ситуациях оно может оказаться более подходящим. Здесь жепод инвариантом понимается практическая, а значит, обычно экономная,но неполная проверка состояния объекта. Понятие инварианта появилось в работах Флойда, Наура и Хора,посвященных пред- и пост-условиям, оно встречается во всех важныхстатьях по абстрактным типам данных и верификации программ запоследние 20 лет. Оно же является основным предметом отладки в C++. Обычно, в течение работы функции-члена инвариант не сохраняется.Поэтому функции, которые могут вызываться в те моменты, когдаинвариант не действует, не должны входить в общий интерфейс класса.Такие функции должны быть частными или защищенными. Как можно выразить инвариант в программе на С++? Простое решение -определить функцию, проверяющую инвариант, и вставить вызовы этойфункции в общие операции. Например: class String { int sz; int* p; public: class Range {}; class Invariant {}; void check(); String(const char* q); ~String(); char& operator[](int i); int size() { return sz; } //... }; void String::check() { if (p==0 || sz<0 || TOO_LARGE<=sz || p[sz-1]) throw Invariant; } char& String::operator[](int i) { check(); // проверка на входе if (i<0 || i<sz) throw Range; // действует check(); // проверка на выходе return v[i]; } Этот вариант прекрасно работает и не осложняет жизнь программиста.Но для такого простого класса как String проверка инварианта будетзанимать большую часть времени счета. Поэтому программисты обычновыполняют проверку инварианта только при отладке: inline void String::check() { if (!NDEBUG) if (p==0 || sz<0 || TOO_LARGE<=sz || p[sz]) throw Invariant; } Мы выбрали имя NDEBUG, поскольку это макроопределение, котороеиспользуется для аналогичных целей в стандартном макроопределенииС assert(). Традиционно NDEBUG устанавливается с целью указать,что отладки нет. Указав, что check() является подстановкой, мыгарантировали, что никакая программа не будет создана, пока константаNDEBUG не будет установлена в значение, обозначающее отладку.С помощью шаблона типа Assert() можно задать менее регулярныеутверждения, например: template<class T, class X> inline void Assert(T expr,X x) { if (!NDEBUG) if (!expr) throw x; } вызовет особую ситуацию x, если expr ложно, и мы не отключилипроверку с помощью NDEBUG. Использовать Assert() можно так: class Bad_f_arg { }; void f(String& s, int i) { Assert(0<=i && i<s.size(),Bad_f_arg()); //... } Шаблон типа Assert() подражает макрокоманде assert() языка С.Если i не находится в требуемом диапазоне, возникает особаяситуация Bad_f_arg. С помощью отдельной константы или константы из класса проверитьподобные утверждения или инварианты - пустяковое дело. Если женеобходимо проверить инварианты с помощью объекта, можно определитьпроизводный класс, в котором проверяются операциями из класса, где нетпроверки, см. упр.8 в $$13.11. Для классов с более сложными операциями расходы на проверки могутбыть значительны, поэтому проверки можно оставить только для "поимки"трудно обнаруживаемых ошибок. Обычно полезно оставлять по крайнеймере несколько проверок даже в очень хорошо отлаженной программе.При всех условиях сам факт определения инвариантов и использованияих при отладке дает неоценимую помощь для получения правильнойпрограммы и, что более важно, делает понятия, представленныеклассами, более регулярными и строго определенными. Дело в том, чтокогда вы создаете инварианты, то рассматриваете класс с другойточки зрения и вносите определенную избыточность в программу.То и другое увеличивает вероятность обнаружения ошибок, противоречийи недосмотров.Мы указали в $$11.3.3.5, что две самые общие формы преобразованияиерархии классов состоят в разбиении класса на два и в выделенииобщей части двух классов в базовый класс. В обоих случаях хорошопродуманный инвариант может подсказать возможность такогопреобразования. Если, сравнивая инвариант с программами операций,можно обнаружить, что большинство проверок инварианта излишни,то значит класс созрел для разбиения. В этом случае подмножество операцийимеет доступ только к подмножеству состояний объекта. Обратно,классы созрели для слияния, если у них сходные инварианты, дажепри некотором различии в их реализации.

Инкапсуляция

Отметим, что в С++ класс, а не отдельный объект, является тойединицей, которая должна быть инкапсулирована (заключена в оболочку).Например: class list { list* next; public: int on(list*); }; int list::on(list* p) { list* q = this; for(;;) { if (p == q) return 1; if (q == 0) return 0; q = q->next; } } Здесь обращение к частному указателю list::next допустимо, посколькуlist::on() имеет доступ ко всякому объекту класса list, на которыйу него есть ссылка. Если это неудобно, ситуацию можно упростить,отказавшись от возможности доступа через функцию-член кпредставлениям других объектов, например: int list::on(list* p) { if (p == this) return 1; if (p == 0) return 0; return next->on(p); } Но теперь итерация превращается в рекурсию, что может сильнозамедлить выполнение программы, если только трансляторне сумеет обратно преобразовать рекурсию в итерацию.


Поделиться:




Поиск по сайту

©2015-2024 poisk-ru.ru
Все права принадлежать их авторам. Данный сайт не претендует на авторства, а предоставляет бесплатное использование.
Дата создания страницы: 2016-02-16 Нарушение авторских прав и Нарушение персональных данных


Поиск по сайту: