Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Language Atlas (mini-courses for more languages)  Module 13 – Crystal for Ruby developers

Crystal macros, fibers and C bindings

Write Crystal macros that generate methods and read their expansion, pass values between fibers with channels and select, and call C through lib and fun.

  • Advanced
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Crystal 1.21.1
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Write a macro that generates methods while the program compiles, and read its expansion with crystal tool expand
  • Predict when fibers run and wait as they pass values through channels with and without room
  • Build a pool of fibers that shares work, and wait for the first answer with select and a timeout
  • Declare C functions with lib and fun, and say which strings and callbacks C cannot take

Before you start

What you’ll learn · Before you start

What you will learn

  • Write a macro that generates methods while the program compiles, and read its expansion with crystal tool expand
  • Predict when fibers run and wait as they pass values through channels with and without room
  • Build a pool of fibers that shares work, and wait for the first answer with select and a timeout
  • Declare C functions with lib and fun, and say which strings and callbacks C cannot take

Before you start

On this page

A macro of seven lines in units.cr writes three conversion methods while the program compiles, and crystal tool expand prints the code it wrote. Three fibers on one thread send three squares to the main fiber, and two of them never print their last line: concurrency in Crystal follows rules you can predict from the run.

This lesson covers the three things Crystal adds to a Ruby developer’s toolbox: macros that write code before the program runs, fibers and channels that share one thread, and lib blocks that call C functions directly, with the strings and the callbacks that C cannot take.

Macros write code while the program compiles

units.cr needs one conversion method per unit. Instead of typing three methods, it calls a macro (Crystal): Code that runs inside the compiler and writes Crystal code where it is called. Its arguments arrive as pieces of the program, {% %} runs loops and conditions, and {{ }} pastes values into the code it writes., define_units, which writes them:

units.cr: a macro that writes one method per unit CR · macros/units.cr
# A macro that writes one conversion method per unit while the program compiles.
macro define_units(base, **factors)
  {% for unit, factor in factors %}
    def self.{{unit.id}}_to_{{base.id}}(value : Float64) : Float64
      value * {{factor}}
    end
  {% end %}
end

module Length
  define_units metre, inch: 0.0254, foot: 0.3048, yard: 0.9144
end

puts Length.inch_to_metre(12.0)
puts Length.foot_to_metre(10.0)
puts Length.yard_to_metre(100.0)
p {{ Length.class.methods.map(&.name.stringify).sort }}

A macro runs inside the compiler. Its arguments arrive as pieces of the program, not as values: base is the name metre, and factors holds three named number literals. Code between {% and %} runs while compiling, so the loop runs three times, and {{ }} pastes a value into the code the macro writes. Step through the call:

Interactive · stage by stage What define_units does while units.cr compiles Step through the macro call, from the arguments the macro receives to the methods the program calls.
What define_units does while units.cr compiles, step 7 of 7. Showing define_units metre, …, Arguments as syntax, The {% for %} loop runs, {{ }} pastes values, Three methods exist, The program runs. In focus: The program runs.feet_to_metre: no methoddefine_units metre, …a call inside module LengthArguments as syntaxbase: metre, three factorsThe {% for %} loop runsonce per unit, while compiling{{ }} pastes valuesdef self.inch_to_metre …Three methods existchecked like code you wroteThe program runs10 feet: 3.048 metres

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. A macro call. module Length calls define_units with metre and three named arguments. The call looks like a method call, but it is handled while the program compiles, before any value exists.
  2. The arguments are syntax. The macro receives its arguments as pieces of the program: base is the name metre, and factors holds inch: 0.0254, foot: 0.3048 and yard: 0.9144 as number literals.
  3. The loop runs in the compiler. The {% for unit, factor in factors %} loop is macro code: it runs three times inside the compiler, once for each named argument, and writes one method each time.
  4. Values pasted into code. {{unit.id}} and {{base.id}} paste the names, {{factor}} the number. The first pass writes def self.inch_to_metre(value : Float64) : Float64 with value * 0.0254 as its body.
  5. Ordinary methods. crystal tool expand printed the three methods the macro wrote. From here the compiler checks them like any code: their types, their calls, their names.
  6. A name the macro did not write. typo.cr calls Length.feet_to_metre. The macro wrote foot_to_metre, so the compiler stops with undefined method and asks Did you mean foot_to_metre.
  7. The program runs. The program calls the methods as if they had been typed in: 12 inches gave 0.30479999999999996 metres, 10 feet 3.048 and 100 yards 91.44.

The methods run, the expansion printed, and two macro-made programs Python · macros_session.py
from session import run

