سلسلة ++c للمحترفين الدرس 13: البرمجة الوصفية (Metaprogramming) في Cpp


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

تشير البرمجة الوصفية (Metaprogramming) في C++‎ إلى استخدام وحدات الماكرو أو القوالب لتوليد شيفرة أثناء وقت التصريف (compile)، ويُفضّل استخدام القوالب بدلًا من وحدات الماكرو رغم أنّها أقلّ شمولية منها.

وغالبًا ما تستفيد البرمجة الوصفية للقوالب من الحسابات أثناء وقت التصريف (compile-time)، سواء عبر القوالب أو عبر دوال ‎constexpr‎ لتوليد الشيفرة البرمجية. تجدر الإشارة إلى أنّ حسابات وقت التصريف لا تُصنّف على أنّها من البرمجة الوصفية.

حساب المضروب (Calculating Factorial)

يمكن حساب المضروب أثناء التصريف باستخدام تقنيّات البرمجة الوصفية للقوالب (template metaprogramming)، انظر:

#include <iostream>

template < unsigned int n >
    struct factorial {
        enum {
            value = n * factorial < n - 1 > ::value
        };
    };
template < >
    struct factorial < 0 > {
        enum {
            value = 1
        };
    };
int main() {
    std::cout << factorial < 7 > ::value << std::endl; // "5040" طباعة
}

المضروب ‎factorial‎ هو بُنية (struct)، بيْد أنّ البرمجة الوصفية للقوالب تُعامله على أنّه دالة قالب وصفية (template metafunction). اصطلاحًا، تُقيَّم دوال القالب الوصفية عن طريق التحقق من عضو معيّن، إمّا ‎::type‎ بالنسبة للدوال الوصفية التي تعيد أنواعًا، أو ‎::value‎ بالنسبة للدوال الوصفية التي تعيد قيمًا. وفي الشيفرة أعلاه، نقيِّم الدالة الوصفية ‎factorial‎ عن طريق استنساخ (instantiating) القالب باستخدام المعامِلات التي نريد تمريرها، ونستخدم ‎::value‎ للحصول على نتيجة التقييم.

تعتمد الدالة الوصفية على استنساخ نفس الدالة الوصفية ذاتيًا لكن مع قيم أصغر، فيما تمثّل الحالة الخاصة ‎factorial <‎0‎‎>‎ شرطَ الإنهاء. وتنطبق على البرمجة الوصفية للقوالب معظم قيود لغات البرمجة الدالّية (functional programming language)، لذا فإنّ الذاتية (recursion) هي البنية الأساسية للتكرار "looping". ولمّا كانت دوال القالب الوصفية تُنفَّذ في وقت التصريف، فيمكن استخدام نتائجها في السياقات التي تتطلب قِيَمَ وقت التصريف (compiletime values). انظر المثال التالي:

int my_array[factorial<5>::value];

يجب أن يُحدَّ حجم المصفوفات أثناء التصريف، وتكونَ نتيجة الدالة الوصفية قيمة ثابتة وقت التصريف كي يمكن استخدامها هنا.

ملاحظة: لن تسمح معظم المُصرّفات بأن تتعمّق العوديّة أبعد من حدّ معيّن. على سبيل المثال، المُصرّف ‎g++‎ افتراضيًّا يحدُّ العوديّة في 256 مستوى. في حالة المصرّف ‎g++‎، يمكن للمُبرمج ضبط عمق الذاتية باستخدام الخيار ‎-ftemplate-depth-‎‎X‎.

الإصدار ≥ C++‎ 11

منذ الإصدار C++‎ 11، يمكن استخدام قالب ‎std::integral_constant‎ في هذا النوع من حسابات القوالب:

#include <iostream>
#include <type_traits>

template < long long n >
    struct factorial:
    std::integral_constant < long long, n * factorial < n - 1 > ::value > {};

template < >
    struct factorial < 0 >:
    std::integral_constant < long long, 1 > {};

int main() {
    std::cout << factorial < 7 > ::value << std::endl; // "5040"
}

كذلك فإن دوال ‎constexpr‎ بديل أفضل. انظر:

