Hàm std :: được thực hiện như thế nào?


98

Theo các nguồn mà tôi đã tìm thấy, một biểu thức lambda về cơ bản được thực hiện bởi trình biên dịch tạo ra một lớp với toán tử gọi hàm được nạp chồng và các biến được tham chiếu là thành viên. Điều này cho thấy rằng kích thước của các biểu thức lambda khác nhau và cung cấp đủ các biến tham chiếu mà kích thước có thể lớn tùy ý .

An std::functionphải có kích thước cố định , nhưng nó phải có thể bọc bất kỳ loại vật liệu nào, bao gồm bất kỳ lambdas nào cùng loại. Nó được thực hiện như thế nào? Nếu std::functionbên trong sử dụng một con trỏ đến đích của nó, thì điều gì sẽ xảy ra, khi std::functionphiên bản được sao chép hoặc di chuyển? Có bất kỳ phân bổ heap nào liên quan không?


2
Tôi đã xem xét việc triển khai gcc / stdlib của std::functionmột thời gian trước. Về cơ bản nó là một lớp xử lý cho một đối tượng đa hình. Một lớp dẫn xuất của lớp cơ sở bên trong được tạo ra để chứa các tham số, được phân bổ trên heap - sau đó con trỏ tới lớp này được giữ như một đối tượng subobject của std::function. Tôi tin rằng nó sử dụng cách đếm tham chiếu std::shared_ptrđể xử lý việc sao chép và di chuyển.
Andrew Tomazos

4
Lưu ý rằng việc triển khai có thể sử dụng phép thuật, tức là dựa vào các phần mở rộng trình biên dịch không có sẵn cho bạn. Điều này trên thực tế là cần thiết đối với một số đặc điểm loại hình. Đặc biệt, trampolines là một kỹ thuật đã biết không có trong C ++ tiêu chuẩn.
MSalters

Câu trả lời:


78

Việc triển khai std::functioncó thể khác nhau giữa các cách triển khai, nhưng ý tưởng cốt lõi là nó sử dụng tính năng xóa kiểu. Mặc dù có nhiều cách để làm điều đó, bạn có thể tưởng tượng một giải pháp tầm thường (không tối ưu) có thể như thế này (đơn giản hóa cho trường hợp cụ thể std::function<int (double)>vì lợi ích đơn giản):

struct callable_base {
   virtual int operator()(double d) = 0;
   virtual ~callable_base() {}
};
template <typename F>
struct callable : callable_base {
   F functor;
   callable(F functor) : functor(functor) {}
   virtual int operator()(double d) { return functor(d); }
};
class function_int_double {
   std::unique_ptr<callable_base> c;
public:
   template <typename F>
   function(F f) {
      c.reset(new callable<F>(f));
   }
   int operator()(double d) { return c(d); }
// ...
};

Trong cách tiếp cận đơn giản này, functionđối tượng sẽ chỉ lưu trữ unique_ptrmột kiểu cơ sở. Đối với mỗi bộ chức năng khác nhau được sử dụng với function, một kiểu mới bắt nguồn từ cơ sở được tạo và một đối tượng của kiểu đó được khởi tạo động. Đối std::functiontượng luôn có cùng kích thước và sẽ phân bổ không gian khi cần thiết cho các chức năng khác nhau trong heap.

Trong cuộc sống thực, có những cách tối ưu hóa khác nhau mang lại lợi thế về hiệu suất nhưng sẽ làm phức tạp câu trả lời. Loại có thể sử dụng tối ưu hóa đối tượng nhỏ, điều phối động có thể được thay thế bằng một con trỏ chức năng tự do lấy hàm functor làm đối số để tránh một cấp độ chuyển hướng ... nhưng ý tưởng về cơ bản là giống nhau.


Về vấn đề cách các bản sao của std::functionhoạt động, kiểm tra nhanh chỉ ra rằng các bản sao của đối tượng có thể gọi nội bộ được thực hiện, thay vì chia sẻ trạng thái.

