oldi

Разбор полетов: как связана эффективность кода и время разработки программы

Приложение к статье «Поговорим о программировании. Размышления бывшего программиста. Часть 4»

Алексей Малинин

С чего все началось

Продолжение следует — обмен мнениями

Как привести программу в рабочее состояние

За счет чего получено резкое повышение производительности?

Варианты замены комбинаций спецсимволов

Вернемся к исходному варианту

 

В данном случае мы будем говорить о программе, реализованной в среде Word 2000/VBA, но речь будет идти о достаточно общих вопросах разработки. Мы будем использовать весьма тривиальные конструкции с достаточно прозрачным синтаксисом.

С чего все началось

1 августа 2000 года я получил письмо от Максима, программиста из Риги, следующего содержания:

 

Здравствуйте!

У меня вопрос по поводу кодировок в VBA for Word:  мне нужно перенести текст (английский, русский, латышский) из DOS’овской кодировки в Unicode!..

(Далее шла информация о различных ситуациях на компьютерах с разными OC и различными региональными установками.)

 

Отмечу сразу: кто и кому отправляет письмо — из текста было непонятно (Максим решил представиться лишь несколько писем спустя). Хорошо, что хоть прислал в приложении программный пример (проект Word 2000 и исходный текст, который надо перекодировать), который приведен в листинге  1 (повторяющиеся конструкции мною изъяты).

Как раз в эти дни у меня была очередная запарка на работе, однако вопрос был довольно интересным и принципиальным. В своем ответе на первое письмо Максима я написал:

  1. Присланный вами пример не работает ни для русского, ни для латышского текста.
  2. В коде его функции Conv имеется одна очевидная ошибка:

    вместо:

    tmp = StrConvW(Chr(k1), vbFromUnicode) ' Ваш вариант   

    нужно написать:

    tmp = StrConv(Chr(k1), vbFromUnicode) ' мой вариант 

    И вообще проще записать всю эту функцию в одну строку:

    conv = StrConv(StrConv(str, vbFromUnicode), vbUnicode, reg_to) 

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

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

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

В начало В начало

Продолжение следует — обмен мнениями

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

  1. Несмотря на то что он уже несколько дней занимается разработкой этой программы перекодировки и сроки задачи проекта уже на исходе, устойчивой работы программы (например, на одной машине работает, на другой тот же пример — нет) добиться не удается.
  2. Присланный мне пример действительно не очень удачный, но это опять же из-за нехватки времени: «то, что я вам послал, это было так, на скорую руку написано, для тестов».
  3. Он специалист по Pascal и C/C++, а VB он не любит.
  4. Размер исходного кода программы действительно довольно велик, но это потому (ВНИМАНИЕ!), что ему нужно было в минимальные сроки написать программу, которая бы работала быстрее. (Последнее было написано в ответ на мой вопрос, почему он не использует циклы и процедуры для повторяющихся фрагментов кода.)
  5. И вообще, у него стаж программирования уже 6 лет, сейчас Маскиму 19 лет, он учится на третьем курсе университета, и «все, что мне нужно для программирования, я знал до поступления в вуз».

В ответ на это я, в свою очередь, высказал следующее:

  1. При правильном подходе к делу решение этой задачи должно занимать не более 3-4 часов.
  2. Лучше бы он про свою занятость не упоминал. Почему кто-то другой (в данном случае — я) должен тратить время, чтобы привести программу в надлежащий вид?
  3. Любить или не любить VB — это сугубо личное дело. Но знать инструмент, с которым ты работаешь, по крайней мере полезно. В ходе нашей переписки оказалось, что Максим, например, не знал о возможности обязательного объявления переменных (у него в программе некоторые ошибки были именно из-за этого), возможности записи нескольких операторов в одной строке, о возможности более компактной записи оператора If Then Else, о наличии функции Replace  и много другого.
  4. Готов биться об заклад, что, упростив его код, можно одновременно повысить скорость обработки по крайней мере раз в десять.
  5. Учитывая вышесказанное, Максиму все же есть чему учиться, в том числе и в университете.

На пункт 4 Максим прислал мне короткий ответ — «хе-хе». Это заставило меня отложить на пару часов свои дела и написать для Максима готовое решение его задачи. Код этого варианта оказался примерно в два раза короче и работал в 25 раз быстрее. К тому же он работал правильно.

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

А.М.: Главный принцип программиста — ищи ошибки сначала у себя, а потом — в операционной системе и компьютере.

Максим: А я слышал о другом! Если не можешь найти ошибку, перепиши модуль заново.