#include <iostream>

constexpr long long factorial(long long n) {
    return (n == 0) ? 1 : n * factorial(n - 1);
}
int main() {
    char test[factorial(3)];
    std::cout << factorial(7) << '\n';
}

يُكتب متن الدالة ‎factorial()‎ كتعليمة واحدة لأن دوال التعابير الثابتة constexpr في C++‎ 11 تستخدم قسمًا محدودًا للغاية من اللغة.

الإصدار ≥ C++‎ 14

صار من السهل كتابة دوال التعابير الثابتة constexpr منذ إصدار C++ 14 بعد إسقاط العديد من القيود على كتابتها، انظر:

constexpr long long factorial(long long n)
{
if (n == 0)
return 1;
else
return n * factorial(n - 1);
}

أو حتى بالصورة التالية:

constexpr long long factorial(int n)
{
long long result = 1;
for (int i = 1; i <= n; ++i) {
result *= i;
}
return result;
}

الإصدار ≥ C++‎ 17

بدءًا من الإصدار C++‎ 17، صار من الممكن استخدام تعبير مطوي (fold expression) لحساب المضروب، انظر:

#include <iostream>
#include <utility>

template < class T, T N, class I = std::make_integer_sequence < T, N >>
    struct factorial;

template < class T, T N, T...Is >
    struct factorial < T, N, std::index_sequence < T, Is... >> {
        static constexpr T value = (static_cast < T > (1) * ... * (Is + 1));
    };

int main() {
    std::cout << factorial < int, 5 > ::value << std::endl;
}

التكرار على حزمة معامِلات

قد نحتاج أحيانًا إلى إجراء عملية على كل عنصر من عناصر حزمة معاملات قالب متغير (variadic template parameter pack)، وهناك عدة طرق لفعل ذلك كما أنها صارت أسهل في C++‎ 17.

إذا افترضنا أننا نريد طباعة كل عنصر في الحزمة يكون أبسط حل لدينا هو الذاتية (Recursion)، انظر المثال التالي:

الإصدار C++‎ 11

void print_all(std::ostream & os) {
    // الحالة الأساسية
}
template < class T, class...Ts >
    void print_all(std::ostream & os, T
        const & first, Ts
        const & ...rest) {
        os << first;

        print_all(os, rest...);
    }

نستطيع استخدام أسلوب الموسِّع expander بدلًا مما سبق من أجل تنفيذ كل عمليات البث (streaming) في دالة واحدة، ولم نكن لنحتاج إلى تحميلٍ زائدٍ آخر، لكن يعيب هذا الأسلوب أنه يجعل من الصعب قراءة الشيفرة. انظر:

الإصدار C++‎ 11

template < class...Ts >
    void print_all(std::ostream & os, Ts
        const & ...args) {
        using expander = int[];
        (void) expander {
            0,
            (void(os << args), 0)...
        };
    }

راجع إجابة TC في موقع stackoverflow لتفهم كيفية عمل ما سبق بشكل أوضح.

الإصدار ≥ C++‎ 17

لدينا بدءًا من الإصدار C++‎ 17 أَداتين جديدتين لحلّ هذه المشكلة، الأولى هي التعبير المطوي (fold-expression):

template < class...Ts >
    void print_all(std::ostream & os, Ts
        const & ...args) {
        ((os << args), ...);
    }