// g++4.8
int main() {
   int value = 5;
   typedef std::function<void()> fun;
   fun f1 = [=]() mutable { std::cout << value++ << '\n' };
   fun f2 = f1;
   f1();                    // prints 5
   fun f3 = f1;
   f2();                    // prints 5
   f3();                    // prints 6 (copy after first increment)
}

Kiểm tra chỉ ra rằng f2nhận được một bản sao của thực thể có thể gọi, thay vì một tham chiếu. Nếu thực thể có thể gọi được chia sẻ bởi các std::function<>đối tượng khác nhau , đầu ra của chương trình sẽ là 5, 6, 7.


@Cole "Cole9" Johnson đoán ông đã viết nó mình
aaronman

8
@Cole "Cole9" Johnson: Đây là sự đơn giản hóa quá mức của mã thực, tôi vừa nhập nó vào trình duyệt, vì vậy nó có thể mắc lỗi chính tả và / hoặc không biên dịch được vì những lý do khác nhau. Mã trong câu trả lời chỉ ở đó để trình bày cách tẩy xóa được thực hiện như thế nào, đây rõ ràng không phải là mã chất lượng sản xuất.
David Rodríguez - dribeas

2
@MooingDuck: Tôi tin rằng lambdas có thể đối phó được (5.1.2 / 19), nhưng đó không phải là câu hỏi, đúng hơn là liệu ngữ nghĩa của std::functioncó chính xác nếu đối tượng bên trong bị sao chép hay không và tôi không nghĩ là như vậy (suy nghĩ một lambda rằng chụp một giá trị và là có thể thay đổi, lưu trữ bên trong một std::function, nếu tình trạng chức năng đã được sao chép số lượng bản sao của std::functionbên trong một thuật toán tiêu chuẩn có thể dẫn đến những kết quả khác nhau, mà là không mong muốn.
David Rodríguez - dribeas

1
@ MiklósHomolya: Tôi đã thử nghiệm với g ++ 4.8 và việc triển khai có sao chép trạng thái bên trong. Nếu thực thể có thể gọi đủ lớn để yêu cầu phân bổ động, thì bản sao của thực std::functionthể sẽ kích hoạt phân bổ.
David Rodríguez - dribeas

4
Trạng thái chia sẻ @ DavidRodríguez-dribeas sẽ là không thể sử dụng được, vì tối ưu hóa đối tượng nhỏ có nghĩa là bạn sẽ chuyển từ trạng thái chia sẻ sang trạng thái không chia sẻ ở ngưỡng kích thước xác định của trình biên dịch và phiên bản trình biên dịch (vì tối ưu hóa đối tượng nhỏ sẽ chặn trạng thái chia sẻ). Điều đó có vẻ có vấn đề.
Yakk - Adam Nevraumont

22

Câu trả lời từ @David Rodríguez - dribeas là tốt để chứng minh kiểu xóa nhưng chưa đủ tốt vì xóa kiểu cũng bao gồm cách các kiểu được sao chép (trong câu trả lời đó, đối tượng hàm sẽ không được sao chép-tạo). Những hành vi đó cũng được lưu trữ trong functionđối tượng, bên cạnh dữ liệu functor.

Thủ thuật, được sử dụng trong triển khai STL từ Ubuntu 14.04 gcc 4.8, là viết một hàm chung, chuyên biệt hóa nó với từng loại bộ chức năng có thể và chuyển chúng thành một loại con trỏ hàm phổ quát. Do đó thông tin loại bị xóa .

Tôi đã soạn thảo một phiên bản đơn giản của điều đó. Hy vọng nó sẽ giúp

#include <iostream>
#include <memory>

template <typename T>
class function;

template <typename R, typename... Args>
class function<R(Args...)>
{
    // function pointer types for the type-erasure behaviors
    // all these char* parameters are actually casted from some functor type
    typedef R (*invoke_fn_t)(char*, Args&&...);
    typedef void (*construct_fn_t)(char*, char*);
    typedef void (*destroy_fn_t)(char*);

    // type-aware generic functions for invoking
    // the specialization of these functions won't be capable with
    //   the above function pointer types, so we need some cast
    template <typename Functor>
    static R invoke_fn(Functor* fn, Args&&... args)
    {
        return (*fn)(std::forward<Args>(args)...);
    }

    template <typename Functor>
    static void construct_fn(Functor* construct_dst, Functor* construct_src)
    {
        // the functor type must be copy-constructible
        new (construct_dst) Functor(*construct_src);
    }

    template <typename Functor>
    static void destroy_fn(Functor* f)
    {
        f->~Functor();
    }

    // these pointers are storing behaviors
    invoke_fn_t invoke_f;
    construct_fn_t construct_f;
    destroy_fn_t destroy_f;

    // erase the type of any functor and store it into a char*
    // so the storage size should be obtained as well
    std::unique_ptr<char[]> data_ptr;
    size_t data_size;
public:
    function()
        : invoke_f(nullptr)
        , construct_f(nullptr)
        , destroy_f(nullptr)
        , data_ptr(nullptr)
        , data_size(0)
    {}

    // construct from any functor type
    template <typename Functor>
    function(Functor f)
        // specialize functions and erase their type info by casting
        : invoke_f(reinterpret_cast<invoke_fn_t>(invoke_fn<Functor>))
        , construct_f(reinterpret_cast<construct_fn_t>(construct_fn<Functor>))
        , destroy_f(reinterpret_cast<destroy_fn_t>(destroy_fn<Functor>))
        , data_ptr(new char[sizeof(Functor)])
        , data_size(sizeof(Functor))
    {
        // copy the functor to internal storage
        this->construct_f(this->data_ptr.get(), reinterpret_cast<char*>(&f));
    }

    // copy constructor
    function(function const& rhs)
        : invoke_f(rhs.invoke_f)
        , construct_f(rhs.construct_f)
        , destroy_f(rhs.destroy_f)
        , data_size(rhs.data_size)
    {
        if (this->invoke_f) {
            // when the source is not a null function, copy its internal functor
            this->data_ptr.reset(new char[this->data_size]);
            this->construct_f(this->data_ptr.get(), rhs.data_ptr.get());
        }
    }

    ~function()
    {
        if (data_ptr != nullptr) {
            this->destroy_f(this->data_ptr.get());
        }
    }

    // other constructors, from nullptr, from function pointers

    R operator()(Args&&... args)
    {
        return this->invoke_f(this->data_ptr.get(), std::forward<Args>(args)...);
    }
};

// examples
int main()
{
    int i = 0;
    auto fn = [i](std::string const& s) mutable
    {
        std::cout << ++i << ". " << s << std::endl;
    };
    fn("first");                                   // 1. first
    fn("second");                                  // 2. second

    // construct from lambda
    ::function<void(std::string const&)> f(fn);
    f("third");                                    // 3. third

    // copy from another function
    ::function<void(std::string const&)> g(f);
    f("forth - f");                                // 4. forth - f
    g("forth - g");                                // 4. forth - g

    // capture and copy non-trivial types like std::string
    std::string x("xxxx");
    ::function<void()> h([x]() { std::cout << x << std::endl; });
    h();

    ::function<void()> k(h);
    k();
    return 0;
}

Ngoài ra còn có một số tối ưu hóa trong phiên bản STL

  • các construct_fdestroy_fđược trộn vào một con trỏ hàm (với một tham số bổ sung mà nói làm gì) như để tiết kiệm một số byte
  • con trỏ thô được sử dụng để lưu trữ đối tượng functor, cùng với một con trỏ hàm trong a union, để khi một functionđối tượng được xây dựng từ một con trỏ hàm, nó sẽ được lưu trữ trực tiếp trong unionkhông gian heap thay vì

Có thể việc triển khai STL không phải là giải pháp tốt nhất như tôi đã nghe về một số cách triển khai nhanh hơn . Tuy nhiên tôi tin rằng cơ chế cơ bản là như nhau.


20

Đối với một số loại đối số nhất định ("nếu mục tiêu của f là một đối tượng có thể gọi được truyền qua reference_wrapperhoặc một con trỏ hàm"), phương std::functionthức khởi tạo của không cho phép bất kỳ ngoại lệ nào, vì vậy việc sử dụng bộ nhớ động là không cần thiết. Đối với trường hợp này, tất cả dữ liệu phải được lưu trữ trực tiếp bên trong std::functionđối tượng.

Trong trường hợp chung, (bao gồm cả trường hợp lambda), việc sử dụng bộ nhớ động (thông qua trình cấp phát tiêu chuẩn hoặc trình cấp phát được chuyển đến phương thức std::functionkhởi tạo) được phép khi việc triển khai thấy phù hợp. Tiêu chuẩn khuyến nghị việc triển khai không sử dụng bộ nhớ động nếu có thể tránh được, nhưng như bạn đã nói đúng, nếu đối tượng hàm (không phải std::functionđối tượng mà là đối tượng được bao bọc bên trong nó) đủ lớn, không có cách nào để ngăn chặn nó, vì std::functioncó kích thước cố định.

Quyền ném ngoại lệ này được cấp cho cả phương thức khởi tạo bình thường và phương thức khởi tạo sao chép, điều này cho phép khá rõ ràng việc cấp phát bộ nhớ động trong quá trình sao chép. Đối với việc di chuyển, không có lý do gì tại sao bộ nhớ động lại cần thiết. Tiêu chuẩn dường như không cấm nó một cách rõ ràng và có lẽ là không thể nếu việc di chuyển có thể gọi hàm tạo chuyển động của loại đối tượng được bao bọc, nhưng bạn có thể giả định rằng nếu cả việc triển khai và các đối tượng của bạn đều hợp lý, thì việc di chuyển sẽ không gây ra bất kỳ phân bổ nào.


-6

Một std::functionquá tải operator()khiến nó trở thành một đối tượng functor, lambda cũng hoạt động theo cách tương tự. Về cơ bản, nó tạo ra một cấu trúc với các biến thành viên có thể được truy cập bên trong operator()hàm. Vì vậy, khái niệm cơ bản cần ghi nhớ là lambda là một đối tượng (được gọi là functor hoặc function object) không phải là một hàm. Tiêu chuẩn nói rằng không sử dụng bộ nhớ động nếu có thể tránh được.


1
Làm thế nào những chiếc lambdas lớn tùy ý có thể vừa với một kích thước cố định std::function? Đó là câu hỏi quan trọng ở đây.
Miklós Homolya

2
@aaronman: Tôi đảm bảo rằng mọi std::functionđối tượng đều có cùng kích thước và không phải là kích thước của lambdas được chứa.
Mooing Duck

5
@aaronman theo cùng một cách mà mỗi std::vector<T...> đối tượng có kích thước cố định (copiletime) độc lập với cá thể / số phần tử của trình cấp phát thực tế.
xem

3
@aaronman: Vâng, có lẽ bạn nên tìm một câu hỏi stackoverflow rằng câu trả lời như thế nào std :: chức năng được thực hiện trong một cách mà nó có thể chứa lambdas tùy tiện có kích thước: P
Mooing Duck

1
@aaronman: Khi thực thể có thể gọi được thiết lập, trong xây dựng, chuyển nhượng ... std::function<void ()> f;không cần phải cấp phát ở đó, std::function<void ()> f = [&]() { /* captures tons of variables */ };hầu hết có thể là cấp phát. std::function<void()> f = &free_function;có lẽ không phân bổ hoặc ...
David Rodríguez - dribeas
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.