Pull to refresh

«Чистый» код, ужасная производительность

Level of difficultyEasy
Reading time17 min
Views70K
Original author: Casey Muratori

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


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

Это бесплатное бонусное видео из серии «Программирование с учетом производительности». Оно показывает настоящую цену следования принципам «чистого кода». Для более подробной информации о курсе можете заглянуть на страницы О нас или Оглавление.

Ниже представлен слегка отредактированный транскрипт видео.

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

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

Если мы посмотрим на сводку «чистого» кода и выделим правила, поистине влияющие на структуру кода, мы получим следующее:

  • Используйте полиморфизм вместо «if/else» и «switch»;

  • Код не должен знать о внутренностях объекта, с которыми он работает;

  • Функции должны быть короткими;

  • Функции должны выполнять одну задачу;

  • «DRY» — Don't Repeat Yourself (не повторяйся)

Эти правила больше касаются того, как отдельные куски кода должны быть созданы, чтобы быть «чистыми». Вопрос, который я хотел бы задать — если мы напишем код, следуя этим правилам, какова будет его производительность?

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

Среди примеров «чистого» кода часто встречается что‑то подобное:

/* ========================================================================
   LISTING 22
   ======================================================================== */

class shape_base
{
public:
    shape_base() {}
    virtual f32 Area() = 0;
};

class square : public shape_base
{
public:
    square(f32 SideInit) : Side(SideInit) {}
    virtual f32 Area() {return Side*Side;}
    
private:
    f32 Side;
};

class rectangle : public shape_base
{
public:
    rectangle(f32 WidthInit, f32 HeightInit) : Width(WidthInit), Height(HeightInit) {}
    virtual f32 Area() {return Width*Height;}
    
private:
    f32 Width, Height;
};

class triangle : public shape_base
{
public:
    triangle(f32 BaseInit, f32 HeightInit) : Base(BaseInit), Height(HeightInit) {}
    virtual f32 Area() {return 0.5f*Base*Height;}
    
private:
    f32 Base, Height;
};

class circle : public shape_base
{
public:
    circle(f32 RadiusInit) : Radius(RadiusInit) {}
    virtual f32 Area() {return Pi32*Radius*Radius;}
    
private:
    f32 Radius;
};

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

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

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

/* ========================================================================
   LISTING 23
   ======================================================================== */

f32 TotalAreaVTBL(u32 ShapeCount, shape_base **Shapes)
{
    f32 Accum = 0.0f;
    for(u32 ShapeIndex = 0; ShapeIndex < ShapeCount; ++ShapeIndex)
    {
        Accum += Shapes[ShapeIndex]->Area();
    }
    
    return Accum;
}

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

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

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

/* ========================================================================
   LISTING 24
   ======================================================================== */

f32 TotalAreaVTBL4(u32 ShapeCount, shape_base **Shapes)
{
    f32 Accum0 = 0.0f;
    f32 Accum1 = 0.0f;
    f32 Accum2 = 0.0f;
    f32 Accum3 = 0.0f;
    
    u32 Count = ShapeCount/4;
    while(Count--)
    {
        Accum0 += Shapes[0]->Area();
        Accum1 += Shapes[1]->Area();
        Accum2 += Shapes[2]->Area();
        Accum3 += Shapes[3]->Area();
        
        Shapes += 4;
    }
    
    f32 Result = (Accum0 + Accum1 + Accum2 + Accum3);
    return Result;
}

Запустив эти две функции с простой надстройкой для тестирования, я получаю грубую оценку количества циклов на фигуру, необходимых для выполнения данной операции:

Эта надстройка измеряет тайминг двух случаев. Первый — запуск кода единожды, при котором выясняется, что происходит в произвольном «холодном» состоянии, где данные должны храниться в L3, а L2 и L1 должны быть очищены, а предсказатель переходов не должен быть «попрактиковавшимся» на цикле.

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

По результатам видно, что между двумя функциями особой разницы нет. Получается около 35 циклов для вычисления площади одной фигуры «чистым» кодом. Иногда, если вам повезет, этот показатель может понизиться до 34.