Что тут скажешь? Нужно искать ошибку, поскольку переписывать программу можно всю жизнь, допуская одни и те же промахи.

В начало В начало

Как привести программу в рабочее состояние

Итак, обратимся к присланной мне программе по перекодировке DOS’овских текстов в Word 2000 (листинг 1).

Проблема здесь заключается в следующем. В DOS’овской кодировке латинские, русские и специальные латышские буквы находятся в одной кодовой таблице 866. А в Unicode они попадают в три разные таблицы (региональные установки 1033, 1049 и 1062). Простое изменение региональных установок в ОС не позволяется преобразовывать данные для трех разных языков. Нужно же написать программу, которая правильно работает независимо от типа ОС и региональных установок конкретного компьютера.

Суть основных замечаний к присланному мне начальному варианту программы (я их изложил в первом же ответе Максиму) в следующем:

  1. Понятно, что основная задача заключается в перекодировке русских и латышских букв. Однако в программе Максима очень большой фрагмент кода связан с преобразованием двух- и трехсимвольных DOS’овских кодов в односимвольные Windows-коды. Например, замену «__T» на «™». Но это преобразование не имеет никакого отношения к проблеме национальных языков. Такую операцию вообще нужно исключить из тестирования и реализовать потом отдельно. Не говоря уже о том, что она выполнена в варианте Максима крайне неэффективно и, по-видимому, с алгоритмическими ошибками.
  2. Код функции Convert представляется крайне запутанным, и при визуальном изучении трудно судить о его правильности. Все это отвлекает от конкретной задачи — преобразования национальных символов. Имеется заметная путаница в использовании переменных str и newstr. Не говоря уже о том, что идентификатор str лучше не использовать — есть такая встроенная функция Basic (это явная ошибка Microsoft — разрешено использовать ключевое слово в качестве идентификатора переменной).
  3. В варианте Максима запуск макрокоманды выполнялся с помощью кнопки, расположенной на форме. Это очень неудобно для тестирования (не говоря уже о наличии дополнительного программного компонента), поэтому я сразу же исключил эту форму, сделав прямое обращение к макрокоманде CommandClick.
  4. Совершенно не понятно, для чего в программе используется установка раскладки клавиатуры Application.Keyboard, которая вообще не влияет на перекодировку символов.
  5. Создание тестового примера требует написания минимального кода, который мог бы продемонстрировать наличие ошибки. В данном случае исходный файл содержит текст, включающий в себя строки с разными национальными символами. Но если создать частный пример со строкой, содержащей лишь один символ (а этого вполне достаточно для локализации ошибки и понимания ее причины), то код можно еще сильнее упростить. А серьезное тестирование предполагает проверку на полный набор символов.
  6. Полное отсутствие комментариев в программе.

С учетом только этих замечаний можно преобразовать исходную программу Максима в более простой вариант (листинг 2). Я убрал ненужные операции преобразования данных и циклы (для частного варианта одного символа в строке), сделал явное определение переменных!. Такой пример отлаживать гораздо проще.

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

Понятно, что задача перекодировки заключается в последовательном преобразовании DOS -> ANSI -> Unicode. Максим разделил эти два этапа обработки в виде последовательно выполняемых функций Convert и Conv (кроме отсутствия комментариев, отмечу также выбор крайне невыразительных имен переменных и процедур), что является принципиально неверным.

Дело в том, что в DOS русские и латышские символы имеют различные коды. Но вполне вероятно, что в ANSI-кодировке (Windows) некоторые символы имеют одинаковые значения (хотя в данном конкретном случае этого не происходит). Именно поэтому Максим вынужден в программе «жестко зашить» определение языка. Обратите внимание, что именно поэтому необходимо изначально знать, что первая строка текстового файла — русская, вторая — английская, третья — латышская. Но если бы он объединил два этапа преобразования в одной процедуре, определение языка выполнялось бы автоматически!

И еще два важных замечания:

  1. Нет смысла вообще выполнять преобразование символов в два этапа. Ведь гораздо проще преобразовать код из DOS в Unicode и не связываться с StrConv, где можно очень легко запутаться в перекодировках и региональных установках. Например, для русского языка это будет выглядеть так:
    ' Сразу формирует правильный Unicode! 
    If kod > 127 And kod < 176 Then 
      kod = kod + 64 + 848 
    ElseIf kod > 223 And kod < 240 Then 
      kod = kod + 16 + 848 
    End if 

    Аналогично для латышского языка можно сразу заменить код ANSI на Unicode. Все это будет работать независимо от версии Word  (в Word 97 нельзя указывать в функции StrConv значение региональной установки).

  1. Перекодировка национальных символов в VB/VBA является не очень простой задачей, поскольку при этом постоянно выполняется преобразование из однобайтового кода в двухбайтовый. Эти операции зависят от кодовых таблиц и региональных установок конкретного компьютера. Самое же неприятное заключается в том, что подобные преобразования часто выполняются в автоматическом режиме, например, при вводе/выводе текста (то есть неконтролируемым на программном уровне образом).

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

