Рефакторинг. Зачем?

DarkGoodWIN

Рефакторинг

Зачем?

Программы, мысли, темы

Введение

Время от времени мне доводилось отвечать на вопросы, которые в конечном счёте можно сформулировать как: «что такое рефакторинг и для чего он нужен?». Конкретные вопросы могли быть разные, например, чем объектно–ориентированное программирование лучше функционального. Этот вопрос относится к рефакторингу куда больше, чем принято считать.

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

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

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

Примеры я буду приводить на языке Object Pascal. В основном я работаю на нём, пишу на Delphi. Предвидя нападки со стороны поклонников C-подобных языков, скажу два тезиса:

1. Подходы к организации программного кода от языка не зависят. Язык может лишь поддерживать или не поддерживать те или иные конструкции.

2. Сугубо субъективно считаю Pascal более читабельным языком. То есть для программирования, возможно, большая локаничность C — плюс, а для обучения, я считаю, лучше годиться более развернутый, более приближенный к человеческой речи синтаксис Pascal.

Ну что же, начнём, пожалуй.

Именование переменных и функций

Первым делом проиллюстрируем то, о чем буду говорить примером:

function func(i1, i2: Integer): Integer;

begin

Result:= i1 * i2;

end;

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

Приведу два основных тезиса:

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

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

Попробуем немного поменять нашу функцию:

function RectArea(Width, Height: Integer): Integer;

begin

Result:= Width * Height;

end;

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

if Mult(Width, Height) > 2 then

WriteLn('Big rectangel')

else

WriteLn('Small rectangel');

И сравним его с таким:

if RectArea(Width, Height) > 2 then

WriteLn('Big rectangel')

else

WriteLn('Small rectangel');

На мой взгляд, последний читается проще. Всё–таки есть разница: «если ширина, умноженная на высоту больше двух, пишем «большой прямоугольник», иначе пишем «маленький прямоугольник» или «если площадь прямоугольника больше двух, пишем «большой прямоугольник», иначе пишем «маленький прямоугольник».

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

function RectArea(Width, Height: Integer): Integer;

begin

Result:= Mult(Width, Height);

end;

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

Стандартные имена функций и переменных

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

Какие можно привести примеры:

— практически все называют переменные цикла буквами i, j, k, для циклов первой, второй и третьей вложенности соответственно;

— какие–то временные, короткоживущие переменные часто снабжают префиксом «temp» или «tmp», например, tempFileName для файла, который вскоре удалится;

— логические переменные становятся куда нагляднее с префиксами типа is, has, can, например, isButtonVisible, canAddPoint, hasPoint;

— в случае коптрования данных из одной переменной в другую, лучше использовать префиксы source и dest, а не суффиксы 1 и 2, «copy(array1, array2)» выглядит менее понятно, чем «copy(sourceArray, destArray)».

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

Преобразование одной большой функции в две маленькие

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

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

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

type

TRect = record

Left: Integer;

Right: Integer;

Top: Integer;

Bottom: Integer;

end;

function RectsLength(Rects: array of TRect): Integer;

var

I: Integer;

Width, Height: Integer;

begin

Result:= 0;

for I:= 0 to Length(Rects) — 1 do

begin

 Width:= Rects[I].Right — Rects[I].Left;

Height:= Rects[I].Bottom — Rects[I].Top;

 Result:= Result + 2 * Width + 2 * Height;

end;

end;

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

function RectsLength(Rects: array of TRect; MinLength: Integer): Integer;

var

I: Integer;

Width, Height, Len: Integer;

begin

Result:= 0;

for I:= 0 to Length(Rects) — 1 do

begin

 Width:= Rects[I].Right — Rects[I].Left;

Height:= Rects[I].Bottom — Rects[I].Top;

Len:= 2 * Width + 2 * Height;

if Len >= MinLength then

 Result:= Result + Len;

end;

end;

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

