16 января 2011 г.

Как писать понятный код - руководство для учащихся

aka "Как писать код, понятный хотя бы себе самому"

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

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

Этот пост - попытка рассказать, что можно сделать с такой ситуацией.

Не способные понять свой же кусок кода, студенты публикуют свой код целиком на форумах, спрашивая, где тут ошибка? Или (наиболее продвинутые): как можно улучшить этот код? Вот пример такого кода (это ещё самый безобидный и маленький код!):
mf[1]:=false;
for i:=2 to num do mf[i]:=true;
for i:=2 to num div 2 do
begin
j:=i+i;
while j <= num do 
begin
mf[j]:=false;
j:=j+i;
end;
end;
for i:=1 to num do
if mf[i] then writeln(i,' - OK');
О, Боже! Что, по-вашему, делает этот кусок кода? Его непонятность не имеет никакого обоснования. Каким бы опытным ни был программист, никто вам так сразу не ответит на этот вопрос. Не удивительно, что и вы его не понимаете! (а уж каково преподавателю, которому нужно вникать в десятки подобного вида работ) Не удивительно, что вы не можете найти ошибки в своём коде - ведь вы его даже не понимаете! И дело тут не в отсутствии комментариев, а в плохом стиле программирования. Имена переменных неинформативны, а форматирование практически отсутствует.

Вот улучшенный вариант кода:
IsPrime[1] := False;
for PrimeCandidate := 2 to MaxPrimes do
  IsPrime[PrimeCandidate] := True;

for Factor := 2 to MaxPrimes div 2 do
begin
  FactorableNumber := Factor + Factor;
  while FactorableNumber <= MaxPrimes do 
  begin
    IsPrime[FactorableNumber] := False;
    FactorableNumber := FactorableNumber + Factor;
  end;
end;

for PrimeCandidate := 1 to MaxPrimes do
  if IsPrime[PrimeCandidate] then
    WriteLn(PrimeCandidate, ' - простое число');
Одного взгляда на этот код достаточно, чтобы понять, что он имеет какое-то отношение к простым числам (prime numbers). Второй взгляд показывает, что он находит простые числа от 1 до MaxPrimes.

Что касается первого фрагмента, то, взглянув пару раз, вы даже не поймёте, где заканчиваются циклы!

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

Форматирование кода

Первый самый важный (и относительно простой) момент - форматирование кода. От форматирования кода не зависят скорость выполнения, объём требуемой памяти и другие внешние аспекты программы. Но от форматирования кода зависит, насколько легко вы можете понять, пересмотреть и исправить код. А также, насколько легко поймёт этот код другой человек (скажем, преподаватель).

Посмотрите на этот код:
{ Вычисление простых чисел от 1 до MaxPrimes }{ Инициализация }IsPrime[1]:=False;for PrimeCandidate:=2 to 
MaxPrimes do IsPrime[PrimeCandidate]:=True;{ Поиск простых чисел: вычёркиваем каждое число, кратное Factor }
for Factor:=2 to MaxPrimes div 2 do begin FactorableNumber:=Factor+Factor;while FactorableNumber<=MaxPrimes 
do begin IsPrime[FactorableNumber]:=False;FactorableNumber:=FactorableNumber+Factor;end;end;{ Вывод 
результатов (списка простых чисел) на консоль }for PrimeCandidate:=1 to MaxPrimes do if
IsPrime[PrimeCandidate] then WriteLn(PrimeCandidate,' - простое число');
Этот код синтаксически корректен. Он прокомментирован и содержит хорошие имена переменных и понятную логику. Если не верите, то попробуйте прочитать его. Это в точности второй блок кода этого поста, но с добавленными комментариями.

Чего этому коду не хватает - так это хорошего форматирования.

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

Вот улучшенный вариант:
{ Вычисление простых чисел от 1 до MaxPrimes }

{ Инициализация }
IsPrime[1] := False;
for PrimeCandidate := 2 to MaxPrimes do
  IsPrime[PrimeCandidate] := True;

{ Поиск простых чисел: вычёркиваем каждое число, кратное Factor }
for Factor := 2 to MaxPrimes div 2 do
begin
  FactorableNumber := Factor + Factor;
  while FactorableNumber <= MaxPrimes do 
  begin
    IsPrime[FactorableNumber] := False;
    FactorableNumber := FactorableNumber + Factor;
  end;
end;

{ Вывод результатов (списка простых чисел) на консоль }
for PrimeCandidate := 1 to MaxPrimes do
  if IsPrime[PrimeCandidate] then
    WriteLn(PrimeCandidate, ' - простое число');