С учетом всего вышесказанного можно написать макрокоманду MyDecodeProcedure перекодировки англо-русско-латышского DOS-текста в Unicode (листинг 3). На мой взгляд, она имеет достаточно простую логическую структуру (на 90% ее можно отладить на уровне визуального изучения кода!) и самое главное — автоматически распознает язык введенного символа и гарантированно выполняет правильные преобразования независимо от версии Word и Windows, кодовых таблиц и региональных установок операционной системы.

В начало В начало

За счет чего получено резкое повышение производительности?

Значительное повышение производительности получено благодаря:

  1. Более эффективной логике программы.
  2. Использованию байтового массива вместо строковых переменных. Отметим также, что Максим использовал крайне неэффективные варианты преобразования строк — даже при работе со строками можно увеличить скорость работы в 10-15 раз.
  3. Явному определению типов переменных (Максим использовал тип Variant по умолчанию). Это исключает лишние операции преобразования данных.
  4. Исключению целого ряда ненужных операций.

Существует и еще один важный момент, который мы обсудим отдельно...

В начало В начало

Варианты замены комбинаций спецсимволов

Как я уже упоминал, в одном из блоков присланной Максимом программы выполнялась замена комбинаций DOS’овских знаков некоторыми специальными символами, например, «__T» на «™».  А поскольку эта задача вообще не имеет отношения к проблеме перекодировки национальных символов, данный программный код следовало бы вообще сразу убрать из текстового приложения. Однако, разобравшись с преобразованием текста на разных языках, вернемся именно к этому фрагменту, так как здесь был допущен целый ряд методических и технических ошибок, а именно:

  1. Код замены спецсимволов, не имея отношения к конкретной проблеме, занимал в программе очень много места (на листинге 1 показано только начало этой повторяющейся конструкции) и поэтому просто мешал.
  2. Реализация кода представляла собой очень запутанную конструкцию, правильность которой трудно определить. Вполне вероятно, что именно в этом кроется причина неверной работы программы в целом. Обратите внимание, что у Максима замена спецсимволов производится в одном цикле с перекодировкой национальных букв. Этот подход принципиально неверен, хотя бы потому, что первая операция связана с изменением длины строки. В предложенном мною варианте две операции разнесены — это позволяет осуществить их автономную отладку и модернизацию.
  3. Сам код замены символов является крайне неэффективным.

Теперь попробуем подробнее рассмотреть допущенные в исходном варианте ошибки на следующем фрагменте кода:

Dim Leng&, i&, kod&, kod2&, kod3& 
Leng = Len(myStr)  
For i = 1 To Leng  
  If i > Leng Then Exit For  
  kod = Asc(Mid(myStr, i, 1))  
  If i <= Leng - 2 Then  
   kod2 = Asc(Mid(myStr, i + 1, 1))  
   kod3 = Asc(Mid(myStr, i + 2, 1))  
   If (kod = 95) And (kod2 = 95) And (kod3 = 79) Then  
    myStr = Left(myStr, i - 1) + Chr(187) + Right(myStr, Leng - i - 2)  
    Leng = Leng - 2  
   ElseIf (kod = 95) And (kod2 = 95) And (kod3 = 111) Then  
    myStr = Left(myStr, i - 1) + Chr(188) + Right(myStr, Leng - i - 2)  
    Leng = Leng - 2  
   ElseIf (kod = 95) And (kod2 = 95) And (kod3 = 131) Then  
    myStr = Left(myStr, i - 1) + Chr(200) + Right(myStr, Leng - i - 2)  
    Leng = Leng - 2  
   ElseIf (kod = 95) And (kod2 = 95) And (kod3 = 82) Then  
    myStr = Left(myStr, i - 1) + "®" + Right(myStr, Leng - i - 2)  
    Leng = Leng - 2  
   ElseIf (kod = 95) And (kod2 = 95) And (kod3 = 114) Then  
    myStr = Left(myStr, i - 1) + "®" + Right(myStr, Leng - i - 2)  
    Leng = Leng - 2  
   ElseIf (kod = 95) And (kod2 = 95) And (kod3 = 84) Then  
    myStr = Left(myStr, i - 1) + "™" + Right(myStr, Leng - i - 2)  
    Leng = Leng - 2  
   End If  
  End If  
