الكاري والتطبيق الجزئي في C ++ 14

سأخبرك في هذه المقالة عن أحد خيارات الكاري والتطبيق الجزئي للوظائف في C ++ وهو المفضل لدي الشخصي. سأقوم أيضًا بعرض تنفيذي التجريبي لهذا الشيء وشرح نقطة الكاري دون صيغة رياضية معقدة ، مما يجعل الأمر بسيطًا بالنسبة لك. سنرى أيضًا ما يوجد أسفل غطاء مكتبة kari.hpp الذي سنستخدمه في وظائف الكاري. على أي حال ، هناك الكثير من الأشياء الرائعة في الداخل ، فمرحبا بكم!


الكاري


ما هو الكاري؟ أعتقد أنها واحدة من تلك الكلمات التي تسمعها من مبرمجي Haskell طوال الوقت (بعد monad ، بالطبع). في الأساس ، تعريف المصطلح بسيط للغاية ، لذلك لا يشعر هؤلاء القراء الذين كتبوا بالفعل على لغات من نوع ML أو Haskell ، أو من يعرفون ماذا يعني ذلك من مكان آخر ، بالحرية في تخطي هذا القسم.


Currying - هي تقنية تحويل دالة تأخذ وسيطات N إلى دالة واحدة ، والتي تأخذ وسيطة واحدة وتقوم بإرجاع وظيفة الوسيطة التالية ، وتستمر حتى تعود دالة الوسيطة الأخيرة ، والتي ستمثلها النتيجة الكلية. أعتقد أنه يساعد إذا عرضت لك أمثلة:


int sum2(int lhs, int rhs) { return lhs + rhs; } 

هنا لدينا وظيفة الجمع الثنائي. وماذا لو أردنا تحويله إلى وظيفة واحدة متغيرة؟ انها في الواقع بسيطة جدا:


 auto curried_sum2(int lhs) { return [=](int rhs) { return sum2(lhs, rhs); }; } 

لا ، ماذا فعلنا؟ لقد اتخذنا قيمة تستند إلى وسيطة واحدة تسمى lambda والتي بدورها تأخذ الوسيطة الثانية وتؤدي الإضافة نفسها. نتيجةً لذلك ، يمكننا تطبيق الدالة curried_sum2 على curried_sum2 واحدة تلو الأخرى:


 // output: 42 std::cout << sum2(40, 2) << std::endl; std::cout << curried_sum2(40)(2) << std::endl; 

وهذا هو في الواقع بيت القصيد من عملية الكاري. بالطبع ، من الممكن القيام بذلك مع وظائف من أي مكان - ستعمل بنفس الطريقة تمامًا. سنقوم بإرجاع دالة متعرجة للوسائط N-1 في كل مرة نأخذ فيها القيمة من وسيطة أخرى:


 auto sum3(int v1, int v2, int v3) { return v1 + v2 + v3; } auto curried_sum3(int v1) { return [=](int v2){ return [=](int v3){ return sum3(v1, v2, v3); }; }; } // output: 42 std::cout << sum3(38, 3, 1) << std::endl; std::cout << curried_sum3(38)(3)(1) << std::endl; 

تطبيق جزئي


التطبيق الجزئي - هو وسيلة لاستدعاء وظائف الوسائط N عندما تأخذ جزءًا فقط من الوسائط وتعرض دالة أخرى للوسائط المتبقية.


في هذا الصدد ، تجدر الإشارة إلى أنه في لغات مثل هاسكل ، تعمل هذه العملية تلقائيًا خلف ظهر مبرمج. ما نحاول القيام به هنا هو القيام بذلك بشكل صريح ، sum3 استدعاء دالة sum3 لدينا مثل هذا: sum3(38,3)(1) أو ربما مثل هذا: sum3(38)(3,1) . علاوة على ذلك ، إذا أرجعت إحدى الوظائف وظيفة أخرى تم تحريفها ، فيمكن أن تسمى أيضًا باستخدام قائمة وسيطات الدالة الأولى. لنرى المثال:


 int boo(int v1, int v2) { return v1 + v2; } auto foo(int v1, int v2) { return kari::curry(boo, v1 + v2); } // output: 42 std::cout << kari::curry(foo)(38,3,1) << std::endl; std::cout << kari::curry(foo)(38,3)(1) << std::endl; std::cout << kari::curry(foo)(38)(3,1) << std::endl; 