Это форматирование определённо находится в строго положительной части шкалы вариантов форматирования. Теперь код гораздо удобнее читать, а усилия, приложенные к комментированию и выбору хороших имён переменных, теперь очевидны. Всё это было и в предыдущем куске кода, но с таким плохим форматированием от них не было никакой пользы!

Единственное различие между этими двумя кусками кода заключается в расстановке пробелов и переносов строк - в остальном код совершенно одинаков. Пробелы и разрывы строк нужны исключительно людям. Машина читает все эти куски кода одинаково легко.

Самое простое, что можно сделать с форматированием - использовать инструмент автоматического форматирования кода. Например, в некоторых версиях Delphi такой инструмент уже есть. Вызывается он из меню Project / Format Project Sources:


Среда спросит вас, точно ли вы хотите отформатировать код в стандартный стиль оформления. Отвечайте "Yes" (Да) и весь код, подключенный в проект будет отформатирован в стандартном стиле.

Если вы используете Lazarus, то аналогичная команда находится в меню Service (Сервис):


А если вы используете PascalABC.NET, то аналогичная команда находится в меню Сервис (но только в автономной версии среды, а не online WDE):


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

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

Что делать, если в вашей версии Delphi такой команды нет? Вы можете воспользоваться программой JEDI Code Format (скачать) или DelForExp (скачать). Скачайте архив с программой (для DelForExp проще всего выбрать "Standalone version"). Распакуйте архив с программой. Теперь запускайте программу - файл JCFGui.exe для JEDI Code Format или DelFor.exe для DelForExp.

Для JEDI Code Format вам также понадобятся настройки стиля форматирования. Можете взять вот эти. Распакуйте этот архив в ту же папку, куда вы распаковали JEDI Code Formatter. Затем, укажите этот файл в настройках программы:


Теперь вы можете использовать команду File / Open (или соответствующую кнопку на панели инструментов), чтобы указать файл для форматирования:


Вы можете также установить опцию "No backup", как я сделал это на снимке экрана выше - такая настройка переформатирует файл "на месте".

Теперь достаточно нажать кнопку с зелёной стрелочкой и файл будет переформатирован.

Что касается DelForExp, то в нём всё то же самое: File / Open, указали файл, нажали на кнопку форматирования (только там нарисована молния, а не стрелочка, как в JEDI Code Format) и сохранили результат:


К сожалению, все описываемые способы имеют разные возможности. Кто-то выполняет очень мало действий и имеет мало настроек (или не имеет их вовсе), кто-то позволяет довольно много всего. Наиболее функциональными вариантами видятся JEDI Code Format и форматтер в Delphi. Наименее функциональными - встроенные варианты в Lazarus и PascalABC.NET.

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

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

Комментирование кода

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

К примеру, что делает этот код?
// вывод сумм чисел 1..n для всех n от 1 до num
current := 1;
previous := 0;
sum := 1;
for i := 0 to num do
begin
  WriteLn("Sum = ", sum);
  Sum := Current + Previous;
  Previous := Current;
  Current := Sum;
end;
Этот метод вычисляет первые num чисел Фибоначчи. Стиль кодирования этого метода чуть лучше, чем у самого первого нашего примера, но комментарий, описывающий код, неверен. Это сводит на нет всю пользу от комментирования кода. Если вы поверите этому комментарию, то будете введены в заблуждение.

А что скажете насчёт такого кода?
// присваивание переменной "product" значения переменной "base"
product := base;

// цикл от 2 до "num"
for i := 2 to num 
  // умножение "base" на "product"
  product := product * base;

WriteLn('Результат: ', product);
Этот метод возводит целое число base в целую степень num. Комментарии в этом коде верны, но они не говорят о коде ничего нового. Это не более чем многословная версия самого кода. Цикл от 2 до "num"? Я и так вижу, что это цикл от 2 до num - зачем это повторять? Это только создаёт лишний шум (мусор).

Наконец, ещё один код:
// вычисление квадратного корня из num с помощью аппроксимации Ньютона-Рафсона
r := num / 2;
while abs(r - num/r) > tolerance do
  r := 0.5 * (r + num/r);
WriteLn('r = ', r);
Код вычисляет квадратный корень из num. Код не идеален, но комментарий верен и комментирует цель кода, а не дублирует код.

Какой метод было проще всего понять? Все они написаны довольно плохо - особенно неудачны имена переменных. Эти куски кода иллюстрируют достоинства и недостатки комментариев. Комментарий первого кода неверен. Комментарии второго кода просто дублируют код и потому бесполезны. Только комментарии третьего кода оправдывают своё существование.

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

