or are they untagged unions? I’m mainly trying to relate to principles in other programming languages. I couldn’t find anything in the docs. Thanks.
I’m not sure what your question is aiming at, why is this relevant to you?
For some simple code like
struct A
@a : Int64 = 1
@b : Int64 = 2
end
a = "foo".as(String|Int32)
b = "foo".as(String|Int64)
c = "foo".as(String?)
d = 1.as(Int32?)
e = "foo".as(String|A)
The types in the LLVM IR are:
%"(Int32 | String)" = type { i32, [1 x i64] }
%"(Int64 | String)" = type { i32, [1 x i64] }
%"(Int32 | Nil)" = type { i32, [1 x i64] }
%"(A | String)" = type { i32, [2 x i64] }
As you can see the union type is represented with a tag and a field big enough for the actual value. Except for unions between a reference type and Nil
, there the nil
value is simply represented as a null pointer.
I was trying to find out the best way in crystal to model the enum ip address data type example from rust. https://doc.rust-lang.org/book/ch06-01-defining-an-enum.html. Given rust’s enums are basically sum types.
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
perhaps there is a better place/category to talk about this. But I am posting ways I would model it in crystal for completeness. Personally I wouldn’t mind hearing other people’s thoughts on this.
- using static duck typing
struct V4
getter :octet1
def initialize(
@octet1 : Int32,
@octet2 : Int32,
@octet3 : Int32,
@octet4 : Int32)
end
end
struct V6
def initialize(@str : String)
end
end
alias Ip = V4 | V6
home = V4.new(127,0,0,1)
loopback = V6.new("::1")
- using module as interface
module Ip
end
class V4
include Ip
getter :octet1
def initialize(
@octet1 : Int32,
@octet2 : Int32,
@octet3 : Int32,
@octet4 : Int32)
end
end
class V6
include Ip
def initialize(@str : String)
end
end
home = V4.new(127,0,0,1)
loopback = V6.new("::1")
- using an abstract class
abstract class Ip
end
class V4 < Ip
getter :octet1
def initialize(
@octet1 : Int32,
@octet2 : Int32,
@octet3 : Int32,
@octet4 : Int32)
end
end
class V6 < Ip
def initialize(@str : String)
end
end
home = V4.new(127,0,0,1)
loopback = V6.new("::1")
all three ways we can use the case
to match the type
def show_ip(ip)
case ip
when V4 then "ip v4 #{ip.octet1}"
when V6 then "ip v6"
end
end
Personally I would argue the aliased union, so your number 1, is the most idiomatic solution. But then I never understood why we added abstract classes…
My arguments for that would have nothing to do with the internal data presentation though. Crystal should be viewed as a high level language where you only care about these details when optimizing an identified performance bottleneck in your application. I would simply argue it makes the best use of the language features.
I’m sure it’s just an example, but just for the benefit of anybody stumbling on this in the future, of course stdlib sports a datatype for IP addresses: https://crystal-lang.org/api/0.34.0/Socket/IPAddress.html
@epoch Only alias
is the equivalent to Rust because in the next version apparently you will be able to do:
case ip
in V4
in V6
end
and if you forget one of the cases you will get a compile error. Using classes or modules you will not get an error. And in Rust you get an error, so only alias
is equivalent.
Ideally you would be able to use classes or modules with a sealed annotation but it seems not many like the idea so maybe it won’t happen. It would be nice if defining a closed group of types would be orthogonal to being able to check they are all covered in a case, like you can do in Scala and Kotlin, but not in Crystal.
alias union
is also my favourite usually but I believe all three are exhaustive.
on version 0.34.0 I am getting a warning on all three which I should be getting a compile error on the next version?
163 | case ip
^
Warning: case is not exhaustive.
Missing types:
- V6
A total of 1 warnings were found.
Try this:
module Ip
end
class V4
include Ip
end
class V6
include Ip
end
class IpHolder
getter ip
def initialize(@ip : Ip)
end
end
holder = IpHolder.new(V4.new)
case holder.ip
when V4
when V6
end
You get a warning where “Ip” is in the missing types.
Try now this:
class Ip
end
class V4 < Ip
end
class V6 < Ip
end
class IpHolder
getter ip
def initialize(@ip : Ip)
end
end
holder = IpHolder.new(V4.new)
case holder.ip
when V4
when V6
end
Same warning.
If you do this:
ip = V4.new || V6.new
case ip
...
end
it’s not the same because apparently the compiler types it as V4 | V6
, not a Ip
. You need to have an Ip
to verify it.
alias
is the only equivalent way to how it’s done in Rust.
The semantics are changing in 0.35.0
.
case value
when x then ...
end
Will now act like it did before 0.34.0
. Exhaustive case is now opt in via:
case value
in x then ...
in y then ...
end
Totally agree with you here Hank. I learned it the hard way thinking modules interfaces work like in golang (structural typing matching the shape) which sort of works in crystal for methods if you don’t annotate the type but unfortunately does not work for instance variables when you initialize.
Thank you for the link to sealed annotation. Keen to see where it goes in the future but It seems alias
is the way to go for now.
@epoch unions in crystal are not tagged. They are implicit.
In Haskell you have Maybe a = Just a | Nothing
, in crystal is T | Nil
directly. So there is no need to tag a value with a Just
.
But, since they are implicit, there are ways to code in a more tagged/explicit fashion if you like. But then you might need to fight at some point with method restrictions. A Just a
is not an a
.