# define_units writes three methods while units.cr compiles; tool expand shows the code it wrote; a name it did not
# write is a compile error.
run("""
cd macros
crystal run units.cr
crystal tool expand -c units.cr:11:3 units.cr
crystal run accessors.cr
!crystal run typo.cr
""")

Output

$ cd macros
$ crystal run units.cr
0.30479999999999996
3.048
91.44
["foot_to_metre", "inch_to_metre", "yard_to_metre"]
$ crystal tool expand -c units.cr:11:3 units.cr
1 expansion found
expansion 1:
   define_units(metre, inch: 0.0254, foot: 0.3048, yard: 0.9144)

# expand macro 'define_units' (units.cr:2:1)
~> def self.inch_to_metre(value : Float64) : Float64
     value * 0.0254
   end

   def self.foot_to_metre(value : Float64) : Float64
     value * 0.3048
   end

   def self.yard_to_metre(value : Float64) : Float64
     value * 0.9144
   end
$ crystal run accessors.cr
C7: 3 guests
["guests", "guests=", "initialize", "seat"]
$ crystal run typo.cr
Showing last frame. Use --error-trace for full trace.

In typo.cr:14:13

 14 | puts Length.feet_to_metre(10.0)
                  ^------------
Error: undefined method 'feet_to_metre' for Length:Module

Did you mean 'foot_to_metre'?
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 macros_session.py

crystal tool expand -c units.cr:11:3 names the line and column of the call and printed the three methods the macro wrote, exactly as if they had been typed. The last line of units.cr runs macro code too: {{ Length.class.methods.map(&.name.stringify).sort }} listed the class methods of Length while compiling, and the program printed that list. Ruby developers know the reflection the guide for Rubyists says Crystal leaves out, such as methods; this is where Crystal does it, at compile time.

You have used macros already. property and getter in accessors.cr are macros of the standard library: the compiler’s list of Booking’s methods shows guests and guests= for property guests, and seat alone for getter seat. And a name that no macro wrote does not exist: typo.cr calls feet_to_metre, the compiler stops with undefined method 'feet_to_metre', and it suggests the method the macro did write, foot_to_metre.

See what a macro wrote

Give crystal tool expand -c file.cr:line:column file.cr the line and column of a macro call. It is the quickest way to see why a generated method has the wrong name or type.

Checkpoint

Check: Macros that write methods while compiling

  1. Question 1 of 2 When does the {% for unit, factor in factors %} loop of define_units run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: While the program compiles, once per named argument, writing one method each time

    A macro takes its arguments as syntax and writes code while compiling. The program that runs contains the three methods the loop wrote, as if they had been typed.

  2. Question 2 of 2 You add rod: 5.0292 to the call define_units metre, …. Which new method can the program call?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Length.rod_to_metre

    The macro writes def self.{{unit.id}}_to_{{base.id}}, so every named argument becomes a method named unit_to_base. A name it did not write, such as feet_to_metre, is a compile error.

Fibers take turns on one thread, and channels hand values over

spawn starts a fiber: A lightweight unit of execution that Crystal's scheduler runs, started with spawn. Fibers take turns on a thread: one runs until it waits on a channel, input, output or a sleep, or until it ends., a lightweight unit of execution that Crystal’s scheduler runs. The concurrency guide states the rule that explains every output in this section: a program runs one fiber at a time by default, and fibers are cooperative, so a fiber keeps the thread until it waits, on a channel, input, output or a sleep, or ends. round_trip.cr sends three squares through a channel (Crystal): A typed queue that fibers send values through. Channel(Int32).new has no room, so send waits until a fiber receives; Channel(Int32).new(3) has room for three values, so send goes on until it is full. with no room in it, Channel(Int32).new:

round_trip.cr: three fibers, one channel without room CR · fibers/round_trip.cr
# Three fibers each send one square through an unbuffered channel; the main fiber receives three times.
squares = Channel(Int32).new

3.times do |i|
  spawn do
    puts "fiber #{i} sends #{i * i}"
    squares.send(i * i)
    puts "fiber #{i} is done"
  end
end

puts "main waits"
got = Array.new(3) { squares.receive }
puts "main got #{got}"
The round trip without room in the channel, then with room for three Python · fibers_session.py
from session import run

# Three fibers send one square each to the main fiber, through a channel without room and through one with room for
# three: the order of the lines shows when each fiber runs and when it waits.
run("""
cd fibers
crystal run round_trip.cr
crystal run round_trip_buffered.cr
""")

Output

