سلسلة ++c للمحترفين الفصل 48: دلالات النقل Move Semantics في Cpp


محمد الميداوي

دلالات النقل هي وسيلة لنقل كائن إلى آخر في C++‎، عبر إفراغ الكائن القديم، وتبديل محتوياته بمحتويات الكائن الجديد. ولفهم دلالات النقل، من الضروري فهم المقصود بالمرجع اليميني (rvalue reference). وهي، أي المراجع اليمينية (‎T&&‎ حيث T يمثّل نوع الكائن) لا تختلف كثيرًا عن المراجع العادية (‎T&‎، يُطلق عليها الآن مراجع يسارية - lvalue references)، لكنهما تتصرّفان كنوعين مختلفين. ونستطيع إنشاء مُنشئات أو دوال تأخذ أحد النوعين، وهو أمر ضروري عند التعامل مع دلالات النقل.

ونحن نحتاج إلى نوعين مختلفين من أجل تحديد سلوكين مختلفين، إذ ترتبط مُنشئات المراجع اليسارية بالنّسخ، في حين ترتبط مُنشئات المراجع اليمينية بالنقل. ولأجل نقل كائن، سنستخدم الدالة ‎std::move(obj)‎ التي تعيد مرجعًا يمينيًا يشير إلى الكائن، ويمكننا استخدام ذلك لنقل بيانات هذا الكائن إلى كائن جديد.

هناك عدّة طرق لفعل ذلك وسنناقشها فيما يلي، فكلّ ما عليك تذكّره الآن هو أنّ استخدام ‎std::move‎ يخلق مرجعًا يمينيًا، أي أن تعليمة std::move(obj)‎ لا تغيّر محتوى الكائن obj، أمّا auto obj2 = std::move(obj)‎ فقد تفعل ذلك.

استخدام std::move لتخفيض التعقيد من O(n²)‎‎ إلى O(n)‎‎

أدخلت C++‎ 11 دعم نقل الكائنات في اللغة الأساسية وفي المكتبة القياسية، فإذا كان لدينا كائن o مؤقّت وكنّا نريد إنشاء نسخة منطقية (logical copy) منه، فعندئذ قد يكون من الآمن أخذ موارده ببساطة، مثل المخزن المؤقّت المخصّص ديناميكيًا (dynamically allocated buffer) الخاص به، تاركين الكائن o فارغًا منطقيًا (logically empty)، ولكن سيبقى قابلاً للتدمير والنسخ.

يشمل دعم اللغة الأساسية (core language) ما يلي:

  • باني نوع المراجع اليمينية (rvalue reference type builder‏) ‎&&‎، على سبيل المثال، ‎std::string&&‎ هو مرجع يميني يشير إلى سلسلة نصية (‎std::string‎)، ممّا يبيّن أنّ هذا المرجع مؤقّت، ويمكن نقل موارده.
  • دعم خاص لمنشئ النقل - move constructor‏ - ‎T( T&& )‎، الذي يفترض أن ينقل الموارد بكفاءة من الكائن المُحدّد الآخر، بدلاً من نسخ تلك الموارد.
  • دعم خاص لعامل إسناد النقل (move assignment operator‏) ‎auto operator=(T&&) -> T&‎، والذي يفترض أيضًا أن يجري عمليّة النقل من المصدر.

يتركّز الدعم الذي تقدمه المكتبة القياسية بالأساس في قالب الدالّة ‎std::move‎ من الترويسة ، وتنتج هذه الدالّة مرجعًا يمينيًا يشير إلى الكائن المُحدّد، وذلك يبيّن أنّه من الممكن النقل منه كما لو كان مؤقّتًا.

تعقيد (complexity) عمليّة نسخ حاوية يساوي عادةً O(n)‎‎، حيث يمثّل n عدد عناصر الحاوية، أمّا النقل فتَعقيده يساوي O(1)‎‎، أي أنّ وقته ثابت. وبالنسبة لخوارزمية تنسخ تلك الحاوية n مرّة منطقيًا، فيمكن لذلك أن يقلّل التعقيد من القيمة O (n²)‎‎ (غير العملية)، إلى التعقيد الخطي O(n)‎‎.

قدّم أندرو كوينج (Andrew Koenig) في مقالته “Containers That Never Change” مثالًا مثيرًا للاهتمام عن عدم كفاءة الخوارزمية عند استخدام نمط برمجة خاص تكون فيه المتغيّرات غير قابلة للتغيير بعد تهيئتها، ففي مثل هذا النمط البرمجي، تُنفَّذ الحلقات (loops) بشكل عام باستخدام التكرارية، وبالنسبة لبعض الخوارزميات، مثل خوارزمية إنشاء تسلسل Collatz‏ (Collatz sequence‏)‏، يتطلب التكرار نسخ الحاوية بشكل منطقي. انظر المثال التالي المبني على مثال كوينج في الرابط أعلاه:

namespace my
{
    template < class Item>
        using Vector_ = /*E.g. std::vector<Item> */ ;
        auto concat( Vector_<int> const& v, int const x ) -> Vector_<int>
        {
            auto result
            {
                v
            };
            result.push_back(x);
            return result;
        }

    auto collatz_aux( int const n, Vector_<int> const& result ) -> Vector_<int>
        {
            if (n == 1)
            {
                return result;
            }

            auto const new_result = concat(result, n);
            if (n % 2 == 0)
            {
                return collatz_aux(n / 2, new_result);
            }
            else
            {
                return collatz_aux(3 *n + 1, new_result);
            }
        }

    auto collatz( int const n ) -> Vector_<int>

        {
            assert(n != 0);
            return collatz_aux(n, Vector_<int> ());
        }

    please split, thi
}    // my فضاء الاسم
#include <iostream>
using namespace std;
auto main()->int
{
    for (int
        const x: my::collatz(42))
    {
        cout << x << ' ';
    }

    cout << '\n';
}

الخرج:

42 21 64 32 16 8 4 2

عدد عمليات نسخ العناصر الناجمة عن نسخ المتجهات يقارب O (n ²)‎ هنا، لأنّه يساوي المجموع 1 + 2 + 3 + … n.

باستخدام المٌصرّفين g++‎ و Visual C++‎، سيُنتج الاستدعاء الوارد أعلاه للتعبير ‎collatz(42)‎ تسلسل Collatz مؤلفًا من 8 عناصر، وسيَنطوي على 36 عملية نسخ للعناصر (‎‎8 * 7/2 = 28، بالإضافة إلى بعض العمليات) في استدعاءات مُنشئ نسخ المتجه.

يمكن تجنّب كل عمليات نسخ العناصر تلك عن طريق نقل المتجهات التي لم تعد هناك حاجة لقيَمِها. وكي نفعل هذا فمن الضروري إزالة ‎const‎ والرجوع إلى وسائط نوع المتجه، وتمرير المتجهات بالقيمة (by value).

تحسَّن القيم المُعادة من الدالة تلقائيا، أما بالنسبة للاستدعاءات التي تُمرّر فيها المتجهات ثمّ لا تُستخدم مرّة أخرى في الدالّة، فما عليك سوى تطبيق ‎std::move‎ لنقل تلك المخازن المؤقّتة (buffers) بدلاً من نسخها:

using std::move;
auto concat( Vector_<int> v, int const x )-> Vector_<int>

    {
        v.push_back(x);

تنبيه: نقل كائن محلي في تعليمة return يمنع ترك النَّسخ، انظر الرابط التالي من SO، نتابع المثال …

// return move(v);
return v;
}

auto collatz_aux( int const n, Vector_<int> result ) -> Vector_<int>

    {
        if (n == 1)
        {
            return result;
        }

        auto new_result = concat(move(result), n);
        struct result;    // بعد الآن `result` التحقق من عدم استخدام
        if (n % 2 == 0)
        {
            return collatz_aux(n / 2, move(new_result));
        }
        else
        {
            return collatz_aux(3 *n + 1, move(new_result));
        }
    }

auto collatz(int
        const n) -
    > Vector_ < int>
    {
        assert(n != 0);
        return collatz_aux(n, Vector_<int> ());
    }

هنا، وباستخدام المصرّفين g++‎ و Visual C++‎، فإنّ عدد عمليات نسخ العناصر الناجمة عن استدعاءات مُنشئ نسخ المتجهات يساوي 0.

ما زال تعقيد الخوارزمية يساوي O (n)‎‎، بيْد أنّه أفضل بكثير من O (n ²)‎‎. ومع بعض الدعم من لغة C++‎، يمكننا أن نستخدم النقل ونفرض جمود المتغيّر (immutability) منذ لحظة تهيتئه وحتّى النقل النهائي، بعد ذلك، فأيّ محاولة لاستخدام هذا المُتغيّر ستُعدّ خطأ. بأي حال فإن ++C لا تدعم هذا بدءًا من الإصدار C++‎ 14.

يمكن فرض قاعدة عدم الاستخدام بعد النقل بالنسبة للشيفرات الخالية من الحلقات (loop-free code)، عبر إعادة التصريح عن الاسم باعتباره بنية (‎struct‎) غير مكتملة، كما هو الحال في ‎struct result;‎ أعلاه، لكن هذا المنظور سيئ ويستبعد أن يفهمها المبرمجون الآخرون؛ أيضًا، قد يكون التشخيص مربكًا.

