Khi nào đệ quy đuôi được đảm bảo trong Rust?


8

ngôn ngữ C

Trong ngôn ngữ lập trình C, thật dễ dàng có đệ quy đuôi :

int foo(...) {
    return foo(...);
}

Chỉ cần trả về là giá trị trả về của cuộc gọi đệ quy. Điều đặc biệt quan trọng khi lần đệ quy này có thể lặp lại hàng ngàn hoặc thậm chí một triệu lần. Nó sẽ sử dụng rất nhiều bộ nhớ trên ngăn xếp .

Rỉ

Bây giờ, tôi có một hàm Rust có thể gọi đệ quy một triệu lần:

fn read_all(input: &mut dyn std::io::Read) -> std::io::Result<()> {
    match input.read(&mut [0u8]) {
        Ok (  0) => Ok(()),
        Ok (  _) => read_all(input),
        Err(err) => Err(err),
    }
}

(đây là một ví dụ tối thiểu, cái thực phức tạp hơn, nhưng nó nắm bắt được ý chính)

Ở đây, giá trị trả về của cuộc gọi đệ quy được trả về, nhưng:

Nó có đảm bảo rằng trình biên dịch Rust sẽ áp dụng đệ quy đuôi không?

Chẳng hạn, nếu chúng ta khai báo một số biến cần được hủy như a std::Vec, nó sẽ bị hủy ngay trước lệnh gọi đệ quy (cho phép đệ quy đuôi) hoặc sau khi trả lại cuộc gọi đệ quy (cấm đệ quy đuôi)?


2
Tôi tin rằng bạn đang nói về đệ quy đuôi , bạn có thể có đầu vào tốt hơn cho câu hỏi của mình nếu bạn nói "đuôi" thay vì "thiết bị đầu cuối"
Tadhg McDonald-Jensen

7
"Trong ngôn ngữ lập trình C, một đệ quy đầu cuối rất dễ được đảm bảo" Tôi sẽ ngạc nhiên nếu C thực hiện bất kỳ đảm bảo nào như vậy.
mcarton


5
Tôi nghĩ rằng bạn đang trộn đệ quy đuôi với tối ưu hóa cuộc gọi đuôi. Ví dụ. Mã C của bạn là đệ quy đuôi nhưng nó có thể thổi tung ngăn xếp vì nó không đảm bảo tối ưu hóa cuộc gọi đuôi
Sylwester

Câu trả lời:


10

Các cuộc gọi đuôi được đảm bảo bất cứ khi nào chức năng đệ quy của bạn được gọi ở vị trí đuôi (về cơ bản là câu lệnh cuối cùng của chức năng).

Tối ưu hóa cuộc gọi đuôi không bao giờ được đảm bảo bởi Rust, mặc dù trình tối ưu hóa có thể chọn thực hiện nó.

nếu chúng ta khai báo một số biến cần phải hủy

Theo hiểu biết của tôi thì đây là một trong những điểm gắn bó, vì việc thay đổi vị trí của các biến ngăn xếp bị phá hủy sẽ gây tranh cãi.

Xem thêm:


8

Câu trả lời của Shepmaster giải thích rằng tối ưu hóa cuộc gọi đuôi, mà tôi thích gọi là loại bỏ cuộc gọi đuôi, không được đảm bảo xảy ra ở Rust. Nhưng đó không phải là toàn bộ câu chuyện! Có rất nhiều khả năng giữa "không bao giờ xảy ra" và "được bảo đảm". Chúng ta hãy xem trình biên dịch làm gì với một số mã thực.

Nó có xảy ra trong chức năng này ?

Tính đến thời điểm hiện tại, bản phát hành mới nhất của Rust có sẵn trên Compiler Explorer là 1.39 và nó không loại bỏ lệnh gọi đuôi read_all.

example::read_all:
        push    r15
        push    r14
        push    rbx
        sub     rsp, 32
        mov     r14, rdx
        mov     r15, rsi
        mov     rbx, rdi
        mov     byte ptr [rsp + 7], 0
        lea     rdi, [rsp + 8]
        lea     rdx, [rsp + 7]
        mov     ecx, 1
        call    qword ptr [r14 + 24]
        cmp     qword ptr [rsp + 8], 1
        jne     .LBB3_1
        movups  xmm0, xmmword ptr [rsp + 16]
        movups  xmmword ptr [rbx], xmm0
        jmp     .LBB3_3