function RectLength(Rect: TRect): Integer;

var

Width, Height: Integer;

begin

 Width:= Rect. Right — Rect. Left;

Height:= Rect. Bottom — Rect. Top;

Result:= 2 * Width + 2 * Height;

end;

function RectsLength(Rects: array of TRect; MinLength: Integer): Integer;

var

I: Integer;

Len: Integer;

begin

Result:= 0;

for I:= 0 to Length(Rects) — 1 do

begin

Len:= RectLength(Rects[I]);

if Len >= MinLength then

Result:= Result + Len;

end;

end;

И пусть вас не смущает, что в сумме кода стало немного больше. Мне ещё ни разу не приходилось жалеть о такого рода рефакторинге. То есть были, конечно, случаи, когда он был не оправдан и только путал… У каждого бывали ошибки. Но никогда проблемы не были связаны с увеличением суммарного объема кода. Иногда, даже если вы смогли разделить функцию на две функции того же размера (суммарное увеличение кода в два раза), но, при этом хорошо разделили их логически — это бывает оправдано.

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

Признаки необходимости выделения функции

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

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

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

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

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

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

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

В качестве иллюстрации — можете посмотреть пример к прошлой главе. Там мы благополучно избавились от переменных Width и Height в функции RectsLength. Опятьь же из опыта скажу, что большое количество локальных переменных в функции усложняет восприятие.

4. Внутри функции выполняется какое–то законченное, осмысленное действие. Даже если три строки, вычисляющие периметр не кажутся вам сложным фрагментом кода, рекомендую его всё равно вынести в отдельную функцию. Причин для этого можно назвать несколько:

— Через какое–то время ваш фрагмент и функция в целом может стать значительно сложнее, в середину понятного ранее куска кода могут попасть посторонние, не относящаеся к нему строки. В результате этого на минутное изначально дело можно потратить в разы большее количество времени. При этом риск допустить ошибку будет также выше;

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

— Код станет проще читать.

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

function RectsLength(Rects: array of TRect; MinLength: Integer): Integer;

var

I: Integer;

Len: Integer;

Widths, Heights: array of Integer;

begin

Result:= 0;

SetLength(Widths, Length(Rects));

SetLength(Heights, Length(Rects));

for I:= 0 to Length(Rects) — 1 do

begin

Widths[I]:= Rects[I].Right — Rects[I].Left;

Heights[I]:= Rects[I].Bottom — Rects[I].Top;

end;

for I:= 0 to Length(Rects) — 1 do

begin

 Len:= 2 * Widths[I] + 2 * Heights[I];

if Len >= MinLength then

 Result:= Result + Len;

end;

end;

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

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

procedure AddPointToRect(x, y: Integer; Rect: TRect);

begin

if (x >= Rect. Left) and (x <= Rect. Right) and (y >= Rect. Top) and (y <= Rect. Bottom) then

AddPoint(x, y);

end;

Лучше заменить на:

function PointOnRect(x, y: Integer; Rect: TRect): Boolean;

begin

Result:= (x >= Rect. Left) and (x <= Rect. Right) and (y >= Rect. Top) and (y <= Rect. Bottom);

end;

procedure AddPointToRect(x, y: Integer; Rect: TRect);

begin

if PointOnRect(x, y, Rect) then

AddPoint(x, y);

end;

Однако для функции PointOutsideRect, добавляющей точку за пределами прямоугольника, лучше не писать «if PointOutsideRect(x, y, Rect) then», а написать «if not PointOnRect(x, y, Rect) then».

6. Высокий уровень вложенности. Бывает функция как матрёшка. Блок кода, в нём ещё блок кода, в нём ещё и так далее. Читать это также довольно трудно. Пример приводить не буду, чтобы не захламлять текст, отмечу лишь, что блок целиком (текст между скобками «begin end» или «{}» для C-подобных языков) очень часто легко переносится в отдельную функцию.

