Разбор полетов: как связана эффективность кода и время разработки программы
Приложение к статье «Поговорим о программировании. Размышления бывшего программиста. Часть 4»
Продолжение следует — обмен мнениями
Как привести программу в рабочее состояние
За счет чего получено резкое повышение производительности?
Варианты замены комбинаций спецсимволов
В данном случае мы будем говорить о программе, реализованной в среде Word 2000/VBA, но речь будет идти о достаточно общих вопросах разработки. Мы будем использовать весьма тривиальные конструкции с достаточно прозрачным синтаксисом.
С чего все началось
1 августа 2000 года я получил письмо от Максима, программиста из Риги, следующего содержания:
Здравствуйте!
У меня вопрос по поводу кодировок в VBA for Word: мне нужно перенести текст (английский, русский, латышский) из DOS’овской кодировки в Unicode!..
(Далее шла информация о различных ситуациях на компьютерах с разными OC и различными региональными установками.)
Отмечу сразу: кто и кому отправляет письмо — из текста было непонятно (Максим решил представиться лишь несколько писем спустя). Хорошо, что хоть прислал в приложении программный пример (проект Word 2000 и исходный текст, который надо перекодировать), который приведен в листинге 1 (повторяющиеся конструкции мною изъяты).
Как раз в эти дни у меня была очередная запарка на работе, однако вопрос был довольно интересным и принципиальным. В своем ответе на первое письмо Максима я написал:
- Присланный вами пример не работает ни для русского, ни для латышского текста.
- В коде его функции Conv имеется одна очевидная ошибка:
вместо:
tmp = StrConvW(Chr(k1), vbFromUnicode) ' Ваш вариант
нужно написать:
tmp = StrConv(Chr(k1), vbFromUnicode) ' мой вариант
И вообще проще записать всю эту функцию в одну строку:
conv = StrConv(StrConv(str, vbFromUnicode), vbUnicode, reg_to)
После такого исправления тест начинает работать правильно. Но есть некоторые сомнения, что программа будет верно функционировать в более общем случае, с другим содержимым исходных файлов.
Дело в том, что весь ваш программный код мне показался слишком запутанным и неудачно оформленным. Конечно, это ваше личное дело, но именно запутанность кода сильно затрудняет отладку и локализацию ошибки.
Далее я высказал несколько более конкретных замечаний по присланной программе, чтобы Максим смог довести программу до рабочего состояния самостоятельно.
Продолжение следует — обмен мнениями
Однако мой ответ лишь положил начало весьма активной двухдневной переписке с обсуждением некоторых философских проблем программирования, на которых хотелось бы акцентировать внимание читателя. В ходе этой дискуссии по электронной почте Максим высказал следующие соображения:
- Несмотря на то что он уже несколько дней занимается разработкой этой программы перекодировки и сроки задачи проекта уже на исходе, устойчивой работы программы (например, на одной машине работает, на другой тот же пример — нет) добиться не удается.
- Присланный мне пример действительно не очень удачный, но это опять же из-за нехватки времени: «то, что я вам послал, это было так, на скорую руку написано, для тестов».
- Он специалист по Pascal и C/C++, а VB он не любит.
- Размер исходного кода программы действительно довольно велик, но это потому (ВНИМАНИЕ!), что ему нужно было в минимальные сроки написать программу, которая бы работала быстрее. (Последнее было написано в ответ на мой вопрос, почему он не использует циклы и процедуры для повторяющихся фрагментов кода.)
- И вообще, у него стаж программирования уже 6 лет, сейчас Маскиму 19 лет, он учится на третьем курсе университета, и «все, что мне нужно для программирования, я знал до поступления в вуз».
В ответ на это я, в свою очередь, высказал следующее:
- При правильном подходе к делу решение этой задачи должно занимать не более 3-4 часов.
- Лучше бы он про свою занятость не упоминал. Почему кто-то другой (в данном случае — я) должен тратить время, чтобы привести программу в надлежащий вид?
- Любить или не любить VB — это сугубо личное дело. Но знать инструмент, с которым ты работаешь, по крайней мере полезно. В ходе нашей переписки оказалось, что Максим, например, не знал о возможности обязательного объявления переменных (у него в программе некоторые ошибки были именно из-за этого), возможности записи нескольких операторов в одной строке, о возможности более компактной записи оператора If Then Else, о наличии функции Replace и много другого.
- Готов биться об заклад, что, упростив его код, можно одновременно повысить скорость обработки по крайней мере раз в десять.
- Учитывая вышесказанное, Максиму все же есть чему учиться, в том числе и в университете.
На пункт 4 Максим прислал мне короткий ответ — «хе-хе». Это заставило меня отложить на пару часов свои дела и написать для Максима готовое решение его задачи. Код этого варианта оказался примерно в два раза короче и работал в 25 раз быстрее. К тому же он работал правильно.
Далее я попытаюсь объяснить, в чем заключались основные ошибки программы Максима. Но пока приведу наш диалог еще по одному вопросу:
А.М.: Главный принцип программиста — ищи ошибки сначала у себя, а потом — в операционной системе и компьютере.
Максим: А я слышал о другом! Если не можешь найти ошибку, перепиши модуль заново.
Что тут скажешь? Нужно искать ошибку, поскольку переписывать программу можно всю жизнь, допуская одни и те же промахи.
Как привести программу в рабочее состояние
Итак, обратимся к присланной мне программе по перекодировке DOS’овских текстов в Word 2000 (листинг 1).
Проблема здесь заключается в следующем. В DOS’овской кодировке латинские, русские и специальные латышские буквы находятся в одной кодовой таблице 866. А в Unicode они попадают в три разные таблицы (региональные установки 1033, 1049 и 1062). Простое изменение региональных установок в ОС не позволяется преобразовывать данные для трех разных языков. Нужно же написать программу, которая правильно работает независимо от типа ОС и региональных установок конкретного компьютера.
Суть основных замечаний к присланному мне начальному варианту программы (я их изложил в первом же ответе Максиму) в следующем:
- Понятно, что основная задача заключается в перекодировке русских и латышских букв. Однако в программе Максима очень большой фрагмент кода связан с преобразованием двух- и трехсимвольных DOS’овских кодов в односимвольные Windows-коды. Например, замену «__T» на «™». Но это преобразование не имеет никакого отношения к проблеме национальных языков. Такую операцию вообще нужно исключить из тестирования и реализовать потом отдельно. Не говоря уже о том, что она выполнена в варианте Максима крайне неэффективно и, по-видимому, с алгоритмическими ошибками.
- Код функции Convert представляется крайне запутанным, и при визуальном изучении трудно судить о его правильности. Все это отвлекает от конкретной задачи — преобразования национальных символов. Имеется заметная путаница в использовании переменных str и newstr. Не говоря уже о том, что идентификатор str лучше не использовать — есть такая встроенная функция Basic (это явная ошибка Microsoft — разрешено использовать ключевое слово в качестве идентификатора переменной).
- В варианте Максима запуск макрокоманды выполнялся с помощью кнопки, расположенной на форме. Это очень неудобно для тестирования (не говоря уже о наличии дополнительного программного компонента), поэтому я сразу же исключил эту форму, сделав прямое обращение к макрокоманде CommandClick.
- Совершенно не понятно, для чего в программе используется установка раскладки клавиатуры Application.Keyboard, которая вообще не влияет на перекодировку символов.
- Создание тестового примера требует написания минимального кода, который мог бы продемонстрировать наличие ошибки. В данном случае исходный файл содержит текст, включающий в себя строки с разными национальными символами. Но если создать частный пример со строкой, содержащей лишь один символ (а этого вполне достаточно для локализации ошибки и понимания ее причины), то код можно еще сильнее упростить. А серьезное тестирование предполагает проверку на полный набор символов.
- Полное отсутствие комментариев в программе.
С учетом только этих замечаний можно преобразовать исходную программу Максима в более простой вариант (листинг 2). Я убрал ненужные операции преобразования данных и циклы (для частного варианта одного символа в строке), сделал явное определение переменных!. Такой пример отлаживать гораздо проще.
После такого упрощения программы сразу выявляются некоторые принципиальные дефекты ее алгоритма.
Понятно, что задача перекодировки заключается в последовательном преобразовании DOS -> ANSI -> Unicode. Максим разделил эти два этапа обработки в виде последовательно выполняемых функций Convert и Conv (кроме отсутствия комментариев, отмечу также выбор крайне невыразительных имен переменных и процедур), что является принципиально неверным.
Дело в том, что в DOS русские и латышские символы имеют различные коды. Но вполне вероятно, что в ANSI-кодировке (Windows) некоторые символы имеют одинаковые значения (хотя в данном конкретном случае этого не происходит). Именно поэтому Максим вынужден в программе «жестко зашить» определение языка. Обратите внимание, что именно поэтому необходимо изначально знать, что первая строка текстового файла — русская, вторая — английская, третья — латышская. Но если бы он объединил два этапа преобразования в одной процедуре, определение языка выполнялось бы автоматически!
И еще два важных замечания:
- Нет смысла вообще выполнять преобразование символов в два этапа. Ведь гораздо
проще преобразовать код из 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 значение региональной установки).
- Перекодировка национальных символов в VB/VBA является не очень простой задачей, поскольку при этом постоянно выполняется преобразование из однобайтового кода в двухбайтовый. Эти операции зависят от кодовых таблиц и региональных установок конкретного компьютера. Самое же неприятное заключается в том, что подобные преобразования часто выполняются в автоматическом режиме, например, при вводе/выводе текста (то есть неконтролируемым на программном уровне образом).
Радикальным решением этой проблемы является использование байтовых массивов вместо строковых переменных — в этом случае никаких преобразований при вводе/выводе не производится.
С учетом всего вышесказанного можно написать макрокоманду MyDecodeProcedure перекодировки англо-русско-латышского DOS-текста в Unicode (листинг 3). На мой взгляд, она имеет достаточно простую логическую структуру (на 90% ее можно отладить на уровне визуального изучения кода!) и самое главное — автоматически распознает язык введенного символа и гарантированно выполняет правильные преобразования независимо от версии Word и Windows, кодовых таблиц и региональных установок операционной системы.
За счет чего получено резкое повышение производительности?
Значительное повышение производительности получено благодаря:
- Более эффективной логике программы.
- Использованию байтового массива вместо строковых переменных. Отметим также, что Максим использовал крайне неэффективные варианты преобразования строк — даже при работе со строками можно увеличить скорость работы в 10-15 раз.
- Явному определению типов переменных (Максим использовал тип Variant по умолчанию). Это исключает лишние операции преобразования данных.
- Исключению целого ряда ненужных операций.
Существует и еще один важный момент, который мы обсудим отдельно...
Варианты замены комбинаций спецсимволов
Как я уже упоминал, в одном из блоков присланной Максимом программы выполнялась замена комбинаций DOS’овских знаков некоторыми специальными символами, например, «__T» на «™». А поскольку эта задача вообще не имеет отношения к проблеме перекодировки национальных символов, данный программный код следовало бы вообще сразу убрать из текстового приложения. Однако, разобравшись с преобразованием текста на разных языках, вернемся именно к этому фрагменту, так как здесь был допущен целый ряд методических и технических ошибок, а именно:
- Код замены спецсимволов, не имея отношения к конкретной проблеме, занимал в программе очень много места (на листинге 1 показано только начало этой повторяющейся конструкции) и поэтому просто мешал.
- Реализация кода представляла собой очень запутанную конструкцию, правильность которой трудно определить. Вполне вероятно, что именно в этом кроется причина неверной работы программы в целом. Обратите внимание, что у Максима замена спецсимволов производится в одном цикле с перекодировкой национальных букв. Этот подход принципиально неверен, хотя бы потому, что первая операция связана с изменением длины строки. В предложенном мною варианте две операции разнесены — это позволяет осуществить их автономную отладку и модернизацию.
- Сам код замены символов является крайне неэффективным.
Теперь попробуем подробнее рассмотреть допущенные в исходном варианте ошибки на следующем фрагменте кода:
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).
Вернемся к исходному варианту
Здесь явно несколько сугубо алгоритмических проблем:
- Переменная 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)
- Здесь видны явные повторы кода при замене символов в строке. Конечно же,
имеет смысл заменить:
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 (а какую-то из них легко пропустить!).
- Но самой главной ошибкой является неверно выбранная (с точки зрения эффективности
вычислений) логическая схема. В данном случае видно, что у автора серьезные
пробелы познаний в булевой алгебре. Ведь довольно очевидно, что конструкция:
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
- Но самое интересное, что после выполненных преобразований изучение
кода программы показывает, что мы вообще напрасно занимались модификацией
исходной строки 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