IT Notes

Указатель на функцию в C++

Введение

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

Вариант 1. Функция-Посетитель

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

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

typedef void ( *FileVisitor )( const char* const );

void forEachFile( const char* const path, FileVisitor visitor ) {
    QDir dir( path );
    const QStringList fileNames = dir.entryList( QDir::Files );
    foreach( const QString& fileName, fileNames ) {
        visitor( dir.absoluteFilePath( fileName ).toStdString().c_str() );
    }
}

Указатель на функцию мы определили с помощью typedef. Само определение состоит из трех частей:

<Возвращаемый тип> ( *<Имя> )( <Аргументы> )

В нашем конкретном случае <Возвращаемый тип>=void, <Имя>=FileVisitor и <Аргументы>=const char* const. Под это описание подойдет любая функция, которая ничего не возвращает, а на вход принимает C-строку, в которой ожидает получить полный путь к файлу. Скоро мы напишем несколько Посетителей с такой сигнатурой. Но сначала давайте закончим с функцией forEachFile().

Ее первым аргументом является путь к каталогу path. В качестве второго она принимает указатель на функцию FileVisitor. Заметим, что мы могли бы записать то же самое без использования typedef следующим образом:

void forEachFile( const char* const path, void ( *visitor )( const char* const ) );

Сигнатура указателя на функцию здесь практически такая же, как и в typedef, но есть и отличия. Теперь то, что записано в качестве поля <Имя>, становится указателем, который можно вызывать внутри функции forEachFile(). В этом случае нам пришлось писать немного меньше кода, но обычно хорошей практикой является использование typedef. Это особенно актуально для сложных сигнатур, в которых можно запутаться.

Реализация forEachFile() полностью основана на функции-члене entryList() класса QDir. Мы просто запрашиваем список файлов в каталоге, расположенном в пути path; а затем для каждого файла в списке передаем соответствующий полный путь нашему Посетителю visitor, как обычной функции.

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

void println( const char* const text ) {
    std::cout << text << std::endl;
}

Обратите внимание, что сигнатура функции-Посетителя должна соответствовать FileVisitor. Если бы println() принимала больше аргументов или возвращала какое-то значение, то мы бы не смогли ее использовать в функции обхода forEachFile(). А теперь проверим, что все работает:

int main() {
    static const char* const DIR_PATH = "/etc/mc";

    std::cout << "******************** PRINTLN ********************" << std::endl;
    forEachFile( DIR_PATH, println );
    std::cout << std::endl;
    
    return 0;
}

В качестве рабочего пути я указал папку с системными настройками mc (про него вы можете почитать в заметке Десять советов для эффективного использования Midnight Commander). Если вы пользуетесь Windows или у вас не установлен mc, то укажите вместо этого пути любой другой по своему желанию. Но убедитесь, что соответствующий каталог существует и в нем есть какие-нибудь файлы. Сам адрес функции, на которую будет указывать указатель, мы передаем, как обычный параметр. При желании мы могли бы показать более явно, что это именно адрес, поставив &:

forEachFile( DIR_PATH, &println );

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

Если вы все сделали правильно, то после запуска приложения увидите что-нибудь на подобии:

******************** PRINTLN ********************
/etc/mc/edit.indent.rc
/etc/mc/filehighlight.ini
/etc/mc/mc.default.keymap
/etc/mc/mc.emacs.keymap
/etc/mc/mc.ext
/etc/mc/mc.keymap
/etc/mc/mc.menu
/etc/mc/mc.menu.sr
/etc/mc/mcedit.menu
/etc/mc/sfs.ini

Но ведь мы это все затеяли, чтобы иметь возможность легко добавлять новых Посетителей. Так давайте сделаем это! И в этот раз выведем не пути к файлам, а их имена с размером в байтах:

void printFileSize( const char* const filePath ) {
    QFileInfo fileInfo( filePath );
    std::cout << fileInfo.fileName().toStdString() << ": " << fileInfo.size() << std::endl;
}

Чтобы вывести только имя файла, а не полный путь, мы воспользовались функцией-членом fileName() класса QFileInfo. Этот же класс помог нам легко определить размер файла с помощью функции size(). Добавим в функцию main() еще один вызов для нового Посетителя:

int main() {
    // ...
    
    std::cout << "**************** PRINT_FILE_SIZE ****************" << std::endl;
    forEachFile( DIR_PATH, printFileSize );
    std::cout << std::endl;

    // ...
}

А вот что вывело приложение у меня:

**************** PRINT_FILE_SIZE ****************
edit.indent.rc: 788
filehighlight.ini: 1119
mc.default.keymap: 8626
mc.emacs.keymap: 8564
mc.ext: 18954
mc.keymap: 8626
mc.menu: 9024
mc.menu.sr: 10409
mcedit.menu: 12637
sfs.ini: 737