Таким образом, мы можем ожидать от следования этим правилам 35 циклов. А что произойдет, если мы нарушим только первое правило? Что если вместо полиморфизма, мы возьмем просто switch? (Вообще, я не сказал бы, что switch по сути своей менее полиформичен нежели vtable. Это просто две разные реализации одной и той же вещи. Но правила «чистого» кода говорят использовать полиморфизм вместо конструкции switch, поэтому я просто использую их терминологию, согласно которой они, по всей видимости, не относят switch к полиморфизму)

Тут я написал точно такой же код, заменив только иерархию классов (и в рантайме, следовательно, vtable) на enum и тип фигуры, который сплющивает все в один struct:

/* ========================================================================
   LISTING 25
   ======================================================================== */

enum shape_type : u32
{
    Shape_Square,
    Shape_Rectangle,
    Shape_Triangle,
    Shape_Circle,
    
    Shape_Count,
};

struct shape_union
{
    shape_type Type;
    f32 Width;
    f32 Height;
};

f32 GetAreaSwitch(shape_union Shape)
{
    f32 Result = 0.0f;
    
    switch(Shape.Type)
    {
        case Shape_Square: {Result = Shape.Width*Shape.Width;} break;
        case Shape_Rectangle: {Result = Shape.Width*Shape.Height;} break;
        case Shape_Triangle: {Result = 0.5f*Shape.Width*Shape.Height;} break;
        case Shape_Circle: {Result = Pi32*Shape.Width*Shape.Width;} break;
        
        case Shape_Count: {} break;
    }
    
    return Result;
}

Это «олдскульный» способ, который бы мы использовали до «чистого» кода.

Заметьте, что теперь у нас нет отдельных типов для каждой фигуры, поэтому если у какой‑то из них нет одного из рассматриваемых значений («высоты», например), то она попросту не будет его использовать.

Теперь вместо получения площади вызовом виртуальной функции пользователь struct'а берет его из функции с switch, а это ровно то, что на лекции по «чистому» коду вам бы сказали никогда не делать. Но даже так за исключением уменьшения размеров, код остался тем же. Каждая ветка switch‑выражения представляет собой ровно тот же код, который содержится в соответствующей виртуальной функции в классовой иерархии.

Что касается циклов суммирования, вы можете увидеть, что они крайне идентичны «чистой» версии:

/* ========================================================================
   LISTING 26
   ======================================================================== */

f32 TotalAreaSwitch(u32 ShapeCount, shape_union *Shapes)
{
    f32 Accum = 0.0f;
    
    for(u32 ShapeIndex = 0; ShapeIndex < ShapeCount; ++ShapeIndex)
    {
        Accum += GetAreaSwitch(Shapes[ShapeIndex]);
    }

    return Accum;
}

f32 TotalAreaSwitch4(u32 ShapeCount, shape_union *Shapes)
{
    f32 Accum0 = 0.0f;
    f32 Accum1 = 0.0f;
    f32 Accum2 = 0.0f;
    f32 Accum3 = 0.0f;
    
    ShapeCount /= 4;
    while(ShapeCount--)
    {
        Accum0 += GetAreaSwitch(Shapes[0]);
        Accum1 += GetAreaSwitch(Shapes[1]);
        Accum2 += GetAreaSwitch(Shapes[2]);
        Accum3 += GetAreaSwitch(Shapes[3]);
        
        Shapes += 4;
    }
    
    f32 Result = (Accum0 + Accum1 + Accum2 + Accum3);
    return Result;
}

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

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

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

Что же компилятор со всеми полученными преимуществами сможет для нас сделать? Вот результаты запуска всех четырех функций:

Глядя на результаты, мы видим нечто весьма примечательное: одно лишь изменение — написание кода старомодным путем нежели "чистым", — дало нам непосредственный прирост производительности в 1.5 раза. Прирост в 1.5 раза лишь из-за того, что мы удалили все посторонние вещи, необходимые для использования полиморфизма в C++.