.LBB3_1:
        cmp     qword ptr [rsp + 16], 0
        je      .LBB3_2
        mov     rdi, rbx
        mov     rsi, r15
        mov     rdx, r14
        call    qword ptr [rip + example::read_all@GOTPCREL]
        jmp     .LBB3_3
.LBB3_2:
        mov     byte ptr [rbx], 3
.LBB3_3:
        mov     rax, rbx
        add     rsp, 32
        pop     rbx
        pop     r14
        pop     r15
        ret
        mov     rbx, rax
        lea     rdi, [rsp + 8]
        call    core::ptr::real_drop_in_place
        mov     rdi, rbx
        call    _Unwind_Resume@PLT
        ud2

Lưu ý dòng này: call qword ptr [rip + example::read_all@GOTPCREL] . Đó là cuộc gọi đệ quy. Như bạn có thể nói từ sự tồn tại của nó, nó đã không bị loại bỏ.

So sánh điều này với một chức năng tương đương với một hàm rõ ràngloop :

pub fn read_all(input: &mut dyn std::io::Read) -> std::io::Result<()> {
    loop {
        match input.read(&mut [0u8]) {
            Ok (  0) => return Ok(()),
            Ok (  _) => continue,
            Err(err) => return Err(err),
        }
    }
}

không có lệnh gọi đuôi để loại bỏ, và do đó biên dịch thành hàm chỉ có một call trong đó (theo địa chỉ được tính của input.read).

Ồ tốt Có lẽ Rust không tốt bằng C. Hay là vậy?

Nó có xảy ra ở C không?

Đây là một hàm đệ quy đuôi trong C thực hiện một nhiệm vụ rất giống nhau:

int read_all(FILE *input) {
    char buf[] = {0, 0};
    if (!fgets(buf, sizeof buf, input))
        return feof(input);
    return read_all(input);
}

Điều này sẽ siêu dễ dàng cho trình biên dịch để loại bỏ. Cuộc gọi đệ quy ở ngay dưới cùng của hàm và C không phải lo lắng về việc chạy các hàm hủy. Nhưng tuy nhiên, có cuộc gọi đuôi đệ quy , khó chịu không được loại bỏ:

        call    read_all

Hóa ra, tối ưu hóa cuộc gọi đuôi cũng không được đảm bảo trong C. Tôi đã thử Clang và gcc dưới các mức tối ưu hóa khác nhau, nhưng không có gì tôi đã thử sẽ biến chức năng đệ quy khá đơn giản này thành một vòng lặp.

Phải không có bao giờ xảy ra không?

Được rồi, vì vậy nó không được bảo đảm. Trình biên dịch có thể làm điều đó không? Đúng! Đây là một hàm tính toán các số Fibonacci thông qua hàm bên trong đệ quy đuôi:

pub fn fibonacci(n: u64) -> u64 {
    fn fibonacci_lr(n: u64, a: u64, b: u64) -> u64 {
        match n {
            0 => a,
            _ => fibonacci_lr(n - 1, a + b, a),
        }
    }
    fibonacci_lr(n, 1, 0)
}

Không chỉ loại bỏ cuộc gọi đuôi, toàn bộ fibonacci_lrchức năng được nội tuyến fibonacci, chỉ mang lại 12 hướng dẫn (và không phải calltrong tầm nhìn):

example::fibonacci:
        push    1
        pop     rdx
        xor     ecx, ecx
.LBB0_1:
        mov     rax, rdx
        test    rdi, rdi
        je      .LBB0_3
        dec     rdi
        add     rcx, rax
        mov     rdx, rcx
        mov     rcx, rax
        jmp     .LBB0_1
.LBB0_3:
        ret

Nếu bạn so sánh điều này với một whilevòng lặp tương đương , trình biên dịch sẽ tạo ra gần như cùng một cụm.

Vấn đề ở đây là gì?

Có lẽ bạn không nên dựa vào tối ưu hóa để loại bỏ các cuộc gọi đuôi, trong Rust hoặc ở C. Thật tuyệt khi điều đó xảy ra, nhưng nếu bạn cần chắc chắn rằng một hàm sẽ biên dịch thành một vòng lặp chặt chẽ, ít nhất là cho Bây giờ, là sử dụng một vòng lặp.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.