воскресенье, 8 августа 2010 г.

Правила разработки программного обеспечения Ларри, часть 1: каждый программист должен примерно представлять, какой ассемблерный код генерируют его исходники

Это перевод Larry's rules of software engineering, part 1: Every software engineer should know roughly what assembly language their code generates. Автор: Ларри Остерман.

Это первая часть в будущей серии (другими словами, как только мне придут в голову ещё правила, я напишу ещё постов).

Этот пост был написан под вдохновением от поста Реймонда, где в комментариях человек спросил: “Ты считаешь, что я обязан знать ассемблер, чтобы делать мою работу?”.

Мой ответ этому человеку: “Да, лично я ожидаю знания ассемблера от каждого”. Потому что если вы не знаете его - вы просто не понимаете, что делает ваш код.

Вот простой пример: сколько строк создаётся в следующем коде?
var
  foo, bar, baz: String;
begin
  foo := bar + baz + 'abc';
end;
Правильный ответ? Пять. Три из них очевидны - это foo, bar и baz. Две другие скрыты в самом выражении.

Первая строка из двух скрытых является временным строковым объектом, который создаётся, чтобы вместить строку 'abc'. Вторая скрытая переменная содержит промежуточный результат: соединение строк baz + 'abc', которое затем добавляется к bar, чтобы получить foo. Одна эта строчка кода создала 188 байт кода. Сегодня это не много, но ведь это может быть много, если его повторять часто.

Примечание переводчика: этот код плохо ложится (в смысле перевода) на Delphi, потому что ответ для Delphi - 3. Во-первых, строка для 'abc' не создаётся - она является константой: в том смысле, что константой записана переменная String (вместе с длинной и счётчиком ссылок), а не только текстовые символы 'abc'. Во-вторых, выражение на самом деле представляет собой вызов (магией компилятора) UStrCatN([bar, baz, 'abc']), которая создаёт новую строку с достаточной длинной, чтобы вместить символы всех трёх строк, после чего делает 3 раза Move. Новая строка становится foo. Однако, пусть этот пример и не показывает задуманного, но такая разница в поведении всё равно служит примером важности знания ассемблера: как иначе вы могли это узнать? Тем более, что, будучи скомпилированным в другом компиляторе Паскаля, этот код может быть больше похож на оригинальный пример Ларри.

Я познакомился с этим правилом много лет назад - ещё во времена DOS 4. Я работал над BIOS DOS 4, а один из разработчиков, который работал над BIOS до меня, создал несколько ОЧЕНЬ полезных макросов для управления критическими секциями. Вы могли сказать ENTER_CRITICAL_SECTION(criticalsectionvariable) и LEAVE_CRITICAL_SECTION(criticalsectionvariable) - и это бы сработало так, как вы это ожидаете.

В какой-то момент Gordon Letwin озаботился размером BIOS, который был 20 Кб, а он не понимал, почему он должен быть таким большим. Поэтому он начал копать. И он заметил эти два макроса. Из использования макроса не было очевидно, что каждое его упоминание создаёт 20 или 30 байт кода. Он изменил макросы с inline-функций на обычные функции и размер BIOS усох на 4 Кб. Когда вы работали в DOS, то это было ОГРОМНОЙ экономией (пояснение: BIOS в DOS 4 был написан на ассемблере, поэтому очевидно, что я знал ассемблер. Но я не знал ассемблерный код, который генерирует макрос).

Сегодня у вас нет таких строгих ограничений на память, но всё ещё критически важно, чтобы вы знали во что превращается ваш исходник. Это особенно справедливо для сложных языков типа C++, где очень легко спрятать тонны кода за безвинными строчками. К примеру, если у вас есть:
type
  TComPtr<T: IInterface> = class
    // ...
  end;

var
  Document: TComPtr<IXMLDomDocument>;
  Node: TComPtr<IXMLDomNode>;
  Element: TComPtr<IXMLDomElement>;
  Value: TComPtr<IXMLDomValue>;
То сколько отдельных реализаций TComPtr будет в вашем приложении? (прим.пер.: если этот код выглядит для вас странно - читайте обобщённые типы в Delphi) Ну, ответ - 4. У вас будет 4 отдельных реализации - т.е. весь код класса TComPtr будет продублирован в вашем приложении ЧЕТЫРЕ раза. В некоторых языках (Delphi к ним не относится) умный линкёр увидит, что все эти реализации генерируют одинаковый машинный код и разместит только одну копию реализации. Но если вы используете компилятор попроще, то вы не можете гарантировать, что упоминание n-раз TComPtr в вашем коде не даст вам n-реализаций. Кроме того, такое сворачивание методов не работает для многих шаблонов. Сравните:
var
  document: TList<ShortInt>;
  node: TList<Integer>;
  element: TList<Extended>;
  value: TList<Boolean>;