Получается, что нарушением первого правила чистого кода, которое к тому же является одним из центральных, мы смогли добиться снижения количества циклов на фигуру с 35 до 24, выяснив, что код, следующий правилу номер один, в 1.5 раза медленнее кода, который этого не делает. Если проецировать это на аппаратное обеспечение, это как взять iPhone 14 Pro Max и понизить его до iPhone 11 Pro Max. Это вычеркивание трех или четырех лет эволюции аппаратного обеспечения просто потому что кто-то сказал использовать полиморфизм вместо switch.

Но мы только начинаем.

Что если мы нарушим больше правил? Что если мы также нарушим второе правило, "незнание внутренностей"? Что если наши функции смогли бы использовать знание о том, с чем они вообще работают, и стать более эффективными?

Если мы снова взглянем на конструкцию switch, в которой идет нахождение площади, мы увидим, что все вычисления схожи:

case Shape_Square: {Result = Shape.Width*Shape.Width;} break;
case Shape_Rectangle: {Result = Shape.Width*Shape.Height;} break;
case Shape_Triangle: {Result = 0.5f*Shape.Width*Shape.Height;} break;
case Shape_Circle: {Result = Pi32*Shape.Width*Shape.Width;} break;

Они все делают что-то вроде умножения ширины на высоту, ширины на ширину, с опциональным коэффициентом. В случае с треугольником этот коэффициент равен 0.5, а с кругом — Пи. Как‑то так.

Это одна из причин, по которым я, в отличие от защитников «чистого» кода, считаю, что конструкция switch замечательна! Она делает подобные паттерны легко заметными. Когда ваш код организован по операциям, а не по типам, становится легко выявлять и выводить общие паттерны. Для сравнения, если мы снова посмотрим на версию с классами, возможно, вы никогда и не заметите подобных паттернов из‑за объема boilerplate‑кода, которым они покрыты. А сторонники «чистого» кода еще и рекомендуют класть каждый класс в отдельный файл, еще больше понижая вероятность заметить подобные вещи.

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

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

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

/* ========================================================================
   LISTING 27
   ======================================================================== */

f32 const CTable[Shape_Count] = {1.0f, 1.0f, 0.5f, Pi32};
f32 GetAreaUnion(shape_union Shape)
{
    f32 Result = CTable[Shape.Type]*Shape.Width*Shape.Height;
    return Result;
}

И оба суммирующих цикла для этой версии точно такие же, в них нет необходимости менять что-то кроме замены вызова GetAreaSwitch на вызов GetAreaUnion.

Давайте посмотрим, что произойдет, если мы запустим новую версию:

Здесь вы можете видеть, что воспользовавшись тем, что мы знаем, какие типы у нас есть — эффективно переключившись с основанного на типах мышления на мышление, основанное на функциях, — мы получаем огромный прирост скорости. Мы перешли от оператора switch, который был всего в 1.5 раза быстрее, к версии с таблицей, которая справляется с ровно этой же задачей в 10 раз быстрее.

И чтобы этого добиться, мы всего лишь использовали один поиск по таблице и одну строчку кода! Получилось не просто намного быстрее, но и куда менее семантически комплексно. Меньше токенов, меньше операций, меньше строк кода.

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

10x — это настолько большой прирост производительности, что мы даже не сможем спроецировать его на iPhone, потому что тесты iPhone не уходят корнями в достаточно далекое прошлое. Если взять iPhone 6, самый старый телефон, показывающийся в современных тестах, то он медленнее iPhone 14 Pro Max всего примерно в три раза. Поэтому мы даже не сможем использовать телефоны для описания этой разницы.

Если взять однопоточный перформанс на ПК, 10-кратный прирост скорости будет аналогичен переходу с современного среднестатистического процессора на средний процессор из далекого 2010! Концепция первых двух правил «чистого кода» пожирает за раз 12 лет эволюции железа.

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

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

/* ========================================================================
   LISTING 32
   ======================================================================== */

class shape_base
{
public:
    shape_base() {}
    virtual f32 Area() = 0;
    virtual u32 CornerCount() = 0;
};