$ cd fibers
$ crystal run round_trip.cr
main waits
fiber 0 sends 0
fiber 0 is done
fiber 1 sends 1
fiber 2 sends 4
main got [0, 1, 4]
$ crystal run round_trip_buffered.cr
main waits
fiber 0 sends 0
fiber 0 is done
fiber 1 sends 1
fiber 1 is done
fiber 2 sends 4
fiber 2 is done
main got [0, 1, 4]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fibers_session.py

The first run printed fiber 0 is done and never the same line for fibers 1 and 2. Step through it in the order of its lines:

Interactive · message by message Three squares through a channel without room Step through the run of round_trip.cr in the order its lines were printed; the channel holds no value of its own.
Three squares through a channel without room, step 7 of 7. Messages so far: fibers 0, 1, 2 to main fiber: 0; fibers 0, 1, 2 to main fiber: 1 and 4.main fibersquares channelcapacity 0fibers 0, 1, 201 and 4main ends the program

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Spawn three fibers. spawn puts each fiber in the queue of fibers ready to run. None of them runs yet: the main fiber keeps going.
  2. main waits. main prints main waits and calls receive. The channel holds nothing, so main blocks, and the scheduler runs the first fiber in the queue.
  3. Fiber 0 hands its value over. Fiber 0 prints fiber 0 sends 0 and calls send. main is already waiting, so the value goes straight to it and fiber 0 does not wait.
  4. Fiber 0 finishes. Fiber 0 goes on, prints fiber 0 is done and ends. A fiber gives up the thread only when it waits or ends.
  5. Fibers 1 and 2 wait. Fiber 1 prints fiber 1 sends 1, but main has not come back to receive yet, so send blocks. Fiber 2 prints fiber 2 sends 4 and blocks the same way.
  6. main takes 1 and 4. main runs again with 0 and calls receive twice more, taking 1 from fiber 1 and 4 from fiber 2.
  7. The program ends. main prints main got [0, 1, 4] and ends, and the program ends with it. Fibers 1 and 2 were ready to run again, but they never printed fiber 1 is done or fiber 2 is done.

A send on a channel without room waits until a fiber receives. Fiber 0 found main already waiting and went on; fibers 1 and 2 found nobody and waited. By the time main had taken their values it was the last thing to run, and a Crystal program ends when its main fiber does, whatever other fibers still have to do.

The second run made one change, Channel(Int32).new(3), a channel with room for three values. Now no send had to wait, so each fiber printed both of its lines before the next one started, and main found the 1 and the 4 waiting in the channel. The values were the same, [0, 1, 4]; the order of everything else changed with the capacity.

Key idea

On one thread a fiber runs until it waits or ends: a send on a channel without room waits for a receiver, a channel with room lets the sender go on, and the program ends when the main fiber does.

Checkpoint

Check: Fibers, channels and the order of a run

  1. Question 1 of 2 In the run of round_trip.cr, why did fiber 0 print fiber 0 is done while fibers 1 and 2 never did?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Fiber 0 found main waiting, so its send did not wait; 1 and 2 waited, and main ended the program first

    Fibers 1 and 2 were ready to run again once main had taken their values, but main ran first, printed its line and ended, and the program ended with it.

  2. Question 2 of 2 With Channel(Int32).new(3), every fiber printed both of its lines. Why did the capacity change that?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: With room for three values no send had to wait, so each fiber ran to its end before the next began

    The values were the same and everything around them changed. That is the kind of difference to look for when a concurrent program behaves differently after a change to a channel's capacity.

A pool of fibers shares the work, and select takes the first answer

pool.cr checks the status of six hosts with three worker fibers. The statuses come from a table in the program, so the run needs no network; a real checker would make a request where the table is read, and each wait for an answer would let another fiber run:

pool.cr: three workers between two channels CR · fibers/pool.cr
# A status checker: three worker fibers take hosts from one channel and send results to another.
# The statuses come from a table in the program, so the run needs no network.
STATUS = {
  "api.local"     => 200,
  "billing.local" => 503,
  "cdn.local"     => 200,
  "docs.local"    => 404,
  "mail.local"    => 200,
}

record Result, index : Int32, host : String, status : Int32?, worker : Int32

hosts = ["api.local", "billing.local", "cdn.local", "docs.local", "mail.local", "wiki.local"]
jobs = Channel({Int32, String}).new
results = Channel(Result).new

3.times do |worker|
  spawn do
    while job = jobs.receive?
      index, host = job
      results.send(Result.new(index, host, STATUS[host]?, worker))
    end
  end
