مرحبا يا هبر.
في المرة الأخيرة ، وصفنا " Has
نمط" ، أوجزنا المشكلات التي حلها ، وكتبنا بعض الحالات المحددة:
instance HasDbConfig AppConfig where getDbConfig = dbConfig instance HasWebServerConfig AppConfig where getWebServerConfig = webServerConfig instance HasCronConfig AppConfig where getCronConfig = cronConfig
انها تبدو جيدة. ما الصعوبات التي يمكن أن تنشأ هنا؟

حسنًا ، دعنا نفكر في الحالات الأخرى التي قد نحتاجها. بادئ ذي بدء ، تعتبر الأنواع الملموسة ذات التكوين بحد ذاتها مرشحة جيدة للتنفيذ (التافه) لهذه الحروف ، والتي تمدنا بثلاث حالات أخرى حيث يتم تنفيذ كل طريقة عبر id
، على سبيل المثال
instance HasDbConfig DbConfig where getDbConfig = id
أنها تتيح لنا بسهولة كتابة الاختبارات الفردية أو الأدوات المساعدة المساعد مستقلة عن AppConfig
بأكمله.
هذا ممل بالفعل ، لكنه لا يزال مستمراً. من السهل أن نتخيل أن بعض اختبارات التكامل تتحقق من تفاعل زوج من الوحدات ، وما زلنا لا نريد الاعتماد على تكوين التطبيق بأكمله ، لذلك نحن بحاجة الآن إلى كتابة ست حالات (حالتان لكل نوع) ، سيتم تخفيض كل واحدة منها إلى fst
أو snd
. على سبيل المثال ، من أجل DbConfig
:
instance HasDbConfig (DbConfig, b) where getDbConfig = fst instance HasDbConfig (a, DbConfig) where getDbConfig = snd
الرعب. من المأمول ألا نحتاج أبدًا إلى اختبار تشغيل ثلاث وحدات في نفس الوقت - وإلا فسيتعين عليك كتابة تسع حالات مملة. على أي حال ، أنا شخصياً غير مرتاح بالفعل ، وأود أن أقضي عدة ساعات في أتمتة هذه المسألة من بضع دقائق في كتابة أكثر من عشرة أسطر إضافية من التعليمات البرمجية.
إذا كنت مهتمًا بكيفية حل هذه المشكلة بشكل عام ، علاوة على ذلك ، فهذه أنواع تابعة ، وكيف سينتهي كل الأمر وكأنها قطة Haskell - Welkom.
تلخيص الفصل
أولاً ، لاحظ أن لدينا فئات مختلفة لبيئات مختلفة. قد يتداخل هذا مع صنع حل عالمي ، لذلك نخرج البيئة في معلمة منفصلة:
class Has part record where extract :: record -> part
يمكننا القول أن هناك Has part record
يعني أنه يمكن استخراج بعض قيمة part
النوع من قيمة record
النوع. في هذه الشروط ، يصبح HasDbConfig
القديم الجيد HasDbConfig
Has DbConfig
، وبالمثل بالنسبة للوحات الطباعة الأخرى التي كتبناها سابقًا. لقد بدا تغييرًا تقريبًا بحتًا ، وعلى سبيل المثال ، يتحول نوع إحدى الوظائف من منشورنا السابق إلى
doSmthWithDbAndCron :: (MonadReader rm, HasDbConfig r, HasCronConfig r) => ...
في
doSmthWithDbAndCron :: (MonadReader rm, Has DbConfig r, Has CronConfig r) => ...
التغيير الوحيد هو بضع مسافات في الأماكن الصحيحة.
بالإضافة إلى ذلك ، لم نفقد الكثير في استنتاج الكتابة: لا يزال بإمكان المؤقت إخراج قيمة الإرجاع المطلوبة من extract
في السياق المحيط في الغالبية العظمى من الحالات التي صودفت في الممارسة العملية.
الآن بما أننا لا نهتم بنوع البيئة المحدد ، فلنرى السجلات التي يمكنها تنفيذ فئة Has part record
الخاص part
الثابت. هذه المهمة لديها هيكل حثي جيد:
- كل نوع له نفسه: يتم تنفيذ
Has record record
بطريقة تافهة ( extract = id
). - إذا كان
record
عبارة عن منتج لأنواع rec1
و rec2
، فسيتم تنفيذ Has part record
إذا كان فقط إذا كان Has part rec1
أو Has part rec2
. - إذا كان
record
هو مجموع الأنواع rec1
و rec2
، فسيتم تنفيذ Has part record
إذا وفقط إذا كان Has part rec1
و Has part rec2
. على الرغم من أن الانتشار العملي لهذه الحالة في هذا السياق ليس واضحًا ، إلا أنه لا يزال من الجدير بالذكر اكتماله.
لذلك ، يبدو أننا قمنا بصياغة مخطط لخوارزمية لتحديد ما إذا كان Has part record
تم تطبيق record
part
على part
record
البيانات تلقائيًا!
لحسن الحظ ، فإن مثل هذا التفكير الاستقرائي للأنواع يناسب تمامًا آلية Haskell Generics . باختصار وتبسيط ، يعد Generics أحد أساليب البرمجة الوصفية المعممة في Haskell ، وينتج عن الملاحظة أن كل نوع إما عبارة عن نوع sum أو نوع منتج أو نوع أساسي وحيد البنية مع حقل واحد.
لن أكتب برنامجًا تعليميًا آخر عن الأدوية الجنيسة ، لذا انتقل إلى الرمز.
المحاولة الأولى
سوف نستخدم الطريقة الكلاسيكية للتطبيق Generic
لـ Has
خلال فئة GHas
المساعدة:
class GHas part grecord where gextract :: grecord p -> part
هنا grecord
هو تمثيل عام لنوع record
بنا.
تتبع تطبيقات GHas
التركيب الاستقرائي الذي GHas
أعلاه:
instance GHas record (K1 i record) where gextract (K1 x) = x instance GHas part record => GHas part (M1 it record) where gextract (M1 x) = gextract x instance GHas part l => GHas part (l :*: r) where gextract (l :*: _) = gextract l instance GHas part r => GHas part (l :*: r) where gextract (_ :*: r) = gextract r
K1
يتوافق مع القضية الأساسية.M1
- البيانات الوصفية الخاصة بالأدوية التي لا نحتاج إليها في مهمتنا ، لذلك نحن ببساطة نتجاهلها ونتعرف عليها.- المثيل الأول لنوع المنتج
l :*: r
يتوافق مع الحالة عندما يكون للجزء "الأيسر" من المنتج قيمة part
النوع الذي نحتاجه (ربما ، بشكل متكرر). - وبالمثل ، فإن المثيل الثاني لنوع المنتج
l :*: r
يتوافق مع الحالة التي يكون فيها للجزء "الأيمن" من المنتج قيمة part
الكتابة الذي نحتاج إليه (بشكل طبيعي ، أيضًا ، بشكل متكرر).
نحن ندعم فقط أنواع المنتجات هنا. انطباعي الشخصي هو أن الكميات لا تستخدم غالبًا في سياقات MonadReader
والفئات المماثلة ، لذلك يمكن إهمالها لتبسيط الاعتبار.
بالإضافة إلى ذلك ، من المفيد أن نلاحظ أن كل منها يمكن تمثيل نوع المنتج -ary (a1, ..., an)
على أنه تكوين أزواج (a1, (a2, (a3, (..., an))))
، لذلك اسمحوا لي أن أربط أنواع المنتجات مع أزواج.
من خلال GHas
لدينا ، يمكنك كتابة تطبيق افتراضي لـ Has
الذي يستخدم الأدوية العامة:
class Has part record where extract :: record -> part default extract :: Generic record => record -> part extract = gextract . from
القيام به.
أم لا؟
المشكلة
إذا حاولنا ترجمة هذا الرمز ، فسوف نرى أنه لا taypechaetsya حتى من دون أي محاولة لاستخدام هذا التطبيق بشكل افتراضي ، والإبلاغ عن بعض الحالات المتداخلة هناك. الأسوأ من ذلك ، هذه الحالات هي نفسها في بعض النواحي. يبدو أن الوقت قد حان لمعرفة كيفية عمل آلية حل الحالات في هاسكل.
قد يكون لدينا
instance context => Foo barPattern bazPattern where ...
(بالمناسبة ، يسمى هذا الشيء بعد =>
رأس المثيل.)
يبدو من الطبيعي قراءة هذا كـ
دعونا نحتاج إلى اختيار مثيل لـ Foo bar baz
. إذا كان context
مقتنعًا ، فيمكنك تحديد هذا المثال شريطة أن يتوافق bar
baz
مع barPattern
و bazPattern
.
ومع ذلك ، هذا تفسير خاطئ ، والعكس صحيح:
دعونا نحتاج إلى اختيار مثيل لـ Foo bar baz
. إذا كان bar
baz
و baz
barPattern
مع barPattern
و bazPattern
، فعندئذ نختار هذا المثيل ونضيف context
إلى قائمة الثوابت التي يجب حلها.
الآن أصبح من الواضح ما المشكلة. دعنا نلقي نظرة فاحصة على الحالات التالية:
instance GHas part l => GHas part (l :*: r) where gextract (l :*: _) = gextract l instance GHas part r => GHas part (l :*: r) where gextract (_ :*: r) = gextract r
لديهم نفس رؤساء المثال ، لذلك لا عجب أنها تتقاطع! بالإضافة إلى ذلك ، لا يوجد منهم أكثر تحديدًا من الآخر.
بالإضافة إلى ذلك ، لا توجد طريقة لتحسين هذه الحالات بطريقة ما بحيث تتوقف عن التداخل. حسنًا ، إلى جانب إضافة المزيد من معلمات GHas
.
أنواع معبرة الاندفاع إلى الإنقاذ!
يكمن حل المشكلة في حساب "المسار" مسبقًا على القيمة التي تهمنا ، واستخدام هذا المسار لتوجيه اختيار الحالات.
نظرًا لأننا وافقنا على عدم دعم أنواع المبلغ ، فإن المسار بالمعنى الحرفي هو سلسلة من المنعطفات اليسرى أو اليمنى في أنواع المنتجات (أي اختيارات المكون الأول أو الثاني من الزوج) ، تنتهي بمؤشر كبير "هنا" ، بمجرد العثور على النوع المرغوب . نكتب هذا:
data Path = L Path | R Path | Here deriving (Show)
على سبيل المثالالنظر في الأنواع التالية:
data DbConfig = DbConfig { dbAddress :: DbAddress , dbUsername :: Username , dbPassword :: Password } data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig }
ما هي بعض أمثلة المسارات من AppConfig
؟
- إلى
DbConfig
⟶ L Here
. - إلى
WebServerConfig
⟶ R (L Here)
. - إلى
CronConfig
⟶ R (R Here)
. - إلى
DbAddress
⟶ L (L Here)
.
ماذا يمكن أن يكون نتيجة البحث عن قيمة من النوع المطلوب؟ هناك خياران واضحان: يمكننا العثور عليه أو عدم العثور عليه. ولكن في الواقع ، كل شيء أكثر تعقيدًا: يمكننا العثور على أكثر من قيمة من هذا النوع. من الواضح أن السلوك الأكثر عقلانية في هذه الحالة المثيرة للجدل سيكون أيضًا رسالة خطأ. أي اختيار لقيمة معينة سيكون له قدر معين من العشوائية.
في الواقع ، النظر في مثال خدمة الويب القياسية لدينا. إذا أراد شخص ما الحصول على قيمة من النوع (Host, Port)
، فهل يجب أن يكون عنوان خادم قاعدة البيانات أو عنوان خادم الويب؟ من الأفضل عدم المخاطرة بذلك.
على أي حال ، دعنا نعبر عن ذلك في الكود:
data MaybePath = NotFound | Conflict | Found Path deriving (Show)
نحن نفصل بين NotFound
و Conflict
، نظرًا لأن التعامل مع هذه الحالات يختلف اختلافًا جوهريًا: إذا حصلنا على NotFound
في أحد فروع نوع المنتج لدينا ، فلن يكون من المؤلم العثور على القيمة المطلوبة في بعض الفروع الأخرى ، بينما يعني Conflict
في أي فرع كامل فشل.
الآن نعتبر حالة خاصة لأنواع المنتجات (والتي ، كما اتفقنا ، نعتبرها أزواج). كيف تجد قيمة النوع المطلوب فيها؟ يمكنك إجراء بحث متكرر في كل مكون من مكونات زوج ، والحصول على النتائج p1
و p2
على التوالي ، ثم دمجها بطريقة أو بأخرى.
نظرًا لأننا نتحدث عن اختيار مثيلات timeclasses التي تحدث أثناء التحويل البرمجي ، فنحن في الواقع نحتاج إلى حسابات compiltime ، والتي يتم التعبير عنها في Haskell من خلال عمليات حسابية على الأنواع (حتى لو كانت الأنواع ممثلة من خلال المصطلحات التي أثيرت في الكون باستخدام DataKinds
). وفقًا لذلك ، يتم تمثيل هذه الوظيفة على الأنواع كعائلة من النوع:
type family Combine p1 p2 where Combine ('Found path) 'NotFound = 'Found ('L path) Combine 'NotFound ('Found path) = 'Found ('R path) Combine 'NotFound 'NotFound = 'NotFound Combine _ _ = 'Conflict
هذه الوظيفة تمثل عدة حالات:
- إذا نجحت إحدى عمليات البحث المتكررة ، وأدى الآخر إلى
NotFound
، NotFound
المسار من البحث الناجح ونلحق الدور في الاتجاه الصحيح. - إذا تم إنهاء كل من عمليات البحث المتكررة مع
NotFound
، فمن الواضح أن البحث بأكمله سينتهي مع NotFound
. - في أي حالة أخرى ، نحصل على
Conflict
.
سنقوم الآن بكتابة وظيفة على مستوى النصائح تأخذ part
يمكن العثور عليه ، وتمثيلًا عامًا للنوع الذي سيتم فيه العثور على part
، والبحث:
type family Search part (grecord :: k -> *) :: MaybePath where Search part (K1 _ part) = 'Found 'Here Search part (K1 _ other) = 'NotFound Search part (M1 _ _ x) = Search part x Search part (l :*: r) = Combine (Search part l) (Search part r) Search _ _ = 'NotFound
لاحظ أننا حصلنا على شيء مشابه جدًا في معنى محاولتنا السابقة مع GHas
. هذا أمر متوقع ، لأننا نقوم بالفعل بإعادة إنتاج الخوارزمية التي حاولنا التعبير عنها من خلال الإطارات الزمنية.
GHas
، كل ما تبقى بالنسبة لنا هو إضافة معلمة إضافية إلى هذه الفئة ، المسؤولة عن المسار الموجود سابقًا ، والتي ستعمل على تحديد حالات محددة:
class GHas (path :: Path) part grecord where gextract :: Proxy path -> grecord p -> part
أضفنا أيضًا وسيطة إضافية لـ gextract
بحيث يمكن للمترجم تحديد المثيل الصحيح للمسار المحدد (والذي يجب ذكره في توقيع الوظيفة لهذا).
الآن ، مثيلات الكتابة سهلة للغاية:
instance GHas 'Here record (K1 i record) where gextract _ (K1 x) = x instance GHas path part record => GHas path part (M1 it record) where gextract proxy (M1 x) = gextract proxy x instance GHas path part l => GHas ('L path) part (l :*: r) where gextract _ (l :*: _) = gextract (Proxy :: Proxy path) l instance GHas path part r => GHas ('R path) part (l :*: r) where gextract _ (_ :*: r) = gextract (Proxy :: Proxy path) r
في الواقع ، نحن ببساطة اختيار المثيل المطلوب على أساس المسار في path
الذي قمنا بحسابه في وقت سابق.
كيف الآن لكتابة التنفيذ default
لدينا extract :: record -> part
وظيفة extract :: record -> part
في فئة Has
؟ لدينا العديد من الشروط:
- يجب أن ينفذ
record
عام ، بحيث يمكن تطبيق الآلية العامة ، حتى نحصل على Generic record
. - يجب أن تجد وظيفة
Search
part
من record
(أو بالأحرى في التمثيل Generic
record
، والذي يتم التعبير عنه Rep record
). في الكود ، يبدو هذا أكثر غرابة: Search part (Rep record) ~ 'Found path
. يعني هذا السجل التقييد الذي يجب أن تساوي نتيجة Search part (Rep record)
'Found path
لبعض path
(والذي ، في الواقع ، مثير للاهتمام بالنسبة لنا)". - يجب أن نكون قادرين على استخدام
GHas
مع part
، والتمثيل العام record
path
من الخطوة الأخيرة ، والتي تتحول إلى GHas path part (Rep record)
.
سنلتقي مع الثوابتين الأخيرتين عدة مرات ، لذلك من المفيد أن نضعهما في مرادف منفصل:
type SuccessfulSearch part record path = (Search part (Rep record) ~ 'Found path, GHas path part (Rep record))
بالنظر إلى هذا مرادف ، نحصل عليه
class Has part record where extract :: record -> part default extract :: forall path. (Generic record, SuccessfulSearch part record path) => record -> part extract = gextract (Proxy :: Proxy path) . from
الآن كل شيء!
باستخدام عام Has
للنظر في كل هذا في العمل ، سنكتب بعض الحالات العامة للدمى:
instance SuccessfulSearch a (a0, a1) path => Has a (a0, a1) instance SuccessfulSearch a (a0, a1, a2) path => Has a (a0, a1, a2) instance SuccessfulSearch a (a0, a1, a2, a3) path => Has a (a0, a1, a2, a3)
هنا SuccessfulSearch a (a0, ..., an) path
هو المسؤول عن حقيقة أن a
يحدث بين a0, ..., an
مرة واحدة بالضبط.
قد يكون لدينا الآن لدينا حسن البالغ من العمر
data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig }
ونريد إخراج Has DbConfig
و Has WebServerConfig
و Has CronConfig
. يكفي تضمين DeriveAnyClass
و DeriveAnyClass
وإضافة الإعلان الصحيح DeriveAnyClass
:
data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } deriving (Generic, Has DbConfig, Has WebServerConfig, Has CronConfig)
نحن محظوظون (أو كنا ثاقبة بما فيه الكفاية) لترتيب الوسائط لـ Has
بحيث يأتي اسم النوع المتداخل أولاً ، حتى نتمكن من الاعتماد على آلية DeriveAnyClass
لتقليل الخربشة.
السلامة تأتي أولاً
ماذا لو لم يكن لدينا أي نوع؟
data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig } deriving (Generic, Has CronConfig)
كلا ، حصلنا على خطأ في نقطة تعريف النوع:
Spec.hs:35:24: error: • Couldn't match type ''NotFound' with ''Found path0' arising from the 'deriving' clause of a data type declaration • When deriving the instance for (Has CronConfig AppConfig) | 35 | } deriving (Generic, Has CronConfig) | ^^^^^^^^^^^^^^
ليست رسالة الخطأ الودية ، ولكن حتى من ذلك لا يزال بإمكانك فهم ما المشكلة: التردد الفردي NotFound
التردد الفردي CronConfig
.
ماذا لو كان لدينا العديد من الحقول من نفس النوع؟
data AppConfig = AppConfig { prodDbConfig :: DbConfig , qaDbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } deriving (Generic, Has DbConfig)
كلا ، كما هو متوقع:
Spec.hs:37:24: error: • Couldn't match type ''Conflict' with ''Found path0' arising from the 'deriving' clause of a data type declaration • When deriving the instance for (Has DbConfig AppConfig) | 37 | } deriving (Generic, Has DbConfig) | ^^^^^^^^^^^^
يبدو أن كل شيء جيد حقًا.
لتلخيص
لذلك ، سنحاول صياغة الطريقة المقترحة لفترة وجيزة.
افترض أن لدينا نوعًا من أنواع typklass ، ونريد عرض مثيلاته تلقائيًا وفقًا لبعض القواعد العودية. بعد ذلك ، يمكننا تجنب الغموض (والتعبير عمومًا عن هذه القواعد إذا كانت غير بديهية ولا تنسجم مع الآلية القياسية لحل الحالات) على النحو التالي:
- نقوم بتشفير القواعد العودية في شكل نوع بيانات استقرائي
T
- سنقوم بكتابة وظيفة على أنواع (في شكل عائلة من النوع) لحساب أولي للقيمة
v
هذا النوع T
(أو ، من حيث Haskell ، اكتب v
النوع T
- أين أنواعي التابعة) ، والتي تصف التسلسل المحدد للخطوات التي يجب اتخاذها. - استخدم هذه
v
كوسيطة إضافية إلى مساعد Generic
لتحديد التسلسل المحدد للحالات التي تتطابق الآن مع قيم v
.
حسنا ، هذا كل شيء!
في الوظائف التالية ، سننظر في بعض الامتدادات الأنيقة (وكذلك القيود الأنيقة) لهذا النهج.
نعم و نعم من المثير للاهتمام تتبع تسلسل تعميماتنا.
- بدأت مع
Env -> Foo
. - ليست عامة بما فيه الكفاية ، اختتم في
Reader Env
monad. - ليس عام بما فيه الكفاية ، إعادة كتابة مع
MonadReader Env m
. - ليست عامة بما فيه الكفاية ،
MonadReader rm, HasEnv r
كتابة MonadReader rm, HasEnv r
. - ليس بالقدر الكافي ،
MonadReader rm, Has Env r
نكتب MonadReader rm, Has Env r
ونضيف الأدوية العامة بحيث يقوم المترجم MonadReader rm, Has Env r
كل شيء هناك. - الآن القاعدة.