class square : public shape_base
{
public:
    square(f32 SideInit) : Side(SideInit) {}
    virtual f32 Area() {return Side*Side;}
    virtual u32 CornerCount() {return 4;}
    
private:
    f32 Side;
};

class rectangle : public shape_base
{
public:
    rectangle(f32 WidthInit, f32 HeightInit) : Width(WidthInit), Height(HeightInit) {}
    virtual f32 Area() {return Width*Height;}
    virtual u32 CornerCount() {return 4;}
    
private:
    f32 Width, Height;
};

class triangle : public shape_base
{
public:
    triangle(f32 BaseInit, f32 HeightInit) : Base(BaseInit), Height(HeightInit) {}
    virtual f32 Area() {return 0.5f*Base*Height;}
    virtual u32 CornerCount() {return 3;}
    
private:
    f32 Base, Height;
};

class circle : public shape_base
{
public:
    circle(f32 RadiusInit) : Radius(RadiusInit) {}
    virtual f32 Area() {return Pi32*Radius*Radius;}
    virtual u32 CornerCount() {return 0;}
    
private:
    f32 Radius;
};

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

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

Чтобы обновить «чистый» суммирующий цикл, добавим нужные расчеты и вызов дополнительной виртуальной функции:

f32 CornerAreaVTBL(u32 ShapeCount, shape_base **Shapes)
{
    f32 Accum = 0.0f;
    for(u32 ShapeIndex = 0; ShapeIndex < ShapeCount; ++ShapeIndex)
    {
        Accum += (1.0f / (1.0f + (f32)Shapes[ShapeIndex]->CornerCount())) * Shapes[ShapeIndex]->Area();
    }
    
    return Accum;
}

f32 CornerAreaVTBL4(u32 ShapeCount, shape_base **Shapes)
{
    f32 Accum0 = 0.0f;
    f32 Accum1 = 0.0f;
    f32 Accum2 = 0.0f;
    f32 Accum3 = 0.0f;
    
    u32 Count = ShapeCount/4;
    while(Count--)
    {
        Accum0 += (1.0f / (1.0f + (f32)Shapes[0]->CornerCount())) * Shapes[0]->Area();
        Accum1 += (1.0f / (1.0f + (f32)Shapes[1]->CornerCount())) * Shapes[1]->Area();
        Accum2 += (1.0f / (1.0f + (f32)Shapes[2]->CornerCount())) * Shapes[2]->Area();
        Accum3 += (1.0f / (1.0f + (f32)Shapes[3]->CornerCount())) * Shapes[3]->Area();
        
        Shapes += 4;
    }
    
    f32 Result = (Accum0 + Accum1 + Accum2 + Accum3);
    return Result;
}

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

Для обновления версии со switch, мы делаем, по сути, те же изменения. В первую очередь, добавляем еще один switch для количества углов с ветвлениями, отражающими версию с иерархией:

/* ========================================================================
   LISTING 34
   ======================================================================== */

u32 GetCornerCountSwitch(shape_type Type)
{
    u32 Result = 0;
    
    switch(Type)
    {
        case Shape_Square: {Result = 4;} break;
        case Shape_Rectangle: {Result = 4;} break;
        case Shape_Triangle: {Result = 3;} break;
        case Shape_Circle: {Result = 0;} break;
        
        case Shape_Count: {} break;
    }
    
    return Result;
}

Потом мы вычисляем то же самое, что и в иерархической версии:

/* ========================================================================
   LISTING 35
   ======================================================================== */

f32 CornerAreaSwitch(u32 ShapeCount, shape_union *Shapes)
{
    f32 Accum = 0.0f;
    
    for(u32 ShapeIndex = 0; ShapeIndex < ShapeCount; ++ShapeIndex)
    {
        Accum += (1.0f / (1.0f + (f32)GetCornerCountSwitch(Shapes[ShapeIndex].Type))) * GetAreaSwitch(Shapes[ShapeIndex]);
    }

    return Accum;
}

