Skip to main content

3 posts tagged with "MoonBit"

View All Tags

MoonBit Pearls Vol.03:01 knapsack problem

· 13 min read

The 0/1 Knapsack Problem is a classic dynamic programming (DP) problem commonly found in algorithm competitions. This article presents five versions of the solution, starting from a basic brute-force approach and gradually evolving into a DP-based implementation.

Problem Definition

There are several items, each with aweightand a value:

struct Item {
  weight : Int
  value : Int
}

Now, given a list of items (items) and a knapsack capacity (capacity), select a subset of the items such that the total weight does not exceed the knapsack capacity, and the total value is maximized.

typealias @list.T as List

let items_1 : List[Item] = @list.of([
  { weight: 7, value: 20 },
  { weight: 4, value: 10 },
  { weight: 5, value: 11 },
])

Take items_1 as an example. If the knapsack capacity is 10, the optimal solution is to select the last two items. Their combined weight is 4 + 5 = 9, and their total value is 10 + 11 = 21.

Note that items cannot be split. Therefore, greedily selecting the item with the highest value-to-weight ratio does not always yield the correct result.

For example, in the case above, if we pick only the first item (which has the highest ratio), we get 20 points in total, but the knapsack will be full and no other items can be added.

Problem Modeling

First, we define some basic objects and operations.

//A combination of items, referred to as "combination" throughout the article
struct Combination {
  items : List[Item]
  total_weight : Int
  total_value : Int
}

//An empty combination
let empty_combination : Combination = {
  items: @list.empty(),
  total_weight: 0,
  total_value: 0,
}

//Add an item to a combination and return a new combination
fn Combination::add(self : Combination, item : Item) -> Combination {
  {
    items: self.items.add(item),
    total_weight: self.total_weight + item.weight,
    total_value: self.total_value + item.value,
  }
}

//Two combinations are considered equal if they have the same total value
impl Eq for Combination with op_equal(self, other) {
  self.total_value == other.total_value
}

//Compare two combinations by their total value
impl Compare for Combination with compare(self, other) {
  self.total_value.compare(other.total_value)
}

Now, we can begin thinking about how to solve the problem.

1.Naive Enumeration

Enumeration is the most straightforward approach. By following the problem definition step by step, we can arrive at a solution:

  1. Enumerate all possible combinations;
  2. Filter out only the valid combinations-those that fit in the knapsack;
  3. Select the one with the maximum total value.

Thanks to the two functions provided by our modeling, we can translate the three steps above directly into MoonBit code. The all_combinations function, which we'll implement later, has the type(List[Item]) -> List[Combination].

fn solve_v1(items : List[Item], capacity : Int) -> Combination {
  all_combinations(items)
  .filter(fn(comb) { comb.total_weight <= capacity })
  .unsafe_maximum()
}

Note that we use unsafe_maximum instead of maximum because we're taking the maximum of a non-empty list, and in this case, maximum will not return None.Since the problem guarantees that a solution exists (as long as capacity is non-negative), we can safely use unsafe_maximum. It skips the empty-list check but implicitly assumes the result will exist.

Now let's implement the enumeration step.Suppose all_combinations takes a list of items and returns a list of combinations, each representing a possible subset of those items. If this seems unclear at first, we can begin by looking at the definition of the list structure, which looks roughly like this:

enum List[A] {
  Empty
  More(A, tail~ : List[A])
}

In other words, a list has two possible forms:

  1. An empty list, represented as Empty;
  2. A non-empty list, represented as More, which contains the first element (A) and the rest of the list (tail: List[A]), which is itself also a list.

This structure gives us a hint for how to recursively build combinations:

  • If the item list is empty, the only possible combination is the empty combination;
  • Otherwise, there must be a first item item1 and a remaining list items_tail. In this case, we can:
    1. Recursively compute all combinations of items_tail, which represent combinations that do not include item1;
    2. For each of those, add item1 to form new combinations that do include it;
    3. Merge both sets to obtain all combinations of the original items list.

For example, if the item list is a, b, c, then the combinations can be divided into two parts:

Without aWith a (by adding a to the left side)
{ }{ a }
{ b }{ a, b }
{ c }{ a, c }
{ b, c }{ a, b, c }
fn all_combinations(items : List[Item]) -> List[Combination] {
  match items {
    Empty => @list.singleton(empty_combination)
    More(item1, tail=items_tail) => {
      let combs_without_item1 = all_combinations(items_tail)
      let combs_with_item1 = combs_without_item1.map(_.add(item1))
      combs_with_item1 + combs_without_item1
    }
  }
}

By using pattern matching (match), we've translated the five lines of logic above into MoonBit code for the first time, line by line.

2.Early Filtering: Enumerate Only Valid Combinations

In the first version, generating all combinations and filtering out those that fit in the knapsack were two separate steps.During the enumeration process, many invalid combinations were generated-combinations that had already exceeded the knapsack capacity but were still extended with more items.If we filter them earlier, we can avoid generating many unnecessary combinations on top of invalid ones, which is clearly more efficient.Looking at the code, we see that invalid combinations are only produced during .map(_.add(item1)).So we can optimize by only adding item1 to combinations that can still fit it.

We now rename all_combinations to all_combinations_valid, which returns only combinations that can actually fit in the knapsack. The generation and filtering processes are now interleaved.

fn all_combinations_valid(
  items : List[Item],
  capacity : Int // Add capacity as a parameter because filtering requires it
) -> List[Combination] {
  match items {
    Empty => @list.singleton(empty_combination) // An empty combination is always valid
    More(item1, tail=items_tail) => {
      // Recursively obtain combinations without this item (by assumption, all valid)
      let valid_combs_without_item1 = all_combinations_valid(
        items_tail, capacity,
      )
      // Add item1 to valid combinations that can still hold it
      let valid_combs_with_item1 = valid_combs_without_item1
        .filter(fn(comb) { comb.total_weight + item1.weight <= capacity })
        .map(_.add(item1))
      // Both parts are valid, so we return their union
      valid_combs_with_item1 + valid_combs_without_item1
    }
  }
}

This structure naturally supports inductive reasoning, making it easy to prove the correctness of all_combinations_valid-it indeed returns only valid combinations.

Since all_combinations_valid already returns only valid combinations, we no longer need to filter in solve.We simply remove the filter from solve:

fn solve_v2(items : List[Item], capacity : Int) -> Combination {
  all_combinations_valid(items, capacity).unsafe_maximum()
}

3.Maintain Order to Enable Early Termination

In the previous version, to construct new combinations, we needed to iterate over every combination in valid_combs_without_item1.

But we can observe: if item1 cannot be added to a certain combination, then it definitely cannot be added to any combination with a greater total weight than that one. In other words, if valid_combs_without_item1 is sorted in ascending order of total weight, then we don't need to traverse it entirely during filtering.

During filtering, as soon as we encounter a combination that can't fit item1, we can immediately discard the remaining combinations.Since this logic is common, the standard library provides a function called take_while, which we use to replace filter.