باختصار، أتاح دعم لغة C++‎ والمكتبة القياسية للنقل تحسينات جذرية على تعقيد الخوارزمية، ولكن بسبب عدم اكتمال الدعم، فإنّ تلك الفائدة ستكون على حساب التخلي عن ضمانات صحّة ووضوح الشيفرة التي يمكن أن توفّرها ‎const‎.

كتتمّة للمثال السابق، سنستخدَم صنف المتجه لقياس عدد عمليات نسخ العناصر الناجمة عن استدعاءات منشئ النسخ:

template < class Item>
    class Copy_tracking_vector
    {
        private:
            static auto n_copy_ops() -> int&{
                static int value;
                return value;
            }

        vector<Item> items_;

        public:
    static auto n() -> int { return n_copy_ops(); }
    void push_back( Item const& o ) { items_.push_back( o ); }
auto begin() const { return items_.begin(); }
    auto end() const { return items_.end(); }
    Copy_tracking_vector(){}
    Copy_tracking_vector( Copy_tracking_vector const& other )  : items_( other.items_ )
    { n_copy_ops() += items_.size(); }
    Copy_tracking_vector( Copy_tracking_vector&& other )  : items_( move( other.items_ ) )
    {}

    };

منشئ النقل (Move constructor)

لنقل أن لدينا الشيفرة التالية:

class A
{
    public:
        int a;
    int b;

    A(const A &other)
    {
        this->a = other.a;
        this->b = other.b;
    }
};

الطريقة العادية لإنشاء منشئ نسخ هي باختيار الصيغة الموضحة أعلاه، وسيكون لدينا مُنشئ للصنف A يأخُذ مرجعًا إلى كائن آخر من النوع A، وينسخ الكائن يدويا داخل التابع.

وبدلًا من ذلك، يمكننا كتابة ‎A(const A &) = default;‎، التي تنسخ تلقائيًا جميع الأعضاء باستخدام مُنشئ النسخ الخاص بها.

سنأخذ مرجعًا يمينيًا بدلًا من اليساري، من أجل إنشاء مُنشئ نقل (move constructor)، كما هو مُوضّح هنا.

class Wallet
{
    public:
        int nrOfDollars;

    Wallet() = default;    //default ctor
    Wallet(Wallet &&other)
    {
        this->nrOfDollars = other.nrOfDollars;
        other.nrOfDollars = 0;
    }
};

يرجى ملاحظة أنّنا ضبطنا القيم القديمة عند القيمة ‎zero‎، ينسخ مُنشئ النقل الافتراضي (‎Wallet(Wallet&&) = default;‎) قيمة ‎nrOfDollars‎، لأنّه نوع بيانات قديم (Plain Old Data أو POD).

وبما أن دلالات النقل مُصمّمة للسماح بسرقة الحالة من النُسخة الأصلية، فالسؤال الآن هو كيف ستظهر النسخة الأصلية بعد تلك "السرقة"؟

Wallet a;
a.nrOfDollars = 1;
Wallet b(std::move(a));    // B(B&& other); استدعاء
std::cout << a.nrOfDollars << std::endl;    //0
std::cout << b.nrOfDollars << std::endl;    //1

وهكذا نكون قد أنشأنا كائنًا بشكل نقليّ (move constructed) من كائن آخر قديم. ربما يكون المثال السابق بسيطًا إلّا أنّه يوضّح آلية عمل مُنشئات النقل، وتظهر فائدته في الحالات الأكثر تعقيدًا، كما يحدث في حالات إدارة الموارد. انظر الشيفرة التالية التي تدير عمليات تتضمن نوعًا بعينه، وتملك مساعِدًا على الكومة (heap) وآخر في ذاكرتها، في المكدَّس غالبًا. وكلا المساعدان DefaultConstructible و CopyConstructible و MoveConstructible:

template < typename T,
    template<typename> typename HeapHelper,
    template<typename> typename StackHelper>
    class OperationsManager
    {
        using MyType = OperationsManager<T, HeapHelper, StackHelper> ;
        HeapHelper<T> *h_helper;
        StackHelper<T> s_helper;
        // ...
        public:
            // Five لـ &Rule المنشئ الافتراضي
            OperationsManager(): h_helper(new HeapHelper < T>) {}

        OperationsManager(const MyType& other): h_helper(new HeapHelper<T> (*other.h_helper)), s_helper(other.s_helper) {}

        MyType& operator=(MyType copy)
        {
            swap(*this, copy);
            return * this;
        }~OperationsManager()
        {
            if (h_helper)
            {
                delete h_helper;
            }
        }

منشئ نقل بدون ()swap، يأخذ *<HeapHelper<T و <StackHelper <T من other من خلال فرض استخدام منشئ النقل الخاص بـ <StackHelper <T. ويٌحِلّ nullptr مكان *<HeapHelper<T الخاص بـ other، لمنع الأخير من حذف مساعِدنا الجديد حين يُدمَّر، نتابع المثال …

        OperationsManager(MyType&& other) noexcept: h_helper(other.h_helper),
            s_helper(std::move(other.s_helper))
            {
                other.h_helper = nullptr;
            }

منشئ نقل (مع ()swap)، نضع أعضاءنا في الشرط الذي نريد أن يكون فيه other ثم نبادل الأعضاء معه، نتابع …

        // OperationsManager(MyType&& other) noexcept : h_helper(nullptr) {
        //       swap(*this, other);
        // }

        // Copy/move helper.
        friend void swap(MyType &left, MyType &right) noexcept
        {
            std::swap(left.h_helper, right.h_helper);
            std::swap(left.s_helper, right.s_helper);
        }
    };

إعادة استخدام كائن منقول

يمكنك إعادة استخدام كائن منقول على النحو التالي:

void consumingFunction(std::vector<int> vec)
{
    // بعض العمليات
}

int main()
{
    // 1, 2, 3, 4 تهيئة المتجه بالقيم
    std::vector<int> vec
    { 1, 2, 3, 4 };
    // by move إرسال المتجه بالنقل
    consumingFunction(std::move(vec));
    // في حالة غير محدّدة vec الكائن
    // نظرا لأنّ الكائن لم يُدمَّر، يمكننا أن نسند إليه محتوى جديدا
    // في هذه الحالة، سنسند قيمة فارغة إلى المتجه ما يجعله فارغًا 
    vec = {};
    // نظرًا لأنّ المتجه قد اكتسب قيمة محدّدة، فيمكننا استخدامه بشكل طبيعي
    vec.push_back(42);
    // إرسال المتجه عبر النقل مجددا
    consumingFunction(std::move(vec));
}

إسناد النقل (Move assignment)

على غرار إسناد قيمة لكائن باستخدام مرجع يساري، ثمّ نسخه، يمكننا أيضًا نقل القيم من كائن إلى آخر دون إنشاء كائن جديد، إذ نستدعي إسناد النقل، ثمّ ننقل القيم من كائن إلى كائن آخر موجود.

سيتعيّن علينا أن نزيد تحميل العامل ‎operator =‎ لنجعله يأخذ مرجعًا يمينيًا.

class A
{
    int a;
    A& operator=(A&& other)
    {
        this->a = other.a;
        other.a = 0;
        return * this;
    }
};

هذه هي الصيغة النموذجية لتعريف إسناد النقل، إذ نزيد تحميل‎operator =‎ حتى نتمكن من تزويده بمرجع يميني، وإسناده إلى كائن آخر.

A a;
a.a = 1;
A b;
b = std::move(a);    // A& operator= (A&& other) استدعاء
std::cout << a.a << std::endl;    //0
std::cout << b.a << std::endl;    //1

ومن ثم يمكننا إسناد كائن نقليًا (move assign) إلى كائن لآخر.

استخدام دلالات النقل على الحاويات

يمكنك نقل حاوية بدلاً من نسخها على النحو التالي:

void print(const std::vector<int>& vec)
{
    for (auto&& val: vec)
    {
        std::cout << val << ", ";
    }

    std::cout << std::endl;
}

int main()
{
    // 1, 2, 3, 4 بالقيم vec1 تهيئة المتجه
   // كمتجه فارغ vec2 ثمّ تهيئة
    std::vector<int> vec1
    { 1, 2, 3, 4 };
    std::vector<int> vec2;

    // 1, 2, 3, 4 السطر التالي سيطبع
    print(vec1);

    // السطر التالي سيطبع سطرا جديدا
    print(vec2);

    // عبر إسناد النقل vec2 أُسنِدت قيمة المتجه
    // دون نسخها vec1 هذا سيسرق قيمة

    vec2 = std::move(vec1);
    // هنا في حالة غير محدّدة، لكن يبقى صالحًا vec1 المتجه
    // لم يُدمَّر بعد، لكن لا يوجد ضمان بخصوص الكائنات التي يحتويها vec1 الكائن

    // 1, 2, 3, 4 السطر التالي سيطبع
    print(vec2);
}

هذا الدرس جزء من سلسلة دروس عن C++‎.

ترجمة -بتصرّف- للفصل Chapter 106: Move Semantics من كتاب C++ Notes for Professionals





تفاعل الأعضاء


لا توجد أيّة تعليقات بعد



يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن