company_banner

Анонимные функции в PHP: сеанс чёрной магии с разоблачением



    Начать, наверное, следует с того, что анонимная функция(замыкание) в PHP — это не функция, а объект класса Closure. Собственно, на этом статью можно было бы и закончить, но если кому-то интересны подробности — добро пожаловать под кат.


    Дабы не быть голословным:
    $func = function (){};
    var_dump($func);
    
    ---------
    object(Closure)#1 (0) {
    }

    Забегая вперёд, скажу, что на самом деле это не совсем обычный объект. Давайте разберёмся.

    Например, такой код
    $func = function (){
        echo 'Hello world!';
    };
    $func();

    компилируется в такой набор опкодов:
    line     #* E I O op                       fetch   ext  return  operands
    --------------------------------------------------------------------------
       8     0  E >   DECLARE_LAMBDA_FUNCTION                       '%00%7Bclosure%7D%2Fin%2FcrvX50x7fabda9ed09e'
      10     1        ASSIGN                                        !0, ~1
      11     2        INIT_DYNAMIC_CALL                             !0
             3        DO_FCALL                         0          
      11     2      > RETURN                                        1
    
    Function %00%7Bclosure%7D%2Fin%2FcrvX50x7fabda9ed09e:
    function name:  {closure}
    line     #* E I O op                       fetch   ext  return  operands
    --------------------------------------------------------------------------
       9     0  E >   ECHO                                          'Hello+world%21'
      10     1      > RETURN                                        null

    Блок с описанием тела функции нам не особо интересен, а вот в первом блоке присутствуют два интересных нам опкода: DECLARE_LAMBDA_FUNCTION и INIT_DYNAMIC_CALL. Начнём со второго.

    INIT_DYNAMIC_CALL


    Этот опкод используется в случае, когда компилятор видит вызов функции на переменной или массиве. Т.е.
    $variable();
    ['ClassName', 'staticMethod']();

    Это не какой-то уникальный опкод, специфичный только для замыканий. Такой синтаксис также работает для объектов, вызывая метод __invoke(), для строковых переменных, содержащих имя функции ($a = 'funcName'; $a();), и для массивов, содержащих имена класса и статического метода в нём.

    В случае замыкания нас интересует вызов на переменной с объектом, что логично.
    Углубляясь в код VM, обрабатывающий этот опкод, мы дойдём до функции zend_init_dynamic_call_object, в которой увидим следующее (нарезка):
    zend_execute_data *zend_init_dynamic_call_object(zend_object *function, uint32_t num_args)
    {
    	zend_function *fbc;
    	zend_class_entry *called_scope;
    	zend_object *object;
    	...
    	if (EXPECTED(function->handlers->get_closure) &&
    	    EXPECTED(function->handlers->get_closure(function, &called_scope, &fbc, &object) == SUCCESS)) {
    		...
    	} else {
    		zend_throw_error(NULL, "Function name must be a string");
    		return NULL;
    	}
    	...
    }

    Забавно, что привычный всем вызов метода __invoke в терминах VM является попыткой вызова замыкания — get_closure.

    Собственно, на этом месте начинается разница в обработке вызова анонимной функции и метода __invoke обычного объекта.
    В PHP у каждого объекта существует набор различных обработчиков, определяющий его служебные и магические методы.
    Стандартный набор выглядит так
    ZEND_API const zend_object_handlers std_object_handlers = {
      0,                       /* offset */
       zend_object_std_dtor,         /* free_obj */
      zend_objects_destroy_object,    /* dtor_obj */
      zend_objects_clone_obj,           /* clone_obj */
      zend_std_read_property,           /* read_property */
      zend_std_write_property,       /* write_property */
      zend_std_read_dimension,       /* read_dimension */
      zend_std_write_dimension,      /* write_dimension */
      zend_std_get_property_ptr_ptr,  /* get_property_ptr_ptr */
      NULL,                     /* get */
      NULL,                     /* set */
      zend_std_has_property,        /* has_property */
      zend_std_unset_property,       /* unset_property */
      zend_std_has_dimension,           /* has_dimension */
      zend_std_unset_dimension,      /* unset_dimension */
      zend_std_get_properties,       /* get_properties */
      zend_std_get_method,          /* get_method */
      zend_std_get_constructor,      /* get_constructor */
      zend_std_get_class_name,       /* get_class_name */
      zend_std_compare_objects,      /* compare_objects */
      zend_std_cast_object_tostring,  /* cast_object */
      NULL,                     /* count_elements */
      zend_std_get_debug_info,       /* get_debug_info */
      /* ------- */
      zend_std_get_closure,         /* get_closure */
      /* ------- */
      zend_std_get_gc,             /* get_gc */
      NULL,                     /* do_operation */
      NULL,                     /* compare */
      NULL,                     /* get_properties_for */
    };


    Сейчас нас интересует обработчик get_closure. Для обычного объекта он указывает на функцию zend_std_get_closure, которая проверяет, что для объекта определена функция __invoke, и возвращает либо указатель на неё, либо ошибку. А вот для класса Closure, реализующего анонимные функции, в этом массиве обработчиков переопределены практически все служебные функции, включая те, которые управляют жизненным циклом. Т.е. хоть для пользователя он и выглядит как обычный объект, но на самом деле это мутант с суперспособностями :)
    Регистрация обработчиков для объекта класса Closure
    void zend_register_closure_ce(void) /* {{{ */
    {
    	zend_class_entry ce;
    
    	INIT_CLASS_ENTRY(ce, "Closure", closure_functions);
    	zend_ce_closure = zend_register_internal_class(&ce);
    	zend_ce_closure->ce_flags |= ZEND_ACC_FINAL;
    	zend_ce_closure->create_object = zend_closure_new;
    	zend_ce_closure->serialize = zend_class_serialize_deny;
    	zend_ce_closure->unserialize = zend_class_unserialize_deny;
    
    	memcpy(&closure_handlers, &std_object_handlers, sizeof(zend_object_handlers));
    	closure_handlers.free_obj = zend_closure_free_storage;
    	closure_handlers.get_constructor = zend_closure_get_constructor;
    	closure_handlers.get_method = zend_closure_get_method;
    	closure_handlers.write_property = zend_closure_write_property;
    	closure_handlers.read_property = zend_closure_read_property;
    	closure_handlers.get_property_ptr_ptr = zend_closure_get_property_ptr_ptr;
    	closure_handlers.has_property = zend_closure_has_property;
    	closure_handlers.unset_property = zend_closure_unset_property;
    	closure_handlers.compare_objects = zend_closure_compare_objects;
    	closure_handlers.clone_obj = zend_closure_clone;
    	closure_handlers.get_debug_info = zend_closure_get_debug_info;
            /* ------- */
    	closure_handlers.get_closure = zend_closure_get_closure;
            /* ------- */
    	closure_handlers.get_gc = zend_closure_get_gc;
    }


    В руководстве говорится:
    Кроме методов, описанных здесь, этот класс также имеет метод __invoke. Данный метод необходим только для совместимости с другими классами, в которых реализован магический вызов, так как этот метод не используется при вызове функции.

    И это таки правда. Функция get_closure для замыкания возвращает не __invoke, а вашу функцию, из которой создавалось замыкание.

    Более подробно можете изучить исходники сами — файл zend_closure.c, а мы перейдём к следующему опкоду.

    DECLARE_LAMBDA_FUNCTION


    А вот это уже опкод, который заточен исключительно под замыкания и больше ни с чем не работающий. Под капотом обработчика происходят три основные операции:
    1. Ищется указатель на скомпилированную функцию, которая и будет сутью замыкания.
    2. Определяется контекст создания замыкания (другими словами, this).
    3. На основе двух первых пунктов создаётся объект класса Closure.


    И вот на этом месте начинаются не очень приятные новости.

    Так что же не так с анонимными функциями?


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

    Чтобы понять, где именно поджидают проблемы, разберём случаи, когда происходит создание замыкания.
    Замыкание создаётся заново:
    а) при каждой обработке опкода DECLARE_LAMBDA_FUNCTION.
    Интуитивно — ровно тот кейс, где замыкание смотрится хорошо, но на самом деле новый объект замыкания будет создаваться на каждой итерации цикла.
    foreach($values as $value){
    	doSomeStuff($value, function($args) { closureBody });
    }

    б) при каждом вызове методов bind и bindTo:
    Тут замыкание будет создаваться заново также на каждой итерации.
    $closure = function($args) { closureBody };
    foreach($objects as $object){
    	$closure->bindTo($object);
    	$object->doSomeStuff($closure);
    }
    

    с) при каждом вызове метода call, если в качестве функции используется генератор. А если не генератор, а обычная функция, то выполняется только часть с копированием массива опкодов. Такие дела.

    Выводы


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

    В любом случае теперь вы знаете, что замыкания и циклы, если их готовить неправильно, — такая себе комбинация.

    Спасибо за внимание!
    FunCorp
    Разработка развлекательных сервисов

    Comments 17

      +2
      Со стрелочными функциями в PHP 7.4 всё так же само? Или они тяжелее/легче?
        +2

        Да, стрелочные функции — это те же самые замыкания, но с некоторыми нюансами в логике.
        Кусок функции find_implicit_binds_recursively, которая, в случае стрелочной функции, вызывается рекурсивно.


        if (ast->kind == ZEND_AST_CLOSURE) {
                /* For normal closures add the use() list. */
                zend_ast_decl *closure_ast = (zend_ast_decl *) ast;
                zend_ast *uses_ast = closure_ast->child[1];
                if (uses_ast) {
                    zend_ast_list *uses_list = zend_ast_get_list(uses_ast);
                    uint32_t i;
                    for (i = 0; i < uses_list->children; i++) {
                        zend_hash_add_empty_element(&info->uses, zend_ast_get_str(uses_list->child[i]));
                    }
                }
            } else if (ast->kind == ZEND_AST_ARROW_FUNC) {
                /* For arrow functions recursively check the expression. */
                zend_ast_decl *closure_ast = (zend_ast_decl *) ast;
                find_implicit_binds_recursively(info, closure_ast->child[2]);
            }

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

          0
          Жаль. Я надеялся, что если для неё не создаётся собственный контекст, а используется родительский, то и накладные расходы меньше.
          +1
          Тут есть еще кто-то, кому кажется, что из-за стрелочных функций падает читаемость?
            –7

            На мой взгляд, php вообще находится с противоположной стороны дороги от логики, читаемости и эффективности.


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

          0
          «Доктор, а откуда у вас такие картинки?»
          Спасибо. Где бы еще подобного почитать? Чтоб не совсем хардкорный phpinternals, но с разбором и аналитикой.
            0

            У меня несколько статей есть :)
            А так даже и не припомню, чтоб видел где. Собственно потому и сам взялся разбираться.

            0
            А как обстоят дела с замыканиями в функциях?
            array_map(function($item){}, []);
              0

              Создаётся каждый раз при исполнении этой строки (т.е. если без цикла, то один раз), а потом array_map протаскивает замыкание внутрь себя и начинается интрисиковая магия. Как оно там внутри используется — надо лазить по исходникам.

              0
              rjhdby Интересует вариант а) для цикла и статического замыкания. Там все также? Или будет по лучше из-за отсутствия передачи контекста?
                0

                Статического — это которое создалось вне цикла, а используется внутри? Тогда всё ок.

                  0

                  Не совсем. Например так


                  class Foo 
                  {
                      public function bar()
                      {
                          foreach($values as $value){
                              doSomeStuff($value, static function($args) { closureBody });
                          }
                      }
                  }
                    0

                    А, вы про это. Никакой сколько нибудь значимой разницы.

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

                  Vulcan Logic Dumper от Derick Rethans.

                    +2
                    Позволю себе дополнить, что выдачу VLD очень легко смотреть на 3v4l.org
                      0
                      Спасибо!

                Only users with full accounts can post comments. Log in, please.