Выделение функции в процессе написания

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

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

Как же писать «короткими фразами»? Разумеется, в первую очередь, это дело привычки. Не думаю, что мне будет по силам формализовать этот процесс, я лишь попробую передать свои ощущения о том, как можно себе помочь.

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

Например, вы говорите себе: «если точка внутри прямоугольника, то» и при этом пишите: «if (x >= Rect. Left) and (x <= Rect. Right) and (y >= Rect. Top) and (y <= Rect. Bottom) then». Кто мешает вам написать сразу «if PointOnRect(x, y, Rect) then»? И не важно, что у вас пока нет функции PointOnRect, вы её легко напишите следующим шагом. А если даже забудите — комптлятор вам подскажет.

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

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

begin

Rect:= ПолучитьПрямоугольник;

Point:= ПолучитьТочку;

if ТочкаВПрямоугольнике(Point, Rect) then

ДобавитьТочку(Point); 

end;

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

Сам устыдился своих упрёков и решил перевести в тот код, который сам бы и написал:

begin

Rect:= GetRect;

Point:= GetPoint;

if PointOnRect(Point, Rect) then

 AddPoint(Point); 

end;

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

Пользуясь случаем, приведу ещё один довод в пользу коротких функций. Как правило, они более конкретны. То есть не «делают то, то и ещё это за одно», а делают что–то одно и только это. Так вот, если принять во внимание, что не бывает код без ошибок, а это вообще говоря так и есть, возникает проблема: как узнать правильно работает функция или нет. Разве легко это сделать, если назначение функции не совсем понятно? Чем больше у вас кода, правильную работу которого легко проверить, тем лучше.

Когда не следует выделять функцию

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

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

1. Процедура выбора из однотипной информации. Как правило, такая проблема возникает в конструкциях case (switch для C-подобныхьязыков) или «if … then … else if … then … else if … then …». Данные блоки могу включать в себя десятки, сотни и даже тысячи условий, занимая, разумеется, значительно больше одного экрана, но, разбивать такие блоки всё–таки не стоит.

Это справедливо для блоков с именно однотипными проверками. Если условия можно как–то сгруппировать, то можно каждую группу вынести в свою функцию (например, слова на букву «А» обрабатывает одна функция, на «Б» — другая и так далее).

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

3. Математические выражения также редко удаётся разбить на составляющие. Например, если вы что–то считаете по формуле — вам будет крайне сложно придумать адекватное название для расчёта части этой формулы.

4. Когда функция вам не мешает. То есть не следует проводить рефакторинг ради рефакторинга, маниакально выискивая «плохие» места в коде. Куда разумнее улучшать код только после того, как наткнулся на него в рамках какой–то задачи или более крупного рефакторинга.

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

Использование модулей

Удобно когда всё в одном файле и ничего искать не нужно? С одной стороны, конечно, да. Но это справедливо только для весьма небольших проектов.

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

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

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

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

Более сложные способы организации данных

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

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

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

Я уже писал, что название переменной должно отражать её содержимое. То есть переменная с названием Line — должна каким–то образом описывать отрезок или прямую. Чаще всего данную сущность описывают двумя точками. Если пользоваться только простыми типами — нам придётся завести 4 переменные, например, X1, Y1, X2, Y2. А в случае, если у нас несколько линий, для того, чтобы отличать одну от другой — нам придётся переназвать переменные, например: Line1X1, Line1Y1, Line1X2, Line1Y2. Не очень удобно, правда?

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

И так, мы решили создать класс для нашей линии. Вот так видит декларация (описание) класса:

type

TLine = class(TObject)

public

X1: Integer;

Y1: Integer;

X2: Integer;

Y2: Integer;

end;

В данном случае — ключевое слово type — определяет начало блока объявлений новых типов данных. Стандартная конструкция языка Pascal. Для других языков синтаксис будет отличаться. TLine — название нашего класса. “= class(TObject)» — означает, что мы определяем класс, наследованный от класса TObject. Это базовый класс в Object Pascal. Все классы так или иначе наследованы от него. Подробнее на эту тему поговорим, когда будем обсуждать наследование.

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

