Skip to content

Custom Data Structures

0.0.7 experimental 7.2

CKSP supports user-defined data structures in the form of structs, which allow you to group related data and define custom methods for manipulating that data. With structs, you can model complex entities, making your scripts more organized and aligned with real-world applications. Structs in CKSP behave similarly to classes in object-oriented languages, offering familiar functionality such as fields, methods, and constructors. This chapter explores how to create and use structs effectively, providing insights into memory management and pointer functionality to optimize your data handling in CKSP.


Introduction to Data Structures

In programming, data structures play a fundamental role in organizing information logically, making it easier to manage, manipulate, and interpret. CKSP’s structs let you combine related data fields and methods, giving you the tools to create complex data types that reflect real-world concepts. These structures allow for more readable and maintainable code, particularly in scripts with complex data requirements.

Structs in CKSP are pseudo-dynamically allocated, meaning they can be created within any scope, and the compiler manages their memory automatically using reference counting. When the scope in which a struct was created exits, the compiler cleans up the struct if it's no longer referenced elsewhere. Since vanilla KSP does not allow for dynamically allocating memory, the compiler tries to model this behaviour by automatically allocating sufficiently large arrays in the on init callback. This approach effectively simulates a "heap" where struct instances reside.


Defining a Simple Struct

Structs are blueprints for creating custom data types. They enable you to define fields for data storage and methods for data manipulation, much like classes in object-oriented languages. Let’s start by defining a simple struct in CKSP to represent a musical Note with a pitch and a velocity.

Consider the following Note struct, which models the attributes of a musical note:

1
2
3
4
struct Note
    declare pitch: int
    declare velocity: int
end struct

In this example, the Note struct contains two integer fields, pitch and velocity, which represent the note’s pitch and volume intensity. Each instance of Note can store its own values for these fields, allowing you to create and manipulate multiple notes individually.

Allowed struct Member Types

Struct member variables can be standard variables, arrays, or multidimensional arrays. However, UI control variables are excluded from struct definitions, so you cannot include them as fields within a struct. This ensures that struct instances remain focused on managing and representing data rather than interfacing directly with UI elements.


Adding a Constructor

To initialize fields when a new instance of a struct is created, CKSP uses a special method called __init__. This method, often referred to as a constructor, ensures that all necessary fields are set with meaningful values as soon as a struct is created. In CKSP, __init__ works similarly to constructors in Python and is called automatically when a struct is instantiated.

If you do not define an __init__ method, CKSP will generate one for you. This auto-generated function initializes fields in the order they are declared, setting up each instance without requiring manual intervention. However, defining your own __init__ method provides more control over how fields are initialized.

Here’s how you can define an explicit __init__ method for the Note struct:

1
2
3
4
5
6
7
8
9
struct Note
    declare pitch: int
    declare velocity: int

    function __init__(self, p: int, v: int)
        self.pitch := p
        self.velocity := v
    end function
end struct

In this version, __init__ takes two parameters, p and v, and assigns them to pitch and velocity. The keyword self allows access to the struct’s own fields, ensuring that each new Note instance has independent values for pitch and velocity. Therefore self must always be the first parameter in all struct methods.


Default Values and Persistence

Member variables in structs can be initialized with default values directly in their declarations. If a variable is not assigned a value in its declaration or through the __init__ method, it will take on the default value for its data type. However, in cases where a field remains unset (as in the example above) and is not initialized in the constructor, it may unintentionally retain the values of previously deleted instances. To avoid such issues, it’s good practice to ensure that all fields are initialized in the constructor.

Struct fields can also be made persistent, meaning they retain their values even after the instrument is closed and reopened. However, this persistence is reliable only for struct instances that are declared globally, as instances created within local scopes are deleted when their scope ends.

Constructors and Asynchronous Operations

Asynchronous operations like wait are not allowed in constructors due to variable consistently and possible memory corruption when a subsequent callback is triggered before the constructor completes. This restriction ensures that the struct's memory is stable and predictable during its initialization phase. The compiler will raise an error if such usage is detected.