والثانية هي تعليمة ‎if constexpr‎، والتي تسمح لنا بكتابة حلّنا الذاتي الأول في دالة واحدة، انظر المثال التالي حيث يُستنسَخ سطر if constexpr في حالة وجود وسائط أخرى فقط، أما إن كان rest فارغًا فلن يكون أي استدعاءٍ لـ (print_all(os.

template < class T, class...Ts >
    void print_all(std::ostream & os, T
        const & first, Ts
        const & ...rest) {
        os << first;
        if constexpr(sizeof...(rest) > 0) {
                      print_all(os, rest...);
        }
    }

التكرار عبر std::integer_sequence

صار قالب الصنف (class template) متاحًا منذ إصدار C++‎ 14، انظر:

template < class T, T...Ints >
    class integer_sequence;
template < std::size_t...Ints >
    using index_sequence = std::integer_sequence < std::size_t, Ints... > ;

وكذلك دالةُ عُليا مولِّدة (generating metafunction) لأجله:

template < class T, T N >
    using make_integer_sequence = std::integer_sequence < T, /* a sequence 0, 1, 2, ..., N-1 */ > ;
template < std::size_t N >
    using make_index_sequence = make_integer_sequence < std::size_t, N > ;

ورغم أنّ هذا لم يصبح معتمدًا إلا منذ الإصدار C++‎ 14، إلا أنه يمكن تنفيذه باستخدام أدوات C++‎ 11، ونستطيع استخدام هذه الأداة لاستدعاء دالّة مع تمرير صفّ ‎std::tuple‎ من الوسائط (اعتُمِدَت في C++‎ 17 كـ ‎std::apply‎) انظر:

namespace detail {
template <class F, class Tuple, std::size_t... Is>
decltype(auto) apply_impl(F&& f, Tuple&& tpl, std::index_sequence<Is...> ) {
return std::forward<F>(f)(std::get<Is>(std::forward<Tuple>(tpl))...);
}
}

template <class F, class Tuple>
decltype(auto) apply(F&& f, Tuple&& tpl) {
return detail::apply_impl(std::forward<F>(f),
std::forward<Tuple>(tpl),
std::make_index_sequence<std::tuple_size<std::decay_t<Tuple>>::value>{});
}

//  3 هذا سيطبع 
int f(int, char, double);
auto some_args = std::make_tuple(42, 'x', 3.14);
int r = apply(f, some_args); // تستدعي f(42, 'x', 3.14)

إرسال الوسوم (Tag Dispatching)

إحدى أبسط الطرق للاختيار بين الدوال عند وقت التصريف هي إرسال دالة إلى زوج من الدوال المُحمّلة بحمل زائد والتي تأخذ وسمًا كأحد وسائطها (attributes) -يكون الأخير عادة-، فمثلًا لتطبيق ‎std::advance()‎، نستطيع تنفيذ الإرسال على فئة المُكرّر، انظر:

namespace details {
    template < class RAIter, class Distance >
        void advance(RAIter & it, Distance n, std::random_access_iterator_tag) {
            it += n;
        }
    template < class BidirIter, class Distance >
        void advance(BidirIter & it, Distance n, std::bidirectional_iterator_tag) {
            if (n > 0) {
                while (n--) ++it;
            } else {
                while (n++) --it;
            }
        }
    template < class InputIter, class Distance >
        void advance(InputIter & it, Distance n, std::input_iterator_tag) {
            while (n--) {
                ++it;
            }
        }
}
template < class Iter, class Distance >
    void advance(Iter & it, Distance n) {
        details::advance(it, n,
            typename std::iterator_traits < Iter > ::iterator_category {});
    }

وسائط ‎std::XY_iterator_tag‎ الخاصّة بدوال ‎details::advance‎ ذات الأحمال الزائدة هي مُعامِلات غير مستخدمة، ذلك أن التطبيق الفعلي لا يهم لأنه فارغ تمامًا، والغرض الوحيد منها هو السماح للمصرّف باختيار التحميل الزائد بناءً على وسم الصنف ‎details::advance‎ الذي استُدعي معه.

تستخدم ‎advance‎ في هذا المثال دالةَ ‎iterator_traits<T>::iterator_category‎ الوصفية، والتي تعيد أحد أصناف ‎iterator_tag‎ بناءً على نوع ‎Iter‎ الفعلي، ثم بعد ذلك يتيح كائن منشأ افتراضيًا من نوع iterator_category<Iter>::type للمصرِّف اختيار أحد تحميلات details::advance الزائدة. سيتجاوز المصرِّفُ على الأرجح معامِل الدالة ذاك بما أنه كائن منشأ افتراضيًا لِبُنيَة فارغة لم تستخدم.

إرسال الوسوم يجعل الشيفرة أسهل في القراءة مقارنة بغيرها، وذلك باستخدام قاعدة "خطأ التعويض ليس خطأً" أو SFINAE اختصارًا بالإنجليزية، وكذلك استخدام enable_if.

ملاحظة: رغم أنّ if constexpr قد تبسِّط تنفيذ advance بشكل خاص، فإنها -خلاف إرسال الوسوم- غير مناسبة للتطبيقات المفتوحة (open implementations).

التحقق من صحة تعبير ما

نستطيع التحقق إن كان يجوز استدعاء عامل (operator) أو دالة على نوع ما، كذلك يمكن التحقق إن كان صنف ما لديه تحميل زائد‏‏ على std::hash، وذلك كما يلي:

#include <functional> // for std::hash
#include <type_traits> // for std::false_type and std::true_type
#include <utility> // for std::declval

template<class, class = void>
struct has_hash
: std::false_type
{};
template<class T>
struct has_hash<T, decltype(std::hash<T>()(std::declval<T>()), void())>
: std::true_type
{};

الإصدار ≥ C++‎ 17

يمكن استخدام ‎std::void_t‎ لتبسيط هذا النوع من الإنشاءات منذ الإصدار C++‎ 17.

#include <functional> // for std::hash
#include <type_traits> // for std::false_type, std::true_type, std::void_t
#include <utility> // for std::declval

template<class, class = std::void_t<> >
struct has_hash
: std::false_type
{};
template<class T>
struct has_hash<T, std::void_t< decltype(std::hash<T>()(std::declval<T>())) > >
: std::true_type
{};

حيث تٌعرَّف ‎std::void_t‎ على النحو التالي:

template< class... > using void_t = void;

للتحقق مما إذا كان أحد العوامل مثل ‎operator<‎ مُعرّفًا، فإن بنية الشيفرة تكون نفسها تقريبًا، انظر:

template<class, class = void>
struct has_less_than
: std::false_type
{};

template<class T>
struct has_less_than<T, decltype(std::declval<T>() < std::declval<T>(), void())>
: std::true_type
{};

يمكن الاستفادة مما سبق واستخدامه عند استخدام ‎std::unordered_map<T>‎ إن كان لـ ‎T‎ تحميل زائد على ‎std::hash‎، لكن فيما سوى ذلك فاستخدام ‎std::map<T>‎:

template <class K, class V>
using hash_invariant_map = std::conditional_t<
has_hash<K>::value,
std::unordered_map<K, V>,
std::map<K,V>>;

If-then-else

الإصدار ≥ C++‎ 11

يستطيع نوعُ ‎std::conditional‎ الموجود في ترويسة المكتبة القياسية اختيارَ نوع ما استنادًا إلى قيمة بوليانية محدّدة وقتَ التصريف:

template < typename T >
    struct ValueOrPointer {
        typename std::conditional < (sizeof(T) > sizeof(void * )), T * , T > ::type vop;
    };

تحتوي هذه البنية مؤشّرًا إلى ‎T‎ إن كان ‎T‎ أكبر من حجم المؤشّر، أو إلى ‎T‎ نفسه إذا كان أصغر أو يساوي حجم المؤشر، وعليه سيكون ‎sizeof(ValueOrPointer)‎ أصغر دائمًا من ‎sizeof(void*)‎.

التمييز اليدوي للأنواع عند إعطائها أيَّ نوع T

عند تطبيق قاعدة SFINAE باستخدام ‎std::enable_if‎ يكون مفيدًا أن نصل إلى قوالب مساعِدةٍ (helper templates) تحدّدُ إذا كان نوع ‎T‎ المُعطى يطابق معايير معيّنة. وتوفّر المكتبة القياسيّة نوعين مشابهين لـ ‎true‎ و ‎false‎، وهما ‎std::true_type‎ و ‎std::false_type‎، واللذان يمكن استخدامُهما لتحقيق الغرض أعلاه.

يوضّح المثال التالي كيفية التحقّق ممّا إذا كان نوع ‎T‎ مُؤشِّرًا (pointer) أم لا، ويحاكي قالبُ ‎is_pointer‎ سلوكَ دالة ‎std::is_pointer‎ المساعِدة القياسية:

template < typename T >
    struct is_pointer_: std::false_type {};
template < typename T >
    struct is_pointer_ < T * >: std::true_type {};
template < typename T >
    struct is_pointer: is_pointer_ < typename std::remove_cv < T > ::type > {}

تتألّف الشيفرة أعلاه من ثلاث خطوات (قد لا نحتاج أكثر من خطوتين أحيانًا):

  1. تصريح ‎is_pointer_‎ الأوّل هو الحالة الافتراضية، ويرث من ‎std::false_type‎. يجب أن ترث الحالة الافتراضيّة دائمًا من ‎std::false_type‎ لأنّها تشبه "الشرط الخاطئ ‎false‎".
  2. التصريح الثاني يخصّص القالب ‎is_pointer_‎ لأجل المؤشّر ‎T*‎ بغضِّ النظر عن ماهية ‎T‎، وترث هذه النسخة من ‎std::true_type‎.
  3. التصريح الثالث (وهو التصريح الحقيقي) يزيل أيّ معلومات غير ضروريّة من ‎T‎ (في هذه الحالة يزيل المؤهّليْن (qualifiers) ‏‏‎const‎ و ‎volatile‎) ثم يعود إلى أحد التصريحين السابقين.

وبما أنّ ‎is_pointer<T>‎ هو صنفٌ فإننا نحتاج إلى التالي من أجل الوصول إلى قيمته:

  • استخدم ‎::value‎، مثلًا: ‎is_pointer<int>::value‎: القيمة ‎value‎ هو عضو صنفٍ ثابت (static class member) من النوع ‎bool‎ موروثٌ من std::true_type أو std::false_type.
  • أنشئ كائنًا من هذا النوع، على سبيل المثال ‎is_pointer<int>{}‎: ذلك مناسب لأنّ ‎std::is_pointer‎ ترث منشئها الافتراضي من ‎std::true_type‎ أو ‎std::false_type‎ (التي لها مُنشئات من نوع ‎constexpr‎)، ولكلٍّ من std::true_type و std::false_type معاملاتُ تحويل من constexpr إلى bool.

من الجيد توفير مساعِدات للقوالب المساعدة، تتيح الوصول المباشر إلى القيمة، انظر:

template <typename T>
constexpr bool is_pointer_v = is_pointer<T>::value;

الإصدار ≥ C++‎ 17 توفر معظم قوالب المساعدة في إصدار C++ 17 وما بعده نسخةَ ‎_v‎، انظر المثال التالي:

template< class T > constexpr bool is_pointer_v = is_pointer<T>::value;
template< class T > constexpr bool is_reference_v = is_reference<T>::value;

حساب القوة في C++‎ 11 وما بعدها

صارت العمليات الحسابية في وقت التصريف أيسر بكثير منذ إصدار C++‎ 11، فيمكن حساب قوة عدد معين في وقت التصريف مثلًا على النحو التالي:

template < typename T >
    constexpr T calculatePower(T value, unsigned power) {
        return power == 0 ? 1 : value * calculatePower(value, power - 1);
    }

وتكون الكلمة المفتاحية ‎constexpr‎ مسؤولة عن حساب الدالة في وقت التصريف عند استيفاء جميع المتطلّبات اللازمة، كأن تكون جميع الوسائط معروفة في وقت التصريف.

ملاحظة: يجب أن تكون دوال التعبير الثابت (‎constexpr‎) في C++‎ 11 مكونة من تعليمة return واحدة فقط.

عند مقارنة هذه الطريقة بالطريقة القياسية لحسابات وقت التصريف، فإن هذه الطريقة مفيدة أيضًا في حسابات وقت التشغيل، فإن كانت وسائط دالة ما مجهولةً وقتَ التصريف (مثل القيمة والقوة المعطاتيْن كمدخلات من قِبل المستخدم) فإن الدالة تُنفَّذ في وقت التصريف، فلا حاجة إذًا لتكرار الشيفرة كما كان في المعايير الأقدم من C++‎. انظر المثال التالي:

void useExample() {
    constexpr int compileTimeCalculated = calculatePower(3, 3); //  الحساب وقت التصريف
    // لأنّ كلا المُعاملين معروفان وقت التصريف ويُستخدم في تعبير ثابت
    int value;
    std::cin >> value;
    int runtimeCalculated = calculatePower(value, 3); // runtime calculated,
    // لأنّ القيمة لن تكون معروفة حتى وقت التشغيل
}

الإصدار ≥ C++‎ 17

وهناك طريقة أخرى لحساب القوة في وقت التصريف تستخدم التعابير المطوية، انظر:

#include <iostream>
#include <utility>

template <class T, T V, T N, class I = std::make_integer_sequence<T, N>>
struct power;

template <class T, T V, T N, T... Is>
struct power<T, V, N, std::integer_sequence<T, Is...>> {
static constexpr T value = (static_cast<T>(1) * ... * (V * static_cast<bool>(Is + 1)));
};
int main() {
    std::cout << power < int, 4, 2 > ::value << std::endl;
}

دالة عامّة لتحديد القيم الصغرى والعظمى مع عدد وسائط متغير

الإصدار> C++‎ 11

من الممكن كتابة دالة عامة (على سبيل المثال دالة تحسب الحد الأدنى ‎min‎) تقبل أنواعًا عدديّة مختلفة، وعدد وسائط عشوائي بواسطة قالَب برمجة وصفية. انظر المثال التالي حيث تصرّح الدالة عن دالة ‎min‎ تقبل وسيطين أو أكثر.

template <typename T1, typename T2>
auto min(const T1 &a, const T2 &b)
-> typename std::common_type<const T1&, const T2&>::type
{
return a < b ? a : b;
}

template <typename T1, typename T2, typename ... Args>
auto min(const T1 &a, const T2 &b, const Args& ... args)
-> typename std::common_type<const T1&, const T2&, const Args& ...>::type
{
return min(min(a, b), args...);
}

auto minimum = min(4, 5.8f, 3, 1.8, 3, 1.1, 9);

حسابيات البرمجة الوصفية

فيما يلي أمثلة على استخدام برمجة القوالب الوصفية في C++‎ لإنجاز العمليات الحسابية في وقت التصريف.

حساب الأس ‎في (O(log n

يوضّح هذا المثال طريقة فعالة لحساب الأسّ باستخدام برمجة القوالب الوصفية.

template <int base, unsigned int exponent>
struct power
{
    static const int halfvalue = power<base, exponent / 2>::value;
    static const int value = halfvalue * halfvalue * power<base, exponent % 2>::value;
};
template <int base>
struct power<base, 0>
{
    static const int value = 1;
    static_assert(base != 0, "power<0, 0> is not allowed");
};
template <int base>
    struct power<base, 1>
{
    static const int value = base;
};

مثال تطبيقي:

std::cout << power < 2, 9 > ::value;

الإصدار ≥ C++‎ 14

هذا المثال يعالج الأسّ السلبيّ أيضًا:

template <int base, int exponent>
struct powerDouble
{
    static const int exponentAbs = exponent < 0 ? (-exponent) : exponent;
    static const int halfvalue = powerDouble<base, exponentAbs / 2>::intermediateValue;
    static const int intermediateValue = halfvalue * halfvalue * powerDouble<base, exponentAbs %2>::intermediateValue;
    constexpr static double value = exponent < 0 ? (1.0 / intermediateValue) : intermediateValue;
};
template <int base>
struct powerDouble<base, 0>
{
    static const int intermediateValue = 1;
    constexpr static double value = 1;
    static_assert(base != 0, "powerDouble<0, 0> is not allowed");
};
template <int base>
struct powerDouble<base, 1>
{
    static const int intermediateValue = base;
    constexpr static double value = base;
};
int main()
{
    std::cout << powerDouble<2,-3>::value;
}

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

ترجمة -بتصرّف- للفصل Chapter 16: Metaprogramming والفصل Chapter 125: Arithmitic Metaprogramming من كتاب C++ Notes for Professionals





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


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



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

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

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


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

تسجيل الدخول

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


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