Мы создали новый класс, но как им пользоваться? Для этого надо создать экземпляр класса:

var

Line: TLine;

begin

Line:= TLine. Create;

end;

В данном фрагменте кода мы объявляем переменную Line типа TLine, после чего, создаём новый экземпляр класса TLine и присваиваем его переменной Line.

Часто путают понятия класс и объект, так вот, в нашем случае объект — это Line, а класс — TLine.

Объединение данных и кода

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

function CalculateLineLength(Line: TLine): Double;

begin

Result:= Sqrt(Sqr(Line. X2 — Line. X1) + Sqr(Line. Y2 — Line. Y1));

end;

var

Line: TLine;

LineLenght: Double;

begin

Line:= TLine. Create;

Line. X1:= 10;

Line. Y1:= 10;

Line. X2:= 20;

Line. Y2:= 20;

LineLenght:= CalculateLineLength(Line);

end;

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

Если приглядется, то в нашей новой функции слишком часто упоминается название переменной Line. Возможно есть способ сделать код несколько проще и наглядней? К счастью да. Дело в том, что функцию можно перенести непосредственно в класс:

type

TLine = class(TObject)

public

X1: Integer;

Y1: Integer;

X2: Integer;

Y2: Integer;

function CalculateLineLength: Double;

end;

function TLine. CalculateLineLength: Double;

begin

Result:= Sqrt(Sqr(X2 — X1) + Sqr(Y2 — Y1));

end;

В месте использования код также становтся наглядней. Строка LineLenght:= CalculateLineLength(Line); заменится на строку: LineLenght:= Line. CalculateLineLength;, что несколько короче и куда лучше подчёркивает тот факт, что функция относится именно к линии, а не к чему–то ещё.

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

И немного из терминологии. Функцию член класса принято называть методом. В нашем случае, мы имеем класс с одним методом CalculateLineLength.

Приватные члены класса

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

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

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

Вот как это могло бы выглядеть:

type

TLine = class(TObject)

public

X1: Integer;

Y1: Integer;

X2: Integer;

Y2: Integer;

LengthCalculated: Boolean;

LineLength: Double;

function CalculateLineLength: Double;

end;

function TLine. CalculateLineLength: Double;

begin

if not LengthCalculated then

begin

LineLength:= Sqrt(Sqr(X2 — X1) + Sqr(Y2 — Y1));

LengthCalculated:= True;

end;

Result:= LineLength;

end;

При всей иллюзии работоспособности, в данном коде присутствует целый спектр проблем. Во–первых, тому, кто будет использовать класс снаружи, будет неочевидно, что необходимо вызывать функцию CalculateLineLength и не корректно напрямую использовать поле LineLength. Ну а во–вторых — нет механизма пересчёта длины при изменении координат точек.

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

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

Кроме public, есть ещё и ключевое слово private, которое означает, что члены класса доступны только из методов данного класса и не доступны за его пределами. В Object Pascal реализации, используемой в компиляторе Delphi, у данной функциональности есть особенность. Видимость private распространяется не только на членов класса, но и на весь модуль, в котором объявлен класс, что является неким отклонением от общих принципов, но уж так сложилось исторически, ничего не попишешь.

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

Давайте посмотрим, как будет выглядеть декларация нашего класса, если мы унесём в private секцию всё лишнее:

type

TLine = class(TObject)

private

LengthCalculated: Boolean;

LineLength: Double;

public

X1: Integer;

Y1: Integer;

X2: Integer;

Y2: Integer;

function CalculateLineLength: Double;

end;

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

Свойства

Для того, чтобы можно было каким–то образом реагировать на изменения значений полей классов, были придуманы свойства (property). Они так же могут перекликаться с понятиями getter и setter.