Using Structs and Pointers

Structs become even more useful when you start creating instances, accessing their properties, and referencing them dynamically through pointers. CKSP allows you to instantiate structs, access their fields with dot notation, and manage references to them using pointers.

Creating Instances

You can create an instance of a struct by calling the struct name as if it were a function, which automatically invokes the __init__ method. Additionally, you can use the new keyword to allocate instances dynamically (the usage is optional but recommended for clarity). Here’s an example of creating two Note instances:

Creating Note Instances
declare note_a: Note := new Note(60, 100)
declare note_b: Note := new Note(64, 90)

In this code, note_a and note_b are declared as pointer variables and are assigned instances of the Note struct, initialized with specific values for pitch and velocity. More about the new pointer variable type later.

Accessing and Modifying Properties

Once you have created an instance, you can access and modify its fields using dot notation.

Accessing and Modifying Note Properties
1
2
3
message("Note A pitch: " & note_a.pitch)  // Outputs: "Note A pitch: 60"
note_a.pitch := 62
message("Updated Note A pitch: " & note_a.pitch)  // Outputs: "Updated Note A pitch: 62"

This code demonstrates how to read the pitch field of note_a and update it to a new value. Each struct instance maintains its own set of fields, so changes to note_a.pitch do not affect other instances.

Struct representation

In addition, CKSP offers the __repr__ method, which is automatically called whenever a struct instance is used in a string context, such as within the message function. If no custom __repr__ method is defined, the compiler generates one by default. However, for more user-friendly output, you can explicitly implement this method to provide a more descriptive representation of the struct.

Here’s an example of a custom __repr__ method for the Note struct:

1
2
3
4
5
6
7
8
struct Note
    declare pitch: int
    declare velocity: int

    function __repr__(self): string
        return "Note: Pitch " & self.pitch & ", Velocity " & self.velocity
    end function
end struct

With this custom __repr__, calling message(note_a) would output:

declare note_a: Note := new Note(62, 100)
message(note_a)  // Outputs: "Note: Pitch 62, Velocity 100"

This allows you to easily create structured, readable representations of your struct instances in string contexts, enhancing the clarity of debug output and logs.

Working with Pointer Variables

Pointers provide a way to reference struct instances dynamically. Declared like normal variables, pointers can have any user-defined struct type and hold either a reference to a struct or nil if not currently assigned. Pointers are helpful when managing references to structs without duplicating data or when passing data between functions.

To declare a pointer, specify its type and either initialize it with the constructor of a struct or optionally set it to nil (if you do not want to assign it to anything initially, it will be automatically set to nil):

declare note_ptr: Note := nil

You can then assign a struct to the pointer, making it reference that specific instance:

note_ptr := new Note(67, 80)

Now note_ptr holds a reference to a Note instance with pitch = 67 and velocity = 80. Using pointers allows you to indirectly access or modify the original struct, making it easier to work with instances without copying data.

Similar to other CKSP data types, pointers can be used with composite types. For instance, you can create arrays of pointers for multiple Note instances, or even multidimensional arrays:

1
2
3
4
5
6
declare chord[3]: Note[] := [
        new Note(60, 100), 
        new Note(64, 100), 
        new Note(67, 100)
    ]
declare patterns[2,2]: Note[][] := [new Note(60,80)]

The last example creates a 2x2 array with all elements referencing the same Note instance.

Global Pointers in Asynchronous Callbacks

While local pointer variables are safeguarded against asynchronous operations not. Assigning a global pointer variable within a thread-unsafe callback (i.e., a callback that contains wait commands or calls functions with wait commands) can lead to inconsistencies in memory addresses and incorrect reference counts.

For example, if a global pointer n is assigned to a Note object within an on note callback, and this callback is re-triggered during a wait period, n might be reassigned to a new Note object. When the first wait period expires, the subsequent operations would mistakenly apply to the new object, leaving the original object's reference count un-decremented and potentially causing a memory leak.