في الواقع ، لقد تقدمنا ​​قليلاً على أنفسنا هنا ، مع عرض مثال على استخدام kari.hpp ، لذلك نعم ، إنه يفعل ذلك.


تحديد الأهداف


قبل أن نكتب شيئًا ما ، من الضروري (أو المرغوب فيه) فهم ما نريده في النهاية. ونريد أن تتاح لنا الفرصة لتطبيق وتطبيق أي وظيفة يمكن استدعاؤها في C ++. وهي:


  • لامبدا (بما في ذلك اللمبات العامة)
  • كائنات وظيفية
  • وظائف أي arity (بما في ذلك القوالب)
  • وظائف varadic
  • طرق الفصل

يمكن التغلب على وظائف المتغير عن طريق تحديد عدد محدد من الحجج التي نريد أن نعالجها. التفاعل القياسي مع الأمراض المنقولة جنسيا :: ربط ونتائجها هي أيضا مرغوب فيه. وبالطبع ، نحتاج إلى فرصة لتطبيق وظائف متعددة المتغيرات واستدعاء وظائف متداخلة بحيث يبدو أننا نعمل مع وظيفة واحدة متعرجة.


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


المؤلف ، كنت تحاول اختراع std::bind واحد مرة أخرى!


نعم ولا std::bind هي بلا شك أداة قوية وثابتة ، ولا أنوي كتابة قاتلها أو بديلها. نعم ، يمكن استخدامه للكاري والتطبيق الجزئي الصريح (مع تحديد بالضبط الحجج التي نطبقها ، وأين ، وعددها). لكن من المؤكد أنها ليست الطريقة الأكثر ملاءمة ، ناهيك عن أنها غير قابلة للتطبيق دائمًا حيث يتعين علينا معرفة تعدد الوظائف وكتابة روابط محددة بناءً على ذلك. على سبيل المثال:


 int foo(int v1, int v2, int v3, int v4) { return v1 + v2 + v3 + v4; } // std::bind auto c0 = std::bind(foo, _1, _2, _3, _4); auto c1 = std::bind(c0, 15, _1, _2, _3); auto c2 = std::bind(c1, 20, 2, _1); auto rr = c2(5); std::cout << rr << std::endl; // output: 42 // kari.hpp auto c0 = kari::curry(foo); auto c1 = c0(15); auto c2 = c1(20, 2); auto rr = c2(5); std::cout << rr << std::endl; // output: 42 

API


 namespace kari { template < typename F, typename... Args > constexpr decltype(auto) curry(F&& f, Args&&... args) const; template < typename F, typename... Args > constexpr decltype(auto) curryV(F&& f, Args&&... args) const; template < std::size_t N, typename F, typename... Args > constexpr decltype(auto) curryN(F&& f, Args&&... args) const; template < typename F > struct is_curried; template < typename F > constexpr bool is_curried_v = is_curried<F>::value; template < std::size_t N, typename F, typename... Args > struct curry_t { template < typename... As > constexpr decltype(auto) operator()(As&&... as) const; }; } 



kari::curry(F&& f, Args&&... args)


curry_t كائن دالة من نوع curry_t (دالة curry_t ) مع وسيطات اختيارية يتم تطبيق وسيطات أو نتيجة لتطبيق الوسيطات على الدالة المعطاة f (هل الوظيفة خالية ، أو كانت الوسيطات المنقولة كافية لاستدعائها).


إذا كانت المعلمة f تحتوي على الوظيفة التي تم بالفعل سحبها ، فإنها تُرجع نسختها مع تطبيق الوسيطات.




kari::curryV(F&& f, Args&&... args)


يسمح بالكاري وظائف مع عدد متغير من الحجج. بعد ذلك ، يمكن استدعاء هذه الوظائف باستخدام عامل التشغيل () بدون وسيطات. على سبيل المثال:


 auto c0 = kari::curryV(std::printf, "%d + %d = %d"); auto c1 = c0(37, 5); auto c2 = c1(42); c2(); // output: 37 + 5 = 42 

