يسمح Crystal بأن يتكوّن المتغير من عدة أنواع. ويُسمّى هذا النوع الاتحادي. وفي Crystal، من الشائع أن يستنتج المترجم النوع الاتحادي.
يبقى النوع الاتحادي نوعًا واحدًا في وقت التشغيل، حتى لو كان مكوّنًا من عدة أنواع.
هذا يعني أنه إذا كان النوع الاتحادي مبنيًّا من String وInt32، فلن يكون الاثنين في الوقت نفسه.
بل سيكون إما String أو Int32.
يُعرَّف النوع الاتحادي بفصل الأنواع بخط عمودي (|).
وكثيرًا ما تُوضع بين قوسين، لكن ذلك ليس مطلوبًا.
النوع الاتحادي الأكثر شيوعًا هو (T | Nil) حيث T نوع ما، وهو ما قد يظهر في الطرق التي يمكنها إرجاع Nil.
ويُعرف هذا أيضًا بأن المتغير قابل لأن يكون Nil.
foo : String = "Hello"
foo = nil # Error: type must be String, not (String | Nil)
foo : (String | Nil) = "Hello"
foo = nil
وهو لا يقتصر على نوعين فقط، بل يمكن أن يشمل ما تشاء من الأنواع.
foo : (String | Int32 | Nil | Float64) = "Hello"
foo = 1
foo = nil
foo = 1.0
typeof مقابل Object#class
هناك طريقتان لمعرفة نوع المتغير.
إما باستخدام typeof أو باستخدام Object#class.
والفرق أن typeof تُرجع نوع المتغير في وقت الترجمة، بينما تُرجع Object#class النوع في وقت التشغيل.
وهذا يعني أنك إن أردت معرفة ما إذا كان المتغير من نوع اتحادي، على سبيل المثال، فلن تستطيع Object#class إخبارك بذلك، لأنها تُرجع النوع في وقت التشغيل فقط، وهو نوع واحد.
foo = 0 == 0 ? "a" : 1
typeof(foo) # => (String | Int32)
foo.class # => String
بما أن النوع الاتحادي نوع واحد في وقت التشغيل، فإن جميع العمليات القياسية تعمل عليه. لكن عند ترجمة الكود سيحتاج المترجم إلى معرفة أي نوع هو. ولذلك يجب ترتيب الكود بحيث لا يمكن أن يكون إلا أحد الأنواع عندما تريد استخدام العمليات الخاصة بنوع معيّن.
foo : (String | Int32) = "Hello"
foo.downcase # Error: undefined method 'downcase' for (String | Int32)
لكن Crystal تملك طريقة خاصة للأنواع الاتحادية: طريقة is_a?، التي تأخذ نوعًا كوسيط وتُرجع قيمة منطقية.
وطريقة nil? اختصار لـ is_a?(Nil).
ووضع طريقة is_a? في تعبير تحكمي سيخبر المترجم أي نوع هو، وبذلك يضمن أنه من ذلك النوع.
وفي فرع else سيضمن أنه ليس من ذلك النوع.
foo : (String | Int32) = "Hello"
if foo.is_a?(String)
typeof(foo) # => String
foo.downcase # => "hello"
end
وطريقة is_a? هذه لا تقتصر على نوع واحد كوسيط، بل يمكن أن تأخذ نوعًا اتحاديًا أيضًا.
ويمكن دمجها مع && لعدة أنواع.
طريقة is_a? عند استخدامها مع تعبير تحكمي لا يمكن أن تكون متغير نسخة أو متغير صنف.
بل يجب إسنادها أولًا إلى متغير محلي.
من طرق تحويل النوع الاتحادي إلى نوع واحد أن تجعل دخول فرع ما ممكنًا فقط إذا كان النوع نوعًا محددًا.
وهناك أسلوب آخر هو استخدام طريقة as.
وهي تحوّل النوع الاتحادي إلى نوع واحد بإجراء فحص في وقت التشغيل.
وسيُطرح استثناء إذا لم يكن النوع هو النوع المتوقع.
foo : String | Int32 = "Hello"
foo.as(String).downcase # => "hello"
foo.as(Int32) # Error: can't cast String to Int32
هذا الأسلوب مخصّص فقط للحالات التي تكون فيها واثقًا أن النوع هو النوع المتوقع، أو عندما تريد طرح استثناء إن لم يكن كذلك.
استخدام هذا الأسلوب مع ترتيب غير سليم قد يؤدي إلى سلوك غير متوقع.
as? تعمل بشكل مشابه جدًا لـ as، لكنها ستُرجع nil بدلًا من طرح استثناء إذا لم يكن النوع هو النوع المتوقع.
وهذا يعني أنها ستُرجع نوعًا اتحاديًا من النوع المتوقع وNil.
foo : (String | Int32) = "Hello"
foo.as?(String).downcase # => "hello"
foo.as?(Int32) # => nil
Nil
القابل لأن يكون Nil يعني أن المتغير يمكن أن يكون نوعًا ما أو Nil.
ويمكن كتابة ذلك بالشكل (T | Nil).
ولكن بما أن الأنواع القابلة لأن تكون Nil شائعة نسبيًا، فهناك اختصار لها: T?.
# This:
foo : (String | Nil) = "Hello"
foo = nil
# Is the same as:
foo : String? = "Hello"
foo = nil
سيستنتج المترجم نوعًا اتحاديًا إذا لم يكن متأكدًا من النوع.
على سبيل المثال، في الكود التالي لن يعرف المترجم نوع foo لأنه قد يكون إما String أو Int32.
if true
foo = 1
else
foo = "Hello"
end
typeof(foo) # => (Int32 | String)
يحدث هذا الاستنتاج تلقائيًا، وهناك حالات أخرى يستنتج فيها المترجم، على سبيل المثال، أن طريقة ما تُرجع نوعًا اتحاديًا.
character = "Hello world"[10]?
typeof(character) # => (Char | Nil)