To make valid_combs_without_item1 sorted, we could use a sorting algorithm-but that would require traversing the entire list, which defeats the purpose.Therefore, we adopt a different approach: ensure that the list returned by all_combinations_valid is already sorted in ascending order.

This requires a leap of faith via recursion:

fn all_combinations_valid_ordered(
  items : List[Item],
  capacity : Int
) -> List[Combination] {
  match items {
    Empty => @list.singleton(empty_combination) // A single-element list is naturally in ascending order.
    More(item1, tail=items_tail) => {
      // We assume that all_combinations_valid_ordered returns a list sorted in ascending order (inductive hypothesis).
      let valid_combs_without_item1 = all_combinations_valid_ordered(
        items_tail, capacity,
      )
      // Then valid_combs_with_item1 is also in ascending order, because taking a prefix of an ascending list and adding the same weight to each element still yields an ascending list by total weight.
      let valid_combs_with_item1 = valid_combs_without_item1
        .take_while(fn(comb) { comb.total_weight + item1.weight <= capacity })
        .map(_.add(item1))
      // Now, we only need to ensure that the merged result is also sorted in ascending order to maintain our initial assumption.
      merge_keep_order(valid_combs_with_item1, valid_combs_without_item1)
    }
  }
}

The final task is to implement the function merge_keep_order, which merges two ascending lists into one ascending list:

fn merge_keep_order(
  a : List[Combination],
  b : List[Combination]
) -> List[Combination] {
  match (a, b) {
    (Empty, another) | (another, Empty) => another
    (More(a1, tail=a_tail), More(b1, tail=b_tail)) =>
      // If a1 is lighter than b1, and b1 is part of an ascending list, then:
      //   a1 is lighter than all combinations in b
      // Since list a is also in ascending order
      //   a1 is also lighter than all combinations in a_tail
      // Therefore, a1 is the smallest among all elements in a and b
      if a1.total_weight < b1.total_weight {
        // We first recursively merge the rest of the list, then prepend a1
        merge_keep_order(a_tail, b).add(a1)
      } else { //
        merge_keep_order(a, b_tail).add(b1)
      }
  }
}

Although it might seem a bit verbose, I still want to point this out: by following a case-based analysis aligned with the structure of the code, it's actually easy to prove the correctness of all_combinations_valid_ordered and merge_keep_order - they do return a sorted list.

For an ascending list, the maximum element is simply the last one. So we replaced unsafe_maximum with unsafe_last.

fn solve_v3(items : List[Item], capacity : Int) -> Combination {
  all_combinations_valid_ordered(items, capacity).unsafe_last()
}

Looking back, this version of the optimization might not seem like a big win - after all, we still have to traverse the entire list during the merge. That was my initial impression too, but I later discovered something unexpected.

4.Removing Redundant Combinations with Equal Weights for Optimal Time Complexity

So far, the optimizations we've made haven't addressed time complexity, but they've laid the groundwork for this next step. Now let's consider the algorithm's time complexity.

In the worst case (e.g., when the knapsack is large enough to hold everything), the combination list returned by all_combinations can contain up to 2numberofitems2^{number of items} elements. This results in an exponential time complexity, especially since all_combinations is called multiple times, each time returning a potentially large list.

To reduce the time complexity, we need to limit the length of the candidate combination list. This is based on a simple observation: if two combinations have the same total weight, the one with the higher total value is always better. Therefore, we don't need to keep both in the list.

By eliminating these redundant combinations, the list length will never exceed the knapsack's capacity (thanks to the pigeonhole principle). This optimization reduces the algorithm's time complexity to O(numberofitems×capacity)\mathcal{O}(number of items \times capacity). Upon reviewing the code, the only place where redundant combinations may still be introduced is the else branch of merge_keep_order. To prevent this, we just need to make a small modification to that section.

fnalias @math.maximum

fn merge_keep_order_and_dedup(
  a : List[Combination],
  b : List[Combination]
) -> List[Combination] {
  match (a, b) {
    (Empty, another) | (another, Empty) => another
    (More(a1, tail=a_tail), More(b1, tail=b_tail)) =>
      if a1.total_weight < b1.total_weight {
        merge_keep_order_and_dedup(a_tail, b).add(a1)
      } else if a1.total_weight > b1.total_weight {
        merge_keep_order_and_dedup(a, b_tail).add(b1)
      } else { // "In this case, a1 and b1 have the same weight, creating a duplicate. We keep the one with the higher total value."
        let better = maximum(a1, b1)
        merge_keep_order_and_dedup(a_tail, b_tail).add(better)
      }
  }
}