Вот несколько правил, придерживаясь которых, вы сделаете свою программу понятнее:
  • Не пишите комментарии, которые повторяют код.
  • Пишите комментарии на уровне цели кода. Комментарий должен отвечать на вопрос "зачем?" или "почему?", а не "как?" или "что?". Что и как - видно из кода. А комментарий должен говорить о том, что код не говорит - а зачем мы вообще это делаем? К примеру:
    // Если флаг равен нулю, то...
    if AccountFlag = 0 then
    А вот пример полезного комментария, отвечающего на вопрос "почему?":
    // Если создаётся новый отчёт...
    if AccountFlag = 0 then
    Этот комментарий гораздо лучше, потому что говорит что-то, чего нет в коде.
  • Рассмотрите возможность замены комментария улучшением кода. К примеру, предыдущий пример можно было переписать так:
    const
      atNewType = 0;
    
    ...
    
    if AccountFlag = atNewType then
    или так:
      function IsNewReport: Boolean;
      begin
        Result := (AccountFlag = atNewType);
      end;
    
    ...
    
    if IsNewReport then
    В обоих случаях код становится настолько очевиден, что комментарий уже не нужен. (дальнейшее улучшение: переименовать AccountFlag в AccountType и сделать её не числом, а перечислимым типом.)
  • Большинство полезных комментариев в программе состоят из одной-двух строк и комментируют блок кода за ними, например:
    // Перемена корней местами
    OldRoot := Root[0];
    Root[0] := Root[1];
    Root[1] := OldRoot;
  • Избегайте комментирования отдельных строк кода. Если отдельная строка требует комментирования - это признак, что её надо переписать. Сюда же относятся комментарии в конце строк. Да, иногда бывают и исключения, но обычно польза таких комментариев сомнительна. Хороший пример полезного использования комментарии в конце строк - пояснение цели переменной при её объявлении.
  • Размещайте комментарии на отдельных строках.
  • Используйте
    // простой комментарий
    для однострочных комментариев и
    { длинный
    комментарий }
    для многострочных.
  • Придерживайтесь одного стиля комментирования. К примеру, вставляйте поясняющий комментарий до блока кода, а не после. Не отделяйте комментарий пустыми строками от блока кода, к которому он относится. Но вставьте по пустой строке до и после всего блока с комментарием, чтобы отделить их от других аналогичных блоков.
  • Не украшайте комментарии сверх меры. Это затрудняет чтение и их модификацию. Если вы тратите своё время на исправлениие оформления и выравнивания комментариев или стиля кода после того, как вы переименовали переменную, то вы не программируете - вы занимаетесь ерундой. Используйте такой стиль, который не потребует переформатирования при правках. Вот пример неудачного стиля:
    // Настройка работы программы
    // +------------------------+
    Если длина комментария меняется, вам нужно выравнивать оформление.
  • Избегайте сокрашений. Цель комментария - пояснить код. Использование сокрашений не помогает достижению этой цели. Не нужно заставлять читающих расшифровывать обозначения.

Кодирование

Программирование с псевдокодом

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

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

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

Попробовав этот способ на практике, вы обнаружите, что некоторые способы формулировки ваших мыслей предпочтительнее других:
  • Применяйте формулировки, в точности описывающие отдельные действия.
  • Избегайте использования элементов языка программирования. Псевдокод - это более высокий уровень. Не надо его ограничивать языком программирования.
  • Пишите псевдокод на уровне цели/намерений. Говорите про то, что нужно сделать, а не про то, как это делать.
  • Однако пишите псевдокод на уровне, позволяющем переписать его на языке программирования. Если шаги псевдокода будут иметь слишком высокий уровень, то нужно дальше детализировать псевдокод.
Например, процесс кодирования одной задачи мог бы выглядеть примерно так:
  • Определите решаемую задачу. Сформулируйте задачу, которую будете решать. Определите входные данные (что вводим), выходные данные (результаты, что получаем), обязательно соблюдаемые условия (к примеру, какой-то параметр должен быть больше нуля и т.д.), что метод должен скрывать, а что - показывать.
  • Исследуйте существующую функциональность. Посмотрите, быть может эту задачу решает какая-то стандартная функция языка, либо какой-то другой, уже написанный вами, метод.
  • Выберите название метода или функции, выполняющей задачу. Вопрос выбора хорошего названия кажется тривиальным, но дело это непростое. Затруднение в выборе имени может свидетельствовать, что задача не понятна, либо вы пытаетесь делать несколько вещей в одном месте.
  • Продумайте обработку ошибок. Подумайте о всём плохом, что может случится. Что если кто-то передал вам -1 в параметре, который должен быть больше 0? Что если пользователь ввёл не число? Что если файл уже существует? Продумайте, как вы будете реагировать на эти ситуации.
  • Продумайте типы данных, с которыми вы собираетесь работать.
  • Исследуйте алгоритмы и типы данных. Вы можете взять готовый алгоритм и адаптировать его к своей задаче.
  • Напишите псевдокод. Если вы прошли предыдущие этапы, то это не должно составить сложности. Вы можете писать псевдокод прямо в редакторе кода Delphi. Начните с основных моментов, с самого верхнего уроня, а затем детализируйте их.
  • Вы можете написать несколько вариантов псевдокода и выбрать лучший.
  • Сделайте псевдокод комментариями и закодируйте его на языке программирования.