Next    

В этом фрагменте в отличие от исходного варианта (листинг 1) заменено имя переменной str на mySrt (Str — встроенная функция VBA) и исключена проверка переменной Special (непонятно, зачем она вообще здесь была). Еще раз отмечу, что в исходном варианте никакого определения используемых переменных не было. Здесь приведен фрагмент преобразования только трехсимвольных комбинаций — в программе Максима далее следовало подобное преобразование 20 двухсимвольных комбинаций.

Отдельно следует отметить смысловую запутанность  всего этого кода: совершенно непонятно, что имел в виду автор под Windows-кодами 187, 188 и 200 и DOS’овским 131. Но очевидно, что тут явно кроются будущие проблемы, хотя бы потому, что в кодовой таблице cp1251 код 200 соответствует русскому «И», а в других таблицах — еще чему-то (а ведь мы потом должны провести преобразования из ANSI в Unicode!). И таких очевидных ошибок в блоке преобразования спецсимволов наберется еще не менее десятка.

Итак, вы видите, что мы уже забыли о преобразовании русских и латышских символов, и долго размышляем, что хотел сделать автор с помощью такого хитроумного кода, не имеющего отношения к делу!

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

Что тут можно сказать? В VB/VBA 6.0 имеется готовая встроенная функция Replace, которая выполняет именно эти операции. С ее помощью вся эта конструкция будет выглядеть так:

myStr = Replace(myStr, "__O", "»")) 
myStr = Replace(myStr, "__o", "ј")  
myStr = Replace(myStr, "__" + Chr$(131), "И")  
myStr = Replace(myStr, "__R", "®")  
myStr = Replace(myStr, "__r", "®")  
myStr = Replace(myStr, "__T", "™")  

Обратите внимание на два момента.

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

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

Правда, функция Replace появилась только в VB/VBA 6.0 (именно о новых строковых функциях много писалось в обзорах новшеств VBA, Максим работал с Word 2000).  Но написать ее аналог (и оттестировать только одну процедуру!) можно довольно легко применительно к любой версии Basic (листинг 4).

В начало В начало

Вернемся к исходному варианту

