Learning Julia, Line by Line
Cardsjl is a simple Julia package which demonstrate many interesting bits of the Julia Programming Language.
Reading it is an enjoyable experience. The note I’ve writtend down is here:
# import base multiply(*), bitwise-or(|), and bitwise-and(&)
#
# In Julia, you can load modules with `using` or `import`. The difference is that
# * `using` will load the module **and** reexport the loaded module into the surrounding global
# namespace.
# * `import` will only load the module and rexport the module name to the scope.
import Base: *, |, &
"""
Encode a suit as a 2-bit value (low bits of a `UInt8`):
- 0 = ♣ (clubs)
- 1 = ♢ (diamonds)
- 2 = ♡ (hearts)
- 3 = ♠ (spades)
The suits have global constant bindings: `♣`, `♢`, `♡`, `♠`.
"""
# Here we define a struct `Suit`. A `Suit` contains a `i` variable with type`UInt8`
#
# In Julia, type objects are constructor functions. We can create new instance of the struct
# via calling the function `suit = Suit(0)`
struct Suit
i::UInt8
# Here, we define an "Inner Constructor Method" to define constraints for the constructor
#
# This is also a neat example of Julia's unicode support. Yes, we can use ≤ as `<=`.
Suit(s::Integer) = 0 ≤ s ≤ 3 ? new(s) :
throw(ArgumentError("invalid suit number: $s"))
end
# Julia's abstraction is mostly powered by multiple dispatch.
# Here we define a "new dispatch" for `char()` function to convert normal characters to a Suit.
# Therefore, whenever the `char()` function is applied with a `Suit`, this dispatch will be used.
char(s::Suit) = Char(0x2663-s.i)
# ... and some other helpers. They're surprisingly self-explanatory
Base.string(s::Suit) = string(char(s))
Base.show(io::IO, s::Suit) = print(io, char(s))
# In a normal poker deck, there's only 4 possible suits
# We can write readable codes with the power of unicode symbols in Julia
const ♣ = Suit(0)
const ♢ = Suit(1)
const ♡ = Suit(2)
const ♠ = Suit(3)
const suits = [♣, ♢, ♡, ♠]
"""
Encode a playing card as a 6-bit integer (low bits of a `UInt8`):
- low bits represent rank from 0 to 15
- high bits represent suit (♣, ♢, ♡ or ♠)
Ranks are assigned as follows:
- numbered cards (2 to 10) have rank equal to their number
- jacks, queens and kings have ranks 11, 12 and 13
- there are low and high aces with ranks 1 and 14
- there are low and high jokers with ranks 0 and 15
This allows any of the standard orderings of cards ranks to be
achieved simply by choosing which aces or which jokers to use.
There are a total of 64 possible card values with this scheme,
represented by `UInt8` values `0x00` through `0x3f`.
"""
# A Card is a struct which encode the suit and the rank into a single UInt8
#
# In most high-level languages, this would be kinda tedious to define.
# However, Julia demonstrated its expressiveness straight to the lowest level here.
struct Card
value::UInt8
end
# We create a new dispatch for `Card`'s constructor.
# It will encode the rank and the suit into the UInt8
function Card(r::Integer, s::Integer)
0 ≤ r ≤ 15 || throw(ArgumentError("invalid card rank: $r"))
return Card(((s << 4) % UInt8) | (r % UInt8))
end
# Another dispath when a suit is given instead of an integer for suit
Card(r::Integer, s::Suit) = Card(r, s.i)
# These are getters for getting the rank or suit from a card
suit(c::Card) = Suit((0x30 & c.value) >>> 4)
rank(c::Card) = (c.value & 0x0f) % Int8
# Lets define a new dispatch for Base.show to make it looks good when printed.
function Base.show(io::IO, c::Card)
r = rank(c)
if 1 ≤ r ≤ 14
r == 10 && print(io, '1')
print(io, "1234567890JQKA"[r])
else
print(io, '\U1f0cf')
end
print(io, suit(c))
end
# In Julia, `2 * x` can be written as `2x`.
#
# By creating a new dispatch for the multiply operator `*`, we can write `2♣`
# and it will automatically converted to `Card(2, ♣)`. WOW fancy.
*(r::Integer, s::Suit) = Card(r, s)
# However, "J♣" will not be treated as a multipication.
#
# Here we use `@eval` macro to create these variables as consts.
# such lispy. I like it.
for s in "♣♢♡♠", (r,f) in zip(11:14, "JQKA")
ss, sc = Symbol(s), Symbol("$f$s")
@eval (export $sc; const $sc = Card($r,$ss))
end
"""
Represent a hand (set) of cards using a `UInt64` bit set.
"""
# we use an `UInt64` bit set to store what cards are presented in a hand
# since there's only 52 cards in a deck.
#
# We use `<:` to indicate that a `Hand` is a subtype of a `AbstractSet{Card}`.
# Therefore, `Hand` can be used with all functions with compatiable dispatch to an `AbstractSet`.
struct Hand <: AbstractSet{Card}
cards::UInt64
Hand(cards::UInt64) = new(cards)
end
# convert card value to bit set position
bit(c::Card) = one(UInt64) << c.value
# convert suit to bit set range
bits(s::Suit) = UInt64(0xffff) << 16(s.i)
# a simple constructor to convert a set of cards to a bit set
function Hand(cards)
hand = Hand(zero(UInt64))
for card in cards
card isa Card || throw(ArgumentError("not a card: $repr(card)"))
i = bit(card)
hand.cards & i == 0 || throw(ArgumentError("duplicate cards are not supported"))
hand = Hand(hand.cards | i)
end
return hand
end
# Some more dispatches for our Hand type
Base.in(c::Card, h::Hand) = (bit(c) & h.cards) != 0
Base.length(h::Hand) = count_ones(h.cards)
Base.isempty(h::Hand) = h.cards == 0
Base.lastindex(h::Hand) = length(h)
# Define an iterator for our Hand.
#
# We can define a parameter with default value with the syntax introduced here
function Base.iterate(h::Hand, s::UInt8 = trailing_zeros(h.cards) % UInt8)
(h.cards >>> s) == 0 && return nothing
c = Card(s); s += true
c, s + trailing_zeros(h.cards >>> s) % UInt8
end
# a non-bound-checked function to get a Card from a Hand
function Base.unsafe_getindex(h::Hand, i::UInt8)
card, s = 0x0, 0x5
while true
mask = 0xffff_ffff_ffff_ffff >> (0x40 - (0x1<<s) - card)
card += UInt8(i > count_ones(h.cards & mask) % UInt8) << s
s > 0 || break
s -= 0x1
end
return Card(card)
end
# To avoid having to convert from UInt8 to Integer constantly,
# we create a new dispatch for our unsafe_getindex for all Integeres
Base.unsafe_getindex(h::Hand, i::Integer) = Base.unsafe_getindex(h, i % UInt8)
# Finally, we wrap our not-so-safe fuction with a bounded-checked `getindex` function
function Base.getindex(h::Hand, i::Integer)
# The `@boundscheck` macro allows the bound check to be ignored with `@inbound` macro
@boundscheck 1 ≤ i ≤ length(h) || throw(BoundsError(h,i))
return Base.unsafe_getindex(h, i)
end
# Make a `Hand` looks nice when printed
function Base.show(io::IO, hand::Hand)
if isempty(hand) || !get(io, :compact, false)
print(io, "Hand([")
for card in hand
print(io, card)
(bit(card) << 1) ≤ hand.cards && print(io, ", ")
end
print(io, "])")
else
for suit in suits
s = hand & suit
isempty(s) && continue
show(io, suit)
for card in s
r = rank(card)
if r == 10
print(io, '\u2491')
elseif 1 ≤ r ≤ 14
print(io, "1234567890JQKA"[r])
else
print(io, '\U1f0cf')
end
end
end
end
end
# More dispatch to allow us to combine two hands with `|`, add a card to a hand with `|`
a::Hand | b::Hand = Hand(a.cards | b.cards)
a::Hand | c::Card = Hand(a.cards | bit(c))
c::Card | h::Hand = h | c
# interset two hands with `&`
a::Hand & b::Hand = Hand(a.cards & b.cards)
# fetch cards within a suit range with `&`
h::Hand & s::Suit = Hand(h.cards & bits(s))
s::Suit & h::Hand = h & s
# more new dispatches
Base.intersect(s::Suit, h::Hand) = h & s
Base.intersect(h::Hand, s::Suit) = intersect(s::Suit, h::Hand)
# range operators for our suit and cards
*(rr::OrdinalRange{<:Integer}, s::Suit) = Hand(Card(r,s) for r in rr)
..(r::Integer, c::Card) = (r:rank(c))*suit(c)
..(a::Card, b::Card) = suit(a) == suit(b) ? rank(a)..b :
throw(ArgumentError("card ranges need matching suits: $a vs $b"))
# FINALLY, we create a deck, which contains 52 unique cards
const deck = Hand(Card(r,s) for s in suits for r = 2:14)
# An empty hand can be represented as 0
Base.empty(::Type{Hand}) = Hand(zero(UInt64))
# Use `rand` to get a random subset of the deck
# we use @eval to interpolate all cards into this expression then evaluate it
@eval Base.rand(::Type{Hand}) = Hand($(deck.cards) & rand(UInt64))
# In Julia, a function ends with `!` indicates that it's an in-place update function
#
# Here we define a `deal!` function to fill hands based on specified `counts` layout
function deal!(counts::Vector{<:Integer}, hands::AbstractArray{Hand}, offset::Int=0)
for rank = 2:14, suit = 0:3
while true
hand = rand(1:4)
if counts[hand] > 0
counts[hand] -= 1
hands[offset + hand] |= Card(rank, suit)
break
end
end
end
return hands
end
# Now let's define our `deal` function. It will deal cards to 4 people with the given count
#
# Default dispatch when no argument provided
deal() = deal!(fill(13, 4), fill(empty(Hand), 4))
# a dispatch for `deal` when an `n` Int is provided
function deal(n::Int)
counts = fill(0x0, 4)
hands = fill(empty(Hand), 4, n)
for i = 1:n
deal!(fill!(counts, 13), hands, 4(i-1))
end
return permutedims(hands)
end
# calculate the points of a given hand
function points(hand::Hand)
p = 0
for rank = 11:14, suit = 0:3
card = Card(rank, suit)
p += (rank-10)*(card in hand)
end
return p
end
I have a (rarely updated) email newsletter for reasons I've forgotten