f32 CornerAreaSwitch4(u32 ShapeCount, shape_union *Shapes)
{
    f32 Accum0 = 0.0f;
    f32 Accum1 = 0.0f;
    f32 Accum2 = 0.0f;
    f32 Accum3 = 0.0f;
    
    ShapeCount /= 4;
    while(ShapeCount--)
    {
        Accum0 += (1.0f / (1.0f + (f32)GetCornerCountSwitch(Shapes[0].Type))) * GetAreaSwitch(Shapes[0]);
        Accum1 += (1.0f / (1.0f + (f32)GetCornerCountSwitch(Shapes[1].Type))) * GetAreaSwitch(Shapes[1]);
        Accum2 += (1.0f / (1.0f + (f32)GetCornerCountSwitch(Shapes[2].Type))) * GetAreaSwitch(Shapes[2]);
        Accum3 += (1.0f / (1.0f + (f32)GetCornerCountSwitch(Shapes[3].Type))) * GetAreaSwitch(Shapes[3]);
        
        Shapes += 4;
    }
    
    f32 Result = (Accum0 + Accum1 + Accum2 + Accum3);
    return Result;
}

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

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

/* ========================================================================
   LISTING 36
   ======================================================================== */

f32 const CTable[Shape_Count] = {1.0f / (1.0f + 4.0f), 1.0f / (1.0f + 4.0f), 0.5f / (1.0f + 3.0f), Pi32};
f32 GetCornerAreaUnion(shape_union Shape)
{
    f32 Result = CTable[Shape.Type]*Shape.Width*Shape.Height;
    return Result;
}

Запустив эти функции "угловых площадей", мы можем наблюдать влияние добавления в фигуру нового свойства на производительность:

Как можем видеть, «чистый» код начал справляться еще хуже. switch‑версия, которая была лишь в 1.5 раза быстрее, теперь быстрее почти в 2 раза, а табличная версия и вовсе чуть ли не в 15 раз.

Это показывает еще более глубокую проблему «чистого» кода: чем комплекснее становится задача, тем больше эти идеи вредят производительности. Когда вы попытаетесь расширить «чистые» техники до реальных объектов с кучей полей, вы будете страдать от повсеместных ударов по производительности во всем коде.

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

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

Таким образом, мы с десятикратной разницы допрыгнули до разницы в 15 раз простым добавлением одного свойства в фигуры. Это как вернуться с железа 2023 года на железо аж 2008-ого! Вместо стирания 12 лет мы стираем уже 14 лет просто введением нового параметра в условие задачи.

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

Вот так выглядят результаты запуска функций с слегка оптимизированной AVX‑версией этих же вычислений:

Разница в скорости достигает 20–25 раз и, конечно, никакая часть оптимизированного под AVX кода не использует ничего, даже отдаленно напоминающего принципы «чистого» кода.

Разобрались с четырьмя правилами, что с номером пять?

Если честно, «don't repeat yourself» выглядит неплохо. Как видно из листингов, мы не особо повторялись. Можно было бы засчитать развернутые версии с четырьмя аккумуляторами, но то было лишь для демонстрации. Фактически это ни к чему, если только вы не проводите подобные оценки.

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

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

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

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

Но для существенного сегмента компьютерной индустрии большой долей объяснения на «почему ПО такое медленное» является ответ «из‑за „чистого“ кода». Почти все идеи, лежащие под методологией «чистого» кода, ужасны для производительности, и вы не должны им следовать.

Правила «чистого» кода были разработаны лишь потому, что кто‑то подумал, что они помогут строить кодовые базы, которые легче будет поддерживать. Даже если это было бы правдой, вам надо задаться вопросом: «Какой ценой?»

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

Мы все еще можем попытаться придумать практические правила, которые помогут держать код организованным, легко поддерживаемым и легко читаемым. Это вовсе не плохие цели! Но вот эти правила таковыми не являются. Нужно перестать их продвигать без большой сопровождающей сноски, в которой бы говорилось: «...и ваш код после этого будет работать в 15 раз медленнее».

Tags:
Hubs:
Total votes 95: ↑69 and ↓26+63
Comments222

Articles