Здесь явно несколько сугубо алгоритмических проблем:

  1. Переменная Leng используется в качестве границы индекса в цикле For... Next и при этом модифицируется внутри цикла! Это явная методическая ошибка: в данном случае она не превратилась в физический дефект, но потребовала дополнительно изучения алгоритма. А если бы где-то Leng не уменьшалась, а увеличивалась? В данной случае нельзя применять цикл по индексу и нужно заменить его на такую конструкцию:
    i = 0 
    Do While i < Leng 
      i = i +1 
    ... 
    Loop 

    Еще лучше вообще отказаться от использования переменной Leng, ведь это просто текущая длина строки myStr! Таким образом, вместо:

    Leng = Len(myStr) 
    For i = 1 To Leng 
      If i > Leng Then Exit For 
      kod = Asc(Mid(myStr, i, 1)) 
      If i <= Leng - 2 Then 
        ... 
        If (kod = 95) And (kod2 = 95) And (kod3 = 79) Then 
          myStr = Left(myStr, i - 1) + Chr(187) +        Right(myStr, Leng - i - 2) 
          Leng = Leng - 2  

    можно написать (убрав одну строку из циклического повтора и заменив Right на Mid):

    i = 0 
    Do While i < Len(myStr) 
      i = i +1 
      kod = Asc(Mid(myStr, i, 1)) 
      If i <= Len(MyStr) - 2 Then 
      ... 
        If (kod = 95) And (kod2 = 95) And (kod3 = 79) Then 
          myStr = Left(myStr, i - 1) + Chr(187) +        Mid(myStr, i + 3) 
  2. Здесь видны явные повторы кода при замене символов в строке. Конечно же, имеет смысл заменить:
    If (kod = 95) And (kod2 = 95) And (kod3 = 79) Then 
          myStr = Left(myStr, i - 1) + Chr(187) + Mid(myStr, i + 3)  

    на:

    ii = 3 
    ... 
    If (kod = 95) And (kod2 = 95) And (kod3 = 79) Then 
       NewKod = 187: Gosub InsertStr 
    ... 
    InsertStr:  ' можно использовать для замены фрагментов разной длины        — ii 
      myStr = Left(myStr, i - 1) + NewKod + Mid(myStr, i + ii) 
    Return 

    Итак, нам, кажется, удалось увеличить число строк кода, но самое главное в другом. Во-первых, мы упростили циклическую часть программы (где кроются основные проблемы с отладкой). Во-вторых, самое главное в выделении общих частей программы — повышение ее надежности и удобства отладки. Ведь, например, для замены Right на Mid нужно сделать исправление одной строки, а не 25 (а какую-то из них легко пропустить!).

  3. Но самой главной ошибкой является неверно выбранная (с точки зрения эффективности вычислений) логическая схема. В данном случае видно, что у автора серьезные пробелы познаний в булевой алгебре. Ведь довольно очевидно, что конструкция:
    If v0 And v1 Then ... 
    If v0 And v2 Then ... 
    If v0 And v3 Then ... 

    должна быть заменена на:

    If v0 Then 
      If v1 Then ... 
      If v2 Then ... 
      If v3 Then ... 
    End if 

    Второй вариант работает в 3-10 быстрее, если учесть, что в 99% случаев (а спецсимволы встречают очень редко!) v0 = False, а выражения v1-v3 нужно вычислять. Теперь понятно, почему алгоритм Максима (v1 — v25 !) работал так медленно? Вместо того чтобы проверить, не является  ли текущий символ подчеркиванием, он зачем-то проверял всевозможные комбинации. Не говоря уже о том, что вместо If Then ElseIf.. лучше применять Select Case. Тогда вместо начального кода из 28 строк получится такая элегантная конструкция:

    i = 0 
    Do While i < Len(myStr) 
      i = i +1 
      kod = Asc(Mid(myStr, i, 1)) 
      If (Kod = 95) And (i <= Len(myStr) - 2) Then 
        If Asc(Mid(myStr, i + 1, 1)) = 95 Then   '        переменная Kod2 далее не нужна 
          ii = 3 
          Select Case Asc(Mid(myStr, i + 2, 1)) '        вместо Kod3 ! 
            Case 79: NewKod = 187: Gosub        InsertStr 
            Case 111: NewKod = 188: Gosub        InsertStr 
            Case 131: NewKod = 200: Gosub        InsertStr 
            Case 82, 114: NewKod = 174:        Gosub InsertStr 
            Case 84: NewKod = 188: Gosub        InsertStr 
          End Select 
        End If 
      End if 
    Loop 
  4. Но самое интересное, что  после выполненных преобразований изучение кода программы показывает, что мы вообще напрасно занимались модификацией исходной строки myStr и исходную процедуру Convert можно записать в таком виде (мы не показали здесь преобразование двухбайтовых спецсимволов):
    Leng = Len(myStr): i = 0 
    Do While i < Leng 
      i = i + 1 
      kod = Asc(Mid(myStr, i, 1)) 
      Select Case Kod 
        Case 95  ' преобразование трехбайтовых комбинаций        спецсимволов 
          If i <= Len(myStr) - 2) Then 
            '!! выполняем модицикацию Kod        и увеличиваем i = i + 2 
            If Asc(Mid(myStr, i + 1, 1))        = 95 Then   
              Select Case Asc(Mid(myStr,        i + 2, 1)) 
                Case        79: Kod = 187: i = i + 2 
                Case        111: Kod = 188: i = i + 2 
                Case        131: Kod = 200: i = i + 2 
                Case        82, 114: Kod = 174: i = i + 2 
                Case        84: Kod = 188: i = i + 2 
              End Select 
            End If 
          End if 
        Case 58 
          ' тут будет обработка двухбайтовых комбинаций        спецсимволов 
        
          ' Русские буквы   
        Case 128 To 175: kod = kod + 64 
        Case 224 To 239: kod = kod + 16 
        ' Латышские 
        Case 240: кod = 199 
         ... 
      End Select 
      NewStr = NewStr + Chr(Kod) 
    Loop 
    Convert = newStr 

    Таким образом, модифицируется не исходная строковая переменная, а индекс текущего байта в ней.

    Сравните этот код с исходным вариантом Convert.  На мой взгляд, наша процедура гораздо компактнее и понятнее. И работает в 3-5 раз быстрее (это проверено на тесте). Добавив в него еще четыре строки кода, можно увеличить скорость еще в 3-4 раза (об этом советую почитать в статье «Особенности работы со строковыми переменными в VB» в КомпьютерПресс № 12’99). А еще лучше — перейти к использованию байтового массива и применить процедуру MyDecodeProcedure (листинг 3).

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

На этом закончим разбор полетов с программкой Максима — разработчика из Риги, который думает, что знал все, что нужно для работы, еще три года назад, до поступления в университет.

КомпьютерПресс 4'2001