To demonstrate Structs and Records - lets assume we have a User class that can receive messages posted to it.

code/crystal/src/structs_n_records/user.cr
require "./message_record"

class User
  private getter name : String, email : String

  def initialize(@name, @email)
  end

  def to_s
    "#{name} <#{email}>"
  end

  def post_message(message : Message)
    puts message.to_s
  end
end

1. Structs - Introduction

I like to think of these as flexible immutable data types - with very few if any methods.

Structs (& Records) are basically classes, but: * they are immutable (once created the values don’t change) * they are passed by value NOT by REFERENCE (this means the data is COPIED)

Good uses include: * pass immutable data between Fibers * build Value Objects (like representing money which has a value and a currency).

NOTE:
  • Immutability makes Structs (& Records) safe to pass between Fibers via Channels.

  • Given structs are COPIED - very large structures may slow-down your code and use a lot of memory.

2. Structs

Structs are immutable classes focused on presenting / passing data.

2.1. Data Structs (no methods)

The simplest Data Struct contains a getter and an initialize method - as seen below:

struct Message
  getter sender, receiver, topic, text

  def initialize(@sender  : User, @text : String, @receiver : User? = nil, @topic : String? = nil)
    @text = text.strip
  end
end

Below is an example of how the struct is used.

code/crystal/src/structs_n_records/message_struct_simple.cr
require "./user"

struct Message
  getter sender, receiver, topic, text

  def initialize(@sender  : User, @text : String, @receiver : User? = nil, @topic : String? = nil)
    @text = text.strip
  end
end

user_1 = User.new(name: "user_1", email: "[email protected]")
user_2 = User.new(name: "user_2", email: "[email protected]")

mesg_1 = Message.new(sender: user_2, text: "Hi")
mesg_2 = Message.new(sender: user_1, text: "Hoi", topic: "Greet")
mesg_3 = Message.new(sender: user_1, text: "Hoi", topic: "Greet", receiver: user_2)

user_1.post_message(mesg_1)
user_2.post_message(mesg_2)
user_2.post_message(mesg_3)

# # UserUnpackPost: A User Decorator responsible to unpack User Messages Posted
# class UserUnpackPost
#   def initialize(@user : User)
#   end
#
#   def post_message(message : Message)
#     text     = message.text
#     topic    = message.topic
#     sender   = message.sender
#     receiver = message.receiver
#     output = [] of String
#     output << "-" * 30
#     output << "From: #{sender.to_s}"
#     output << "To: #{receiver.to_s}"       unless receiver.nil?
#     output << "Topic: #{topic.to_s.strip}" unless topic.nil?
#     output << text.strip
#     output << "-" * 30
#     puts output.join("\n")
#   end
# end
#
# UserUnpackPost.new(user_1).post_message(mesg_1)
# UserUnpackPost.new(user_2).post_message(mesg_2)
# UserUnpackPost.new(user_2).post_message(mesg_3)

Run this using:

$ crystal src/structs_n_records/message_struct_simple.cr

This works - but the messages print in a pretty unsatisfactory manner. It’s not great design if User class needs to know the internal structure of Message to print.

One solution would be use a Decorator/Wrapper class which knows the details of the Message internals. Uncomment the decorator code to see the effect (but ideally Message should not many ANY other part of code know its implementation details). The next section shows a much better approach.

2.2. Struct with Methods - (responsible for its own internal structure)

Data Structs work well for the purpose of passing immutable data as seen above, but it is also convenient if other consuming classes don’t need to know the internals of the struct.

To fix this structs can also define methods — they should be at a minimum and should not attempt to mutate the state. Below is an example that adds the to_s to allow the User class to easily consume / print the Message (in a much cleaner way where Message class knows how to present its data).

code/crystal/src/structs_n_records/message_struct.cr
require "./user"

struct Message
  getter sender, receiver, topic, text

  def initialize(@sender  : User, @text : String, @receiver : User? = nil, @topic : String? = nil)
  end

  def to_s
    output = [] of String
    output << "-" * 30
    output << "From: #{sender.to_s}"
    output << "To: #{receiver.to_s}"       unless receiver.nil?
    output << "Topic: #{topic.to_s.strip}" unless topic.nil?
    output << text.strip
    output << "-" * 30
    output.join("\n")
  end
end

user_1 = User.new(name: "user_1", email: "[email protected]")
user_2 = User.new(name: "user_2", email: "[email protected]")

mesg_1 = Message.new(sender: user_2, text: "Hi")
mesg_2 = Message.new(sender: user_1, text: "Hoi", topic: "Greet")
mesg_3 = Message.new(sender: user_1, text: "Hoi", topic: "Greet", receiver: user_2)

user_1.post_message(mesg_1)
user_2.post_message(mesg_2)
user_2.post_message(mesg_3)

Run this using:

$ crystal src/structs_n_records/message_struct.cr

In this way we can add (possibly remove) data and features from the Message all transparently to the User