إذا كانت المعلمة f تحتوي على دالة تم بالفعل حذفها ، فإنها تُرجع نسختها بنوع التطبيق المعدل بعدد متغير من الوسائط مع تطبيق وسيطات الوسيطات.




kari::curryN(F&& f, Args&&... args)


يسمح لك بالكتابة مع عدد متغير من الوسائط من خلال تحديد عدد محدد من الوسائط التي نريد تطبيقها (باستثناء تلك الواردة في الوسائط). على سبيل المثال:


 char buffer[256] = {'\0'}; auto c = kari::curryN<3>(std::snprintf, buffer, 256, "%d + %d = %d"); c(37, 5, 42); std::cout << buffer << std::endl; // output: 37 + 5 = 42 

إذا كانت المعلمة f تحتوي على دالة تم بالفعل حذفها ، فإنها تُرجع نسختها بنوع التطبيق الذي تم تغييره للوسائط N مع تطبيق الوسيطات.




kari::is_curried<F>, kari::is_curried_v<F>


بعض الهياكل المساعدة لفحص ما إذا كان قد تم بالفعل سحب وظيفة. على سبيل المثال:


 const auto l = [](int v1, int v2){ return v1 + v2; }; const auto c = curry(l); // output: is `l` curried? no std::cout << "is `l` curried? " << (is_curried<decltype(l)>::value ? "yes" : "no") << std::endl; // output: is `c` curried? yes std::cout << "is `c` curried? " << (is_curried_v<decltype(c)> ? "yes" : "no") << std::endl; 



kari::curry_t::operator()(As&&... as)


المشغل السماح بالتطبيق الكامل أو الجزئي لوظيفة مجعدة. تُرجع الدالة curered للوسائط المتبقية من الدالة الأولية F ، أو قيمة هذه الوظيفة التي تم الحصول عليها من خلال التطبيق الخاص بها على تراكم الوسائط القديمة والوسائط الجديدة as . على سبيل المثال:


 int foo(int v1, int v2, int v3, int v4) { return v1 + v2 + v3 + v4; } auto c0 = kari::curry(foo); auto c1 = c0(15, 20); // partial application auto rr = c1(2, 5); // function call - foo(15,20,2,5) std::cout << rr << std::endl; // output: 42 

إذا قمت باستدعاء دالة curryV بدون وسيطات تستخدم curryV أو curryN ، فسيتم استدعاؤها إذا كانت هناك وسيطات كافية. وإلا ، فسوف تُرجع دالة مطبقة جزئيًا. على سبيل المثال:


 auto c0 = kari::curryV(std::printf, "%d + %d = %d"); auto c1 = c0(37, 5); auto c2 = c1(42); // force call variadic function std::printf c2(); // output: 37 + 5 = 42 

تفاصيل التنفيذ


عند إعطائك تفاصيل التنفيذ ، سأستخدم C ++ 17 من أجل الحفاظ على نص المقالة قصيرًا وتجنب التفسيرات غير الضرورية وتراكم ملفات SFINAE ، بالإضافة إلى أمثلة للتطبيقات التي اضطررت لإضافتها ضمن C ++ 14 القياسية. كل هذه الأشياء التي يمكنك العثور عليها في مستودع المشروع ، حيث يمكنك أيضًا إضافتها إلى مفضلاتك :)




make_curry(F&& f, std::tuple<Args...>&& args)


دالة مساعدة تقوم بإنشاء كائن دالة curry_t أو تطبيق الدالة المعطاة على وسيطات وسيطات.


 template < std::size_t N, typename F, typename... Args > constexpr auto make_curry(F&& f, std::tuple<Args...>&& args) { if constexpr ( N == 0 && std::is_invocable_v<F, Args...> ) { return std::apply(std::forward<F>(f), std::move(args)); } else { return curry_t< N, std::decay_t<F>, Args... >(std::forward<F>(f), std::move(args)); } } template < std::size_t N, typename F > constexpr decltype(auto) make_curry(F&& f) { return make_curry<N>(std::forward<F>(f), std::make_tuple()); } 