The compiler will issue a warning if it detects an assignment to a global pointer variable (l_value) within a thread-unsafe callback. It is highly recommended to use local pointer variables for object assignments in such scenarios to ensure predictable memory management.


Adding Custom Methods to Structs

Beyond field initialization, you can define custom methods in structs, enabling you to add behavior. For example, you might want a method to play the note based on its pitch and velocity. The following code defines a play method that triggers the note:

Adding a Play Method to the Note Struct
1
2
3
4
5
6
7
8
struct Note
    declare pitch: int
    declare velocity: int

    function play(self): int
        return play_note(self.pitch, self.velocity, 0, -1)
    end function
end struct

With this play method, you can directly trigger a Note instance:

Playing a Note
1
2
3
4
5
on note
    declare current_note : Note := new Note(EVENT_NOTE, EVENT_VELOCITY) // Initialize the note
    ...
    current_note.play() // Plays input note with input velocity
end on

This capability makes structs more flexible, allowing them to represent not only data but also actions associated with that data.


Operator Overloading

experimental

Operator overloading is supported, enabling you to define custom behaviors for standard operators like +, -, *, and more. By implementing specific methods in a struct, you can control how instances interact with operators, making their usage intuitive.

To overload an operator, define the corresponding method in your struct. For example, the __add__ method defines how instances respond to the + operator. Here’s how you could enable addition for the Note struct, treating the addition as combining the velocity of two notes:

1
2
3
4
5
6
7
8
struct Note
    declare pitch: int
    declare velocity: int

    function __add__(self, other: Note): Note // Explicitly declare return type
        return Note(self.pitch, self.velocity + other.velocity)
    end function
end struct

Now you can add two Note instances to create a new Note with combined velocity:

Adding Two Notes
1
2
3
4
declare note_a: Note := new Note(60, 70)
declare note_b: Note := new Note(60, 50)
declare note_c: Note := note_a + note_b  // note_c has pitch 60 and velocity 120
message("Combined Note C: " & note_c) // Output with overriden repr() method: Note: Pitch 60, Velocity 120

Operator Support

Operator Overloading functions are supported for the following operators:

    +   -> __add__(self, other)
    -   -> __sub__(self, other)
    * -> __mul__(self, other)
    /   -> __div__(self, other)
    mod -> __mod__(self, other)
    =   -> __eq__(self, other)
    #   -> __ne__(self, other)
    <   -> __lt__(self, other)
    <=  -> __le__(self, other)
    >   -> __gt__(self, other)
    >=  -> __ge__(self, other)


Memory Management

Automatic Memory Management

CKSP uses reference counting to automatically manage memory. Structs created within a local scope (e.g., inside a function) are automatically cleaned up when that scope exits, provided they are not referenced elsewhere. This ensures efficient memory usage without requiring manual management.

For example, a struct created within a function will be cleaned up once the function finishes:

1
2
3
4
5
6
7
8
9
function create_temporary_note()
    declare temp_note: Note := new Note(64, 90)
    message("Temporary note created:", temp_note.pitch, temp_note.velocity)
end function

on init
    create_temporary_note() // Call the function
    // After create_temporary_note finishes, temp_note is deallocated
end on

Here, temp_note is only valid within the create_temporary_note function. When the function exits, temp_note is automatically deallocated unless assigned to a pointer in an external scope.

Manual Memory Management

However, the reference counting mechanism can lead to memory leaks if you create circular references. For example, if two structs reference each other, their reference counts will never reach zero, preventing them from being automatically cleaned up even if they are no longer accessible from outside.

Consider this example of a circular reference:

Circular Reference Example
struct Node
    declare value: int
    declare next: Node
end struct

on init
    declare node_a: Node := new Node(10, nil)
    declare node_b: Node := new Node(20, nil)

    node_a.next := node_b // node_a points to node_b
    node_b.next := node_a // node_b points back to node_a, creating a cycle

    // At this point, node_a and node_b each have a reference count of at least 1,
    // even if 'node_a' and 'node_b' variables go out of scope.
    // They will never be automatically deallocated.