Simply replace the corresponding parts with all_combinations_valid_ordered_nodup (arguably the longest function name I've ever written) and solve_v4.

fn all_combinations_valid_ordered_nodup(
  items : List[Item],
  capacity : Int
) -> List[Combination] {
  match items {
    Empty => @list.singleton(empty_combination)
    More(item1, tail=items_tail) => {
      let combs_without_item1 = all_combinations_valid_ordered_nodup(
        items_tail, capacity,
      )
      let combs_with_item1 = combs_without_item1
        .take_while(fn(comb) { comb.total_weight + item1.weight <= capacity })
        .map(_.add(item1))
      merge_keep_order_and_dedup(combs_with_item1, combs_without_item1)
    }
  }
}

fn solve_v4(items : List[Item], capacity : Int) -> Combination {
  all_combinations_valid_ordered_nodup(items, capacity).unsafe_last()
}

At this point, we've essentially reinvented the dynamic programming solution for the 0/1 knapsack problem.

Conclusion

This article's content came from a sudden idea I had one morning while lying in bed. From the first to the fourth version, all code was written entirely on my phone without any debugging-yet correctness was easily guaranteed. Compared to conventional solutions often seen in algorithm competitions, the functional programming style used here brings the following advantages:

  1. No loops, only recursion and structural decomposition. To extract elements from a list, pattern matching (match) is required, which naturally prompts consideration of empty cases and expresses intent more clearly than initializing a DP array.
  2. Composition of higher-order functions. Standard functions like filter, take_while, map, and maximum replace boilerplate loops (for, while), making the traversal purpose clearer at a glance.
  3. Declarative code. The later versions of the solution are direct translations of the first one. Rather than just implementing a solution, the code describes the problem itself. This ensures the correctness of the first version. Each subsequent improvement was made without affecting the correctness, allowing for a safe and iterative process.

Of course, this solution doesn't apply state space compression, so a tradeoff between readability and efficiency remains. The functional style is slightly idealistic but still leaves room for many optimizations. A future direction would be to convert the list structure into a tree, exploiting the fact that functions only pass two argument groups throughout execution-possibly even a single value. This could reduce space complexity to O(capacity), though it's beyond the scope of this article. We believe the approach here offers a beginner-friendly and understandable way to write correct code.

Appendix

Further Optimization Details

Given that the order of items does not affect the total value of the result, we can convert all_combinations into a tail-recursive version.

Additionally, since the list produced by take_while is immediately discarded after being passed to map, certain syntax-level techniques can be used to avoid creating this temporary list altogether.

fn all_combinations_loop(
  items : List[Item],
  capacity : Int
) -> List[Combination] {
  loop items, @list.singleton(empty_combination) {
    Empty, combs_so_far => combs_so_far
    More(item1, tail=items_tail), combs_so_far => {
      let combs_without_item1 = combs_so_far
      let combs_with_item1 = combs_without_item1
        .iter()
        .take_while(fn(comb) { comb.total_weight + item1.weight <= capacity })
        .map(_.add(item1))
        |> @list.from_iter
      continue items_tail,
        merge_keep_order_and_dedup(combs_with_item1, combs_without_item1)
    }
  }
}

fn solve_v5(items : List[Item], capacity : Int) -> Combination {
  all_combinations_loop(items, capacity).unsafe_last()
}

Side Notes

  1. In the first version, the Combination generated by all_combinations(items) even contains one more node than More, making it a true master of linked list node reuse.
  2. An ascending order can't be treated as "broken" order, so the corresponding take_while must be converted to drop_while. When using an array, we can directly cut segments via binary_search on indices.
  3. If you're interested, consider how to generalize the above approach to various other knapsack problems.
  4. The original name of all_combinations_loop was: generate_all_ordered_combination_that_fit_in_backpack_list_without_duplicates_using_loop.

Test

test {
  for solve in [solve_v1, solve_v2, solve_v3, solve_v4, solve_v5] {
    assert_eq(solve(items_1, 10).total_value, 21)
  }
}

MoonBit Pearls Vol.02:Object-Oriented Programming in Moonbit

· 20 min read
Liu Ziyue

16:9moonbit pearls封面.png

Introduction

In the world of software development, object-oriented programming (OOP) is an unavoidable topic. Languages like Java and C++ have built countless complex systems with their powerful OOP mechanisms. However, Moonbit, as a modern language centered around functional programming, how does it implement OOP?

Moonbit is a language with functional programming at its core, and its approach to object-oriented programming differs significantly from traditional programming languages. It abandons traditional inheritance mechanisms in favor of the "composition over inheritance" design philosophy. At first glance, this might feel unfamiliar to programmers accustomed to traditional OOP, but upon closer inspection, you'll discover an unexpected elegance and practicality in this approach.

This article will take you through an in-depth exploration of object-oriented programming in Moonbit using a vivid RPG game development example. We'll dissect the three major OOP characteristics—encapsulation, inheritance, and polymorphism—and compare them with their C++ counterparts. Finally, we'll provide some best practice recommendations for real-world development.

Encapsulation

Imagine we're developing a classic single-player RPG game. In this fantasy world, heroes roam, battle monsters, purchase equipment from NPC merchants, and ultimately rescue a trapped princess. To build such a world, we first need to model all its elements.

Whether it's brave heroes, vicious monsters, or simple tables and chairs, all objects in the game world share some common features. We can abstract these objects as Sprites, with each Sprite having a few basic attributes:

  • ID: A unique identifier for the object, like an ID number.
  • x and y: Coordinates on the game map.

Classic Encapsulation in C++

In C++, we're accustomed to using class to encapsulate data:

// A basic Sprite class
class Sprite {
private:
    int id;
    double x;
    double y;

public:
    // Constructor to create objects
    Sprite(int id, double x, double y) : id(id), x(x), y(y) {}

    // Public "getter" methods to access data
    int getID() const { return id; }
    double getX() const { return x; }
    double getY() const { return y; }

    // "Setter" methods to modify data
    void setX(double newX) { x = newX; }
    void setY(double newY) { y = newY; }
};

You might ask, "Why bother with all these get methods? Why not just make the fields public?" This question touches on the core idea of encapsulation.

Why Encapsulation?

Imagine if your colleague directly modified sprite.id = enemy_id. The hero could instantly "transform" into an ally of the enemy and waltz straight to the end—clearly not the game mechanic we want! Encapsulation acts like a protective net around data. private fields paired with getter methods ensure that external code can only read critical data but not modify it arbitrarily. This design makes the code more robust, avoiding unintended side effects.

Elegant Encapsulation in Moonbit

In Moonbit, the approach to encapsulation undergoes a subtle yet important shift. Let's look at a simple version first:

// Defining Sprite in Moonbit
pub struct Sprite {
  id: Int          // Immutable by default, readable but not writable externally
  mut x: Double    // `mut` keyword indicates mutability
  mut y: Double
}

// We can define methods for the struct
pub fn Sprite::get_x(self: Self) -> Double {
  self.x
}

pub fn Sprite::get_y(self: Self) -> Double {
  self.y
}

pub fn Sprite::set_x(self: Self, new_x: Double) -> Unit {
  self.x = new_x
}

pub fn Sprite::set_y(self: Self, new_y: Double) -> Unit {
  self.y = new_y
}

Two key differences stand out here:

1. Explicit Mutability Declaration

In Moonbit, fields are immutable by default. If you want a field to be modifiable, you must explicitly use the mut keyword. In our Sprite, id remains immutable—perfectly aligning with our design intent, as we don't want an object's identity to be arbitrarily altered. Meanwhile, x and y are marked as mut because sprites need to move freely in the world.

2. Simpler Access Control

Since id is immutable by default, we don't even need to write a get_id method for it! External code can directly read it via sprite.id, but any attempt to modify it will be firmly rejected by the compiler. This is more concise than C++'s "private + getter" pattern while maintaining the same level of safety.

💡 Practical Advice

When designing data structures, prioritize determining which fields truly need to be mutable. Moonbit's default immutability helps avoid many unintended state modification bugs.

Inheritance

The second pillar of object-oriented programming is inheritance. In our RPG world, there are various types of Sprites. For simplicity, we'll define three:

  • Hero: The player-controlled character.
  • Enemy: Opponents to be defeated.
  • Merchant: NPCs who sell items.

Inheritance Hierarchy in C++

In C++, we naturally use class inheritance to build this hierarchy:

class Hero : public Sprite {
private:
    double hp;
    double damage;
    int money;

public:
    Hero(int id, double x, double y, double hp, double damage, int money)
        : Sprite(id, x, y), hp(hp), damage(damage), money(money) {}

    void attack(Enemy& e) { /* ... */ }
};

class Enemy : public Sprite {
private:
    double hp;
    double damage;

public:
    Enemy(int id, double x, double y, double hp, double damage)
        : Sprite(id, x, y), hp(hp), damage(damage) {}

    void attack(Hero& h) { /* ... */ }
};

class Merchant : public Sprite {
public:
    Merchant(int id, double x, double y) : Sprite(id, x, y) {}
    // Merchant-specific methods...
};

C++'s object-oriented approach is built on the "is-a" relationship: Hero is a Sprite, Enemy is a Sprite. This way of thinking is intuitive and easy to grasp.

Composition-Based Thinking in Moonbit

Now, let's switch to Moonbit. Here, we need to make an important mental shift: Moonbit's structs do not support direct inheritance. Instead, we use traits and composition.

This design forces us to rethink the problem: we no longer treat Sprite as an inheritable "parent class" but instead split it into two independent concepts:

  1. SpriteData: A pure data structure storing all shared Sprite data.
  2. Sprite: A trait defining the behaviors all Sprites should have.

Here's the actual code:

// 1. Define the shared data structure
pub struct SpriteData {
  id: Int
  mut x: Double
  mut y: Double
}

// 2. Define the Trait describing common behaviors
pub trait Sprite {
  getSpriteData(Self) -> SpriteData  // Core method that must be implemented
  getID(Self) -> Int = _             // = _ indicates a default implementation
  getX(Self) -> Double = _
  getY(Self) -> Double = _
  setX(Self, Double) -> Unit = _
  setY(Self, Double) -> Unit = _
}

// Default implementations for Sprite
// Once getSpriteData is implemented, other methods become automatically available
impl Sprite with getID(self) {
  self.getSpriteData().id
}

impl Sprite with getX(self) {
  self.getSpriteData().x
}

impl Sprite with getY(self) {
  self.getSpriteData().y
}

impl Sprite with setX(self, new_x) {
  self.getSpriteData().x = new_x
}

impl Sprite with setY(self, new_y) {
  self.getSpriteData().y = new_y
}

Understanding the Power of Traits

The Sprite trait defines a "contract": any type claiming to be a Sprite must be able to provide its SpriteData. Once this condition is met, methods like getID, getX, and getY become automatically available. The = _ syntax indicates that the method has a default implementation, a new syntactic feature in Moonbit.

With this foundation, we can implement concrete game characters:

// Define Hero
pub struct Hero {
  sprite_data: SpriteData  // Composing SpriteData
  hp: Double
  damage: Int
  money: Int
}

// Implement the Sprite trait, only needing to provide getSpriteData
pub impl Sprite for Hero with getSpriteData(self) {
  self.sprite_data
}

pub fn Hero::attack(self: Self, e: Enemy) -> Unit {
  // Attack logic...
}

// Define Enemy
pub struct Enemy {
  sprite_data: SpriteData
  hp: Double
  damage: Int
}

pub impl Sprite for Enemy with getSpriteData(self) {
  self.sprite_data
}

pub fn Enemy::attack(self: Self, h: Hero) -> Unit {
  // Attack logic...
}

// Define Merchant
pub struct Merchant {
  sprite_data: SpriteData
}

pub impl Sprite for Merchant with getSpriteData(self) {
  self.sprite_data
}

Note the shift in mindset here: Moonbit adopts a "has-a" relationship instead of the traditional OOP "is-a" relationship. Hero has SpriteData and implements the Sprite capabilities.

Does Moonbit Seem More Complex?

At first glance, Moonbit's code might appear to require more "boilerplate" than C++. But this is only superficial! Here, we deliberately avoided many complexities inherent in C++: constructors, destructors, const correctness, template instantiation, etc. More importantly, Moonbit's design shines in large-scale projects—we'll discuss this in detail later.

Polymorphism

Polymorphism is the third pillar of object-oriented programming, referring to the ability of the same interface to produce different behaviors when applied to different objects. Let's explore this through a concrete example: suppose we need to implement a who_are_you function that can identify the type of an object and return an appropriate response.

Polymorphism Mechanisms in C++

C++'s polymorphism mechanisms are complex, encompassing static polymorphism (templates) and dynamic polymorphism (virtual functions, RTTI, etc.). A full discussion of C++ polymorphism is beyond this article's scope, but we'll focus on two classic runtime polymorphism approaches.

Method 1: Virtual Function Mechanism

The traditional approach is to define virtual functions in the base class and override them in subclasses:

class Sprite {
public:
    virtual ~Sprite() = default;  // Virtual destructor
    // Define a "pure virtual function," forcing subclasses to implement it
    virtual std::string say_name() const = 0;
};

// "Override" this function in subclasses
class Hero : public Sprite {
public:
    std::string say_name() const override {
        return "I am a hero!";
    }
    // ...
};

class Enemy : public Sprite {
public:
    std::string say_name() const override {
        return "I am an enemy!";
    }
    // ...
};

class Merchant : public Sprite {
public:
    std::string say_name() const override {
        return "I am a merchant.";
    }
    // ...
};

// Now the who_are_you function becomes trivial!
void who_are_you(const Sprite& s) {
    std::cout << s.say_name() << std::endl;
}

Method 2: RTTI + dynamic_cast

If we don't want to define virtual functions for each class, we can use C++'s Runtime Type Information (RTTI):

class Sprite {
public:
    // Only classes with virtual functions can use RTTI
    virtual ~Sprite() = default;
};

// Implementation of who_are_you
void who_are_you(const Sprite& s) {
    if (dynamic_cast<const Hero*>(&s)) {
        std::cout << "I am a hero!" << std::endl;
    } else if (dynamic_cast<const Enemy*>(&s)) {
        std::cout << "I am an enemy!" << std::endl;
    } else if (dynamic_cast<const Merchant*>(&s)) {
        std::cout << "I am a merchant." << std::endl;
    } else {
        std::cout << "I don't know who I am" << std::endl;
    }
}

How RTTI Works

With RTTI enabled, the C++ compiler maintains an implicit type_info structure for each object with virtual functions. When dynamic_cast is used, the compiler checks this type information: if it matches, it returns a valid pointer; otherwise, it returns nullptr. While powerful, this mechanism incurs runtime overhead.

However, the second method has issues in large projects:

  1. Type Unsafety. If you add a new subclass but forget to update the who_are_you function, this bug will only surface at runtime! In modern software development, we prefer such errors to be caught at compile time.
  2. Performance Overhead. With RTTI enabled, every type check involves a cumbersome type information lookup, which hampers optimization and can lead to performance issues.
  3. Opaque Data. With RTTI, C++ implicitly adds type information to each class, but this is invisible to the code author. This is problematic for library developers who desire more control over their code. In fact, many large projects disable RTTI—most notably LLVM, a C++ compiler project that itself avoids using RTTI.

Moonbit's ADT Mechanism

Moonbit elegantly solves polymorphism using Algebraic Data Types (ADTs). We introduce a new structure—SpriteEnum:

pub trait Sprite {
  getSpriteData(Self) -> SpriteData
  asSpriteEnum(Self) -> SpriteEnum  // New: type conversion method
}

// Moonbit allows enum tags and type names to overlap
pub enum SpriteEnum {
  Hero(Hero)
  Enemy(Enemy)
  Merchant(Merchant)
}

// We still need to implement getSpriteData for Sprite
pub impl Sprite for Hero with getSpriteData(self) {
  self.sprite_data
}

pub impl Sprite for Enemy with getSpriteData(self) {
  self.sprite_data
}

pub impl Sprite for Merchant with getSpriteData(self) {
  self.sprite_data
}

// Implement asSpriteEnum for the three subclasses
// This essentially "boxes" the concrete type into the enum
pub impl Sprite for Hero with asSpriteEnum(self) {
  Hero(self)  // Note: Hero here is the enum tag, not the type
}

pub impl Sprite for Enemy with asSpriteEnum(self) {
  Enemy(self)
}

pub impl Sprite for Merchant with asSpriteEnum(self) {
  Merchant(self)
}

Now we can implement a type-safe who_are_you function:

test "who are you" {
  fn who_are_you(s: &Sprite) -> String {
    // Use pattern matching for type dispatch
    match s.asSpriteEnum() {
      Hero(_) => "hero"
      Enemy(_) => "enemy"
      Merchant(_) => "merchant"
    }
  }

  let hero = Hero::new();
  let enemy = Enemy::new();
  let merchant = Merchant::new();
  inspect(who_are_you(hero), content="hero")
  inspect(who_are_you(enemy), content="enemy")
  inspect(who_are_you(merchant), content="merchant")
}

The beauty of this approach lies in its compile-time type safety! If you add a new Sprite subclass but forget to update the who_are_you function, the compiler will immediately flag the error instead of waiting until runtime to reveal the issue.

Static Dispatch vs. Dynamic Dispatch

You might notice the &Sprite in the function signature. In Moonbit, this is called a Trait Object, supporting dynamic dispatch similar to C++'s virtual function mechanism. If you write fn[S: Sprite] who_are_you(s: S), it becomes static dispatch (generics), where the compiler generates specialized code for each concrete type.

The key difference lies in handling heterogeneous collections. Suppose a hero has an AOE skill that targets an array of different enemy types. You must use Array[&Sprite] instead of Array[V], as the latter cannot accommodate different concrete types simultaneously.

Moonbit also supports direct method calls akin to C++'s virtual functions:

pub trait SayName {
  say_name(Self) -> String
}

pub impl SayName for Hero with say_name(_) {
  "hero"
}

pub impl SayName for Enemy with say_name(_) {
  "enemy"
}

pub impl SayName for Merchant with say_name(_) {
  "merchant"
}

test "say_name" {
  fn who_are_you(s: &SayName) -> String {
    s.say_name()  // Directly call the trait method, like a virtual function
  }

  let hero = Hero::new();
  let enemy = Enemy::new();
  let merchant = Merchant::new();
  inspect(who_are_you(hero), content="hero")
  inspect(who_are_you(enemy), content="enemy")
  inspect(who_are_you(merchant), content="merchant")
}

Explicit RTTI

Essentially, Moonbit's ADT approach makes C++'s implicit RTTI process explicit. Developers know exactly what types exist, and the compiler can perform completeness checks at compile time.

Multi-Level Inheritance: Building Complex Capability Hierarchies

As the game system evolves, we notice that both Hero and Enemy have hp (hit points), damage (attack power), and an attack method. Can we abstract these common features into a Warrior (fighter) layer?

Multi-Level Inheritance in C++

In C++, we can naturally insert new intermediate layers into the inheritance chain:

class Warrior : public Sprite {
protected: // Using protected so subclasses can access
    double hp;
    double damage;

public:
    Warrior(int id, double x, double y, double hp, double damage)
        : Sprite(id, x, y), hp(hp), damage(damage) {}

    virtual void attack(Sprite& target) = 0; // All warriors can attack

    double getHP() const { return hp; }
    double getDamage() const { return damage; }
};

class Hero final : public Warrior {
    private:
        int money;
    public:
        Hero(int id, double x, double y, double hp, double damage, int money)
            : Warrior(id, x, y, hp, damage), money(money) {}
};

class Enemy final : public Warrior {
    public:
        Enemy(int id, double x, double y, double hp, double damage)
            : Warrior(id, x, y, hp, damage) {}
};

class Merchant final : public Sprite {
    public:
        Merchant(int id, double x, double y) : Sprite(id, x, y) {}
}; // Merchant still directly inherits from Sprite

This forms a clear inheritance chain: Sprite → Warrior → Hero/Enemy, and Sprite → Merchant.

Composition-Based Multi-Level Capabilities in Moonbit

In Moonbit, we stick to composition, building a more flexible capability system:

pub struct WarriorData {
  hp: Double
  damage: Double
}

// Warrior trait inherits from Sprite, forming a capability hierarchy
pub trait Warrior : Sprite {
  getWarriorData(Self) -> WarriorData
  asWarriorEnum(Self) -> WarriorEnum
  attack(Self, target: &Warrior) -> Unit = _  // Default implementation
}

pub enum WarriorEnum {
  Hero(Hero)
  Enemy(Enemy)
}

// Redefine Hero to compose two types of data
pub struct Hero {
  sprite_data: SpriteData    // Base sprite data
  warrior_data: WarriorData  // Warrior data
  money: Int                 // Hero-specific data
}

// Hero needs to implement multiple traits
pub impl Sprite for Hero with getSpriteData(self) {
  self.sprite_data
}

pub impl Warrior for Hero with getWarriorData(self) {
  self.warrior_data
}

pub impl Warrior for Hero with asWarriorEnum(self) {
  Hero(self)
}

// Redefine Enemy
pub struct Enemy {
  sprite_data: SpriteData
  warrior_data: WarriorData
}

pub impl Sprite for Enemy with getSpriteData(self) {
  self.sprite_data
}

pub impl Warrior for Enemy with getWarriorData(self) {
  self.warrior_data
}

pub impl Warrior for Enemy with asWarriorEnum(self) {
  Enemy(self)
}

Sometimes, we might need to convert a parent type to a child type. For example, our merchant might react differently to different Sprites: when encountering a Warrior, they say, "Want to buy something?"; when encountering another merchant, they do nothing. In such cases, we need to convert the Sprite parent type to the Warrior child type. The recommended approach is to add a tryAsWarrior function to the Sprite trait:

pub trait Sprite {
  // other methods
  tryAsWarrior(Self) -> &Warrior? = _  // Attempt to convert to Warrior
}

impl Sprite with tryAsWarrior(self) {
  match self.asSpriteEnum() {
    // The first item needs `as &Warrior` to inform the compiler that the expression returns a &Warrior.
    // Without this `as` statement, the compiler would infer the type as `Hero` based on the first expression,
    // leading to a compilation error.
    Hero(h) => Some(h as &Warrior)
    Enemy(e) => Some(e)
    _ => None
  }
}

pub fn Merchant::ask(self: Merchant, s: &Sprite) -> String {
  match s.tryAsWarrior() {
    Some(_) => "Want to buy something?"  // Speak to warriors
    None => ""                           // Stay silent for other types
  }
}

The brilliance of this design lies in its ultimate flexibility:

  • Hero and Enemy compose SpriteData and WarriorData while implementing both Sprite and Warrior traits, gaining all required capabilities.
  • Merchant only needs to compose SpriteData and implement the Sprite trait.
  • If we later introduce a Mage capability, we simply define MageData and the Mage trait.
  • A character can even be both a Warrior and a Mage, becoming a "spellblade," without dealing with C++'s diamond inheritance problem.

The Diamond Inheritance Problem

Suppose we want to create a Profiteer class that is both a merchant and an enemy. In C++, if Profiteer inherits from both Enemy and Merchant, diamond inheritance occurs: Profiteer ends up with two copies of Sprite data! This can lead to bizarre bugs where modifying one copy of the data doesn't reflect in the other. Moonbit's composition approach avoids this problem entirely.

Deep Issues with Traditional Object-Oriented Programming

At this point, you might think, "Moonbit's approach requires more code and seems more complex!" Indeed, in terms of lines of code, Moonbit appears to need more "boilerplate." However, in real-world software engineering, traditional OOP has several deep-seated issues:

1. Fragile Inheritance Chains

Problem: Any modification to a parent class affects all subclasses, potentially causing unpredictable ripple effects.

Imagine your RPG game has been out for two years, with hundreds of different Sprite subclasses. Now you need to refactor the base Sprite class. You'll quickly realize this is impractical. In a traditional inheritance system, this change impacts every subclass, and even minor tweaks can have massive consequences. Some subclasses might exhibit unexpected behavior changes, requiring you to manually inspect and test all related code.

Moonbit's Solution: Compositional design lets us use ADTs to immediately identify all Sprite subclasses, clearly understanding the scope of any refactoring.

2. The Nightmare of Diamond Inheritance

Problem: Multiple inheritance can lead to diamond inheritance, causing data duplication and method call ambiguities.

As mentioned earlier, when the Profiteer class inherits from both Enemy and Merchant, it ends up with two copies of Sprite data. This not only wastes memory but can also lead to data inconsistency bugs.

Moonbit's Solution: Composition naturally avoids this issue. Profiteer can have SpriteData, WarriorData, and MerchantData, all clearly defined.

3. Runtime Error Risks

Problem: Many issues in traditional OOP only surface at runtime, increasing debugging difficulty and project risk.

Recall the dynamic_cast example earlier? If you add a new subclass but forget to update the relevant type-checking code, the bug only appears when that code path executes. In large projects, this could mean bugs are discovered only in production.

Moonbit's Solution: ADTs paired with pattern matching provide compile-time type safety. Omitting any case triggers a compiler error.

4. Complexity Explosion

Problem: Deep inheritance trees become difficult to understand and maintain.

After years of development, your game might evolve an inheritance tree like this:

Sprite
├── Warrior
│   ├── Hero
│   │   ├── Paladin
│   │   ├── Berserker
│   │   └── ...
│   └── Enemy
│       ├── Orc
│       ├── Dragon
│       └── ...
├── Mage
│   ├── Wizard
│   └── Sorceror
└── NPC
    ├── Merchant
    ├── QuestGiver
    └── ...

When refactoring, you might spend significant time just understanding this complex inheritance web, and any change could have unintended side effects.

Moonbit's Solution: A flat, compositional structure makes the system easier to understand. Each capability is an independent trait, and composition relationships are immediately clear.

Conclusion

Through this in-depth comparison, we've seen two fundamentally different philosophies of object-oriented programming:

  • Traditional OOP in C++: Inheritance-based "is-a" relationships, intuitive but prone to complexity traps.
  • Modern OOP in Moonbit: Composition-based "has-a" relationships, slightly more complex initially but more elegant in the long run.

While Moonbit's approach requires more "boilerplate" code, this extra code buys us:

  • Better Type Safety: More errors caught at compile time.
  • Clearer Architecture: Composition relationships are easier to understand than inheritance.
  • Easier Maintenance: Changes have more controlled impact.
  • Fewer Runtime Errors: ADTs and pattern matching ensure completeness.

We must acknowledge that traditional inheritance still has value for small projects or specific scenarios. However, as software systems grow in complexity, Moonbit's "composition over inheritance" philosophy demonstrates superior adaptability and maintainability.

We hope this article provides valuable guidance for your Moonbit programming journey, helping you leverage Moonbit's design strengths when building complex systems.


Full Code Example

pub struct SpriteData {
  id: Int
  mut x: Double
  mut y: Double
}

pub fn SpriteData::new(id: Int, x: Double, y: Double) -> SpriteData {
  SpriteData::{ id, x, y }
}

// 2. Define the Trait describing common behaviors
pub trait Sprite {
  getSpriteData(Self) -> SpriteData
  asSpriteEnum(Self) -> SpriteEnum
  tryAsWarrior(Self) -> &Warrior? = _
  getID(Self) -> Int = _
  getX(Self) -> Double = _
  getY(Self) -> Double = _
  setX(Self, Double) -> Unit = _
  setY(Self, Double) -> Unit = _
}

// Default implementations for Sprite
impl Sprite with getID(self) {
  self.getSpriteData().id
}

impl Sprite with getX(self) {
  self.getSpriteData().x
}

impl Sprite with getY(self) {
  self.getSpriteData().y
}

impl Sprite with setX(self, new_x) {
  self.getSpriteData().x = new_x
}

impl Sprite with setY(self, new_y) {
  self.getSpriteData().y = new_y
}

impl Sprite with tryAsWarrior(self) {
  match self.asSpriteEnum() {
    Hero(h) => Some(h as &Warrior)
    Enemy(e) => Some(e)
    _ => None
  }
}

pub enum SpriteEnum {
  Hero(Hero)
  Enemy(Enemy)
  Merchant(Merchant)
}

pub struct WarriorData {
  hp: Double
  damage: Double
}

pub trait Warrior : Sprite {  // Warrior inherits from Sprite
  getWarriorData(Self) -> WarriorData
  asWarriorEnum(Self) -> WarriorEnum
  attack(Self, target: &Warrior) -> Unit = _
}

impl Warrior with attack(self, target) {
  ignore((self, target))
  // ...
}

pub enum WarriorEnum {
  Hero(Hero)
  Enemy(Enemy)
}

// Define Hero
pub struct Hero {
  sprite_data: SpriteData
  warrior_data: WarriorData
  money: Int
}

pub fn Hero::new(
) -> Hero {
  let sprite_data = SpriteData::new(0, 42, 33)
  let warrior_data = WarriorData::{ hp: 100, damage: 20 }
  Hero::{sprite_data, warrior_data, money: 1000}
}

pub impl Sprite for Hero with getSpriteData(self) {
  self.sprite_data
}

pub impl Sprite for Hero with asSpriteEnum(self) {
  Hero(self)
}

pub impl Warrior for Hero with getWarriorData(self) {
  self.warrior_data
}

pub impl Warrior for Hero with asWarriorEnum(self) {
  WarriorEnum::Hero(self)
}

// Define Enemy
pub struct Enemy {
  sprite_data: SpriteData
  warrior_data: WarriorData
}

pub fn Enemy::new() -> Enemy {
  let sprite_data = SpriteData::new(0, 42, 33)
  let warrior_data = WarriorData::{ hp: 100, damage: 5}
  Enemy::{sprite_data, warrior_data}
}

pub impl Sprite for Enemy with getSpriteData(self) {
  self.sprite_data
}

pub impl Sprite for Enemy with asSpriteEnum(self) {
  Enemy(self)
}

pub impl Warrior for Enemy with getWarriorData(self) {
  self.warrior_data
}

pub impl Warrior for Enemy with asWarriorEnum(self) {
  WarriorEnum::Enemy(self)
}

// Define Merchant
pub struct Merchant {
  sprite_data: SpriteData
}

pub fn Merchant::new() -> Merchant {
  let sprite_data = SpriteData::new(0, 42, 33)
  Merchant::{sprite_data}
}

pub impl Sprite for Merchant with getSpriteData(self) {
  self.sprite_data
}

pub impl Sprite for Merchant with asSpriteEnum(self) {
  Merchant(self)
}

pub fn Merchant::ask(self: Merchant, s: &Sprite) -> String {
  ignore(self)
  match s.tryAsWarrior() {
    Some(_) =>"what to buy something?"
    None => ""
  }
}

test "who are you" {
  fn who_are_you(s: &Sprite) -> String {
    match s.asSpriteEnum() {
      Hero(_) => "hero"
      Enemy(_) => "enemy"
      Merchant(_) => "merchant"
    }
  }
  let hero = Hero::new();
  let enemy = Enemy::new();
  let merchant = Merchant::new();
  inspect(who_are_you(hero), content="hero")
  inspect(who_are_you(enemy), content="enemy")
  inspect(who_are_you(merchant), content="merchant")
}
pub trait SayName {
  say_name(Self) -> String
}

pub impl SayName for Hero with say_name(_) {
  "hero"
}

pub impl SayName for Enemy with say_name(_) {
  "enemy"
}

pub impl SayName for Merchant with say_name(_) {
  "merchant"
}

test "say_name" {
  fn who_are_you(s: &SayName) -> String {
    s.say_name()
  }

  let hero = Hero::new();
  let enemy = Enemy::new();
  let merchant = Merchant::new();
  inspect(who_are_you(hero), content="hero")
  inspect(who_are_you(enemy), content="enemy")
  inspect(who_are_you(merchant), content="merchant")
}

test "merchant ask" {
  let hero = Hero::new();
  let enemy = Enemy::new();
  let merchant = Merchant::new();

  inspect(merchant.ask(hero), content="what to buy something?")
  inspect(merchant.ask(enemy), content="what to buy something?")
  inspect(merchant.ask(merchant), content="")
}

MoonBit Pearls Vol.01:Implementing a Pratt Parser in MoonBit

· 8 min read
myfreess

Last week, the MoonBit community launched the "MoonBit Pearls" campaign, calling for high-quality documentation and code examples. After meticulous selection, we are proud to officially release the first featured article in the "MoonBit Pearls" column this week.

As a long-term knowledge repository, this column will continuously curate and showcase outstanding documentation. We encourage more developers to contribute in the future, collectively enriching the MoonBit community ecosystem.

Below is the content of the first submission. The author provides a comprehensive case study demonstrating how to implement a Pratt parser in MoonBit:

Implementing a Pratt Parser in MoonBit

During the compilation process, syntax analysis (also known as parsing) is a critical step. The primary responsibility of a parser is to convert a stream of tokens into an Abstract Syntax Tree (AST).

This article introduces an implementation algorithm for parsers: Pratt Parsing, a top-down operator precedence parsing method, and demonstrates how to implement it using MoonBit.

Why Use a Pratt Parser?

Infix expressions are familiar to almost every programmer. Even staunch Lisp/Forth programmers are aware that a majority of the world writes arithmetic expressions like this:

24 * (x + 4)

For compiler (or interpreter) writers, such infix expressions are slightly more challenging to parse compared to the prefix expressions used in Lisp or the postfix expressions used in Forth.

For example, a naive handwritten recursive descent parser would require multiple mutually recursive functions and the elimination of left recursion during syntax analysis, making the code less maintainable as the number of operators grows. Parser generator tools are also not entirely satisfactory for this problem. Consider the BNF for a simple arithmetic expression with addition and multiplication:

Expr ::=
    Factor
    | Expr '+' Factor
Factor ::=
    Atom
    | Factor '*' Atom
Atom ::=
    'number'
    | '(' Expr ')'

This does not look very intuitive and might require revisiting formal language theory from university courses.

Some languages, like Haskell, support custom infix operators, which are almost impossible to handle simply with parser generator tools.

The Pratt parser elegantly solves the problem of parsing infix expressions while also being easily extensible to support new operators (without modifying the source code!). It is recommended alongside recursive descent parsers in the renowned compilation textbook Crafting Interpreters and is used in projects like rust-analyzer.

Binding Power

In Pratt parsers, the concept used to describe associativity and precedence is called binding power. For each infix operator, the binding power is represented as a pair of integers—one for the left and one for the right. For example:

expr:   A     +     B     *     C
power:     3     3     5     5

As the name suggests, a higher number means higher priority in capturing an operand (in this example, A, B, and C are operands).

The above example demonstrates operators with different precedence levels, while the associativity of the same operator is represented by asymmetric binding powers.

expr:   A     +     B     +     C
power:     1     2     1     2

In this case, when parsing reaches B, the expression is grouped as follows due to the higher binding power on the left:

expr:   (A + B)     +     C
power:           1     2

Next, let’s see how the Pratt parser uses this concept during execution.

Overview and Preparation

The main framework of a Pratt parser looks like this:

fn parse(self : Tokens, min_bp : Int) -> SExpr ! ParseError {
    ...
    while true {
       parse(...)
    }
    ...
}

As shown above, it is implemented using a combination of recursion and loops. This corresponds to two modes:

  • The leftmost expression is always the innermost, e.g., "1 + 2 + 3" = "(1 + 2) + 3", which can be parsed using just a loop.
  • The rightmost expression is always the innermost, e.g., "1 + 2 + 3" = "1 + (2 + 3)", which can be parsed using recursion alone.

The min_bp parameter represents the binding power of the nearest unfinished operator on the left.

Our goal is to read a token stream and output a prefix expression without worrying about precedence:

enum SExpr {
  Atom(String)
  Cons(Char, Array[SExpr])
}

impl Show for SExpr with output(self, logger) {
    match self {
        Atom(s) => logger.write_string(s)
        Cons(op, args) => {
            logger.write_char('(')
            logger.write_char(op)
            for i = 0; i < args.length(); i = i + 1 {
                logger.write_char(' ')
                logger.write_string(args[i].to_string())
            }
            logger.write_char(')')
        }
    }
}

test {
    inspect(Cons('+', [Atom("3"), Atom("4")]), content="(+ 3 4)")
}

Since errors may occur during this process, the return type of parseExpr is SExpr ! ParseError.

Before writing the parser, however, we need to split the input string into a simple token stream.

enum Token {
  LParen
  RParen
  Operand(String)
  Operator(Char)
  Eof
} derive(Show, Eq)

struct Tokens {
  mut position : Int
  tokens : Array[Token]
}

This token stream requires two methods: peek() and pop().

The peek() method retrieves the first token in the stream without modifying the state—in other words, it is side-effect-free and merely "peeks" at the upcoming content. For an empty token stream, it returns Eof.

fn peek(self : Tokens) -> Token {
  if self.position < self.tokens.length() {
    self.tokens.unsafe_get(self.position)
  } else {
    Eof
  }
}

The pop() method consumes a token after peeking.

fn pop(self : Tokens) -> Token {
  if self.position < self.tokens.length() {
    let pos = self.position
    self.position += 1
    self.tokens.unsafe_get(pos)
  } else {
    Eof
  }
}

The tokenize function is responsible for converting a string into a token stream.

fn isDigit(this : Char) -> Bool {
    this is '0'..='9'
}

fn isAlpha(this : Char) -> Bool {
    this is 'A'..='Z' || this is 'a'..='z'
}

fn isWhiteSpace(this : Char) -> Bool {
    this == ' ' || this == '\t' || this == '\n'
}

fn isOperator(this : Char) -> Bool {
    let operators = "+-*/"
    operators.contains_char(this)
}

type! LexError Int

fn tokenize(source : String) -> Tokens!LexError {
    let tokens = []
    let source = source.to_array()
    let buf = StringBuilder::new(size_hint = 100)
    let mut i = 0
    while i < source.length() {
        let ch = source.unsafe_get(i)
        i += 1
        if ch == '('{
            tokens.push(LParen)
        } else if ch == ')' {
            tokens.push(RParen)
        } else if isOperator(ch) {
            tokens.push(Operator(ch))
        } else if isAlpha(ch) {
            buf.write_char(ch)
            while i < source.length() && (isAlpha(source[i]) || isDigit(source[i]) || source[i] == '_') {
                buf.write_char(source[i])
                i += 1
            }
            tokens.push(Operand(buf.to_string()))
            buf.reset()
        } else if isDigit(ch) {
            buf.write_char(ch)
            while i < source.length() && isDigit(source[i]) {
                buf.write_char(source[i])
                i += 1
            }
            tokens.push(Operand(buf.to_string()))
            buf.reset()
        } else if isWhiteSpace(ch) {
            continue
        } else {
            raise LexError(i)
        }
    } else {
        return Tokens::{ position : 0, tokens }
    }
}

test {
    inspect(tokenize("(((((47)))))").tokens, content=
      #|[LParen, LParen, LParen, LParen, LParen, Operand("47"), RParen, RParen, RParen, RParen, RParen]
    )
    inspect(tokenize("13 + 6 + 5 * 3").tokens, content=
      #|[Operand("13"), Operator('+'), Operand("6"), Operator('+'), Operand("5"), Operator('*'), Operand("3")]
    )
}

Finally, we need a function to compute the binding power of operators, which can be implemented simply using match. In practice, a key-value container should be used for easy addition of new operators.

fn infix_binding_power(op : Char) -> (Int, Int)? {
  match op {
    '+' => Some((1, 2))
    '-' => Some((1, 2))
    '/' => Some((3, 4))
    '*' => Some((3, 4))
    _ => None
  }
}

Parser Implementation

First, retrieve the first token and assign it to the variable lhs (short for left-hand side, representing the left operand).

  • If it is an operand, store it.
  • If it is a left parenthesis, recursively parse the first expression and then consume a matching right parenthesis.
  • Any other result indicates an error, which should be raised.

Next, we peek at the first operator:

  • If the result is Eof, this is not a failure—a single operand can be a complete expression, so we break the loop.
  • If the result is an operator, proceed normally.
  • If the result is a right parenthesis, break the loop.
  • Any other result raises a ParseError.

We then need to determine which operator the lhs belongs to. This is where the min_bp parameter comes into play, representing the binding power of the nearest unfinished operator on the left. Its initial value is 0 (no operator is competing for the first operand). However, we first check if the operator is a parenthesis—if so, it means we are parsing an expression inside parentheses and should break the loop to end. This is one of the reasons for using the peek method, as we cannot determine whether to consume the operator here.

After calculating the binding power (l_bp, r_bp) of the current operator, we compare l_bp with min_bp:

  • If l_bp is less than min_bp, immediately break, returning lhs to the higher-level operator waiting for the right operand.
  • Otherwise, consume the current operator using pop, recursively call parseExpr to obtain the right operand with r_bp as the new min_bp, and assign the result to lhs before continuing the loop.
type! ParseError (Int, Token) derive (Show)

fn parseExpr(self : Tokens, min_bp~ : Int = 0) -> SExpr ! ParseError {
    let mut lhs = match self.pop() {
        LParen => {
            let expr = self.parseExpr()
            if self.peek() is RParen {
                ignore(self.pop())
                expr
            } else {
                raise ParseError((self.position, self.peek()))
            }
        }
        Operand(s) => Atom(s)
        t => raise ParseError((self.position - 1, t))
    }
    while true {
        let op = match self.peek() {
            Eof | RParen => break
            Operator(op) => op
            t => raise ParseError((self.position, t))
        }
        guard infix_binding_power(op) is Some((l_bp, r_bp)) else {
            raise ParseError((self.position, Operator(op)))
        }
        if l_bp < min_bp {
            break
        }
        ignore(self.pop())
        let rhs = self.parseExpr(min_bp = r_bp)
        lhs = Cons(op, [lhs, rhs])
        continue
    }
    return lhs
}

fn parse(s : String) -> SExpr ! Error {
    tokenize(s).parseExpr()
}

Now we have an extensible parser for arithmetic expressions. Additional test cases can be added to verify its correctness:

test {
  inspect(parse("13 + 6 + 5 * 3"), content="(+ (+ 13 6) (* 5 3))")
  inspect(parse("3 * 3 + 5 * 5"), content="(+ (* 3 3) (* 5 5))")
  inspect(parse("(3 + 4) * 3 * (17 * 5)"), content="(* (* (+ 3 4) 3) (* 17 5))")
  inspect(parse("(((47)))"), content="47")
}

However, the capabilities of Pratt parsers extend beyond this. They can also parse prefix operators (e.g., bitwise NOT !n), array indexing operators arr[i], and even ternary operators c ? e1 : e2. For more detailed parsing techniques, refer to Simple but Powerful Pratt Parsing. The author of this blog implemented an industrial-grade Pratt parser in the renowned program analysis tool rust-analyzer.