end

spawn do
  hosts.each_with_index { |host, index| jobs.send({index, host}) }
  jobs.close
end

arrived = Array.new(hosts.size) { results.receive }
arrived.each { |r| puts "worker #{r.worker}: #{r.host}" }
puts "-- in input order --"
arrived.sort_by(&.index).each do |r|
  puts "#{r.host}: #{r.status || "unknown host"}"
end
A sender fiber feeds hosts into the jobs channel, three workers send results to the results channel, and main sorts them.Sender fiber6 hosts with their indexjobsChannel({Int32, String})Workers 0, 1, 2look up STATUS[host]?resultsChannel(Result)Main fiber6 receives, sort_by indexsend, then closereceive? until nilsend Resultreceive

The status checker: two channels between three kinds of fiber

Text description of the diagram

The diagram is read from top to bottom.

  1. A sender fiber sends each of the six hosts, with its index in the list, into the jobs channel, then closes the channel.
  2. jobs is a Channel of tuples, {Int32, String}: an index and a host name.
  3. Three worker fibers each call receive? on jobs until it returns nil, which happens once the channel is closed and empty. For each job they look the host up in the STATUS table, which gives nil for a host that is not in it.
  4. Each worker sends a Result, holding the index, the host, the status and the worker's number, into the results channel.
  5. The main fiber receives six results, in the order they arrive, and sorts them by index to print the report in the order of the input.

The sender fiber closes jobs after the last host. receive? gives nil once a channel is closed and empty, which ends each worker’s while loop, and the record Result carries the index of its host:

The pool's results as they arrived and in input order, then select Python · pool_session.py
from session import run

# A pool of three worker fibers over six hosts whose statuses come from a table in the program, and select with a
# timeout.
run("""
cd fibers
crystal run pool.cr
crystal run select_timeout.cr
""")

Output

$ cd fibers
$ crystal run pool.cr
worker 0: api.local
worker 0: docs.local
worker 1: billing.local
worker 2: cdn.local
worker 0: mail.local
worker 0: wiki.local
-- in input order --
api.local: 200
billing.local: 503
cdn.local: 200
docs.local: 404
mail.local: 200
wiki.local: unknown host
$ crystal run select_timeout.cr
cache answered
no answer within 200 ms

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pool_session.py

Worker 0 handled four of the six hosts. Each time it sent a result it went straight back to jobs, while workers 1 and 2 were still waiting to hand their results over: the order of arrival comes from the scheduler, not from the input, and with parallelism switched on it would change from run to run. That is why each result carries its index and main sorts by it: the report in input order is the same whoever did the work. wiki.local is not in the table, so STATUS[host]? gave nil and the report says unknown host.

select_timeout.cr waits on two channels and a timeout at once. In the first round the fiber that stands for the cache sent its answer while select waited, so select took it; in the second nothing came, and after 200 milliseconds the timeout branch ran. A checker uses the same pattern to give up on a host that does not answer.

Checkpoint

Check: A pool of fibers, and select with a timeout

  1. Question 1 of 2 Worker 0 handled four of the six hosts, in the order api, docs, mail, wiki. What does that mean for the report?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Results arrive in the scheduler's order, so the report sorts them by the index each result carries

    Never let a report depend on which worker finished first. Carry an index, or a key, with each job and its result, and order the results by it.

  2. Question 2 of 2 In the second round of select_timeout.cr, no channel had a value. What happened?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: After 200 milliseconds the timeout branch ran and printed no answer within 200 ms

    select with a timeout branch is how a fiber gives up on an answer that does not come, such as a host that does not reply.

lib and fun call C directly, with rules for strings and callbacks

A lib block: A Crystal declaration that names C functions with their C types, such as fun strlen(s : UInt8*) : LibC::SizeT inside lib LibText, so that the program can call them like class methods. declares C functions with their C types, and the program then calls them like class methods. The C library is linked into every program already, so these need no @[Link] annotation:

C functions called from Crystal

cbind/libc.cr

# C functions of the system library, declared in lib blocks and called from Crystal.
lib LibText
  fun strlen(s : UInt8*) : LibC::SizeT
  fun toupper(c : Int32) : Int32
end

lib LibMath
  fun cbrt(x : Float64) : Float64
end

puts LibText.strlen("chai")
puts LibText.toupper('q'.ord).chr
puts LibMath.cbrt(27.0)

hindi = "चाय"
puts "#{hindi}: size #{hindi.size}, bytesize #{hindi.bytesize}, strlen #{LibText.strlen(hindi)}"