Сначала я приведу пример кода, а потом поясню что происходит. Думаю так будет понятнее:

type

TLine = class(TObject)

private

LengthCalculated: Boolean;

LineLength: Double;

FX2: Integer;

FY2: Integer;

FX1: Integer;

FY1: Integer;

procedure SetX1(const Value: Integer);

procedure SetX2(const Value: Integer);

procedure SetY1(const Value: Integer);

procedure SetY2(const Value: Integer);

public

property X1: Integer read FX1 write SetX1;

property Y1: Integer read FY1 write SetY1;

property X2: Integer read FX2 write SetX2;

property Y2: Integer read FY2 write SetY2;

function CalculateLineLength: Double;

end;

function TLine. CalculateLineLength: Double;

begin

if not LengthCalculated then

begin

LineLength:= Sqrt(Sqr(X2 — X1) + Sqr(Y2 — Y1));

LengthCalculated:= True;

end;

Result:= LineLength;

end;

procedure TLine. SetX1(const Value: Integer);

begin

LengthCalculated:= False;

FX1:= Value;

end;

procedure TLine. SetX2(const Value: Integer);

begin

LengthCalculated:= False;

FX2:= Value;

end;

procedure TLine. SetY1(const Value: Integer);

begin

LengthCalculated:= False;

FY1:= Value;

end;

procedure TLine. SetY2(const Value: Integer);

begin

LengthCalculated:= False;

FY2:= Value;

end;

И так, что мы сделали. Мы добавили полям X1, Y1, X2, Y2 префик «F» и перенесли в private секцию. Префикс «F» — это стандартный, исторически сложившийся префикс для обозначения приватного поля (сокращение от Field, поле). После этого напрямую менять значения координат снаружи класса стало невозможно.

Параллельно, также в private секции мы создали набор функций SetX1, SetY1, SetX2, SetY2 для корректной установки соответствующих значений. Помимо своих непосредственных обязанностей, они также сбрасывают флаг LengthCalculated, чтобы при последующем обращении некорректная уже длина пересчиталась заново.

Для доступа к приватным полям снаружи — предусмотрены свойства. Строка «property X1: Integer read FX1 write SetX1;” означает, что мы свойство X1, при чтении которого будет возвращаться значение FX1 (то, что после ключевого слова read), а при записи — будет вызываться функция SetX1 (то, что после ключевого слова write).

Таким образом, запись: «X:= Line. X1» эквивалентна записи «X:= Line. FX1», а запись «Line. X1:= X» эквивалентна записи «Line. SetX1(X)».

Наследование

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

type

TCircle = class(TObject)

public

X: Integer;

Y: Integer;

D: Integer;

end;

TRectangle = class(TObject)

public

X1: Integer;

Y1: Integer;

X2: Integer;

Y2: Integer;

end;

Круг вполне описывается координатами центра (X, Y) и диаметром (D), а прямоугольник — двумя точками (X1, Y1 и X2, Y2).

Допустим у нас есть программа для рисования кругов и прямоугольников. Каждый раз, когда мы рисуем новый круг, он кладётся в массив Circles: array of TCircle, а когда рисуем новый прямоугольник, он кладётся в массив Rectangles: array of TRectangle.

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

Заведём для этого в каждом классе функцию HitTest, которая будет возвращать True в случае, если наша точка находится внутри графического примитива и False в противном случае:

type

TCircle = class(TObject)

public

X: Integer;

Y: Integer;

D: Integer;

function HitTest(aX, aY: Integer): Boolean;

end;

type

TRectangle = class(TObject)

public

X1: Integer;

Y1: Integer;

X2: Integer;

Y2: Integer;

function HitTest(X, Y: Integer): Boolean;

end;

function TCircle. HitTest(aX, aY: Integer): Boolean;

begin

Result:= Sqrt(Sqr(X — aX) + Sqr(Y — aY)) <= D;

