Converting a Ruby Class to Crystal

If you’re a Ruby developer, converting classes is a place where the differences between the languages are especially visible. Walking through the transition from Ruby to Crystal with explanations of the error messages along the way can make it easier to see how Crystal’s use of types changes the story. (If you’re not a Ruby developer, feel free to skip to the next section.)

Here’s a simple class in Ruby:

 class​ Mineral
  attr_reader ​:name​, ​:hardness​, ​:crystal_struct
 
 def​ ​initialize​(name, hardness, crystal_struct)
  @name = name
  @hardness = hardness
  @crystal_struct = crystal_struct
 end
 end
 
 def​ ​mineral_with_crystal_struct​(crstruct, minerals)
  minerals.​find​ { |m| m.​crystal_struct​ == crstruct }
 end
 
 def​ ​longest_name​(minerals)
  minerals.​map​ { |m| m.​name​ }.​max_by​ { |name| name.​size​ }
 end

Now let’s add some test data and see if it works:

 minerals = [
  Mineral.​new​(​"gold"​, 1, ​'cubic'​),
  Mineral.​new​(​"topaz"​, 8, ​'orthorombic'​),
  Mineral.​new​(​"apatite"​, 5, ​'hexagonal'​),
  Mineral.​new​(​"wolframite"​, 4.5, ​'monoclinic'​),
  Mineral.​new​(​"calcite"​, 3, ​'trigonal'​),
  Mineral.​new​(​"diamond"​, 10, ​'cubic'​),
 ]
 
 min = mineral_with_crystal_struct(​'hexagonal'​, minerals)
 puts ​"​​#{​min.​crystal_struct​​}​​ - ​​#{​min.​name​​}​​ - ​​#{​min.​hardness​​}​​"
 # => hexagonal - apatite - 5
 
 puts longest_name(minerals)
 # => wolframite

Running this program in a terminal with $ ruby mineral.rb shows the following:

 apatite - hexagonal - 5
 wolframite

Everything seems okay, but what if no mineral with the specified crystal structure exists in your array?

 # Runtime error:
 min = mineral_with_crystal_struct(​'triclinic'​, minerals)
 puts ​"​​#{​min.​crystal_struct​​}​​ - ​​#{​min.​name​​}​​ - ​​#{​min.​hardness​​}​​"
 # 3.5_mineral.rb:39:in `<main>': undefined method 'crystal_struct'
 # for nil:NilClass (NoMethodError)

Ruby throws a runtime error at you because find returns nil when it can’t find a match. Forgetting to check for a nil return value isn’t always as obvious as it is here.

Crystal would prefer to spare you those problems. While converting this Ruby code to Crystal, you’ll run into a lot of errors. Don’t worry—it’s all for the good, and you’ll learn to appreciate Crystal’s character!

Let’s now look at how Crystal deals with this: Save your Ruby code in mineral.cr and build it with $ crystal mineral.cr.

Crystal syntax isn’t quite Ruby syntax, so you’ll run into an error immediately:

<= Syntax error in mineral.cr:20: unterminated char literal,
 use double quotes for strings
 
 Mineral.new("gold", 1, 'cubic'),
  ^

Ruby allows both single and double quotes for strings, but Crystal does not! You’ll need to replace all single-quoted strings with double-quoted ones and then compile again:

An error reports another difference:

 Error in mineral.cr:2: undefined method 'attr_reader'
 
 attr_reader :name, :hardness, :crystal_struct
 ^~~~~~~~~~~

Crystal uses the getter keyword (in fact, it’s a macro, see DRY Your Code with Macros ) instead of attr_reader, setter instead of attr_writer, and property instead of attr_accessor. (There are a few other superficial differences between Ruby and Crystal, but not that many. For an overview, see Appendix 2, Porting Ruby Code to Crystal.)

You can use the name for the property. It doesn’t need to be a symbol. Replacing attr_reader with getter and compiling for the third time yields yet another error, which now points us to an essential difference with Ruby. This message is verbose. Don’t worry—we’ll only show this once:

<= 
 Error in mineral.cr:20:
 instantiating 'Mineral:Class#new(String, Int32, String)'
 Mineral.new("gold", 1, "cubic"),
  ^~~
 in mineral.cr:5:
 Can't infer the type of instance variable '@name' of Mineral
 
 The type of a instance variable, if not declared explicitly with
 `@name : Type`, is inferred from assignments to it across
 the whole program.
 
 The assignments must look like this:
 1. `@name = 1` (or other literals), inferred to the literal's type
 2. `@name = Type.new`, type is inferred to be Type
 3. `@name = Type.method`, where `method` has a return type
  annotation, type is inferred from it
 4. `@name = arg`, with 'arg' being a method argument with a
  type restriction 'Type', type is inferred to be Type
 5. `@name = arg`, with 'arg' being a method argument with a
  default value, type is inferred using rules 1, 2 and 3 from it
 6. `@name = uninitialized Type`, type is inferred to be Type
 7. `@name = LibSome.func`, and `LibSome` is a `lib`, type
  is inferred from that fun.
 8. `LibSome.func(out @name)`, and `LibSome` is a `lib`, type
  is inferred from that fun argument.
 
 Other assignments have no effect on its type.
 Can't infer the type of instance variable '@name' of Mineral
 @name = name
 ^~~~~

Here, Crystal clearly can’t figure out the type of the instance variable @name, and it wants us to specify it. We specified @name = name, and that’s not enough. You can fix this by declaring the type of @name as follows: getter name : String. You should do it for the other properties as well:

 getter name : String
 getter hardness : Int32
 getter crystal_struct : String

Yet another compile-time error emerges:

<= Error in mineral.cr:25: instantiating
 'Mineral:Class#new(String, Float64, String)'
 Mineral.new("wolframite", 4.5, "monoclinic"),
  ^~~
 in mineral.cr:8: instance variable '@hardness' of Mineral
 must be Int32, not Float64
 @hardness = hardness
 ^~~~~~~~~

Okay, wolframite has a hardness of 4.5, and this isn’t an integer. You can replace the @hardness declaration with: getter hardness : Float64, but then the compiler complains that the hardness for the other minerals is still Int32. Better convert the hardness data to floating point numbers.

Another compile run shows us a new error at the “hexagonal” test-data:

<= Error in mineral.cr:31: undefined method 'crystal_struct'
 for Nil (compile-time type is (Mineral | Nil))
 puts "#{min.crystal_struct} - #{min.name} - #{min.hardness}"
  ^~~~
 Rerun with --error-trace to show a complete error trace.

This will remind you of the nil runtime error you got with Ruby. Crystal gives you more in-depth info when you compile with the following:

$ crystal mineral.cr --error-trace

namely, a so-called Nil trace:

 Nil trace:
  mineral.cr:30
 min = mineral_with_crystal_struct("hexagonal", minerals)
 ^~~
  mineral.cr:30
 min = mineral_with_crystal_struct("hexagonal", minerals)
  ^~~~~~~~~~~~~~~~~~~~~~~~~~~
  mineral.cr:13
 def mineral_with_crystal_struct(crstruct, minerals)
  ^~~~~~~~~~~~~~~~~~~~~~~~~~~
  mineral.cr:14
  minerals.find { |m| m.crystal_struct == crstruct }
  ^~~~
  /opt/crystal/src/enumerable.cr:409
  def find(if_none = nil)
  /opt/crystal/src/enumerable.cr:410
  each do |elem|
  ^
  /opt/crystal/src/enumerable.cr:413
  if_none
  ^~~~~~~
  /opt/crystal/src/enumerable.cr:409
  def find(if_none = nil)
  ^

A Nil trace backtracks through the code, starting from the undefined method indication to where the offending type came up. This occurred in the find method in enumerable.cr:

 def​ ​find​(if_none = ​nil​)

This code shows that find returns nil as a default value for the case where nothing is found.

Crystal Is Written in Crystal

images/aside-icons/tip.png

By the way, the Crystal code standard library is entirely implemented in Crystal itself. For example, you can look at how Crystal codes all of the Enumerable methods if you’d like to at https://github.com/crystal-lang/crystal/blob/master/src/enumerable.cr. Go ahead, I’ll wait here.

