سلسلة ++c للمحترفين الدرس 46: قاعدة SFINAE (فشل التعويض ليس خطأ) في Cpp


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

كلمة SFINAE هي اختصار للجملة: Substitution Failure Is Not An Error، وتشير إلى أنّه لا تُعد الشيفرات سيئة الصيغة بسبب تعويض الأنواع (أو القيم) لأجل استنساخ قالب دالة (instantiate a function template) أو قالب صنف، لا تُعدُّ خطأً تصريفيا فادحًا (hard compile error)، وإنّما يتم التعامل معها على أنّها فشل في استنتاج النوع فقط.

فشل استنتاج النوع في قوالب دوالّ الاستنساخ (instantiating function templates) أو تخصيصات قالب الصنف (class template specializations) يؤدي إلى إهمال ذلك النوع أثناء محاولة استنتاج النوع (كما لو أنّ ذلك النوع المرشح المُستبعد لم يكن موجودًا من البداية).

template < class T>
    auto begin(T& c) -> decltype(c.begin()) { return c.begin(); }

template < class T, size_t N>
    T* begin(T (&arr)[N]) { return arr; }

int vals[10];
begin(vals);

تفشل المحاولة الأولى لتعويض قالب الدالة في (begin(vals من الشيفرة السابقة، لأن صيغة ()vals.begin خطأ، لكن لا يعطي هذا خطأً بل ستُزال تلك الدالة من ترشيحها للتحميل الزائد، تاركة إيانا مع التحميل الزائد للمصفوفات (array overload).

لا يعدُّ فشل الاستبدال فشلًا في الاستنتاج إلّا في السياق الفوري (immediate context)، أمّا في الحالات الأخرى، فستُعد أخطاء فادحة (hard errors).

template < class T>
    void add_one(T& val) { val += 1; }

int i = 4;
add_one(i);    // حسنا
std::string msg = "Hello";
add_one(msg);    // error. msg += 1 is ill-formed for std::string, but this
// T لم يحدث الفشل في السياق الفوري لتعويض

void_t

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

‎void_t‎ هي دالّة وصفية (meta-function) تحول الأنواع إلى النوع الفارغ (type void)، وغرضها الأساسي هو تسهيل كتابة سمات النوع (type traits). ستكون std::void_t جزء من C++‎ 17، ولكنّ تنفيذها سهل على أي حال:

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

تتطّلب بعض المٌصرّفات تنفيذًا مختلفًا قليلاً:

template < class... >
    struct make_void
    {
        using type = void;
    };
template < typename...T >
    using void_t = typename make_void < T... >::type;

التطبيق الأساسي لـ ‎void_t‎ هو كتابة سمات النوع التي تتحقّق من صحّة عبارة برمجية. على سبيل المثال، دعنا نتحقّق ممّا إذا كان نوع ما له دالة تابعة ‎foo()‎ لا تأخذ أيّة وسائط:

template < class T, class = void >
    struct has_foo: std::false_type {};
template < class T>
struct has_foo<T, void_t<decltype(std::declval<T&>().foo())>> : std::true_type {};

سيحاول المُصرّف عند محاولة استنساخ ‎has_foo<T>::value‎ أن يبحث عن أفضل تخصيص لـ ‎has_foo<T, void>‎. لدينا خياران هنا، الأوليّ والثانوي، يتطلب الثانوي استنساخ التعبير الأساسي (underlying expression):

  • إذا لم يحتوي‎T‎ على دالة تابعة ‎foo()‎، فسيُحوّل النوع المُعاد إلى ‎void‎، وسيُفضَّل التخصِيص (specialization) على القالب الأوّلي بناءً على قواعد الترتيب الجزئي للقوالب (template partial ordering rules). لذا، فإنّ has_foo<T>::value ستساوي true
  • إذا لم يكن ‎T‎ يتحوي على تلك الدالة التابعة (أو إذا كانت موجودة، بيْد أنّها تتطّلب أكثر من وسيط واحد)، فستفشل عملية الاستبدال بالتخصيص، وسنعود إلى القالب الأساسي. وعندئذ ستساوي ‎has_foo<T>::value‎ القيمة ‎false‎.

هذا المثال لا يستخدم ‎std::declval‎ أو ‎decltype‎:

template < class T, class = void >
    struct can_reference: std::false_type {};
template < class T>
    struct can_reference<T, std::void_t<T&>> : std::true_type {};

لاحظ النمط الشائع لاستخدام الوسيط الفارغ (void argument). يمكننا كتابة الشيفرة التالية:

struct details
{
    template<template < class... > class Z, class = void, class...Ts >
        struct can_apply:
        std::false_type {};
    template<template < class... > class Z, class...Ts >
        struct can_apply<Z, std::void_t<Z < Ts... >>, Ts... >:
        std::true_type {};
};
template<template < class... > class Z, class...Ts >
    using can_apply = details::can_apply<Z, void, Ts... > ;

والتي تتجّنب استخدام ‎std::void_t‎، وتستخدم ‎can_apply‎ بدلًا من ذلك، والتي تتصرّف كمحدّد (indicator) يوضح ما إذا كان النوع المتوفّر كوسيط أوّل للقالب مُصاغًا صيغة صحيحة بعد استبدال الأنواع الأخرى فيه. يمكن الآن إعادة كتابة الأمثلة السابقة باستخدام ‎can_apply‎ على النحو التالي:

template<class T>
using ref_t = T&;
template<class T>
using can_reference = can_apply<ref_t, T>;   // مصاغة صيغة صحيحة T& 

و:

template<class T>
using dot_foo_r = decltype(std::declval<T&>().foo());
template<class T>
using can_dot_foo = can_apply< dot_foo_r, T >;   // مصاغة صيغة صَحيحة T.foo() 

والتي تبدو أبسط من النسخ السابقة، هناك مقترحات بعد الإصدار C++‎ 17 لإنشاء سمات ‎std‎ مماثلة لـ ‎can_apply‎.

يُعزى اكتشاف فائدة ‎void_t‎ إلى والتر براون (Walter Brown)، في العرض الرائع الذي قدّمه في CppCon 2016.

enable_if

std::enable_if هي أداة مساعدة لاستخدام الشروط المنطقية لتفعيل قاعدة SFINAE. وتُعرّف على النحو التالي:

template < bool Cond, typename Result = void >
    struct enable_if {};
template < typename Result>
    struct enable_if<true, Result>
    {
        using type = Result;
    };

بمعنى أنّ ‎enable_if<true, R>::type‎ هو اسم بديل (alias) لـ ‎R‎، في حين أنّ صيغة ‎enable_if<false, T>::type‎ غير صحيحة نظرًا لأنّ تخصيص ‎enable_if‎ لا يحتوي على نوع عضوي (member type‏) ‎type‎.

نستطيع استخدام std::enable_if لتقييد القوالب:

int negate(int i) { return -i; }

template <class F>
auto negate(F f) { return -f(); }

هنا سيفشل استدعاء ‎negate(1)‎ بسبب الغموض، لكنّ التحميل الزائد الثاني ليس مُعدًّا لاستخدامه مع الأنواع العددية الصحيحة، لذلك يمكننا إضافة:

int negate(int i) { return -i; }

template <class F, class = typename std::enable_if<!std::is_arithmetic<F>::value>::type>
auto negate(F f) { return -f(); }

الآن، سيؤدّي استنساخ ‎negate<int>‎ إلى فشل التعويض لأنّ ‎!std::is_arithmetic<int>::value‎ تساوي ‎false‎. لكن بسبب قاعدة SFINAE، فلَن يكون هذا خطأ فادحًا (hard error)، وإنّما سيُزال هذا المرشّح من مجموعة المرشحين للتحميل الزائد وحسب. ونتيجة لذلك، لن يكون لـ negate(1)‎ إلّا مرشح واحد وهو الذي سيُستدعى.

متى نستخدَمها

تذكر أنّ ‎std::enable_if‎ هو مُساعد يعمل مع قاعدة SFINAE، لكن ليس هو الذي يجعلها تعمل في المقام الأول، دعنا ننظر في البديلين التاليين لتنفيذ وظائف مماثلة لـ ‎std::size‎، وهي مجموعة تحميل زائد لـ ‎size(arg)‎ تعيد حجم الحاوية أو المصفوفة:

// للحاويات
template < typename Cont>
    auto size1(Cont const& cont) -> decltype( cont.size() );

// للمصفوفات
template<typename Elt, std::size_t Size>
std::size_t size1(Elt const(&arr)[Size]);

// حذف التنفيذ
template < typename Cont>
    struct is_sizeable;

// للحاويات
template<typename Cont, std::enable_if_t<std::is_sizeable<Cont>::value, int> = 0>
    auto size2(Cont const& cont);

// للمصفوفات
template < typename Elt, std::size_t Size>
    std::size_t size2(Elt const(&arr)[Size]);

على افتراض أنّ ‎is_sizeable‎ مكتوبة بشكل صحيح، فيجب أن يكون هذان التصريحان متكافئين تمامًا بحسب قاعدةSFINAE، فأيّهما أسهل في الكتابة وفي المراجعة والفهم؟

سنحاول الآن تنفيذ بعض المساعِدات الحسابية التي تتفادى طفح (overflow) الأعداد الصحيحة غير المؤشّرة لصالح سلوك الالتفاف (wraparound) أو السلوك القابل للتعديل (modular). هذا يعني مثلًا أنّ ‎incr(i, 3)‎ ستكافئ ‎i += 3‎ باستثناء حقيقة أنّ النتيجة ستكون دائمًا مُعرّفة حتى لو كان ‎i‎ عددًا صحيحًا يساوي ‎INT_MAX‎. ما يلي بديلان ممكنان:

// معالجة الأنواع المؤشَّرة
template < typename Int>
    auto incr1(Int& target, Int amount)
-> std::void_t<int[static_cast<Int>(-1) < static_cast<Int>(0)]>;

// target += amount معالجة الأنواع غير المؤشَّرة عبر
// بما أنّ حسابيات العناصر غير المؤشَّرة تتصرف كما هو مطلوب
template < typename Int>
    auto incr1(Int& target, Int amount)
-> std::void_t<int[static_cast<Int>(0) < static_cast<Int>(-1)]>;

template<typename Int, std::enable_if_t<std::is_signed<Int>::value, int> = 0>
void incr2(Int& target, Int amount);
template<typename Int, std::enable_if_t<std::is_unsigned<Int>::value, int> = 0>
void incr2(Int& target, Int amount);

مرّة أخرى، أيّهما أسهل في الكتابة، وأيّهما أسهل في المراجعة والفهم؟

تتمثّل قوة ‎std::enable_if‎ في طريقة تعاملها مع إعادة الإنتاج (refactoring) وتصميم الواجهات البرمجية، فإذا كان الغرض من ‎is_sizeable<Cont>::value‎ هو التحقّق من صحّة ‎cont.size()‎، فقد يكون استخدام التعبير كما يظهر في ‎size1‎ أوجز، رغم أنّ ذلك قد يعتمد على ما إذا كانت ‎is_sizeable‎ ستُستخدَم في العديد من المواضع أم لا. على النقيض من ذلك، فإنّ ‎std::is_signed‎ أكثر وضوحًا ممّا كانت عليه عندما كان تنفيذها يتسرّب إلى تصريح ‎incr1‎.

is_detected

لتعميم إنشاء type_trait استنادًا إلى قاعدة SFINAE، فهناك بعض السمات التجريبية، وهي: ‎detected_or‎ و ‎detected_t‎ و ‎is_detected‎.

ومع معاملات القوالب ‎typename Default‎ و ‎template <typename...> Op‎ و ‎typename ... Args‎:

  • ‎is_detected‎: هو اسم بديل لـ std::true_type أو std::false_type اعتمادًا على صلاحية Op<Args...>
  • ‎detected_t‎: هو اسم بديل لـ Op<Args...>‎ أو ‎nonesuch‎ اعتمادًا على صلاحية Op<Args...>‎.
  • ‎detected_or‎: هو اسم بديل لبنية لها ‎value_t‎ مرصودة (‎is_detected‎)، ونوع ‎type‎ يحقّقOp<Args...>‎ أو ‎Default‎ اعتمادًا على صلاحية ‎Op<Args...>‎

ويمكن تنفيذ باستخدام ‎std::void_t‎ لأجل قاعدة SFINAE على النحو التالي:

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

namespace detail
{
    template < class Default, class AlwaysVoid,
        template < class... > class Op, class...Args >
        struct detector
        {
            using value_t = std::false_type;
            using type = Default;
        };
    template < class Default, template < class... > class Op, class...Args >
        struct detector<Default, std::void_t<Op < Args... >>, Op, Args... >
        {
            using value_t = std::true_type;
            using type = Op < Args... > ;
        };
}    // تفاصيل فضاء الاسم
// نوع خاص للإشار إلى رصد الخطأ
struct nonesuch
{
    nonesuch() = delete;
   ~nonesuch() = delete;
   nonesuch(nonesuch const&) = delete;
   void operator=(nonesuch const&) = delete;

};
template <template<class...> class Op, class... Args>
using is_detected = typename detail::detector<nonesuch, void, Op, Args...>::value_t;
template <template<class...> class Op, class... Args>
using detected_t = typename detail::detector<nonesuch, void, Op, Args...>::type;
template <class Default, template<class...> class Op, class... Args>
using detected_or = detail::detector<Default, void, Op, Args...>;

يمكن تنفيذ السمات التي ترصد وجود تابع على النحو التالي:

typename <typename T, typename ...Ts>
using foo_type = decltype(std::declval<T>().foo(std::declval<Ts>()...));
struct C1 {};
struct C2
{
    int foo(char) const;
};
template < typename T>
    using has_foo_char = is_detected<foo_type, T, char> ;
static_assert(!has_foo_char<C1>::value, "Unexpected");
static_assert(has_foo_char<C2>::value, "Unexpected");
static_assert(std::is_same<int, detected_t<foo_type, C2, char>>::value,
    "Unexpected");
static_assert(std::is_same<void,    // افتراضي
    detected_or<void, foo_type, C1, char>>::value,
    "Unexpected");
static_assert(std::is_same<int, detected_or<void, foo_type, C2, char>>::value,
    "Unexpected");

تحليل تحميل زائد له عدد كبير من الخيارات

إذا كنت بحاجة إلى الاختيار بين عدة خيارات، فقد يكون تمكين خيار واحد فقط عبر ‎enable_if<>‎ مرهقًا للغاية، إذ يجب إلغاء العديد من الشروط لاستبعاد الخيارات الأخرى، وبدلاً من ذلك، يمكن اختيار ترتيب التحميلات الزائد باستخدام الوراثة، أي بإرسال الوسم (tag dispatch).

وبدلاً من التحقق من صحّة الصيغة وكذلك التحقق من أنّ جميع الشروط الأخرى غير متحقّقة، فإنّنا سنكتفي باختبار الأشياء التي نحتاجها وحسب، ويفضل أن يكون ذلك في ‎decltype‎ في إعادة زائدة (trailing return).

قد يؤدّي هذا إلى تجاهل الكثير من الخيارات ذات الصيغة الصحيحة، لكن نستطيع التفريق بينها باستخدام "الوسوم" (tags)، على غرار وسوم مكرّرات السمات (‎random_access_tag‎). وسيعمل هذا بدون مشاكل لأنّ الحصول على تطابق مباشر أفضل من الصنف الأساسي (base class)، والذي هو بدوره أفضل من الصنف الأساسي لصنف أساسي (base class of a base class)، وهكذا دواليك.

#include <algorithm>
#include <iterator>

namespace detail
{
    // سيعطينا هذا عددا غير محدود من الأنواع التي ترث بعضها بعضا
    template<std::size_t N>
        struct pick: pick < N - 1 > {};
    template < >
        struct pick < 0> {};

التحميل الزائد الذي نريد له أن يكون مفضَّلًا يجب أن تكون قيمة N له أكبر في <pick<N، ما يلي أول دالة قالب مساعِدة، نتابع المثال …

    template < typename T>
        auto stable_sort(T& t, pick<2>)-> decltype( t.stable_sort(), void() )

        {

إن كانت الحاوية لديها stable_sort فاستخدمه، …

            t.stable_sort();
        }

    // المساعد سيكون ثاني أفضل تطابق ممكن
    template < typename T>

template<typename T>
auto stable_sort(T& t, pick<1>)-> decltype( t.sort(), void() )
        {

إذا كان للحاوية عضو sort لكن لم يكن فيها stable_sort فسيكون sort مستقرًا غالبًا، نتابع …

            t.sort();
        }

    // هذا المساعد سيكون آخر مرشح
    template < typename T>
        auto stable_sort(T& t, pick<0>)-> decltype( std::stable_sort(std::begin(t), std::end(t)), void() )

        {

الحاوية لا تحتوي على عضو sort أو stable_sort

           std::stable_sort(std::begin(t), std::end(t));
        }
}

// 'tags' هذه هي الدالة التي يستدعيها المستخدم، ستقوم بإرسال الاستدعاء إلى التقديم الصحيح بمساعدة
template < typename T>
    void stable_sort(T& t)
    {
        // مع قيمة أكبر من القيم السابقة N استخدم
        // هذا سيختار أعلى تحميل زائد من بين التحميلات الزائدة صحيحة الصيغة
        detail::stable_sort(t, detail::pick < 10> {});
    }

هناك عدّة طرق أخرى للتمييز بين التحميلات الزائدة، مثل أنّ المطابقة التامة أفضل من التحويل، والتي هي بدورها أفضل من علامة الحذف (ellipsis). بالمقابل، يمكن أن يتوسّع وسم الإرسال إلى أيّ عدد من الخيارات، وهو أكثر وضوحًا وصراحة في العادة.

الكلمة المفتاحية decltype الزائدة في قوالب الدوالّ

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

يمكن استخدام ‎decltype‎ زائدة (trailing) لتحديد نوع القيمة المُعادة:

namespace details
{
    using std::to_string;
    // to_string(T) ينبغي أن تكون قادرة على استدعاء
    template < class T>
        auto convert_to_string(T const& val, int )-> decltype(to_string(val))

        }

   // ellipsis argument هذه غير مقيّدة، لكن يُفضَّل عدم استخدامها بسبب وسيط علامة الحذف
    template < class T>
        std::string convert_to_string(T const& val, ... )
        {
            std::ostringstream oss;
            oss << val;
            return oss.str();
        }
}

template < class T>
    std::string convert_to_string(T const& val)
    {
        return details::convert_to_string(val, 0);
    }

في حال استدعاء ‎convert_to_string()‎ باستخدام وسيط يمكن استدعاء الدالة ‎to_string()‎ من خلاله، فعندها ستكون لدينا دالتان قابلتان للتطبيق على ‎details::convert_to_string()‎، والأولى مُفضّلة لأنّ تسلسل التحويل الضمني من ‎0‎ إلى ‎int‎ أفضل من التحويل من ‎0‎ إلى ‎...‎

أما في حال استدعاء ‎convert_to_string()‎ باستخدام وسيط لا يمكننا عبره استدعاء ‎to_string()‎، فحينئذٍ سيؤدّي استنساخ قالب الدالة الأوّل إلى فشل الاستبدال - substitution failure - (ليس هناك ‎decltype(to_string(val))‎)، ونتيجة لذلك، يُزال هذا المرشح من مجموعة التحميل الزائد. قالب الدالّة الثاني غير مقيّد ولذا تم اختياره، وسنمرّ عبر ‎operator<<(std::ostream&, T)‎، أمّا في حال لم يكن معرَّفًا فسيحدث خطأ فادح في التصريف لمكدّس القالب (template stack) في سطر ‎oss << val‎.

enableifall / enableifany

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

مثال تحفيزي

لدينا في الشيفرة التالية حزمة قالب متغيّرة (variadic template pack) في قائمة معاملات القالب:

template < typename...Args > void func(Args && ...args)
    {
        //... };

لا تعطينا المكتبة القياسية (قبل الإصدار C++‎ 17) أيّ طريقة مباشرة لكتابة enable_if لفرض قيود قاعدة SFINAE على جميع (أو أيٍّ من) المعاملات في ‎Args‎. توفّر C++‎ 17 حلّين لهذه المشكلة، وهما: ‎std::conjunction‎ و ‎std::disjunction‎. انظر المثال التالي: قيود SFINAE على جميع المعامِلات في Args:

template<typename ...Args,
    std::enable_if_t<std::conjunction_v<custom_conditions_v<Args>...>>* = nullptr>
void func(Args &&...args) { //... };

template<typename ...Args,
    std::enable_if_t<std::disjunction_v<custom_conditions_v<Args>...>>* = nullptr>
void func(Args &&...args) { //... };

إذا كنت تعمل بإصدار أقل من C++‎ 17، فهناك العديد من الحلول الممكنة لتحقيق ذلك، أحدها هو استخدام صنف الحالة الأساسية (base-case class) والتخصيصات الجزئية، كما هو مُوضّح في جواب هذا السؤال.

يمكن أيضًا تنفيذ سلوك ‎std::conjunction‎ و ‎std::disjunction‎ بطريقة مباشرة، وسأوضح في المثال التالي، كيفيّة كتابة التنفيذات وسأجمعها مع std::enable_if لإنتاج كُنيتين: ‎enable_if_all‎ و ‎enable_if_any‎، واللّتان تفعلان بالضبط ما يفترض بهما فعله. قد يكون هذا الحلّ أكثر قابلية للتوسيع.

تطبيق ‎enable_if_all‎ و ‎enable_if_any‎

أولاً، سنحاكي ‎std::conjunction‎ و ‎std::disjunction‎ باستخدام ‎seq_and‎ و ‎seq_or‎:

/// C++14 مساعد لاستخدامه في الإصدارات التي تسبق
template<bool B, class T, class F >
using conditional_t = typename std::conditional<B,T,F>::type;
/// Emulate C++17 std::conjunction.
template<bool...> struct seq_or: std::false_type {};
template<bool...> struct seq_and: std::true_type {};
template<bool B1, bool... Bs>
struct seq_or<B1,Bs...>:
    conditional_t<B1,std::true_type,seq_or<Bs...>> {};
template<bool B1, bool... Bs>
struct seq_and<B1,Bs...>:
    conditional_t<B1,seq_and<Bs...>,std::false_type> {};

الآن سيصبح التنفيذ واضحًا:

template < bool...Bs >
    using enable_if_any = std::enable_if<seq_or < Bs... >::value > ;
template < bool...Bs >
    using enable_if_all = std::enable_if<seq_and < Bs... >::value > ;

وأخيرًا بعض المساعِدات:

template < bool...Bs >
    using enable_if_any_t = typename enable_if_any < Bs... >::type;
template < bool...Bs >
    using enable_if_all_t = typename enable_if_all < Bs... >::type;

كيفية الاستخدام

الاستخدام واضح ومباشر: قيود SFINAE على جميع المعامِلات في Args:

template<typename ...Args,
enable_if_all_t<custom_conditions_v<Args>...>* = nullptr>
void func(Args &&...args) { //... };

template<typename ...Args,
enable_if_any_t<custom_conditions_v<Args>...>* = nullptr>
void func(Args &&...args) { //... };

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

ترجمة -بتصرّف- للفصل Chapter 103: SFINAE (Substitution Failure Is Not An Error)‎ من كتاب C++ Notes for Professionals





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


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



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

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

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


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

تسجيل الدخول

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


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