end;

function TRectangle. HitTest(X, Y: Integer): Boolean;

begin

Result:= (X1 <= X) and (X <= X2) and (Y1 <= Y) and (Y <= Y2);

end;

Это была реализация классов, а тут реализация базовой функции HitTest, которая должна проверить все наши объекты:

var

Circles: array of TCircle;

Rectangles: array of TRectangle;

function HitTest(X, Y: Integer): Boolean;

var

I: Integer;

begin

Result:= False;

for I:= 0 to Length(Rectangles) — 1 do

begin

Result:= Rectangles[I].HitTest(X, Y);

if Result then

Exit;

end;

for I:= 0 to Length(Circles) — 1 do

begin

Result:= Circles[I].HitTest(X, Y);

if Result then

Exit;

end;

end;

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

В чём тут смысл? Вместо нескольких массивов для каждого графического примитива отдельно, мы заводим класс, который описывает графический примитив, назовём его, например, TShape. Далее мы хотим хранить все наши примитивы, независимо от их типа в массиве «Shapes: array of TShape». Но так просто у нас это не получится, компилятор ругнётся, что типы не совместимы.

Для того, чтобы мы смогли положить наши прямоугольники и круги в массив TShape, нам надо поменять их наследование. Заменим TObject на TShape в объявлении класса:

type

TShape = class(TObject)

end;

TCircle = class(TShape)

public

…..

end;

TRectangle = class(TShape)

public

…..

end;

Теперь наш круг одновременно и круг и графический примитив, равно как и в действительности. То же самое можно сказать и о квадрате. Что это значит с точки зрения программирования? То, что класс TCircle, наследник класса TShape можно использовать везде, где можно использовать класс TShape. Более того, все переменные и методы класса TShape (кроме private) будут также доступны в классе TCircle.

Не буду сильно углубляться в теорию, всё–таки я предпочитаю объяснять на примерах, поэтому сразу перейдём к тому как изменится наша функция с попмощью этого нехитрого преобразования:

var

Shapes: array of TShape;

function HitTest(X, Y: Integer): Boolean;

var

I: Integer;

begin

Result:= False;

for I:= 0 to Length(Shapes) — 1 do

begin

if Shapes[I] is TCircle then

Result:= (Shapes[I] as TCircle).HitTest(X, Y)

else if Shapes[I] is TRectangle then

Result:= (Shapes[I] as TRectangle).HitTest(X, Y)

if Result then

Exit;

end;

end;

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

Чтобы оценить мощь наследования нам остался всего один шаг. В класс TShape добавим строку «function HitTest(X, Y: Integer): Boolean; virtual; abstract;”, а в классы TCircle и TRectangle добавим после аналогичных строчек ключевое слово override:

type

TShape = class(TObject)

public

function HitTest(X, Y: Integer): Boolean; virtual; abstract;

end;

TCircle = class(TShape)

public

 …..

function HitTest(X, Y: Integer): Boolean; override;

end;

TRectangle = class(TShape)

public

…..

function HitTest(X, Y: Integer): Boolean; override;

end;

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

Нашу многострадальную функцию теперь можно переписать так:

var

Shapes: array of TShape;

function HitTest(X, Y: Integer): Boolean;

var

I: Integer;

begin

Result:= False;

for I:= 0 to Length(Shapes) — 1 do

begin

Result:= Shapes[I].HitTest(X, Y);

if Result then

Exit;

end;

end;

При этом, в случаю кругов, в реальности будет вызываться функция TCircle. HitTest, а в случае прямоугольников — TRectangle. HitTest.

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

TShape. Move(dx, dy: Integer); virtual; abstract;

для перемещения примитива,

TShape. Rotate(x, y: Integer; angel: Double); virtual; abstract;

для поворота вокруг точки,

TShape. Flip(Line: TLine); virtual; abstract;

для зеркального отображения вокруг прямой.

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