Immutable Cursor Mental Model
This document explains the architectural rationale behind immutable cursor navigation in libscid, contrasting it with mutable iterator patterns and exploring its implications for tree mutation, thread safety, and referential transparency.
1. The Problem with Mutable Cursors
In traditional chess libraries and C/C++ desktop software, navigation cursors are typically stateful, mutable pointers. Calling cursor.next() mutates internal coordinate integers or pointer offsets in place.
While this pattern requires minimal allocation overhead, it introduces severe architectural pitfalls in modern application design:
- Spooky Action at a Distance: If a cursor instance is passed into a helper function (e.g. to evaluate candidate moves or format a PGN branch), any navigation by the callee mutates the caller's position unless defensive copying is rigorously observed.
- Concurrency and Re-entrancy Hazards: Sharing a mutable cursor across asynchronous tasks or threads requires complex mutual exclusion locks.
- Fragile Branching Logic: When traversing tree structures with multiple variations, backtracking requires tracking undo stacks to restore previous coordinates manually.
2. Immutable Value Semantics for Navigation
libscid resolves these issues by adopting functional value semantics for all navigational operations:
graph LR
C0["Cursor 0 (Root)"] -- "c0.next()" --> C1["Cursor 1 (1. e4)"]
C1 -- "c1.next()" --> C2["Cursor 2 (1... e5)"]
C1 -- "c1.enter_variation(0)" --> V1["Cursor V1 (1... c5)"]
Calling navigation methods never modifies the calling instance:
# c0 remains permanently anchored at the game root:
c0 = game.create_cursor()
assert c0.is_line_start
# c1 is a distinct, newly allocated cursor pointing to 1. e4:
c1 = c0.next()
# c0 is unaffected:
assert c0.is_line_start
assert c1.previous_move_san == "e4"
2.1. Referential Transparency and Backtracking
Because cursors represent immutable snapshots of coordinates along the game tree:
- Checkpoints: You can store cursors in dictionaries, lists, or undo stacks without cloning them.
- Effortless Backtracking: Exploring a variation does not disturb earlier line pointers. When finished with a branch, simply discard the branch cursor and resume from the original instance.
- Functional Pipelines: Cursors can be passed cleanly to pure mapping, filtering, or scoring functions without fear of side-effects.
3. The Dual Nature: Immutable Navigation vs Mutable Tree State
A common question from engineers is how immutability reconciles with game editing (such as appending moves, modifying commentary, or inserting sub-lines).
In libscid, the division of responsibility is precise:
┌────────────────────────────────────────────────────────┐
│ Game Aggregate │
│ (Stateful, Mutable C++ Move Tree and Header Registry) │
└───────────────────────────▲────────────────────────────┘
│ Mutates via C ABI
┌───────────┴───────────┐
│ Cursor │
│ (Immutable Pointer) │
└───────────────────────┘
- Cursors are Immutable Pointers: A cursor is simply a lightweight handle pointing to a specific coordinate within the move tree. Its position and history cannot be mutated.
- The Game Aggregate is Mutable: When a mutating method is called on a cursor (such as
append_move(),add_variation(), orset_comment()), the mutation is applied directly to the underlyingGametree. The mutation method then returns a newCursorinstance pointing to the newly created node.
game = libscid.Game()
c0 = game.create_cursor()
# append_move mutates the underlying tree and returns a new pointer:
c1 = c0.append_move("e4")
c2 = c1.append_move("e5")
# The tree now has two moves:
assert game.mainline_move_count == 2
4. Performance and Handle Overhead
Because cursors wrap lightweight C pointers (scid_cursor_t*), allocating a new cursor in Python is exceptionally cheap:
- Minimal Memory Footprint: Each cursor instance wraps a single 64-bit C pointer handle and a Python reference to its parent
Game. - Deterministic Native Reclaim: As temporary cursor objects go out of scope, their native handles are returned to the C allocator via
weakref.finalize, avoiding memory leaks or garbage accumulation.
5. Summary
The immutable cursor pattern provides:
- Predictability: No unexpected positional state mutations across function boundaries.
- Ergonomics: Natural expression of tree traversal and variation exploration.
- Safety: Complete separation between navigational coordinates and game tree modifications.