Неплохо. Но, что если мы захотим найти суммарный размер файлов? Функции не имеют состояния, поэтому рассмотренный в этом разделе вариант не подходит. А это повод перейти к следующему.

Вариант 2. Добавляем пользовательские данные

Сейчас мы ограничены тем, что функция не может хранить данные. Передать адрес функции-члена какого-либо класса мы тоже не можем, поскольку без объекта она работать не будет. Мы могли бы использовать глобальные переменные, но это заведомо плохая идея. Что же делать? - Все достаточно просто. Реализуем передачу в функцию-Посетитель дополнительного параметра в виде указателя на void следующим образом:

typedef void ( *FileVisitorUserData )( const char* const, void* );

void forEachFile( const char* const path, FileVisitorUserData visitor, void* userData ) {
    QDir dir( path );
    const QStringList fileNames = dir.entryList( QDir::Files );
    foreach( const QString& fileName, fileNames ) {
        visitor( dir.absoluteFilePath( fileName ).toStdString().c_str(), userData );
    }
}

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

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

void accumulateFileSize( const char* const filePath, void* userData ) {
    if( int* size = static_cast< int* >( userData ) ) {
        *size += QFile( filePath ).size();
    }
}

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

А вот как мы можем использовать нашу функцию подсчета суммарного размера файлов:

int main() {
    // ...
    
    std::cout << "************* ACCUMULATE_FILE_SIZE **************" << std::endl;
    int size = 0;
    forEachFile( DIR_PATH, accumulateFileSize, &size );
    std::cout << "Accumulated size: " << size / 1024 << "KB" << std::endl;
    std::cout << std::endl;

    // ...
}

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

************* ACCUMULATE_FILE_SIZE **************
Accumulated size: 77KB

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

class FileSizeAccumulator {
public:
    FileSizeAccumulator() : m_size( 0 ) { }
    virtual ~FileSizeAccumulator() { }

    void accumulate( const char* const filePath ) {
        accumulateFileSize( filePath, &m_size );
    }

    int getAccumulatedSize() const {
        return m_size;
    }

private:
    int m_size;
};

Обратите внимание, что для реализации функции-члена accumulate() мы повторно использовали подготовленную ранее функцию accumulateFileSize(). Но как же мы используем этот класс в алгоритме обхода? - Все не так уж сложно. Достаточно добавить вспомогательную статическую функцию:

class FileSizeAccumulator {
public:
    // ...

    static void accumulateHelper( const char* const filePath, void* userData ) {
        if( FileSizeAccumulator* accumulator = static_cast< FileSizeAccumulator* >( userData ) ) {
            accumulator->accumulate( filePath );
        }
    }

    // ...
};

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

int main() {
    // ...

    std::cout << "*********** ACCUMULATE_FILE_SIZE_CLASS **********" << std::endl;
    FileSizeAccumulator accumulator;
    forEachFile( DIR_PATH, FileSizeAccumulator::accumulateHelper, &accumulator );
    std::cout << "Accumulated size: " << accumulator.getAccumulatedSize() / 1024 << "KB" << std::endl;
    std::cout << std::endl;

    // ...
}

Обратите внимание, что адрес функции передается с явным указанием класса, к которому она принадлежит, то есть FileSizeAccumulator::accumulateHelper. С другой стороны, мы могли бы сделать эту функцию обычной и вынести ее за пределы класса. Однако в этом случае вы потеряете доступ к закрытым полям класса. Конечно, можно было бы пойти в обход и использовать friend-функции, но технически это будут практически эквивалентные решения.

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

void forEachFile(
    const char* const path,
    FileSizeAccumulator* accumulator,
    void ( FileSizeAccumulator::*visitor )( const char* const )
) {
    QDir dir( path );
    const QStringList fileNames = dir.entryList( QDir::Files );
    foreach( const QString& fileName, fileNames ) {
        ( accumulator->*visitor )( dir.absoluteFilePath( fileName ).toStdString().c_str() );
    }
}

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

( accumulator->*visitor )( dir.absoluteFilePath( fileName ).toStdString().c_str() );

Это необходимо, чтобы определить нужную последовательность действий. Сначала мы разыменовываем указатель на функцию-Посетитель, затем вызываем ее для экземпляра класса accumulator и лишь затем передаем аргументы. Если убрать скобки вокруг accumulator->*visitor, то последовательность действий изменится и компилятор сообщит об ошибке. Не использовать операцию разыменования * мы тоже не можем, ведь иначе visitor будет интерпретироваться в качестве имени поля, которого нет у класса FileSizeAccumulator.

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