end on

To address such scenarios, the compiler also provides a way to manually clean up memory using the delete keyword, once a pointer is no longer needed:

1
2
3
declare note_ptr: Note := new Note(60, 100)
// ... use note_ptr
delete note_ptr // Decrements reference count and potentially frees memory

This will decrement the reference count of the struct instance to zero, effectively deleting it and freeing up memory. Manual deallocation provides a workaround for cyclic references, which are not automatically handled by the current reference counting implementation due to real-time constraints.

However, be cautious when using delete, as it can lead to dangling pointers if you try to access a deleted instance. Accessing a dangling pointer can cause runtime errors.


Recursive Data Structures

Recursive data structures are those where a struct contains instances of itself. They are useful for modeling hierarchical data like linked lists, trees, and graphs.

Example: The List Struct (Linked List Node)

The List struct represents a node in a linked list, where each node contains a value and a reference to the next node.

1
2
3
4
5
6
7
8
9
struct List
    declare value: string // Changed type to string for better example
    declare next: List

    function __init__(self, val: string, next_node: List) // Renamed parameters for clarity
        self.value := val
        self.next := next_node
    end function
end struct

You can now create instances of List and link them together to form a linked list. This is often done by prepending new elements:

Creating a Linked List from Group Names
declare ls: List := nil // Initialize the head of the list to nil

// This loop creates a linked list where each node stores a group name.
// The list is built in reverse order of how the groups are processed (prepended).
for idx in range(NUM_GROUPS)
    ls := new List(group_name(idx), ls)
end for

// Example: If NUM_GROUPS is 3 and group_name(0) is "Piano", group_name(1) "Strings", group_name(2) "Drums":
// 1st iteration: ls = new List("Piano", nil)
// 2nd iteration: ls = new List("Strings", new List("Piano", nil))
// 3rd iteration: ls = new List("Drums", new List("Strings", new List("Piano", nil)))
// Final 'ls' points to "Drums", which points to "Strings", etc.

To traverse the list and display its elements, you could use a while loop:

Traversing a Linked List
1
2
3
4
5
declare current: List := ls // Start from the head of the list
while current # nil
    message("Node value: " & current.value)
    current := current.next
end while

This loop will output the value of each node in the list until it reaches the end (nil).

Output (Example based on above list creation)
1
2
3
Node value: Drums
Node value: Strings
Node value: Piano

Advanced Usage

Structs can be more complex, containing arrays of structs or other data structures. This allows you to model more intricate relationships and data models.

Structs with Arrays of Structs: The TreeNode Struct

Let's define a tree node that can have multiple child nodes.

1
2
3
4
struct TreeNode
    declare value: int
    declare children[5]: TreeNode[] // Array of TreeNode instances
end struct

In this struct:

  • value: An integer representing the node's value.
  • children: An array of TreeNode instances. By default, array elements are initialized to nil if not explicitly assigned.

The compiler will automatically generate the __init__ method for this struct. For example, a default __init__ might look like:

1
2
3
4
5
6
function __init__(self, value: int, children: TreeNode[])
    self.value := value
    // This will assign the *reference* to the 'children' array passed as an argument.
    // Changes to 'children' inside the function will affect the original array.
    self.children := children
end function

When you pass an array to __init__ or directly assign it, remember that arrays in CKSP are passed by reference (or rather, by sharing the reference). This means if you modify the children array passed into the constructor, the original array might also be affected.

You can define methods to manipulate the tree nodes.

Adding a Child to a TreeNode
1
2
3
4
5
6
7
8
9
function add_child(self, child: TreeNode)
    for el in self.children
        if el = nil
            el := child // Assign the child to the first empty slot
            return
        end if
    end for
    message("Error: Maximum children reached. Cannot add more children to this node.")
end function

As of v0.0.7, recursive functions are not supported, so iterative methods are needed to traverse the tree. This often involves using explicit stacks or queues to manage the traversal state.