Давайте посмотрим, как это работает на примере. Пусть перед нами стоит задача "найти все простые числа до заданного пользователем". Вот как бы могли её решать:
  • Итак, сначала мы определяем условия задачи. Нужно написать программу, которая будет искать простые числа. Максимальный предел вводится пользователем, так что нам понадобится ввести одно число. В результате мы получим список чисел - нам их надо куда-то вывести. По определению, Простые числа больше нуля и 1 - не простое число. Так что разумно наложить ограничение, что входной параметр должен быть целым числом, большим единицы. Выходными данными будет массив целых чисел, представляющих собой простые числа.

    Уже в этот момент можно запустить Delphi, создать новое VCL приложение и бросить на форму Edit (для ввода данных), Memo (для вывода данных) и Button (для запуска поиска).

     
  • Выбор названий. Ну, давайте назовём Edit на форме - edMaxPrime, Memo - mmPrimes, а кнопку - btCalculatePrimes. Здесь же можно быстренько сделать косметические изменения - типа ReadOnly для Memo и так далее.

    Далее надо написать интерфейс метода, который будет выполнять поставленную задачу. В данном случае название метода достаточно очевидно - скажем, CalculatePrimes. Как мы определили в анализе предварительных требований, ему на вход нужно число - максимальное простое число для поиска, а нам он должен вернуть массив простых чисел. Запишем это так:
    type
      TPrimes = array of Integer;
    
      TfmMain = class(TForm)
        edMaxPrime: TEdit;
        mmPrimes: TMemo;
        btCalculatePrimes: TButton;
      private
        function CalculatePrimes(const AMaxPrimes: Integer): TPrimes;
      end;
    
    ...
    
    implementation
    
    ...
    
    function TfmMain.CalculatePrimes(const AMaxPrimes: Integer): TPrimes;
    begin
    end;
    Добавим заголовочный комментарий поясняющий метод, его требования, а заодно и определённый нами пользовательский тип:
    type
      TPrimes = array of Integer; // массив простых чисел  
    
    ...
    
      private
        // Ищет простые числа до заданного в AMaxPrimes (включительно).
        // AMaxPrimes должно быть больше 1
        function CalculatePrimes(const AMaxPrimes: Integer): TPrimes;
      end;
    Как видите, уже здесь в код включается важная информация - условие для AMaxPrimes и описание поведение при краевом случае (включать ли в результат само число AMaxPrimes или нет).

     
  • Далее, надо бы выбрать или придумать алгоритм, которым мы будем решать эту задачу. Иногда, этот шаг фиксируется преподавателем, иногда он остаётся на ваш выбор. В данном случае мы выберем решето Эратосфена (ну, это будет не в точности этот алгоритм, но очень похож).

     
  • Будем последовательно записывать алгоритм на псевдокоде:
    function TfmMain.CalculatePrimes(const AMaxPrimes: Integer): TPrimes;
    begin
      // Найти все простые числа до AMaxPrimes методом решета Эратосфена
    end;
    Затем:
    // Найти все простые числа до AMaxPrimes методом решета Эратосфена
    function TfmMain.CalculatePrimes(const AMaxPrimes: Integer): TPrimes;
    begin
      // Число 1 - не простое (по определению)
      // Все числа от 2 до AMaxPrimes изначально простые
       
      // Для каждого числа от 2 до AMaxPrimes:
        // Вычеркнуть все числа, делящиеся на это число
    
      // Выписать все не вычеркнутые числа
    end;
    (Здесь единственный комментарий в первом варианте является, по сути, заголовочным, поэтому мы вынесем его в описание функции)

    Достаточно просто. Вы можете легко увидеть, что должна делать программа и проверить правильность её действий.

    Мы создали достаточно подробный псевдокод всего в два шага. Если бы алгоритм был бы сложнее, нам бы могло потребоваться несколько проходов с всё более подробным описанием деталей. Хотя, возможно, последний шаг можно было бы расписать подробнее. Но пока остановимся на этом.

     
  • Теперь под каждым блоком комментария нужно написать код. Если с написанием кода возникают сложности, то это признак того, что псевдокод нужно больше детализировать. Начнём с первого блока:
    var
      IsPrime: array of Boolean; // признак, простое ли это число (для индекса массива)
      PrimeCandidate: Integer;   // кандидат в простое число
    begin
      SetLength(IsPrime, AMaxPrimes + 1);
    
      // Числа 0 и 1 - не простые (по определению)
      IsPrime[0] := False;
      IsPrime[1] := False;
    
      // Все числа от 2 до AMaxPrimes изначально простые
      for PrimeCandidate := 2 to AMaxPrimes do
        IsPrime[PrimeCandidate] := True;
    
      // Для каждого числа от 2 до AMaxPrimes:
        // Вычеркнуть все числа, делящиеся на это число
    
      // Выписать все не вычеркнутые числа
    end;
    Вы ввели массив для отслеживания "вычеркнутости" числа и закодировали первые строчки псевдокода. Заметьте, что при переводе псевдокода на реальный код у нас возникли новые детали: динамические массивы в Delphi начинаются с нуля, так что нам пришлось ещё описать ситуацию с нулём.

    Продолжаем кодировать:
    var
      IsPrime: array of Boolean; // признак, простое ли это число (для индекса массива)
      PrimeCandidate: Integer;   // кандидат в простое число
      Factor: Integer;           // исходное число для проверки 
    begin
      SetLength(IsPrime, AMaxPrimes + 1);
    
      // Числа 0 и 1 - не простые (по определению)
      IsPrime[0] := False;
      IsPrime[1] := False;
    
      // Все числа от 2 до AMaxPrimes изначально простые
      for PrimeCandidate := 2 to AMaxPrimes do
        IsPrime[PrimeCandidate] := True;
    
      // Для каждого числа от 2 до AMaxPrimes:
      for Factor := 2 to AMaxPrimes do
      begin
        // Вычеркнуть все числа, делящиеся на это число
      end;
    
      // Выписать все не вычеркнутые числа
    end;
    И далее:
    var
      IsPrime: array of Boolean; // признак, простое ли это число (для индекса массива)
      PrimeCandidate: Integer;   // кандидат в простое число
      Factor: Integer;           // исходное число для проверки 
      FactorableNumber: Integer; // кратное число для вычёркивания
    begin
      SetLength(IsPrime, AMaxPrimes + 1);
    
      // Числа 0 и 1 - не простые (по определению)
      IsPrime[0] := False;
      IsPrime[1] := False;
    
      // Все числа от 2 до AMaxPrimes изначально простые
      for PrimeCandidate := 2 to AMaxPrimes do
        IsPrime[PrimeCandidate] := True;
    
      // Для каждого числа от 2 до AMaxPrimes:
      for Factor := 2 to AMaxPrimes do
      begin
        // Вычеркнуть все числа, делящиеся на это число
        FactorableNumber := Factor + Factor;
        while FactorableNumber <= AMaxPrimes do
        begin
          IsPrime[FactorableNumber] := False;
          FactorableNumber := FactorableNumber + Factor;
        end;
      end;
    
      // Выписать все не вычеркнутые числа
    end;
    И так далее. В итоге вы получите готовый код.

     
  • Проверьте, не нужна ли дальнейшая декомпозиция получившегося кода. К примеру, псевдокод, сконвертированный в реальный код может существенно разростись. Тогда его имеет смысл разбить на несколько методов. Или вы можете увидеть, что в результирующем коде у вас есть большие блоки кода, занимающиеся логически связанным действиями. Либо это может быть повторяющийся код. К примеру, в коде выше первый блок кода проводит инициализацию, второй блок - поиск, а третий - вывод результатов. Вот как вы могли бы переписать код:
    // Найти все простые числа до AMaxPrimes методом решета Эратосфена
    function TfmMain.CalculatePrimes(const AMaxPrimes: Integer): TPrimes;
    var
      IsPrime: array of Boolean; // признак, простое ли это число (для индекса массива)
    
      procedure Init;
      var
        PrimeCandidate: Integer; // кандидат в простое число
      begin
        SetLength(IsPrime, AMaxPrimes + 1);
    
        // Числа 0 и 1 - не простые (по определению)
        IsPrime[0] := False;
        IsPrime[1] := False;
    
        // Все числа от 2 до AMaxPrimes изначально простые
        for PrimeCandidate := 2 to AMaxPrimes do
          IsPrime[PrimeCandidate] := True;
      end;
    
      procedure FindPrimes;
      var
        Factor: Integer;
        FactorableNumber: Integer;
      begin
        // Для каждого числа от 2 до AMaxPrimes:
        for Factor := 2 to AMaxPrimes do
        begin
          // Вычеркнуть все числа, делящиеся на это число
          FactorableNumber := Factor + Factor;
          while FactorableNumber <= AMaxPrimes do
          begin
            IsPrime[FactorableNumber] := False;
            FactorableNumber := FactorableNumber + Factor;
          end;
        end;
      end;
    
      procedure SetResult;
      begin
        ...
      end;
    
    begin
      Init;
      FindPrimes;
      SetResult;
    end;
    Названия подпрограмм говорят сами за себя и не нуждаются в комментировании. Заметьте, как код программы всё больше и больше начинает напоминать сценарий.

    Этот процесс может быть рекурсивным. Т.е. код в выделенных подпрограммах тоже может потребовть дальнейшего разбиения на подпрограммы. К примеру, код SetResult может быть таким:
    procedure SetResult;
    var
      PrimesCount: Integer;        // Итоговое количество найденных простых чисел
      PrimeCandidate: Integer;     // Кандидат в простое число
      CurrentResultIndex: Integer; // Индекс очередного найденного простого числа в результатах
    begin
      // Подсчитать количество не вычеркнутых чисел
      PrimesCount := 0;
      for PrimeCandidate := 1 to AMaxPrimes do
        PrimesCount := PrimesCount + Ord(IsPrime[PrimeCandidate]);
    
      SetLength(Result, PrimesCount);
    
      // Выписать все не вычеркнутые числа
      CurrentResultIndex := 0;
      for PrimeCandidate := 1 to AMaxPrimes do
        if IsPrime[PrimeCandidate] then
        begin
          Result[CurrentResultIndex] := PrimeCandidate;
          Inc(CurrentResultIndex);
        end;
    end;
    Вы можете захотеть выделить в ней такую подпрограмму:
    procedure SetResult;
    
      function CalculatePrimesCount: Integer;
      var
        PrimeCandidate: Integer;   // Кандидат в простое число
      begin
        Result := 0;
        for PrimeCandidate := 1 to AMaxPrimes do
          Result := Result + Ord(IsPrime[PrimeCandidate]);
      end;
    
    var
      PrimesCount: Integer;        // Итоговое количество найденных простых чисел
      PrimeCandidate: Integer;     // Кандидат в простое число
      CurrentResultIndex: Integer; // Индекс очередного найденного простого числа в результатах
    begin
      // Подсчитать количество не вычеркнутых чисел
      PrimesCount := CalculatePrimesCount;
    
      SetLength(Result, PrimesCount);
    
      // Выписать все не вычеркнутые числа
      CurrentResultIndex := 0;
      for PrimeCandidate := 1 to AMaxPrimes do
        if IsPrime[PrimeCandidate] then
        begin
          Result[CurrentResultIndex] := PrimeCandidate;
          Inc(CurrentResultIndex);
        end;
    end;


     
  • Удаление ненужных комментариев. Некоторые получаемые комментарии могут быть избыточны и могут быть удалены. К примеру, "Для каждого числа от 2 до AMaxPrimes" может быть рассмотрен как избыточный (поскольку он дублирует информацию цикла), но, с другой стороны, он является частью следующего комментария. Лучшим решением будет объединить оба комментария. Не нужным является и "Подсчитать количество не вычеркнутых чисел". В итоге, подчистив всё, мы получаем такой код:
    // Найти все простые числа до AMaxPrimes методом решета Эратосфена
    function TfmMain.CalculatePrimes(const AMaxPrimes: Integer): TPrimes;
    var
      IsPrime: array of Boolean; // признак, простое ли это число (для индекса массива)
    
      procedure Init;
      var
        PrimeCandidate: Integer; // кандидат в простое число
      begin
        SetLength(IsPrime, AMaxPrimes + 1);
    
        // Числа 0 и 1 - не простые (по определению)
        IsPrime[0] := False;
        IsPrime[1] := False;
    
        // Все числа от 2 до AMaxPrimes изначально простые
        for PrimeCandidate := 2 to AMaxPrimes do
          IsPrime[PrimeCandidate] := True;
      end;
    
      procedure FindPrimes;
      var
        Factor: Integer;
        FactorableNumber: Integer;
      begin
        // Для каждого числа от 2 до AMaxPrimes - вычеркнуть все числа, делящиеся на это число
        for Factor := 2 to AMaxPrimes do
        begin
          FactorableNumber := Factor + Factor;
          while FactorableNumber <= AMaxPrimes do
          begin
            IsPrime[FactorableNumber] := False;
            FactorableNumber := FactorableNumber + Factor;
          end;
        end;
      end;
    
      procedure SetResult;
    
        function CalculatePrimesCount: Integer;
        var
          PrimeCandidate: Integer;   // Кандидат в простое число
        begin
          Result := 0;
          for PrimeCandidate := 1 to AMaxPrimes do
            Result := Result + Ord(IsPrime[PrimeCandidate]);
        end;
    
      var
        PrimesCount: Integer;        // Итоговое количество найденных простых чисел
        PrimeCandidate: Integer;     // Кандидат в простое число
        CurrentResultIndex: Integer; // Индекс очередного найденного простого числа в результатах
      begin
        PrimesCount := CalculatePrimesCount;
        SetLength(Result, PrimesCount);
    
        // Выписать все не вычеркнутые числа
        CurrentResultIndex := 0;
        for PrimeCandidate := 1 to AMaxPrimes do
          if IsPrime[PrimeCandidate] then
          begin
            Result[CurrentResultIndex] := PrimeCandidate;
            Inc(CurrentResultIndex);
          end;
      end;
    
    begin
      Init;
      FindPrimes;
      SetResult;
    end;


     
  • Обработка ошибок. Для примера я вынес этот момент в самый конец, но по плану он должен стоять ближе к началу. К примеру, в нашем случае это условие для AMaxPrimes. Суть в том, что в методе должны стоять проверки таких ограничений. Делать это можно разными способами, я не буду рассматривать тут варианты, поскольку это выходит за рамки этой заметки. Укажу лишь способ, которой предпочитаю лично я: в самом методе все его ограничения заворачиваются в Assert, вот так:
    function TfmMain.CalculatePrimes(const AMaxPrimes: Integer): TPrimes;
    
      ...
    
    begin
      Assert(AMaxPrimes > 1);
      Init;
      FindPrimes;
      SetResult;
    end;
    Что касается необходимости получать "читабельные" сообщения об ошибках, то делать это может вызывающий. Пример этого рассмотрен чуть ниже.

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

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

    Дальнейшее тестирование - это прогон программы под отладчиком и проверка её работы экспериментами и бета-тестированием. Чтобы сделать это, нам нужно вообще-то как-то вызвать метод. Если бы это писалось для опытного программиста, то в этом месте стояло бы написание модульных тестов. Для начинающих это явно чересчур сложно, поэтому мы просто вставим метод в программу и проверим его ручками. Вот как это можно сделать:
    procedure TfmMain.btCalculatePrimesClick(Sender: TObject);
    
      procedure PrimesToMemo(const APrimes: TPrimes);
      var
        PrimeNumber: Integer;
      begin
        mmPrimes.Lines.BeginUpdate;
        try
          mmPrimes.Lines.Clear;
    
          for PrimeNumber := 0 to High(APrimes) do
            mmPrimes.Lines.Add(IntToStr(APrimes[PrimeNumber]));
        finally
          mmPrimes.Lines.EndUpdate;
        end;
      end;
    
    var
      MaxPrimes: Integer;
      Primes: TPrimes;
    begin
      MaxPrimes := StrToIntDef(edMaxPrime.Text, -1);
      if MaxPrimes <= 1 then
        raise Exception.Create('Верхняя граница должна быть целым положительным числом, большим 1');
    
      Primes := CalculatePrimes(MaxPrimes);
      PrimesToMemo(Primes);
    end;
    Остаётся запустить программу и погонять её.

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

     
Ну... вот и всё.