The compiler signals a possible occurrence of a null-reference exception but without running the code. This avoids complaints from customers about creepy errors, at least this kind of error. As discussed earlier in No to the Billion-Dollar Mistake, this is one of the highlights of Crystal. The error message also says that the compile-time type is Mineral | Nil.

This is a union-type (see Using Arrays): normally the variable min references a Mineral object, but if you have no mineral of that specific crystal structure in your data collection, min is nil. Crystal checks whether every method called on min is available for all the types in the union type. If not, bingo: an error.

You can fix this in the same way that you would in Ruby:

 if​ min
  puts ​"​​#{​min.​crystal_struct​​}​​ - ​​#{​min.​name​​}​​ - ​​#{​min.​hardness​​}​​"
 else
  puts ​"No mineral found with this crystal structure!"
 end

If this looks strange to you, revisit our discussion on falsy and truthy at Controlling the Flow. When min is nil, this amounts to false, and the first branch of the if statement isn’t executed. So inside the first branch, we know that the type of min is Mineral and not Nil. In the else branch, it’s Nil and not Mineral.

At last, everything works and you get the same output as Ruby did:

 apatite - hexagonal - 5
 wolframite

Finally, let’s introduce a nice shorthand code syntax (borrowed from CoffeeScript). Instead of:

 def​ ​initialize​(name, hardness, crystal_struct)
  @name = name
  @hardness = hardness
  @crystal_struct = crystal_struct
 end

you can write:

 def​ ​initialize​(@name, @hardness, @crystal_struct)
 end

Instance variables get their value directly from creating the object, @name becomes “gold,” @hardness gets value 1.0, and so on:

 Mineral.​new​(​"gold"​, 1.0, ​"cubic"​)

You could also type the properties in the initialize method instead of in the getter clause, like this:

 def​ ​initialize​(@name : String, @hardness : Float64, @crystal_struct : String)

That wasn’t so bad, was it?

Here’s the complete Crystal code for our program:

 class​ Mineral
  getter name : String
  getter hardness : Float64
  getter crystal_struct : String
 
 def​ ​initialize​(@name, @hardness, @crystal_struct)
 end
 end
 
 def​ ​mineral_with_crystal_struct​(crstruct, minerals)
  minerals.​find​ { |m| m.​crystal_struct​ == crstruct }
 end
 
 def​ ​longest_name​(minerals)
  minerals.​map​ { |m| m.​name​ }.​max_by​ { |name| name.​size​ }
 end
 
 minerals = [
  Mineral.​new​(​"gold"​, 1.0, ​"cubic"​),
  Mineral.​new​(​"topaz"​, 8.0, ​"orthorombic"​),
  Mineral.​new​(​"apatite"​, 5.0, ​"hexagonal"​),
  Mineral.​new​(​"wolframite"​, 4.5, ​"monoclinic"​),
  Mineral.​new​(​"calcite"​, 3.0, ​"trigonal"​),
  Mineral.​new​(​"diamond"​, 10.0, ​"cubic"​),
 ]
 
 min = mineral_with_crystal_struct(​"hexagonal"​, minerals)
 if​ min
  puts ​"​​#{​min.​crystal_struct​​}​​ - ​​#{​min.​name​​}​​ - ​​#{​min.​hardness​​}​​"
 else
  puts ​"No mineral found with this crystal structure!"
 end
 # => hexagonal - apatite - 5
 min = mineral_with_crystal_struct(​"triclinic"​, minerals)
 if​ min
  puts ​"​​#{​min.​crystal_struct​​}​​ - ​​#{​min.​name​​}​​ - ​​#{​min.​hardness​​}​​"
 else
  puts ​"No mineral found with this crystal structure!"
 end
 # => "No mineral found with this crystal structure!"
 
 puts longest_name(minerals)
 # => wolframite

Crystal guides you firmly and expects a higher level of quality and thoroughness in your code than Ruby does. When you use Ruby, you have to rely more on your test suite’s completeness in order to detect and avoid that kind of mistake.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.202.27