Лекция пятая Разделяй и властвуй

(редакция от 08.05.86)

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

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

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

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

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

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

Но сначала разберемся с тем, что такое модуль.

Модуль - это замкнутая программа, которую можно вызвать из любого другого модуля программы и можно отдельно транслировать. То есть, в терминологии рl/1 это - внешняя процедура.

Модуль имеет три свойства:

1. Он выполняет одну или несколько функций. Пример: функция модуля dеtеr - вычисление определителя матрицы. Кроме определителя, модуль может может вычислять и характеристический многочлен - тогда у него будет уже две функции.

2. Он обладает некоторой логикой. Не надо путать логику с функцией: одну и ту же функцию вы можете выполнить, используя различную логику. например, факториал числа n вы можете получить простым перемножением, а можете и рекурсивно.

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

Модуль обладает, кроме того, различными характеристиками:

1. Размером: он может быть большим или маленьким.

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

3. Сцеплением: модуль может сильно или слабо зависеть от работы или изменений других модулей.

4. Предсказуемостью: модуль должен работать одинаково для одних и тех же входных данных.

Сейчас мы рассмотрим все эти характеристики.

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

Что значит "небольшой"? на этот счет существуют различные мнения - называют цифры от 50 до трехсот строк. На мой взгляд, небольшим следует считать модуль, умещающися на обычном листе бумаги, ну - максимум на двух. В Р-технологии такой подход принят стандартно - там вообще, в принципе, невозможно создать граф больше, чем на один экран. И сами разработчики, и пользователи РТК говорят, что такой подход весьма естественнен, и что ни разу не потребовалось больше экрана. Я тоже опробовал этот принцип и написал систему I, используя только "одностраничные" модули. Могу подтвердить - ничего страшного, одни приятности. Итак, модуль должен занимать одну-две странички распечатки.

Теперь о прочности модулей. Грубо говоря, операторы составляющие модули, могут оказаться в одном модуле случайно, менее случайно, либо вообще в силу необходимости. Чем необходимее характер их объединения, тем прочнее модуль. Вообще говоря, существует семь классов прочности, но мы рассмотри только некоторые, в порядке возрастания их прочности. Модуль, прочный по логике, при каждом вызове выполняет одну функцию из набора связанных с ним. вот простой пример. Вы обнаруживаете, что у вас модули чтения из базы данных и записи в базу данных имеет много общего: блокировка - разблокировка, поиск записи, и т.д. и вы, чтобы не дублировать усилия, пишете один модуль чтения-записи. Чтобы модуль знал, что ему нужно сделать, ему подается параметр, содержащий код функции. например, r - чтение, w - запись. Идея, в общем, неплохая, но с этим модулем вы намучаетесь.

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

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

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

Далее. Модуль прочный по классу тоже выполняет несколько функций, но выполняет эти функции последовательно. Например, в системе ДЕЛЬТА на этапе счета нужно ввести и проверить задание, разместить управляющие таблицы, обработать параметры и т.д. Это довольно большой кусок текста и, чтобы он не загораживал собственно логику счета - т.е. организацию цикла по таблицам и вызов обрабатывающих программ, этот кусок текста просто вынесен в отдельный модуль. Функции внутри этого модуля мало связаны между собой. У таких модулей есть два существенных недостатка: во-первых они очень зависят от других модулей, а во вторых, часто при модификации системы требуется какую-то функцию такого модуля использовать отдельно. В результате мы вынуждены либо выделить отдельный модуль ( а в старом тексте вместо него поставить саll), либо продублировать текст, что тоже не сладко. Лучше бы сразу выделить отдельные модули для каждой функции. Близко к прочности по классу стоит процедурная прочность. Процедурно прочный модуль последовательно выполняет набор связанных с ним функций, т.е. он отличается от модуля, прочного по классу, тем, что его многочисленные функции выполняются в определенном порядке. Проблемы, связанные с процедурно прочными модулями, в общем-то те же, что и для предыдущего класса.

Первые версии системы ДЕЛЬТА состояли из десятка здоровенных процедурно прочных модулей. Развитие и модификация системы в значительной степени сводились к тому, что эти модули раздирались на части, мельчали на глазах и в результате сейчас дельта состоит из двухсот мелких модулей. Это весьма поучительный пример, и я хотел бы остановиться на нем.

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

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

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

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

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

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