الآن ، هناك شيئان مثيران للاهتمام حول هذه الوظيفة:


  • نحن نطبقها على الوسيطات فقط إذا كانت قابلة للاستدعاء لهذه الوسائط وكان عداد التطبيق N عند الصفر
  • إذا كانت الوظيفة غير قابلة للاستدعاء ، فإننا نعتبر هذه الدعوة تطبيقًا جزئيًا curry_t لكائن دالة يحتوي على الوظيفة curry_t



struct curry_t


كائن دالة من المفترض أن يقوم بتخزين تراكم الحجج والدالة التي سوف نسميها عند تطبيقها في النهاية. هذا الكائن هو ما سنقوم بالاتصال به والتطبيق جزئيًا.


 template < std::size_t N, typename F, typename... Args > struct curry_t { template < typename U > constexpr curry_t(U&& u, std::tuple<Args...>&& args) : f_(std::forward<U>(u)) , args_(std::move(args)) {} private: F f_; std::tuple<Args...> args_; }; 

هناك عدد من الأسباب وراء قيامنا بتخزين تراكم الوسائط args_ في std :: tuple :


1) يتم التعامل مع المواقف مع std :: ref تلقائيًا لتخزين المراجع عند الحاجة ، بشكل افتراضي استنادًا إلى القيمة
2) تطبيق مناسب لوظيفة وفقًا لوسيطتها ( std :: application)
3) إنه جاهز ، لذلك لن تضطر إلى كتابته من نقطة الصفر :)


لقد قمنا بتخزين الكائن الذي نسميه والدالة f_ بقيمتها أيضًا ، وكن حذرًا عند اختيار النوع عند إنشاء واحد (سأقوم بالتوسع في هذه المشكلة أدناه) ، أو نقله أو نسخه باستخدام مرجع عالمي في المنشئ.


تعمل المعلمة قالب N بمثابة عداد تطبيق لوظائف varadic.




curry_t::operator()(const As&...)


