Variables and methods are powerful, but they need to be organized into larger structures to be very useful. As Crystal is a truly object-oriented language, classes are the main tool for doing that. Classes let you define combinations of methods and associated data, which you can then turn into objects with new. When you’ve built classes that work with each other, you can organize them into larger modules. Basic Crystal classes look much like Ruby classes, but Crystal makes some changes.
Classes group publicly visible methods and properties, and can have additional methods and variables inside of them to make things work smoothly. Class names start with an uppercase letter, but the rest of the name is typically lowercase or CamelCase, to contrast with variable and method names. When you create a class, you’ve also defined a new type. This extremely simple example shows defining an empty class, creating an object from it, and checking the type of the object.
| class Mineral |
| |
| end |
| mine = Mineral.new() |
| puts typeof(mine) # => Mineral |
It’s not much of an object, but it’s easy to add more. When you create a mineral, you should give it a common name and specify its hardness, something like mine = Mineral.new("talc", 1.0). (Hardness isn’t necessarily an integer, so this object shifted to floats.) In Crystal, as in Ruby, that means adding an initialize method that takes those arguments.
| class Mineral |
| def initialize(common_name : String, hardness : Float64) |
| @common_name = common_name |
| @hardness = hardness |
| end |
| end |
| mine = Mineral.new("talc", 1.0) |
| puts typeof(mine) # => Mineral |
Because the common_name and hardness arguments aren’t yet used beyond simple assignment, the compiler lacks the information it needs to determine their type, and will complain if you don’t specify them. @common_name and @hardness are instance variables specific to the object created here.
Crystal can also save you some typing on the initialize method. If you use the instance variable names, the ones prefixed with @, as the names of the arguments, Crystal just puts the arguments into the instance variables.
| class Mineral |
| def initialize(@common_name : String, @hardness : Float64) |
| end |
| end |
| mine = Mineral.new("talc", 1.0) |
| puts typeof(mine) # => Mineral |
But there’s currently no way to access those instance variables from outside of the object. If you ask for Mineral.common_name, for example, you’ll get undefined method ’common_name’ for Mineral.class. You could create a method called common_name= that returns the value of @common_name, but Crystal offers something easier: getters (and setters). To allow reading and manipulation of the common_name outside of the object, and reading of the hardness, you could write:
| class Mineral |
| getter common_name : String |
| setter common_name |
| getter hardness : Float64 |
| |
| def initialize(common_name, hardness) |
| @common_name = common_name |
| @hardness = hardness |
| end |
| end |
| mine = Mineral.new("talc", 1.0) |
| puts mine.common_name # => talc |
| mine.common_name="gold" |
| puts mine.common_name # => gold |
| puts mine.hardness # => 1.0 |
The compiler can find type declarations you specify anywhere in there, but near the top of the class is easy for humans to find, so this set is on the getter. This version lets you read and change the name of the mineral, and read the hardness. If you try to set the hardness, you’ll still get an undefined method name error because there is no setter. (If you’re a Rubyist, you might have noticed that getter is equivalent to Ruby’s attr_reader, setter is equivalent to Ruby’s attr_writer, and property is equivalent to attr_accessor. They’re just more concise.)
Unlike variables, methods in Crystal classes are visible outside the object by default. They look like the methods you defined earlier but need to be referenced through an object or from inside it. A simple object method would look like:
| class Mineral |
| getter common_name : String |
| setter common_name |
| getter hardness : Float64 |
| getter crystal_struct : String |
| |
| def initialize(@common_name, @hardness, @crystal_struct) |
| end |
| |
| def describe |
| "This is #{common_name} with a Mohs hardness of #{hardness} |
| and a structure of #{crystal_struct}." |
| end |
| end |
| mine = Mineral.new("talc", 1.0, "monoclinic") |
| puts mine.describe # => This is talc with a Mohs hardness of 1.0 |
| # => and a structure of monoclinic. |
To give describe something more to do, the example adds a crystal_struct variable. The describe method gathers the three variables of the object and presents them in a sentence. (It also demonstrates that strings can contain line breaks, something that can be useful or annoying depending on your context and preference.)
In Part II, Chapter 5, Using Classes and Structs, we’ll visit classes in depth and will discuss visibility, inheritance, and the class hierarchy.
➤ a. Suppose you want to be able to make Mineral objects for minerals for which you don’t (yet) know the crystal structure. How could you do this? (Hint: Use a union type for the property.)
➤ b. Add a to_s method that makes a String representation of a Mineral object, and use it in the output. (Hint: The current object is self.)
Modules group methods and classes that implement related functionality. For example, the module Random from the standard library contains methods for generating all sorts of random values. A class can include one or more modules—a so-called mixin. That way, the objects of the class can use the methods of the module. Let’s make a module called Hardness that contains a method, hardness, to return that value for a given mineral:
| module Hardness |
| def data |
| {"talc" => 1, "calcite" => 3, "apatite" => 5, "corundum" => 9} |
| end |
| |
| def hardness |
| data[self.name] |
| end |
| end |
In this example, our class Mineral now has only the name property, but it includes the module Hardness:
| class Mineral |
| include Hardness |
| getter name : String |
| |
| def initialize(@name) |
| end |
| end |
By including that module, you can invoke its methods on any Mineral object:
| min = Mineral.new("corundum") |
| min.hardness # => 9 |
A class can also extend a module, but then its methods are called on the class.