заголовок модуля

объявление данных

вход для функции 1

реализация функции 1

вход для функции 2

реализация функции 2

..................

вход для функции n

реализация функции n

конец модуля

например на рl/1:

mоd8:рrос орtiоns(mаin);

dсl . . . . . . . .

vх1:еntry(а,b); /* функция 1 */

а=b+4;

rеturn;

vх2:еntry(k,l,m); /* функция 2 */

k=l**m-1;

rеturn; и так далее

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

Вот пример. Мы писали подсистему ДЕЛЬТЫ, печатающую произвольные таблицы. Нужно было работать с подтаблицами, состоящими из строк. Мы написали модуль доступа, в котором были отдельные точки входа для каждой функции с подтаблицей: открытия ее, получения очередной строки, записи очередной строки и т.д.

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

Очень полезно "упрятывать" в информационно-прочные модули данные, которые используются многими модулями. В той же подсистеме ДЕЛЬТЫ мы упрятали главную управляющую таблицу. Это был массив структур, причем в структуре было больше сорока полей. Почти каждый модуль использовал какое-либо поле этой структуры. Что было бы, если бы мы ее не упрятали? А вот что. Во-первых, в каждом модуле нам пришлось бы описать всю эту структуру целиком, хотя использовалось одно-два поля. Конечно, это бы не улучшило читабельность модуля - в нем появилась бы куча информации, не имеющей никакого отношения к логике данного модуля. Я уже не говорю о том, что весь этот (с точки зрения данного модуля) мусор пришлось бы набить и отловить неизбежные ошибки перфорации, т.е. провести абсолютно ненужную работу.

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

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

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

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

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

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

Всего способов сцепления 6, но я опять-таки расскажу вам не обо всех.

Группа модулей сцеплена по общей области, если они ссылаются на одну и ту же глобальную структуру данных. В рl/1 это можно сделать, объявив некую структуру как ехтеrnаl. В фортране же, как вы уже, наверное, догадались, для этого используют соммоn. Это один из самых жестких видов сцепления, и вот почему. А вот пожалуйста: соммоn А(30),B(40),С означает, что выделена общая область в 71 слово. Этот соммоn, естественно, присутствует почти во всех модулях. И вот вам понадобилось изменить размерность А на 40. Bам придется изменить все модули, содержащие соммоn, даже если они не имеют отношения к А. соммоn может быть очень длинный, а значит, легко можно ошибиться и пропустить одну-две ошибки, поскольку ошибки возникают не в том модуле, в котором проявляются. например в указанном соммоn где-то пропустили B, но на данном модуле это не сказалось, т.к. B и не использовалось. зато в этом модуле присваивалось С. Естественно, оно присвоилось не С, а B(1), и вот в результате отсутствует нужное значение С и запорчено B(1). Когда вы обнаруживаете запорченное B(1), вы, конечно же, станете проверять модули, работающие с массивом B, а в этот модуль, в котором все и произошло, вы заглянуть не удосужитесь. Вот вам и занятие в долгие зимние вечера - искать ошибку в модулях, в которых ее нет. Могу подтвердить это своим большим опытом консультанта: когда я вижу в программе соммоn, первым делом обнаруживаешь в ней как минимум две ошибки, до которых консультируемый, может быть, еще и не дошел.

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

Во-вторых, объявив отдельные соммоnы и тем самым переведя сцепление по общей области в сцепление по внешним данным, вы избавляетесь только от одной проблемы - физического порядка элементов соммоn. Но остается множество других. Самая серьезная из них - это повышение взаимодействия модулей. Некий модуль х положил нечто в переменную а, находящуюся в общей области, и считает, что тем самым он передает это модулю y. Однако некий модуль z использует переменную а для других целей, и так получается, что он прорабатывает раньше модуля y, а значит, до модуля y "посылка" модуля х попросту не дойдет. Что это означает? Это означает, что, работая с модулем х, мы должны помнить логику модуля z, и, может быть, еще нескольких модулей. Это еще возможно, хотя и неприятно, если с системой работаете только вы; если же работают несколько человек, то, одновременно со вводом соммоn, смело отодвигайте планируемый срок окончания отладки месяца на два - эти два месяца вы будете решать проблемы, связанные с использованием общих областей.

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

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

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

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

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

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

На оглавление


© Алексей Бабий 1980-1986