order = "tea\0coffee"
puts "order: size #{order.size}, bytesize #{order.bytesize}, strlen #{LibText.strlen(order)}"
begin
  order.check_no_null_byte
rescue e : ArgumentError
  puts "check_no_null_byte: #{e.message}"
end

cbind/qsort.cr

# qsort from the C library sorts a Crystal array in place, calling back into a Crystal proc.
lib LibSort
  fun qsort(base : Void*, count : LibC::SizeT, size : LibC::SizeT, compare : (Void*, Void*) -> Int32)
end

marks = [72, 18, 95, 43, 60]
LibSort.qsort(marks.to_unsafe.as(Void*), marks.size, sizeof(Int32), ->(a : Void*, b : Void*) {
  a.as(Int32*).value <=> b.as(Int32*).value
})
p marks

cbind/closure.cr

# A proc that uses a local variable is a closure, and C cannot hold one.
lib LibSort
  fun qsort(base : Void*, count : LibC::SizeT, size : LibC::SizeT, compare : (Void*, Void*) -> Int32)
end

marks = [72, 18, 95, 43, 60]
descending = true
LibSort.qsort(marks.to_unsafe.as(Void*), marks.size, sizeof(Int32), ->(a : Void*, b : Void*) {
  order = a.as(Int32*).value <=> b.as(Int32*).value
  descending ? -order : order
})
p marks
strlen, toupper and cbrt, qsort with a callback, and a refused closure Python · cbind_session.py
from session import run

# C functions called from Crystal: strlen, toupper and cbrt, qsort with a Crystal callback, and a callback C refuses.
run("""
cd cbind
crystal run libc.cr
crystal run qsort.cr
!crystal run closure.cr
""")

Output

$ cd cbind
$ crystal run libc.cr
4
Q
3.0
चाय: size 3, bytesize 9, strlen 9
order: size 10, bytesize 10, strlen 3
check_no_null_byte: String contains null byte
$ crystal run qsort.cr
[18, 43, 60, 72, 95]
$ crystal run closure.cr
Showing last frame. Use --error-trace for full trace.