Здесь у вас получится четыре отдельные реализации и они не могут быть свёрнуты в одну, которая будет разделяться всеми случаями (потому что размер параметров типов в байтах различен, поэтому различен и получающийся ассемблерный код). Если вы заранее не знаете, что это произойдёт - то вы устанете слушать жалобы вашего босса на размер рабочего набора вашего приложения во время работы.

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

Это случается довольно часто. Я недавно унаследовал класс, который интенсивно использовал перегрузку операторов (прим.пер.: в Delphi пока такое можно делать только у записей). Затем я начал использовать код, как я обычно это делаю, и во время пошагового прохода по коду (когда я смотрел, работает ли мой код) я осознал, что реализация класса постоянно вызывает копирующий конструктор. Более того, оказалось что класс вообще нельзя использовать без полудюжины вызовов к менеджеру памяти. Но я (как пользователь класса) не знал этого – я не видел, что простая операция присваивания приводит к двум выделениям памяти, нескольким вызовам к printf и одному разбору (парсингу) строки. Автор класса сам не знал этого, когда я указал ему на это - для него оно стало полной неожиданностью, потому что эти вызовы оказались побочным эффектом других его вызовов. Это не было критично в тот раз, класс работал как и было задумано (просто он был не таким эффективным), но если бы этот класс использовался бы в критичном для производительности окружении, то мы бы потонули.

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

Если бы этот разработчик понимал, что происходит под капотом языковых конструкций - он с самого начала не стал бы проектировать класс таким образом. Но он не следовал правилу Ларри №1 - и обжёгся.

Примечание переводчика: позволю себе добавить пример от себя: не зная ассемблера, вы не сможете найти причину некоторых багов, сошлётесь на "это глюки Delphi" и перепишете код по-другому. Например:
procedure DoSomething(const AProc: TProcedure);
var
  X: Integer;
begin
  for X := 1 to 3 do
    AProc;
end;

function Test: Integer;
var
  N: Integer;
  
  procedure UseLocalVar;
  begin
    Inc(N);
  end;

begin
  N := 0;
  DoSomething(@UseLocalVar);
  Result := N;
end;

procedure TForm1.Button1Click(Sender: TObject);
begin
  Caption := IntToStr(Test); // ожидается: 3; реальный результат: access violation
end;
Это вполне реальный, имеющий смысл код из одного приложения, из которого вырезано всё ненужное. Попробуйте понять, что идёт не так, не зная ассемблера.

3 комментария:

  1. Да уж...
    Функция UseLocalVar обращается в не свой стековый фрейм. По-видимому, в дельфи для этого применяются различные косвенные штучки, а не передаваемый в функцию указатель на чужой фрейм. И эти косвенные штучки, очевидно, не срабатывают, если в цепочку вызовов вклинивается "левая" функция наподобие DoSomething.

    ОтветитьУдалить
  2. Не зная ассемблера я предположу, что внутренние процедуры нельзя передавать вовне. (И перепишу код, кстати).
    Зная ассемблер, я предположу, что в процедуру передается неверное число параметров :)

    В далекие времена TurboVision в методы TCollection ForEach и FirstThat, наоборот, можно было передавать только внутренние процедуры...

    ОтветитьУдалить
  3. Довольно интересно...Код не просто бесмысленный, он специально написан так что бы мы задумались.
    В данном случае что видно мне, выполнение процедуры
    DoSomething(@UseLocalVar) не иеет смысл, как я понимаю, UseLocalVar являеться вложенной процедурой, а учитывая организацию стека, что бы получить ее адрес нужно начать выполнять функцию Test, без этого получаеться адрес бдует недоступен.
    Что касается этой инструкции Caption := IntToStr(Test), то опять же она не возможна, так как

    function Test: Integer;
    var
    N: Integer;
    procedure UseLocalVar;
    begin
    Inc(N);
    end;

    Значаение N в test не является тем же что и в UseLocalVar, они у каждой процедуры свои, такова организация стека.

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

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

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

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

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

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

Примечание. Отправлять комментарии могут только участники этого блога.