1. 程式人生 > 其它 >看看PHP 7中怎麼優化遞迴的!

看看PHP 7中怎麼優化遞迴的!

本篇文章帶大家瞭解一下遞迴,介紹一下PHP 7 中對遞迴的優化。

⒈ 遞迴
  遞迴因其簡潔、優雅的特性在程式設計中經常會被使用。遞迴的程式碼更具宣告性和自我描述性。遞迴不需要像迭代那樣解釋如何獲取值,而是在描述函式的最終結果。
  以累加和斐波那契數列的實現為例:

  • 迭代方式實現
// 累加函式
// 給定引數 n,求小於等於 n 的正整數的和
function sumBelow(int $n)
{
    if ($n <= 0) {
        return 0;
    }
    $result = 0;
    for ($i = 1; $i <= $n; $i ++) {
        $result += $i;
    }
    return $result;
}
// 斐波那契數列
// 給定引數 n,取得斐波那契數列中第 n 項的值
// 這裡用陣列模擬斐波那契數列,斐波那契數列第一項為 1,第二項為 2,初始化陣列 $arr = [1, 1],則斐波那契數列第 n 項的值為 $arr[n] = $arr[n-1] + $arr[n-2]
function fib(int $n)
{
    if ($n <= 0) {
        return false;
    }
    if ($n == 1) {
        return 1;
    }
    $arr = [1, 1];
    for ($i = 2, $i <= $n; $i ++) {
        $arr[$i] = $arr[$i - 1] + $arr[$i - 2];
    }
    return $arr[$n];
}
  • 遞迴方式實現
// 累加函式
function sumBelow(int $n)
{
    if ($n <= 1) {
        return 1;
    }
    return $n + sumBelow($n - 1);
}
// 斐波那契數列
function fib(int $n)
{
    if ($n < 2) {
        return 1;
    }
    return fib($n - 1) + fib($n - 2);
}

相比之下,遞迴的實現方式更簡潔明瞭,可讀性更強,更容易理解。
⒉ 遞迴存在的問題
  程式中的函式呼叫,在底層通常需要遵循一定的呼叫約定(calling convention)。通常的過程是:
首先將函式的引數和返回地址入棧
然後 CPU 開始執行函式體中的程式碼
最後在函式執行完成之後銷燬這塊佔空間,CPU 回到返回地址所指的位置
  這個過程在低階語言(例如彙編)中非常快,因為低階語言直接與 CPU 互動,而 CPU 的執行速度非常快。在 x86_64 架構的 Linux 中,引數往往直接通過暫存器傳遞,記憶體中的棧空間會被預載入到 CPU 的快取中,這樣 CPU 反問棧空間會非常非常快。
  同樣的過程在高階語言(例如 PHP)中卻截然不同。高階語言無法直接與 CPU 互動,需要藉助虛擬機器來虛擬化一套自身的堆、棧等概念。同時,還需要藉助虛擬機器來維護和管理這套虛擬化出來的堆疊。
  高階語言中的函式呼叫過程相較於低階語言已經很慢,而遞迴會讓這種情況雪上加霜。以上例中的累加函式為例,每到一個 sumBelow,ZVM 都需要構造一個函式呼叫棧(具體呼叫棧的構造之前的文章已經講過),隨著 n 的增大,需要構造的呼叫棧會越來越多,最終導致記憶體溢位。相較於累加函式,斐波那契函式的遞迴會使得呼叫棧的數量呈現幾何級數式的增加(因為每一個呼叫棧最終會新產生兩個呼叫棧)。

⒊ 使用蹦床函式(trampoline)和尾呼叫(tail call)來優化遞迴
  ① 尾呼叫
  尾呼叫指的是一個函式最後只返回對自身的呼叫,再沒有其他的任何操作。由於函式返回的是對自身的呼叫,因此編譯器可以複用當前的呼叫棧而不需要新建呼叫棧。

將前述的累加函式和斐波那契函式改為尾呼叫的實現方式,程式碼如下

// 累加函式的尾呼叫方式實現
function subBelow(int $n, int $sum = 1)
{
    if ($n <= 1) {
        return $sum;
    }
    return subBelow($n - 1, $sum + $n);
}
// 斐波那契函式的尾呼叫實現
function fib(int $n, int $acc1 = 1, int $acc2 = 2)
{
    if ($n < 2) {
        return $acc1;
    }
    return fib($n - 1, $acc1 + $acc2, $acc1);
}

② 蹦床函式
  累加函式相對簡單,可以很方便的轉換成尾呼叫的實現方式。斐波那契函式的尾呼叫實現方式就相對比較麻煩。但在實際應用中,很多遞迴夾雜著很多複雜的條件判斷,在不同的條件下進行不同方式的遞迴。此時,無法直接把遞迴函式轉換成尾呼叫的形式,需要藉助蹦床函式。

  所謂蹦床函式,其基本原理是將遞迴函式包裝成迭代的形式。以累加函式為例,首先改寫累加函式的實現方式:

function trampolineSumBelow(int $n, int $sum = 1)
{
    if ($n <= 1) {
        return $sum;
    }
    return function() use ($n, $sum) { return trampolineSumBelow($n - 1, $sum + $n); };

}

在函式的最後並沒有直接進行遞迴呼叫,而是把遞迴呼叫包裝進了一個閉包,而閉包函式不會立即執行。此時需要藉助蹦床函式,如果蹦床函式發現返回的是一個閉包,那麼蹦床函式會繼續執行返回的閉包,知道蹦床函式發現返回的是一個值。

function trampoline(callable $cloure, ...$args)
{
    while (is_callable($cloure)) {
        $cloure = $cloure(...$args);
    }
    return $cloure;
}
echo trampoline('trampolineSumBelow', 100);

蹦床函式是一種比較通用的解決遞迴呼叫的問題的方式。在蹦床函式中,返回的閉包被以迭代的方式執行,避免了函式遞迴導致的記憶體溢位。

⒋ ZVM 中對遞迴的優化
  在 PHP 7 中,通過尾呼叫的方式優化遞迴主要應用在物件的方法中。仍然以累加函式為例:

class Test
{
    public function __construct(int $n)
    {
        $this->sum($n);
    }
    public function sum(int $n, int $sum = 1)
    {
        if ($n <= 1) {
            return $sum;
        }
        return $this->sum($n - 1, $sum + $n);
    }
}
$t = new Test($argv[1]);
echo memory_get_peak_usage(true), PHP_EOL;
// 經測試,在 $n <= 10000 的條件下,記憶體消耗的峰值恆定為 2M

以上程式碼對應的 OPCode 為:

// 主函式
L0:    V2 = NEW 1 string("Test")
L1:    CHECK_FUNC_ARG 1
L2:    V3 = FETCH_DIM_FUNC_ARG CV1($argv) int(1)
L3:    SEND_FUNC_ARG V3 1
L4:    DO_FCALL
L5:    ASSIGN CV0($t) V2
L6:    INIT_FCALL 1 96 string("memory_get_peak_usage")
L7:    SEND_VAL bool(true) 1
L8:    V6 = DO_ICALL
L9:    ECHO V6
L10:   ECHO string("
")
L11:   RETURN int(1)
// 建構函式
L0:     CV0($n) = RECV 1
L1:     INIT_METHOD_CALL 1 THIS string("sum")
L2:     SEND_VAR_EX CV0($n) 1
L3:     DO_FCALL
L4:     RETURN null
// 累加函式
L0:    CV0($n) = RECV 1
L1:    CV1($sum) = RECV_INIT 2 int(1)
L2:    T2 = IS_SMALLER_OR_EQUAL CV0($n) int(1)
L3:    JMPZ T2 L5
L4:    RETURN CV1($sum)
L5:    INIT_METHOD_CALL 2 THIS string("sum")
L6:    T3 = SUB CV0($n) int(1)
L7:    SEND_VAL_EX T3 1
L8:    T4 = ADD CV1($sum) CV0($n)
L9:    SEND_VAL_EX T4 2
L10:   V5 = DO_FCALL
L11:   RETURN V5
L12:   RETURN null

當 class 中的累加函式 sum 發生尾呼叫時執行的 OPCode 為 DO_FCALL ,對應的底層實現為:

# define ZEND_VM_CONTINUE() return
# define LOAD_OPLINE() opline = EX(opline)
# define ZEND_VM_ENTER() execute_data = EG(current_execute_data); LOAD_OPLINE(); ZEND_VM_INTERRUPT_CHECK(); ZEND_VM_CONTINUE()
static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_DO_FCALL_SPEC_RETVAL_USED_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    USE_OPLINE
    zend_execute_data *call = EX(call);
    zend_function *fbc = call->func;
    zend_object *object;
    zval *ret;
    SAVE_OPLINE();
    EX(call) = call->prev_execute_data;
    /* 判斷所呼叫的方法是否為抽象方法或已廢棄的函式 */
    /* ... ... */
    LOAD_OPLINE();
    if (EXPECTED(fbc->type == ZEND_USER_FUNCTION)) {
        /* 所呼叫的方法為開發者自定義的方法 */
        ret = NULL;
        if (1) {
            ret = EX_VAR(opline->result.var);
            ZVAL_NULL(ret);
        }
        call->prev_execute_data = execute_data;
        i_init_func_execute_data(call, &fbc->op_array, ret);
        if (EXPECTED(zend_execute_ex == execute_ex)) {
            /* zend_execute_ex == execute_ex 說明方法呼叫的是自身,發生遞迴*/
            ZEND_VM_ENTER();
        } else {
            ZEND_ADD_CALL_FLAG(call, ZEND_CALL_TOP);
            zend_execute_ex(call);
        }
    } else if (EXPECTED(fbc->type < ZEND_USER_FUNCTION)) {
        /* 內部方法呼叫 */
        /* ... ... */
    } else { /* ZEND_OVERLOADED_FUNCTION */
        /* 過載的方法 */
        /* ... ... */
    }
fcall_end:
    /* 異常判斷以及相應的後續處理 */
    /* ... ... */
    zend_vm_stack_free_call_frame(call);
    /* 異常判斷以及相應的後續處理 */
    /* ... ... */

    ZEND_VM_SET_OPCODE(opline + 1);
    ZEND_VM_CONTINUE();
}

從 DO_FCALL 的底層實現可以看出,當發生方法遞迴呼叫時(zend_execute_ex == execute_ex),ZEND_VM_ENTER() 巨集將 execute_data 轉換為當前方法的 execute_data ,同時將 opline 又置為 execute_data 中的第一條指令,在檢查完異常(ZEND_VM_INTERRUPT_CHECK())之後,返回然後重新執行方法。

  通過蹦床函式的方式優化遞迴呼叫主要應用在物件的魔術方法 __call 、__callStatic 中。

class A
{
    private function test($n)
    {
        echo "test $n", PHP_EOL;
    }
    public function __call($method, $args)
    {
        $this->$method(...$args);
        var_export($this);
        echo PHP_EOL;
    }
}
class B extends A
{
    public function __call($method, $args)
    {
        (new parent)->$method(...$args);
        var_export($this);
        echo PHP_EOL;
    }
}
class C extends B
{
    public function __call($method, $args)
    {
        (new parent)->$method(...$args);
        var_export($this);
        echo PHP_EOL;
    }
}
$c = new C();
//$c->test(11);
echo memory_get_peak_usage(), PHP_EOL;
// 經測試,僅初始化 $c 物件消耗的記憶體峰值為 402416 位元組,呼叫 test 方法所消耗的記憶體峰值為 431536 位元組

在物件中嘗試呼叫某個方法時,如果該方法在當前物件中不存在或訪問受限(protected、private),則會呼叫物件的魔術方法 __call(如果通過靜態呼叫的方式,則會呼叫 __callStatic)。在 PHP 的底層實現中,該過程通過 zend_std_get_method 函式實現

static union _zend_function *zend_std_get_method(zend_object **obj_ptr, zend_string *method_name, const zval *key)
{
    zend_object *zobj = *obj_ptr;
    zval *func;
    zend_function *fbc;
    zend_string *lc_method_name;
    zend_class_entry *scope = NULL;
    ALLOCA_FLAG(use_heap);
    if (EXPECTED(key != NULL)) {
        lc_method_name = Z_STR_P(key);
#ifdef ZEND_ALLOCA_MAX_SIZE
        use_heap = 0;
#endif
    } else {
        ZSTR_ALLOCA_ALLOC(lc_method_name, ZSTR_LEN(method_name), use_heap);
        zend_str_tolower_copy(ZSTR_VAL(lc_method_name), ZSTR_VAL(method_name), ZSTR_LEN(method_name));
    }
    /* 所呼叫的方法在當前物件中不存在 */
    if (UNEXPECTED((func = zend_hash_find(&zobj->ce->function_table, lc_method_name)) == NULL)) {
        if (UNEXPECTED(!key)) {
            ZSTR_ALLOCA_FREE(lc_method_name, use_heap);
        }
        if (zobj->ce->__call) {
            /* 當前物件存在魔術方法 __call */
            return zend_get_user_call_function(zobj->ce, method_name);
        } else {
            return NULL;
        }
    }
    /* 所呼叫的方法為 protected 或 private 型別時的處理邏輯 */
    /* ... ... */
}
static zend_always_inline zend_function *zend_get_user_call_function(zend_class_entry *ce, zend_string *method_name)
{
    return zend_get_call_trampoline_func(ce, method_name, 0);
}
ZEND_API zend_function *zend_get_call_trampoline_func(zend_class_entry *ce, zend_string *method_name, int is_static)
{
    size_t mname_len;
    zend_op_array *func;
    zend_function *fbc = is_static ? ce->__callstatic : ce->__call;
    ZEND_ASSERT(fbc);
    if (EXPECTED(EG(trampoline).common.function_name == NULL)) {
        func = &EG(trampoline).op_array;
    } else {
        func = ecalloc(1, sizeof(zend_op_array));
    }
    func->type = ZEND_USER_FUNCTION;
    func->arg_flags[0] = 0;
    func->arg_flags[1] = 0;
    func->arg_flags[2] = 0;
    func->fn_flags = ZEND_ACC_CALL_VIA_TRAMPOLINE | ZEND_ACC_PUBLIC;
    if (is_static) {
        func->fn_flags |= ZEND_ACC_STATIC;
    }
    func->opcodes = &EG(call_trampoline_op);
    func->prototype = fbc;
    func->scope = fbc->common.scope;
    /* reserve space for arguments, local and temorary variables */
    func->T = (fbc->type == ZEND_USER_FUNCTION)? MAX(fbc->op_array.last_var + fbc->op_array.T, 2) : 2;
    func->filename = (fbc->type == ZEND_USER_FUNCTION)? fbc->op_array.filename : ZSTR_EMPTY_ALLOC();
    func->line_start = (fbc->type == ZEND_USER_FUNCTION)? fbc->op_array.line_start : 0;
    func->line_end = (fbc->type == ZEND_USER_FUNCTION)? fbc->op_array.line_end : 0;
    //??? keep compatibility for "\0" characters
    //??? see: Zend/tests/bug46238.phpt
    if (UNEXPECTED((mname_len = strlen(ZSTR_VAL(method_name))) != ZSTR_LEN(method_name))) {
        func->function_name = zend_string_init(ZSTR_VAL(method_name), mname_len, 0);
    } else {
        func->function_name = zend_string_copy(method_name);
    }
    return (zend_function*)func;
}
static void zend_init_call_trampoline_op(void)
{
    memset(&EG(call_trampoline_op), 0, sizeof(EG(call_trampoline_op)));
    EG(call_trampoline_op).opcode = ZEND_CALL_TRAMPOLINE;
    EG(call_trampoline_op).op1_type = IS_UNUSED;
    EG(call_trampoline_op).op2_type = IS_UNUSED;
    EG(call_trampoline_op).result_type = IS_UNUSED;
    ZEND_VM_SET_OPCODE_HANDLER(&EG(call_trampoline_op));

ZEND_CALL_TRAMPOLINE 的底層實現邏輯:

static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_CALL_TRAMPOLINE_SPEC_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    zend_array *args;
    zend_function *fbc = EX(func);
    zval *ret = EX(return_value);
    uint32_t call_info = EX_CALL_INFO() & (ZEND_CALL_NESTED | ZEND_CALL_TOP | ZEND_CALL_RELEASE_THIS);
    uint32_t num_args = EX_NUM_ARGS();
    zend_execute_data *call;
    USE_OPLINE
    args = emalloc(sizeof(zend_array));
    zend_hash_init(args, num_args, NULL, ZVAL_PTR_DTOR, 0);
    if (num_args) {
        zval *p = ZEND_CALL_ARG(execute_data, 1);
        zval *end = p + num_args;
        zend_hash_real_init(args, 1);
        ZEND_HASH_FILL_PACKED(args) {
            do {
                ZEND_HASH_FILL_ADD(p);
                p++;
            } while (p != end);
        } ZEND_HASH_FILL_END();
    }
    SAVE_OPLINE();
    call = execute_data;
    execute_data = EG(current_execute_data) = EX(prev_execute_data);
    ZEND_ASSERT(zend_vm_calc_used_stack(2, fbc->common.prototype) <= (size_t)(((char*)EG(vm_stack_end)) - (char*)call));
    call->func = fbc->common.prototype;
    ZEND_CALL_NUM_ARGS(call) = 2;
    ZVAL_STR(ZEND_CALL_ARG(call, 1), fbc->common.function_name);
    ZVAL_ARR(ZEND_CALL_ARG(call, 2), args);
    zend_free_trampoline(fbc);
    fbc = call->func;
    if (EXPECTED(fbc->type == ZEND_USER_FUNCTION)) {
        if (UNEXPECTED(!fbc->op_array.run_time_cache)) {
            init_func_run_time_cache(&fbc->op_array);
        }
        i_init_func_execute_data(call, &fbc->op_array, ret);
        if (EXPECTED(zend_execute_ex == execute_ex)) {
            ZEND_VM_ENTER();
        } else {
            ZEND_ADD_CALL_FLAG(call, ZEND_CALL_TOP);
            zend_execute_ex(call);
        }
    } else {
        /* ... ... */  
    }
    /* ... ... */
}

從 ZEND_CALL_TRAMPOLINE 的底層實現可以看出,當發生 __call 的遞迴呼叫時(上例中 class C、class B、class A 中依次發生 __call 的呼叫),ZEND_VM_ENTER 將 execute_data 和 opline 進行變換,然後重新執行。

  遞迴之後還需要返回,返回的功能在 RETURN 中實現。所有的 PHP 程式碼在編譯成 OPCode 之後,最後一條 OPCode 指令一定是 RETURN(即使程式碼中沒有 return,編譯時也會自動新增)。而在 ZEND_RETURN 中,最後一步要執行的操作為 zend_leave_helper ,遞迴的返回即時在這一步完成。

# define LOAD_NEXT_OPLINE() opline = EX(opline) + 1
# define ZEND_VM_CONTINUE() return
# define ZEND_VM_LEAVE() ZEND_VM_CONTINUE()
static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL zend_leave_helper_SPEC(ZEND_OPCODE_HANDLER_ARGS)
{
    zend_execute_data *old_execute_data;
    uint32_t call_info = EX_CALL_INFO();
    if (EXPECTED((call_info & (ZEND_CALL_CODE|ZEND_CALL_TOP|ZEND_CALL_HAS_SYMBOL_TABLE|ZEND_CALL_FREE_EXTRA_ARGS|ZEND_CALL_ALLOCATED)) == 0)) {
        /* ... ... */
        LOAD_NEXT_OPLINE();
        ZEND_VM_LEAVE();
    } else if (EXPECTED((call_info & (ZEND_CALL_CODE|ZEND_CALL_TOP)) == 0)) {
        i_free_compiled_variables(execute_data);
        if (UNEXPECTED(call_info & ZEND_CALL_HAS_SYMBOL_TABLE)) {
            zend_clean_and_cache_symbol_table(EX(symbol_table));
        }
        EG(current_execute_data) = EX(prev_execute_data);
        /* ... ... */
        zend_vm_stack_free_extra_args_ex(call_info, execute_data);
        old_execute_data = execute_data;
        execute_data = EX(prev_execute_data);
        zend_vm_stack_free_call_frame_ex(call_info, old_execute_data);
        if (UNEXPECTED(EG(exception) != NULL)) {
            const zend_op *old_opline = EX(opline);
            zend_throw_exception_internal(NULL);
            if (RETURN_VALUE_USED(old_opline)) {
                zval_ptr_dtor(EX_VAR(old_opline->result.var));
            }
            HANDLE_EXCEPTION_LEAVE();
        }
        LOAD_NEXT_OPLINE();
        ZEND_VM_LEAVE();
    } else if (EXPECTED((call_info & ZEND_CALL_TOP) == 0)) {
        /* ... ... */
        LOAD_NEXT_OPLINE();
        ZEND_VM_LEAVE();
    } else {
        /* ... ... */
    }
}

在 zend_leave_helper 中,execute_data 又被換成了 prev_execute_data ,然後繼續執行新的 execute_data 的 opline(注意:這裡並沒有將 opline 初始化為 execute_data 中 opline 的第一條 OPCode,而是接著之前執行到的位置繼續執行下一條 OPCode)。

以上就是看看PHP 7中怎麼優化遞迴的!的詳細內容。(拼多多培訓