In Ruby, everything is a class (well, almost). Want to represent a point? Class. A coordinate? Class. A color? Class. Ruby doesn't give you a choice.
Crystal says: "What if small, simple data could be stored differently - faster and more memory-efficient?" Enter structs.
Structs are value types (passed by value), while classes are reference types (passed by reference). This distinction doesn't exist in Ruby, and it fundamentally changes how you write certain kinds of code.
Classes work mostly like Ruby:
class Person
property name : String
property age : Int32
def initialize(@name, @age)
end
def greet
"Hi, I'm #{@name}"
end
end
alice = Person.new("Alice", 30)
bob = alice # bob points to the same object as alice
bob.name = "Bob"
puts alice.name # => "Bob" (same object!)Classes are reference types:
- Creating an instance allocates heap memory
- Variables hold references (pointers) to the object
- Assigning one variable to another copies the reference
- Both variables point to the same object
- Changes through one variable affect the other
Structs look similar but behave differently:
struct Point
property x : Int32
property y : Int32
def initialize(@x, @y)
end
def distance_from_origin
Math.sqrt(x**2 + y**2)
end
end
p1 = Point.new(3, 4)
p2 = p1 # p2 is a COPY of p1
p2.x = 10
puts p1.x # => 3 (different object!)
puts p2.x # => 10Structs are value types:
- Stored directly (often on the stack)
- Variables hold the actual value, not a reference
- Assigning one variable to another copies the entire value
- Each variable has its own independent copy
- Changes to one don't affect the other
class Box
property value : Int32
def initialize(@value)
end
end
box1 = Box.new(42)
box2 = box1 # Both reference same object
box2.value = 100
puts box1.value # => 100 (same object)
puts box2.value # => 100struct Box
property value : Int32
def initialize(@value)
end
end
box1 = Box.new(42)
box2 = box1 # box2 is a copy
box2.value = 100
puts box1.value # => 42 (different object)
puts box2.value # => 100Use structs for:
struct Color
getter red : UInt8
getter green : UInt8
getter blue : UInt8
def initialize(@red, @green, @blue)
end
def to_hex : String
"#%02x%02x%02x" % {red, green, blue}
end
end
color = Color.new(255, 0, 0)
puts color.to_hex # => "#ff0000"Perfect for RGB colors, coordinates, dimensions, etc.
struct Vector3
property x : Float64
property y : Float64
property z : Float64
def initialize(@x, @y, @z)
end
def +(other : Vector3) : Vector3
Vector3.new(x + other.x, y + other.y, z + other.z)
end
def magnitude : Float64
Math.sqrt(x**2 + y**2 + z**2)
end
end
v1 = Vector3.new(1.0, 2.0, 3.0)
v2 = Vector3.new(4.0, 5.0, 6.0)
v3 = v1 + v2
puts v3.magnitude# Struct: fast, stack-allocated
struct Position
property row : Int32
property col : Int32
def initialize(@row, @col)
end
end
# Processing millions of positions? Struct is faster
positions = Array.new(1_000_000) { Position.new(rand(100), rand(100)) }Structs map directly to C structs, making them essential for FFI.
Use classes for:
class User
property name : String
property email : String
def initialize(@name, @email)
end
end
# Each user is a unique entity with identity
user1 = User.new("Alice", "alice@example.com")
user2 = User.new("Alice", "alice@example.com")
# Same data, but different objects
puts user1.object_id != user2.object_id # => trueclass Counter
property count : Int32
def initialize(@count = 0)
end
def increment
@count += 1
end
end
counter = Counter.new
counter.increment
counter.increment
puts counter.count # => 2With a struct, this would be awkward because you'd need to reassign:
struct Counter # Don't do this!
property count : Int32
def initialize(@count = 0)
end
def increment
@count += 1 # Changes the copy!
end
end
counter = Counter.new
counter.increment # Changes counter... but you need to reassign
counter = counter # Wait, what?class Database
def initialize(@connection_string : String)
@connected = false
end
def connect
# Complex connection logic
@connected = true
end
def query(sql : String)
raise "Not connected" unless @connected
# Execute query
end
endabstract class Animal
abstract def speak : String
end
class Dog < Animal
def speak : String
"Woof!"
end
end
class Cat < Animal
def speak : String
"Meow!"
end
endStructs cannot inherit! Only classes can be part of inheritance hierarchies.
# Struct: often stack-allocated (very fast)
struct Point
property x : Int32
property y : Int32
def initialize(@x, @y); end
end
# Class: heap-allocated (slower, but flexible)
class Point
property x : Int32
property y : Int32
def initialize(@x, @y); end
endStack allocation is faster because:
- No garbage collector involvement
- Better cache locality
- No allocation overhead
# Struct: copied on assignment
struct LargeStruct
property data : StaticArray(Int32, 1000)
# 4000 bytes copied on each assignment!
end
# Class: only reference copied
class LargeClass
property data : Array(Int32)
# Only 8 bytes (pointer) copied on assignment
endRule of thumb: Keep structs small. If your struct is more than 16-32 bytes, consider using a class.
struct Point
property x : Int32
property y : Int32
def initialize(@x, @y); end
end
def move_point(point : Point)
point.x += 10 # Modifies the copy
end
p = Point.new(5, 5)
move_point(p)
puts p.x # => 5 (original unchanged)To modify the original, pass by reference:
def move_point(point : Point*)
point.value.x += 10 # Modifies through pointer
end
p = Point.new(5, 5)
move_point(pointerof(p))
puts p.x # => 15 (original changed)(Pointers are advanced - you usually won't need them unless doing C interop)
class Box
property value : Int32
def initialize(@value); end
end
def modify_box(box : Box)
box.value = 100 # Modifies the original
end
b = Box.new(42)
modify_box(b)
puts b.value # => 100 (original changed)struct Point
property x : Int32
property y : Int32
def initialize(@x, @y); end
end
p1 = Point.new(3, 4)
p2 = Point.new(3, 4)
puts p1 == p2 # => true (same values)Structs automatically get == based on their fields.
class Point
property x : Int32
property y : Int32
def initialize(@x, @y); end
end
p1 = Point.new(3, 4)
p2 = Point.new(3, 4)
puts p1 == p2 # => false (different objects)
# Need to define == for value equality
class Point
def ==(other : Point)
x == other.x && y == other.y
end
end
p1 = Point.new(3, 4)
p2 = Point.new(3, 4)
puts p1 == p2 # => true (custom equality)# Value type: position is just data
struct Position
property x : Float64
property y : Float64
def initialize(@x, @y)
end
def distance_to(other : Position) : Float64
dx = x - other.x
dy = y - other.y
Math.sqrt(dx**2 + dy**2)
end
end
# Reference type: entity has identity and state
class Entity
property position : Position
property health : Int32
property name : String
def initialize(@name, @position, @health = 100)
end
def move_to(new_position : Position)
@position = new_position
end
def take_damage(amount : Int32)
@health -= amount
end
def alive? : Bool
@health > 0
end
end
# Usage
player = Entity.new("Player", Position.new(0.0, 0.0))
enemy = Entity.new("Enemy", Position.new(10.0, 10.0))
# Position is a struct: cheap to copy and compare
distance = player.position.distance_to(enemy.position)
puts "Distance: #{distance}"
# Entity is a class: maintains identity
player.move_to(Position.new(5.0, 5.0))
player.take_damage(20)
puts "Player health: #{player.health}"Notice:
Positionis a struct: small, immutable-ish, just dataEntityis a class: has identity, mutable state, behavior
Sometimes you start with one and realize you need the other:
# Started as struct
struct Point
property x : Int32
property y : Int32
def initialize(@x, @y); end
end
# Realize you need reference semantics
class Point
property x : Int32
property y : Int32
def initialize(@x, @y); end
endJust change struct to class. The syntax is the same, but the behavior changes.
struct Container
property items : Array(String)
def initialize(@items)
end
end
c1 = Container.new(["apple"])
c2 = c1 # Copy the struct
c2.items << "banana"
puts c1.items # => ["apple", "banana"] (WAT?)The struct was copied, but the Array is a class (reference type), so both structs point to the same array!
Lesson: Structs with reference-type fields still share those references.
struct HugeStruct
property data1 : StaticArray(Int32, 1000)
property data2 : StaticArray(Int32, 1000)
property data3 : StaticArray(Int32, 1000)
# 12,000 bytes!
end
def process(hs : HugeStruct) # Copies 12KB on each call!
# ...
endLesson: Keep structs small or use classes for large data.
struct Mutable
property value : Int32
def initialize(@value); end
end
arr = [Mutable.new(1), Mutable.new(2)]
arr[0].value = 100 # Modifies a copy!
puts arr[0].value # => 1 (unchanged)
# To modify in array:
item = arr[0]
item.value = 100
arr[0] = item # Must reassignLesson: Modifying structs in collections requires reassignment.
Use a struct when:
- Small data (< 32 bytes)
- Immutable or rarely changes
- Value semantics make sense
- Performance critical
- No inheritance needed
- Pure data, minimal behavior
Use a class when:
- Needs identity
- Mutable state
- Inheritance required
- Larger than 32 bytes
- Complex behavior
- Traditional OOP patterns
When in doubt, use a class. You can always change to a struct later if profiling shows it's worth it.
- Implement a
Rectanglestruct with width and height, and methods for area and perimeter - Create a
Playerclass that has aPositionstruct property - Write a benchmark comparing struct vs class for 1 million allocations
- Identify which should be structs vs classes: Email, ShoppingCart, Coordinate, UserSession, RGB
- Structs are value types (passed by value), classes are reference types (passed by reference)
- Structs are copied on assignment, classes share references
- Structs are great for small, data-focused types
- Classes are better for objects with identity and mutable state
- Structs can't inherit, classes can
- Structs get automatic value-based equality
- Keep structs small for best performance
You now understand the fundamental types Crystal offers. Next, we'll explore concurrency - one of Crystal's killer features. Crystal's concurrency model uses fibers and channels to make concurrent programming approachable and safe. Get ready to write code that does multiple things at once without the usual thread-related headaches.
Continue to Chapter 06 - Concurrency: Fibers, Channels, and Why You'll Sleep Better →