Другие темы кодирования

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

Самое минимальное, что тут можно сказать - давайте названия компонентам, которые вы бросаете на форму! Не оставляйте им названия вроде Button1, Edit1, Edit2 и т.д. И снова пример (это реальный пример кода с форума):
procedure TForm6.Button7Click(Sender: TObject);
var
  f,p:string;
begin
  f:=edit6.text;
  p:=label4.caption; 
  s(p + f + '_new');
end;
Что делает этот код? Я понятия не имею. И никто этого не знает, за исключением самого автора кода. Но если вы назовёте компоненты, то код станет понятным:
procedure TEditRecordForm.SaveButtonClick(Sender: TObject);
begin
  f:=FileNameEdit.text;
  p:=PathLabel.caption; 
  s(p + f + '_new');
end;
Тут уже стало понятно, что речь идёт про сохранение редактируемой записи в файл. И ещё небольшие улучшения дадут нам:
procedure TEditRecordForm.SaveButtonClick(Sender: TObject);
var
  FileName: String;
  Path: String;
begin
  FileName := FileNameEdit.Text;
  Path     := PathLabel.Caption; 
  SaveFile(Path + FileName + '_new');
end;

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

Заключение

Я надеюсь, что следуя этим советам, вы сможете писать понятный код. Для удобства я приведу контрольный список вещей для выполнения:
  • Форматирование кода:
    • Делали ли вы автоматическое форматирование кода?
    • Применяется ли форматирование кода для логического структурирования кода?
    • Однообразно ли форматирование?
    • Улучшает ли стиль форматирование читаемость кода?
    • Отделяются ли последовательные блоки друг от друга пустыми строками?
    • Форматируются ли сложные выражения для повышения удобочитаемости?
    • Не содержит ли какая-то строка более одного оператора?
    • Сделаны ли для комментариев такие же отступы, как и для кода?
    • Отформатированы ли прототипы функций и методов так, чтобы их было легко читать?
    • Используются ли пустые строки для разделения составных частей функций и методов?
    • Применяете ли вы схему хранения и именования множества функций, методов и классов?
  • Комментирование кода:
    • Использовали ли вы прототипирование с псевдокодом?
    • Может ли сторонний человек, взглянув на код, понять его?
    • Объясняют ли комментарии цель кода?
    • Переписали ли вы непонятный код, вместо того, чтобы комментировать его?
    • Актуальны ли и правдивы ли комментарии?
    • Позволяет ли стиль комментариев быстро их менять?
  • Стиль кода:
    • Присвоены ли переменным, типам и функциям удачные имена?
    • Выполняют ли функции и методы лишь одно действие?
    • Имеют ли ваши функции и методы небольшой размер?
    • Вынесен ли дублирующийся код в отдельные функции или методы?
    • Очевиден ли и ясен интерфейс каждой функции, метода и класса?
    • Используются ли переменные лишь один раз, а не повторно?
    • Используются ли переменные с той целью, с которой они названы?
    • Используются ли перечислимые типы вместо целочисленных типов с константами или логических флажков?
    • Используте ли вы волшебные константы?
    • Используете ли вы дополнительные переменные для пояснения кода?
    • Просты ли ваши типы данных? Помогают ли они понять программу?
    • Очевиден ли стандартный путь выполнения программы?
    • Сгруппированы ли связанные операторы?
    • Вынесен ли независимый код в отдельные функции или методы?
    • Просты ли управляющие структуры?
    • Выполняют ли циклы только одну функцию?
    • Пишете ли вы программу в терминах проблемной области?