وبالطبع ، الشيء الذي يجعل كل شيء يعمل - العامل الذي يستدعي كائن الوظيفة.


 template < std::size_t N, typename F, typename... Args > struct curry_t { // 1 constexpr decltype(auto) operator()() && { return detail::make_curry<0>( std::move(f_), std::move(args_)); } // 2 template < typename A > constexpr decltype(auto) operator()(A&& a) && { return detail::make_curry<(N > 0 ? N - 1 : 0)>( std::move(f_), std::tuple_cat( std::move(args_), std::make_tuple(std::forward<A>(a)))); } // 3 template < typename A, typename... As > constexpr decltype(auto) operator()(A&& a, As&&... as) && { return std::move(*this)(std::forward<A>(a))(std::forward<As>(as)...); } // 4 template < typename... As > constexpr decltype(auto) operator()(As&&... as) const & { auto self_copy = *this; return std::move(self_copy)(std::forward<As>(as)...); } } 

مشغل الاتصال لديه أربع وظائف مثقلة.


  1. وظيفة بدون أي معاملات تسمح ببدء تطبيق الدالة varadic (تم إنشاؤها بواسطة curryV أو curryN ). هنا نقوم بتخفيض عداد التطبيق إلى الصفر ، مما يجعل من الواضح أن الوظيفة جاهزة للتطبيق ، ومن ثم نقدم كل ما هو مطلوب من أجل هذه الوظيفة.


  2. دالة من وسيطة واحدة تخفض عداد التطبيق بمقدار 1 (إذا لم تكن عند الصفر) وتضع الوسيطة الجديدة في قائمة المتراكمة من الوسائط args_ وتنقل كل هذا إلى make_curry .


  3. دالة متغيرة تمثل بالفعل خدعة للتطبيق الجزئي للوسائط المختلفة. ما يفعله هو تطبيقها بشكل متكرر ، واحدا تلو الآخر. الآن ، هناك سببان لعدم إمكانية تطبيقها جميعًا مرة واحدة:


    • يمكن أن ينخفض ​​عداد التطبيق إلى الصفر قبل عدم وجود وسيطات
    • يمكن استدعاء الدالة f_ سابقًا وإرجاع دالة أخرى مجعدة ، لذا ستكون كل الوسائط التالية مخصصة لها

  4. تعمل الوظيفة الأخيرة كجسر بين استدعاء curry_t باستخدام lvalue ووظائف الاستدعاء باستخدام rvalue .



علامات الوظائف المؤهلة المرجع تجعل العملية برمتها سحرية تقريبًا. باختصار ، من خلال مساعدتهم ، نتعرف على أنه تم استدعاء كائن باستخدام مرجع rvalue ويمكننا فقط نقل الوسائط بدلاً من نسخها في وظيفة استدعاء النهاية make_curry . وإلا فسنضطر إلى نسخ الوسائط من أجل استمرار فرصة استدعاء هذه الوظيفة مرة أخرى ، والتأكد من أن الوسائط لا تزال موجودة.


مكافآت


قبل الانتقال إلى الاستنتاج ، أود أن أوضح لك مثالين على السكر النحوي الموجود في kari.hpp والذي يمكن اعتباره مكافآت.


أقسام المشغل


يجب أن يكون المبرمجون الذين عملوا بالفعل مع Haskell على دراية بأقسام المشغلين التي تتيح إعطاء وصف قصير للمشغلين المطبقين. على سبيل المثال ، تنشئ البنية (*2) دالة ذات وسيطة واحدة ، وتعيد نتيجة ضرب هذه الوسيطة إلى 2. لذلك ، ما أردت هو محاولة كتابة شيء مثل ذلك في C ++. لم يقل قال من القيام به!


 using namespace kari::underscore; std::vector<int> v{1,2,3,4,5}; std::accumulate(v.begin(), v.end(), 0, _+_); // result: 15 std::transform(v.begin(), v.end(), v.begin(), _*2); // v = 2, 3, 6, 8, 10 std::transform(v.begin(), v.end(), v.begin(), -_); // v = -2,-3,-6,-8,-10 

تكوين وظيفة


وبالطبع لن أكون wacko كاملاً إذا لم أحاول كتابة تكوين وظيفة . كمشغل للتركيب ، اخترت operator * كأقرب (بمظهره) لجميع الرموز المتاحة لعلامة التركيب في الرياضيات. لقد استخدمته لتطبيق الوظيفة الناتجة عن وسيطة ، أيضًا. هذا ما حصلت عليه:


 using namespace kari::underscore; // 1 std::cout << (_*2) * (_+2) * 4 << std::endl; // output: 12 // 2 std::cout << 4 * (_*2) * (_+2) << std::endl; // output: 10 

  1. يتم تطبيق تكوين الوظائف (*2) و (+2) على 4 . (4 + 2) * 2 = 12
  2. يتم تطبيق الوظيفة (*2) على 4 ثم نطبق (+2) على النتيجة. (4 * 2 + 2) = 10

بنفس الطريقة التي يمكنك بها بناء تركيبات معقدة للغاية بأسلوب pointfree ، ولكن ضع في اعتبارك أن مبرمجي Haskell هم فقط الذين يفهمون هذه :)


 // (. (+2)) (*2) $ 10 == 24 // haskell analog std::cout << (_*(_+2))(_*2) * 10 << std::endl; // output: 24 // ((+2) .) (*2) $ 10 == 22 // haskell analog std::cout << ((_+2)*_)(_*2) * 10 << std::endl; // output: 22 

الخاتمة


أعتقد أنه كان واضحًا قبل ذلك أنه لا توجد حاجة لاستخدام هذه التقنيات في المشروعات الحقيقية. ولكن لا يزال ، يجب أن أذكر ذلك. بعد كل شيء ، كان هدفي هو إثبات نفسي والتحقق من معيار C ++ الجديد. هل سأكون قادرًا على القيام بذلك؟ و C ++؟ حسنا ، أعتقد ، لقد رأيت للتو مثلما لدينا نوع من القيام بذلك. وأنا ممتن حقًا لجميع الرجال الذين قرأوا كل شيء.

Source: https://habr.com/ru/post/ar436488/


All Articles