In closure.cr:8:69

 8 | LibSort.qsort(marks.to_unsafe.as(Void*), marks.size, sizeof(Int32), ->(a : Void*, b : Void*) {
                                                                         ^
Error: can't send closure to C function (closured vars: descending)
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cbind_session.py

The types are C’s, written the Crystal way. C’s char * is UInt8*, a pointer to bytes, and a String passed for it goes as a pointer to its bytes, through its to_unsafe. C counts bytes, not characters: चाय, tea in Hindi, has 3 characters and 9 bytes in UTF-8, and strlen said 9. It also stops at the first zero byte, so for "tea\0coffee" it said 3 where bytesize said 10. A C function given that string sees only tea; check_no_null_byte raises an ArgumentError for such a string, so call it before you pass text from outside the program to C.

qsort.cr sorts a Crystal array with the C library’s qsort, which calls a Crystal proc back to compare two elements. Step through the call:

Interactive · message by message qsort calling back into Crystal Step through one call of qsort from Crystal: what Crystal passes, how C calls the proc back, and the proc C cannot take.
qsort calling back into Crystal, step 5 of 5. Messages so far: Crystal program to qsort: pointer, 5, 4, proc; qsort to compare proc: a, b : Void*; compare proc to qsort: -1, 0 or 1; qsort to Crystal program: marks sorted.Crystal programqsortC librarycompare proca Crystal procpointer, 5, 4, proca, b : Void*-1, 0 or 1marks sortedclosured vars: descending

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Crystal calls qsort. qsort.cr passes four things: a pointer to the array's first Int32, marks.to_unsafe, the count 5, the size of one element, 4, and a proc that compares two elements.
  2. C calls the proc. qsort knows nothing about Int32: it hands the proc two Void* pointers into the array, and the proc reads the two values through them.
  3. The proc answers. The proc returns a.as(Int32*).value <=> b.as(Int32*).value: a negative number, 0 or a positive number. qsort swaps elements from those answers and calls it again.
  4. The array is sorted in place. qsort returns, and the Crystal array it was given is sorted: p marks printed [18, 43, 60, 72, 95].
  5. A proc C cannot take. closure.cr's proc reads descending, a local variable of the program, so it is a closure. A C function pointer has no room for that variable, and the compiler refuses: can't send closure to C function.

The proc in closure.cr reads descending, a local variable, so it is a closure: A function, such as a Crystal proc, that uses variables of the code around it and carries them along. A C function pointer has no room for those variables, so Crystal refuses to pass a closure to C., and C has no room for the variable it carries: the compiler said can't send closure to C function (closured vars: descending). The callbacks reference shows the way out for C functions that take a user-data pointer: pass a proc without a closure and send the data through that pointer instead.

Pointers switch Crystal's checks off

The Pointer reference calls it the only unsafe type in Crystal: a pointer does not know how much memory after it is valid, so reading past an array through one is never stopped. Keep pointer code in a small wrapper around the C call, check lengths there, and use a Slice, a pointer with a size and bounds checks, wherever Crystal code reads the memory.

Checkpoint

Check: C functions through lib and fun

  1. Question 1 of 2 What did strlen return for "tea\0coffee", whose bytesize is 10?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 3: strlen stops at the first zero byte

    A C function sees a Crystal string as bytes up to the first zero byte. For text from outside the program, call check_no_null_byte before passing it to C.

  2. Question 2 of 2 Why did the compiler refuse the proc in closure.cr?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It reads descending, a local variable, so it is a closure, and C cannot hold one

    A C function pointer is only a code address. Data for a callback goes through the user-data pointer that most C APIs offer, as the callbacks reference shows with Box.

Practise: macro output and C strings, in Python

Both steps of the lab rebuild something this lesson ran. The first writes the methods that define_units wrote, as text, and its sample tests compare your text with what crystal tool expand printed; the second works out what a C function sees of a Crystal string. Each step has starter code, sample tests and a first hint:

Code lab · 2 steps

Macro output and C strings, in Python

Write the methods define_units writes for any units, and the size, bytesize and strlen of a Crystal string, so that both match what the sessions printed.

  1. Write the methods define_units writes To do
  2. Work out what C sees of a Crystal string To do

Exercise · Easy · Python

Write the methods define_units writes

A macro is code that writes code. Write the same generator in Python: define_units(base, factors) in expand.py returns, as one string, the Crystal methods that the lesson's define_units macro writes.

  • base is the unit converted to, such as "metre"; factors is a list of (unit, factor) pairs, the factor as text, such as [("inch", "0.0254"), ("foot", "0.3048")].
  • Each pair gives three lines: def self.<unit>_to_<base>(value : Float64) : Float64, then the body value * <factor> indented by two spaces, then end.
  • The methods come in the order of factors, with one empty line between two methods and no line break after the last end. No pairs give an empty string.

The sample tests hold the three methods that crystal tool expand printed for metre and the lesson's three units, without the ~> marker and the indentation that the tool adds when it shows them.

Starter code · expand.py

def define_units(base, factors):
    """The Crystal methods that define_units writes for base and the (unit, factor) pairs."""
    code = ""
    for unit, factor in factors:
        code += f"def {unit}_to_{base}(value)\n  value * {factor}\nend\n"
    return code
The sample tests · test_expand.py
from expand import define_units

LENGTH = """def self.inch_to_metre(value : Float64) : Float64
  value * 0.0254
end

def self.foot_to_metre(value : Float64) : Float64
  value * 0.3048
end

def self.yard_to_metre(value : Float64) : Float64
  value * 0.9144
end"""


def test_the_lessons_three_units():
    """what crystal tool expand printed for units.cr"""
    factors = [("inch", "0.0254"), ("foot", "0.3048"), ("yard", "0.9144")]
    assert define_units("metre", factors) == LENGTH


def test_one_unit():
    """a single method, with no empty line after it"""
    assert define_units("gram", [("ounce", "28.349523125")]) == (
        "def self.ounce_to_gram(value : Float64) : Float64\n  value * 28.349523125\nend"
    )


def test_order_follows_the_factors():
    """the methods come in the order of the pairs"""
    code = define_units("litre", [("pint", "0.568261"), ("cup", "0.25")])
    assert code.index("pint_to_litre") < code.index("cup_to_litre")
    assert code.count("\n\n") == 1


def test_no_units():
    """no pairs, no methods"""
    assert define_units("metre", []) == ""
A hint

Build one string per method with an f-string of three lines joined by "\n", collect them in a list, and join the list with "\n\n", which puts exactly one empty line between two methods.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Easy · Python

Work out what C sees of a Crystal string

A Crystal string is UTF-8 text, and C reads it as bytes up to the first zero byte. Write two functions in lengths.py:

  • lengths(text): a tuple of the three numbers the lesson printed for a string: its size in characters, its bytesize in UTF-8 bytes, and what C's strlen returns for it, the number of bytes before the first zero byte.
  • safe_for_c(text): True when the string holds no zero byte, so that check_no_null_byte would let it through and a C function sees all of it.

The sample tests start with the lesson's strings: "chai", चाय and "tea\0coffee".

Starter code · lengths.py

def lengths(text):
    """(size, bytesize, strlen) of a Crystal string that holds text."""
    return (len(text), len(text), len(text))


def safe_for_c(text):
    """True when a C function would see the whole string."""
    return True
The sample tests · test_lengths.py
from lengths import lengths, safe_for_c


def test_the_lessons_strings():
    """chai, चाय and tea\\0coffee, as libc.cr printed them"""
    assert lengths("chai") == (4, 4, 4)
    assert lengths("चाय") == (3, 9, 9)
    assert lengths("tea\0coffee") == (10, 10, 3)


def test_more_characters():
    """a cup of tea in one character, three bytes; an empty string"""
    assert lengths("☕") == (1, 3, 3)
    assert lengths("") == (0, 0, 0)
    assert lengths("\0") == (1, 1, 0)


def test_zero_bytes_are_unsafe_for_c():
    """check_no_null_byte refuses a string with a zero byte"""
    assert safe_for_c("चाय") is True
    assert safe_for_c("tea\0coffee") is False
    assert safe_for_c("") is True
A hint

text.encode("utf-8") gives the bytes Crystal keeps, and len() of it is the bytesize. For strlen, find where the first zero byte is in those bytes: bytes.find(b"\0") gives -1 when there is none.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then the self-check on the four ideas of the lesson. It asks about the idea your answers show least first, and every answer comes with the reason:

Practice that adapts to you

Practise this lesson

11 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 11 Which command printed the methods that define_units wrote?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: crystal tool expand -c units.cr:11:3 units.cr

    crystal tool expand shows the code of the macro call at a given place, so you can read what the compiler checks.

  2. Question 2 of 11 accessors.cr declares getter seat and property guests. Which methods did the program list?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: guests, guests=, initialize and seat

    property, getter and setter are macros of the standard library, the Crystal names of Ruby's attr_accessor, attr_reader and attr_writer.

  3. Question 3 of 11 A teammate writes a macro that adds two numbers known only when the program runs. What do you suggest?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: A plain method: a macro runs while compiling and sees syntax, not the values of a run

    Prefer a method whenever one can do the job: it is easier to read, test and debug. A macro is for writing code from the program's own text, such as names.

  4. Question 4 of 11 What does send do on Channel(Int32).new, a channel without room, when no fiber is waiting to receive?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: It waits until a fiber receives the value

    That is what made fibers 1 and 2 of round_trip.cr wait. A channel with room, new(3), lets send go on until it is full.

  5. Question 5 of 11 A program spawns a fiber that prints a line, and then ends without waiting for anything. What does it print?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: Usually nothing: a spawned fiber does not run at once, and the program ends with its main fiber

    Give the fiber a turn by waiting: receive from a channel it sends to, use a WaitGroup, or at least Fiber.yield. round_trip.cr waited with three receives.

  6. Question 6 of 11 A handler runs a loop that computes for two seconds without any channel, input, output or sleep. What happens to the other fibers?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: They wait for the whole two seconds: a fiber keeps the thread until it waits or ends

    Long work without waits starves every other fiber. Split it, call Fiber.yield inside the loop, or move it to a context with its own threads.

  7. Question 7 of 11 What ends each worker's while job = jobs.receive? loop in pool.cr?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: receive? returning nil once jobs is closed and empty

    close and receive? together end a pool cleanly: the sender closes when it has sent everything, and each worker stops when there is nothing left.

  8. Question 8 of 11 pool.cr checks six hosts with three workers. How many times must main call results.receive to get every result?

    Type a number.

    A hint

    Count results, not workers.

    Show the answer: Question 8 of 11

    Answer: 6 receives

    One result per host, whichever worker handled it: Array.new(hosts.size) { results.receive } called it six times.

  9. Question 9 of 11 चाय has 3 characters. How many bytes did strlen count for it?

    Type a number.

    A hint

    libc.cr printed size, bytesize and strlen for it.

    Show the answer: Question 9 of 11

    Answer: 9 bytes

    Each of the three characters takes three bytes in UTF-8, so bytesize and strlen both gave 9. C counts bytes, never characters.

  10. Question 10 of 11 A C function takes const char *name. How do you declare that parameter in a lib block?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: name : UInt8*

    The types of a binding are C's, written the Crystal way: char is UInt8, int is Int32 and size_t is LibC::SizeT.

  11. Question 11 of 11 A wrapper reads 10 values through a pointer that a C function returned, but the C library documents only 8. What happens, and what do you change?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: Crystal does not stop it, since a pointer knows no length; check the count and read through a Slice of 8

    Keep pointer code in a small wrapper that knows the true count, and give the rest of the program a Slice or an Array, which Crystal checks.

Interview questions

Warm-up (mid-level to senior): When do you write a Crystal macro instead of a method?

When the code to write depends on the program itself rather than on values: methods named after the units of a list, accessors for instance variables, or code that needs the program’s types. A macro runs inside the compiler, takes its arguments as syntax, and writes code that the compiler then checks like any other, so a mistake in what it writes shows up as a compile error with a name, as feet_to_metre did. A method runs while the program runs and takes values. Prefer a method whenever one can do the job: it is easier to read, to test and to debug. When you do write a macro, crystal tool expand shows exactly what it wrote, and the standard library’s property, getter and record are the models to follow.

Two longer questions, on a C call that stalls a server and on a pool whose report changes between machines:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to staff

    A Crystal service handles many requests at once with fibers. One handler calls a C library function through a lib block, and the call takes about two seconds. While it runs, every other request stalls. Why, and what are your options?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to senior

    A status checker built as a pool of fibers prints its report in a different order on a teammate's machine, and a test that compares the report with a saved file fails there but passes on yours. What is going on, and how do you fix both the program and the test?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. When does a Crystal macro run, and what are its arguments?

    Inside the compiler, while the program compiles. Its arguments are pieces of the program, such as the name metre, not values; {% %} runs loops and conditions and {{ }} pastes values in.

  2. How do you see the code a macro call wrote?

    crystal tool expand -c file.cr:line:column file.cr, giving the line and column of the call. It prints the generated methods as if they had been typed.

  3. How do Crystal fibers share one thread?

    Cooperatively: one runs at a time and keeps the thread until it waits on a channel, input, output or a sleep, or until it ends.

  4. Why did fibers 1 and 2 of round_trip.cr never print their last line?

    A send on a channel with no room waits for a receiver. They waited, main took their values and finished, and a program ends with its main fiber, whatever the others still have to do.

  5. What changed when round_trip.cr's channel was given room for three values?

    No send had to wait, so each fiber printed both lines before the next started. The values were still [0, 1, 4]; the order of everything else changed.

  6. Why does each result of the worker pool carry its host's index?

    Results arrive in the scheduler's order, not the input's, so main sorts by index and the report reads in input order whoever did the work.

  7. What does C's strlen return for the Crystal strings चाय and "tea\0coffee"?

    9 and 3: C counts bytes and stops at the first zero byte. check_no_null_byte raises an ArgumentError for such a string before it reaches C.

  8. Why was a proc that reads the local variable descending refused as a qsort callback?

    It is a closure, and a C function pointer has no room for the variables it carries. Pass a proc without a closure, and send data through a user-data pointer when C offers one.

Key takeaways

  • A macro runs inside the compiler, takes its arguments as syntax, loops with {% %} and pastes with {{ }}; the methods it writes are checked like any code, and crystal tool expand shows them.
  • property, getter and record are macros of the standard library, and macro code can read the program’s own methods while compiling.
  • Fibers take turns on one thread: a fiber runs until it waits or ends, a send on a channel without room waits for a receiver, and the program ends with its main fiber.
  • In a pool, results arrive in the scheduler’s order, so they carry an index; receive? ends the workers once the jobs channel is closed, and select with timeout gives up on an answer that does not come.
  • A lib block declares C functions with C types: strings go as bytes and stop at the first zero byte, and a proc that is a closure cannot be passed to C.

Terms in this lesson

channel (Crystal)
A typed queue that fibers send values through. Channel(Int32).new has no room, so send waits until a fiber receives; Channel(Int32).new(3) has room for three values, so send goes on until it is full.
closure
A function, such as a Crystal proc, that uses variables of the code around it and carries them along. A C function pointer has no room for those variables, so Crystal refuses to pass a closure to C.
fiber
A lightweight unit of execution that Crystal's scheduler runs, started with spawn. Fibers take turns on a thread: one runs until it waits on a channel, input, output or a sleep, or until it ends.
lib block
A Crystal declaration that names C functions with their C types, such as fun strlen(s : UInt8*) : LibC::SizeT inside lib LibText, so that the program can call them like class methods.
macro (Crystal)
Code that runs inside the compiler and writes Crystal code where it is called. Its arguments arrive as pieces of the program, {% %} runs loops and conditions, and {{ }} pastes values into the code it writes.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.