int main() {
    // ...

    std::cout << "************* FILE_SIZE_ACCUMULATOR *************" << std::endl;
    FileSizeAccumulator accumulator2;
    forEachFile( DIR_PATH, &accumulator2, &FileSizeAccumulator::accumulate );
    std::cout << "Accumulated size: " << accumulator2.getAccumulatedSize() / 1024 << "KB" << std::endl;
    std::cout << std::endl;

    // ...
}

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

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

Вариант 3. В стиле ООП

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

class AbstractFileVisitor {
public:
    virtual ~AbstractFileVisitor() { }
    virtual void visit( const char* const filePath ) = 0;
};

void forEachFile( const char* const path, AbstractFileVisitor* visitor ) {
    if( !visitor ) {
        return;
    }

    QDir dir( path );
    const QStringList fileNames = dir.entryList( QDir::Files );
    foreach( const QString& fileName, fileNames ) {
        visitor->visit( dir.absoluteFilePath( fileName ).toStdString().c_str() );
    }
}

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

Рассмотрим пример реализации класса-Посетителя:

class AccumulatorFileVisitor : public AbstractFileVisitor, public FileSizeAccumulator {
public:
    void visit( const char* const filePath ) {
        accumulate( filePath );
    }
};

Заметим, что у нас уже был реализован класс FileSizeAccumulator, который делает именно то, что нам нужно, но имеет немного другой интерфейс. Поэтому мы унаследовали наш новый класс от него и лишь реализовали чисто виртуальную функцию visit(), в которой вызвалиaccumulate() из базового класса.

А вот как это выглядит в деле:

int main() {
    // ...

    std::cout << "*********** ACCUMULATOR_FILE_VISITOR ************" << std::endl;
    AccumulatorFileVisitor visitor;
    forEachFile( DIR_PATH, &visitor );
    std::cout << "Accumulated size: " << visitor.getAccumulatedSize() / 1024 << "KB" << std::endl;
    std::cout << std::endl;

    // ...
}

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

Вариант 4. Функторы

Вот мы и дошли до самого универсального варианта решения нашей задачи. И начнем сразу с функции обхода:

template< typename FileVisitorFunc >
void forEachFileGeneric( const char* const path, FileVisitorFunc visitor ) {
    QDir dir( path );
    const QStringList fileNames = dir.entryList( QDir::Files );
    foreach( const QString& fileName, fileNames ) {
        visitor( dir.absoluteFilePath( fileName ).toStdString().c_str() );
    }
}

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

Этот вариант полностью включает в себя первый. То есть мы можем использовать в качестве второго аргумента и println(), и printFileSize(). Вот как это выглядит:

forEachFileGeneric( DIR_PATH, println );
forEachFileGeneric( DIR_PATH, printFileSize );

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

int testFunc( const void* text ) { }

// ...

forEachFileGeneric( DIR_PATH, testFunc );

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

А как дела обстоят с классами? - Вполне нормально. Для этого мы можем создать класс, реализующий оператор operator(), чтобы его можно было вызвать. Подобные классы называются функторами. Рассмотрим пример:

class LineNumberPrinter {
public:
    LineNumberPrinter() : m_lineNumber( 0 ) { }

    void operator()( const char* const line ) {
        std::cout << m_lineNumber << ": " << line << std::endl;
        ++m_lineNumber;
    }

private:
    int m_lineNumber;
};

Мы создали функтор, который выводит каждую строку с ее номером. Вот как мы можем его использовать:

forEachFileGeneric( DIR_PATH, LineNumberPrinter() );

В качестве второго аргумента мы передали объект функтора (обратите внимание на скобки). После запуска приложения на консоль для этого вызова будет выведено следующее:

0: /etc/mc/edit.indent.rc
1: /etc/mc/filehighlight.ini
2: /etc/mc/mc.default.keymap
3: /etc/mc/mc.emacs.keymap
4: /etc/mc/mc.ext
5: /etc/mc/mc.keymap
6: /etc/mc/mc.menu
7: /etc/mc/mc.menu.sr
8: /etc/mc/mcedit.menu
9: /etc/mc/sfs.ini

А для C++11 мы можем использовать еще и лямбда-функции:

forEachFileGeneric(
    DIR_PATH,
    [] ( const char* const filePath ) {
        std::cout << filePath << std::endl;
    }
);

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

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

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

Заключение

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

Понравилась статья?
Не забудь поделиться ей с друзьями!

Похожие публикации

Комментарии

Спасибо. Получил новые знания.

Пожалуйста. Спасибо за комментарий.

RSS RSS-рассылка

Популярное

Дешевый хостинг