SoftCraft
разноликое программирование

Top.Mail.Ru

О недостатках процедурного подхода

© 2019
Легалов А.И.

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

Спагетти-код

Рисунок 1 – Процедурная программа как предмет для критики

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

Что критикуют в процедурном подходе

Процедурное программирование подвергается критике на многих сайтах. В частности, отмечаются следующие недостатки:

  1. Риск возникновения множества ошибок при работе над большим проектом. Приходится писать много процедур, и это не может не сказаться на чистоте и работоспособности кода.

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

  3. Структура библиотек также не прозрачна: при модификации, можно одним "неловким движением" испортить всю библиотеку, это реально бывает.

  4. Все данные процедуры доступны только внутри нее. Их нельзя вызвать из другого места программы и при необходимости придется писать аналогичный код. А это уже противоречит одному из основополагающих принципов программирования, который звучит как Don’t Repeat Yourself (Не повторяйся).

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

  6. Недостаточность уровней абстракции для представления инкапсуляции, наследования и полиморфизма.

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

  8. И многое, многое другое в различных вариациях...

Пример для критики

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

  • создать контейнер для геометрических фигур;

  • ввести геометрические фигуры с консоли в контейнер в формате, удобном для обработки компьютером;

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

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

    // main.cpp - содержит главную функцию, 
    // обеспечивающую простое тестирование
    #include <iostream>
    #include "shape_atd.h"

    namespace simple_shapes {
        shape* In();
        void Out(shape &s);
    }
    using namespace std;
    using namespace simple_shapes;

    // Контейнер на основе одномерного массива
    int const max_len = 100; // макс. длина
    struct container   {
        int len; // текущая длина
        shape *cont[max_len];
    };

    int main() {
        container c;
        //!!  Init(c);
        c.len = 0;

        //!! In(c);
        cout << "Do you want to input next shape " 
            "(yes=\'y\', no=other character)? " 
                                            << endl;
        char k;
        cin >> k;
        while(k == 'y') {
            cout << c.len << ": ";
            if((c.cont[c.len] = In()) != 0) {c.len++;}
            cout << "Do you want to input next shape " 
                              "(yes=\'y\', no=other character)? " 
                                                            << endl;
            cin >> k;
        }
        
        //!! Out(c);
        cout << "Container contents " << c.len 
                                << " elements." << endl;
        for(int i = 0; i < c.len; i++) {
            cout << i << ": ";
            Out(*(c.cont[i]));
        }

        //!! Clear(c);
        for(int i = 0; i < c.len; i++) {
            delete c.cont[i];
        }
        //?? c.len = 0; // то, что вроде бы можно и не ставить так как дальше выполнять не нужно
        return 0;
    }

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

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

Для прямоугольника:

    #ifndef __rectangle_atd__
    #define __rectangle_atd__
    // rectangle_atd.h - описание прямоугольника
    
    namespace simple_shapes {
        // прямоугольник
        struct rectangle {
            int x, y; // ширина, высота
        };
    } // end simple_shapes namespace

    #endif

Для треугольника:

    #ifndef __triangle_atd__
    #define __triangle_atd__
    // triangle_atd.h - описание треугольника

    namespace simple_shapes {
        // треугольник
        struct triangle {
            int a, b, c; // стороны
        };
    } // end simple_shapes namespace

    #endif    

Для фигуры - обобщения:

    #ifndef __shape_atd__
    #define __shape_atd__
    // shape.h - содержит описание обобщающей геометрической фигуры,
    #include "rectangle_atd.h"
    #include "triangle_atd.h"

    namespace simple_shapes {
        // структура, обобщающая все имеющиеся фигуры
        enum key {RECTANGLE, TRIANGLE};
        struct shape {
            // значения ключей для каждой из фигур
            key k; // ключ
            // используемые альтернативы
            union { // используем простейшую реализацию
                rectangle r;
                triangle t;
            };
        };
    } // end simple_shapes namespace

    #endif

На этом наше терпение по построению абстракций заканчивается. Поэтому напрямую пишем код функций ввода и вывода обобщенной фигуры:

    // shape_In.cpp - ввод созданной любой фигуры
    #include <iostream>
    #include "shape_atd.h"
    using namespace std;
    
    namespace simple_shapes {
    
        // Ввод параметров обобщенной фигуры 
        shape* In()  {
            shape *sp;
            cout << "Input key: for Rectangle is 1, " 
                    " for Triangle is 2, else break: ";
            int k;
            cin >> k;
            switch(k) {
            case 1:
                sp = new shape;
                sp->k = RECTANGLE;
                //!! In(sp->r);
                cout << "Input Rectangle: x, y = ";
                cin >> sp->r.x >> sp->r.y;
                return sp;
            case 2:
                sp = new shape;
                sp->k = TRIANGLE;
                //!! In(sp->t);
                cout << "Input Triangle: a, b, c = ";
                cin >> sp->t.a >> sp->t.b >> sp->t.c;
                return sp;
            default:
                return 0;
            }
        }
    } // end simple_shapes namespace

    // shape_Out.cpp - вывод параметров 
    // для любой геометрической фигуры
    #include <iostream>
    #include "shape_atd.h"
    using namespace std;
    
    namespace simple_shapes {
        void Out(shape &s) {
            switch(s.k) {
            case RECTANGLE: // Out(s.r);
                cout << "It is Rectangle: x = " 
                << s.r.x << ", y = " << s.r.y << endl;
                break;
            case TRIANGLE: // Out(s.t);
                cout << "It is Triangle: a = " 
                        << s.t.a << ", b = " << s.t.b
                        << ", c = " << s.t.c << endl;
                break;
            default:
                cout << "Incorrect figure!" << endl;
            }
        }
    } // end simple_shapes namespace

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

