Custom Data Structures
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:
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:
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 | |
|---|---|
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 | |
|---|---|
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:
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):
You can then assign a struct to the pointer, making it reference that specific instance:
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:
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 | |
|---|---|
With this play method, you can directly trigger a Note instance:
| Playing a Note | |
|---|---|
This capability makes structs more flexible, allowing them to represent not only data but also actions associated with that data.
Operator Overloading
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:
Now you can add two Note instances to create a new Note with combined velocity:
| Adding Two Notes | |
|---|---|
Operator Support
Operator Overloading functions are supported for the following operators:
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:
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:
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:
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.
You can now create instances of List and link them together to form a linked list. This is often done by prepending new elements:
To traverse the list and display its elements, you could use a while loop:
| Traversing a Linked List | |
|---|---|
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) | |
|---|---|
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.
In this struct:
value: An integer representing the node's value.children: An array ofTreeNodeinstances. By default, array elements are initialized tonilif not explicitly assigned.
The compiler will automatically generate the __init__ method for this struct. For example, a default __init__ might look like:
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 | |
|---|---|
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.