Суммируя совсем кратко: верный способ написать ужасный и непонятный код - сразу же кинуться его писать, не продумав.

Ещё материалы для чтения:
Примечание: я очень ленив, поэтому я просто скомпоновал материал от Макконнелла, плюс чуть-чуть добавил от себя.

13 комментариев :

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

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

    ОтветитьУдалить
  2. Видимо, задача обучения студента писать внятный код попросту неразрешима.
    :-D Наверное, так оно и есть

    При чтении статьи не понял этого предложения (раздел Комментирование кода, в самом конце):
    ...и отделяйте его одной строкой до комментария, но разделяйте пустой строкой код от поясняющего его комментария.
    К это? Что от чего и чем отделять?

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

    ОтветитьУдалить
  4. Александр, у вас во втором, третьем и четвертом блоке кода в последнем цикле используется для вывода i, а не PrimeCandidate.

    ОтветитьУдалить
  5. Похвально, что не поленился статью составить:)
    Можно ещё на Королевство Делфи дубль статьи заслать.

    ОтветитьУдалить
  6. Кто-то доходит до этого самостоятельно путём проб и ошибок, лично мне помогла книга "Ремесло программиста //практика написания хорошего кода".

    ОтветитьУдалить
  7. Заставлю своих учеников прочитать этот материал. Благодарю за работу!

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

    Это не совсем верно. Тут нужно учесть целевую аудиторию этого поста: школьники и студенты.

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

    Написание комментариев - тоже не очень сложное действие (после того, как вы узнали про его правила), которое может здорово улучшить код. Поэтому оно идёт вторым.

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

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

    ОтветитьУдалить
  9. >>> Заставлю своих учеников прочитать этот материал.

    Палками и утюгом. И пригрозить двухлетней чисткой картошки на подводной лодке :D

    ОтветитьУдалить
  10. Замечу, что имена i, j, mf и т.п. появляются не на пустом месте. Они такие, потому что так записано в тетради у студента. Эти имена его не смущают, потому что он знает, что это такое, потому что это знание, хотя и не выражено в коде программы, сидит у него в голове, куда оно попало из тетради, да лекций.

    Но в этом-то и проблема. Спроси этого студента через недели две: ну а что такое тут у тебя mf? "Ээээээ.... сейчас посмотрю в записях".

    Неудивительно, что когда такие студенты начинают писать код, они "по привычке" дают аналогичные имена всем переменным в программе. Только вот роль "тетрадки" играет бумажка на столе (в лучшем случае), а в худшем - их голова.

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

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

    Я не вставил этот текст в сам пост, потому что это как-бы продолжение, не слишком имеющее отношение к студентам.

    ОтветитьУдалить
  11. Спасибо тебе)))

    ОтветитьУдалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и (опционально) ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку (поддерживается OpenID).

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

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