Исходные тексты данного примера можно скачать по этой ссылке.

Есть ли просветы при процедурном подходе?

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

  1. По поводу первого недостатка. Никто не отрицает, что при правильном построении больших программных проектов количество процедур и функций становиться огромным. Однако и методов при использовании ООП тоже будет не меньше, а то и больше. Это обуславливается использованием при любой парадигме программирования общеизвестного принципа: разделяй и властвуй, обеспечивающего за счет декомпозиции уменьшение размерности решаемой задачи. Кроме того (см. пример выше) использование такой декомпозиции уменьшает размерность решаемой задачи. Поэтому появление большого числа процедур или методов - это общепринятый принцип, не связанный только с процедурным подходом. Весь вопрос заключается в правильном построении этих процедур. Это же касается недостатков 2 и 3, связанных с разработкой библиотек. Использование ООП тоже не гарантирует их понятного и компактного построения, а также легкой модификации, о чем свидетельствует критика широко используемой в свое время библиотеки Microsof Foundation Classes (MFC). Это же касется и процедурного подхода. При правильном выборе и использовании абстракций можно организовать гибкое расширение и модификацию любого разрабатываемого кода.

  2. Относительно третьего недостатка можно отметить, что данные процедуры доступны только внутри нее, если они в ней локально объявлены. Но это же касается и методов объектно-ориентированных языков. К их локальным данным тоже нельзя обратиться из других точек программы. Вместе с тем можно отметить, что любая процедура может напрямую работать и с внешними данными, видимыми как глобально, так и локально (статические данные в языке программирования C и C++, данные внутри модулей различных языков, данные в неименованных пространствах имен C++ и т.д.). Не всегда доступ к глобальным данным считается хорошим тоном, но помимо этого возможен доступ через параметры процедуры. Одним из таких параметров может являться абстрактный тип данных, эквивалентный классу, что обеспечивает практически аналогичную обработку по сравнению с ОО подходом. Поэтому, к данному аргументу вряд ли стоит тоже относиться серьезно. Нужно только правильно выстраивать структуру процедурной программы.

  3. Четвертый недостаток продемонстрирован выше примером для критики. Ради справедливости следует отметить, что при использовании ОО стиля нужно постараться, чтобы построить аналогичный монолит, так как декомпозиция по классам "принуждает" разработчика создавать методы, осуществляющие обработку данных, расположенных внутри класса, а уже потом эти методы могут вызываться в других классах, убирая тем самым прямой доступ. Эта ограниченность ООП обеспечивает повышение надежности формируемого кода. При процедурном подходе подобные решения тоже возможны. Но для этого программисту нужно "ограничивать" себя, формируя определенную культуру кодирования. Поэтому написание больших монолитных кусков можно отнести к отсутствии культуры программирования, когда, при возможности использовать множество путей, выбирается не лучший.

  4. Нельзя также упрекнуть языки, поддерживающие процедурное программирование, в недостатке уровней абстракции. Абстрактные типы данных изначально появились именно в процедурном программировании. Использование наследования возможно при написании процедурных программ в C++. Язык программирования Оберон и его последняя версия, созданная Н. Виртом, Oberon-7 также используют наследование для расширения записей. Что касается инкапсуляции, то процедурный подход обеспечивает, по крайней мере, не менее эффективную ее поддержку, о чем гласит статья Скота Мейерса. Простой пример процедурной инкапсуляции также приведен при описании процедурного и ОО программирования. Если говорить о полиморфизме, формирование которого в ООП опирается на наследование и виртуализацию, то в настоящее время это не является единственным вариантом. Ряд новых языков программирования, таких как Go и Rust, позиционируются как процедурные (по крайней мере, не объектно-ориентированные), но при этом обходятся без наследования, реализуя полиморфизм посредством использования интерфейсов и/или типажей. Также полиморфизм, включая и множественный полиморфизм, поддерживает процедурно-параметрическая парадигма. Таким образом вряд ли стоит говорить о более низком уровне абстрации в контексте современных процедурных языков и их последующих версий. Они могут обеспечивать поддержку практически всего, что делают ОО языки, и даже больше.

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

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

"И целого мира мало"

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

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

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

Заключение

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

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

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