tl;dr

Cello is a set of C++ classes to work with ValueTree objects from the JUCE application framework. If none of those words mean anything to you, probably best to hit the eject button right now.

I created this document as a way to capture my motivations, goals, and design decisions made along the way while creating this library. You should not need to read anything in this document to use Cello successfully, but there are probably some tips and usage patterns that I’ve discovered while developing and using it on projects that reading this in addition to the documentation will, I hope, make your use more successful and pleasant.

Goals

This project has several overlapping goals:

  • make working with ValueTrees more like working with C++ objects and less like calling API functions
  • explore the gray area between compile-time strong typing as in C++ and the kind of runtime dynamic typing that’s possible using the ValueTree API
  • explore the available methods of reactive programming enabled with this system
  • build out new functionality that’s implied by the capabilities of ValueTrees but perhaps not obvious, like:
    • creating a kind of NoSQL database
    • creating a simple IPC implementation
  • In general, add support for more complex use cases where the complexity can be hidden inside the framework.
  • Simplify common use cases:
    • undo/redo managed within the framework implicitly
    • persistance to/from file

cello::Value

A new cello::Value type provides:

  • guaranteed initialization of ValueTree properties
  • type safety (including transparent conversion from arbitrary C++ types and the JUCE var type used within ValueTrees)
  • optional validator functions called on set/get
  • implementation of all the in-place arithmetic operators (for numeric types).
  • watchable at the level of individual ValueTree properties; pass a callable to a Value object and that callable will be executed whenever the Value changes.

cello::Object

A cello::Object type acts as a container of cello::Value objects, exposing them so your code can access them as if they were plain data members of your class derived from it. cello::Object also manages all of the fiddly and potentially error-prone bits of working with ValueTrees:

  • Initialization
  • Navigating the tree hierarchy
  • Searching/querying the tree
  • Persistence to/from disk
  • Undo/redo management

Additional support classes to safely use ValueTrees across thread and process boundaries (including over TCP sockets and named pipes) simplify those use cases.

The design of the classes also simplifies the implementation of applications where the internals can be both loosely and dynamically coupled together using a super fine-grained implementation of the Observer pattern to support a reactive programming style.

Motivation

Before continuing, if ValueTrees or JUCE are new to you, I’d recommend reading the offical JUCE ValueTree tutorial, watching David Rowland’s talk at ADC‘17 or the quick crash course here and then come back.

Raw ValueTrees

Since we’re assuming that you have at least a passing familiarity with ValueTrees, we’ll just start looking at some of the issues that combined to make me avoid them. Let’s assume that you have some data that you would represent like this if you were working in plain C++:

struct Child { /* whatever... */ };

struct Root 
{
    int prop1; 
    juce::String prop2;
    Child child;
};

– a simple structure that contains an int, a String, and a child structure (with internals that we don’t care about at the moment).

To get this working using ValueTrees, there’s a lot of boilerplate to write and hidden/implicit rules that need to be documented and kept in the head of every developer that will end up maintaining this code:

// [1] create Identifiers for the tree/properties. 
// please give things better names than this! 
static juce::Identifier rootTypeId { "root" };
static juce::Identifier childTypeId { "leaf" };
static juce::Identifier prop1Id { "property1" };
static juce::Identifier prop2Id { "property2" };

// [2] create a tree
juce::ValueTree rootNode { rootTypeId };

// [3] set some property values 
rootNode.setProperty (prop1Id, 42, nullptr);
rootNode.setProperty (prop2Id, "the answer!", nullptr);

// [4] create and append an empty Child tree
juce::ValueTree childNode { childNodeId };
rootNode.appendChild (childNode);

Later, someplace else in the code, you want to retrieve those values to do something with them, and the code looks like:

int val1 { rootNode.getProperty (prop1Id) };
juce::String val2 { rootNode.getProperty (prop2Id); };

Issues that are immediately obvious here:

  1. It’s really noisy – a lot of characters to do what should be (in plain C++) just int val1 { rootNode.prop1 };
  2. When I designed my little structs, I knew a priori that my data would be an integer and a string. Forcing things through the narrow aperture of a var object drops that knowledge on the ground, where you’re forced to pick it up and put it back into your head at the point of retrieval, every time you need to touch the data.
  3. As we saw above after step [2], it’s a completely valid state for a ValueTree to exist in a state where it has a type, but not yet containing all of the properties that you expect there. If you call getProperty() for a property that doesn’t exist within the tree, you’ll receive a var object that’s not valid. To work safely here you need to add some kind of defensive clauses around that code to make sure you’re behaving reasonably, like perhaps:
// easy, but little noisy, pass in a default value if the property
// doesn't exist in this tree.
int val1 { rootNode.getProperty (prop1Id, 0) };

// or you can just get the var that's returned...
juce::var val2Var { rootNode.getProperty (prop2Id); };
// ...then inspect it and respond based on its existence
juce::String val2 { val2Var.isVoid() ? "default string" : val2Var.toString() };
  1. If you want to use the (incredibly useful!) undo/redo behavior that’s built into ValueTrees, at every point where you modify one (like [3] above), you need to remember to pass in a juce::UndoManager that you’ve created separately and maintain somewhere in your code that’s visible from everywhere you need to alter a tree. Forget it once and the undo/redo behavior becomes… unpredictable from the user’s point of view.
  2. Because type info is lost in this interface, we can’t follow the Almost Always Auto practice that’s preferred in modern C++. You need to know what the desired data type is.
  3. The missing/implicit type issue is there on both ends–any type that can be represented in a juce::var can be used to set the value of a property.

ValueTree::Listener classes

If you want to be notified of changes to a ValueTree, you need to write one or more classes that implement the ValueTree::Listener interface and implement logic to respond to whichever of the events your app is interested.

ValueTree Wrappers

In 2021, I joined the team at Artiphon (RIP) who were writing and maintaining the cross-platform companion app that works with instruments manufactured by the company, and ValueTrees were inescapable in the codebase, but mediated through a ValueTreeWrapper class originally created by Chris Roberts (known as cpr2323 on the JUCE forum and elsewhere). This class addresses many of the things about working with ValueTrees that I found problematic, especially regarding type safety.

Some interesting design features of this approach:

  • The ‘wrapping’ behavior was designed to allow you to initialize an instance of a ValueTreeWrapper class with either a ValueTree of the type expected in the wrapper class, or a ValueTree that has a child ValueTree of that type. If either of those are the case, the VTW object will wrap that tree and begin using it as its data storage. If not passed (whether directly or as a child) a tree of the expected type, the VTW system will create a new ValueTree of the expected type, initialize it (based on code in an override of a virtual method), and add it as a child to the ValueTree passed as an argument to its ctor.
  • Type safety is implemented by exposing data in the tree through a pair of getXXX and setXXX methods for each property stored in the tree, each acccepting or returning data in the type required for that property.
  • For each property for which a developer will want to be alerted on change, an std::function signature of the appropriate type will be defined and available as a public data member of the VTW class.
  • The VTW class itself is-a ValueTree::Listener, and when working with the class, you will add code in the override of valueTreePropertyChanged() for each of the properties in the ValueTree you’re listening to, calling the std::function associated with the identifer if the property that has changed.

All of these details were a nice improvement on the usability of working with ValueTrees through the raw API, but as I worked with them, there was still enough friction to make me idly start thinking about how there must be ways to reduce or eliminate each of those pain points that I was stubbing my toes on regularly.

The rest of this document will contain a record of the thought processes and design attempts that I went through to develop the Cello library and document some of the ways that I’ve found it useful in my daily practice writing JUCE code.

A Skeptic Awakes

At some point when thinking about this, I realized that underneath all of the noise in the API, programming using ValueTrees wasn’t that different from working in the Python language, which I love. A Python object is really just an associative array that maps names to some things, and it’s possible to add and remove attributes from an object at runtime. The things pointed at by names in an object can be of arbitrary type, and most of the time you don’t care what the actual type of that thing is, as long as it can respond to whatever requests you make of it.

Obviously, the metaphor only goes so far, but this insight was enough to make me interested in seeing how far I could take things, even if only for the purpose of a little programming workout.

Nomenclature and Conventions

In this document, Object and Value (uppercased and styled as code) will indicate the classes defined in the Cello library. Lowercased ‘object’ and ‘value’ will carry their conventional, generic meanings. Wherever I felt there was a potential for confusion I incluse explicit namespace indications, e.g. cello::Object or juce::ValueTree, at least on first usage.

You should be able to decode a sentence like “The foo object is derived from Object and we retrieve an integer value from its Value named bar.”

When referring to the chunk of data inside a ValueTree that is accessed using a Value we will call it a ‘property’.

Cello itself takes its name from the idea of ‘wrapping’ a JUCE ValueTree; one of the most important developments in packaging was the widespread availability of cellophane to wrap products to keep them dry and clean. Both pronunciations “Sello” and “Chello” are accepted and encouraged equally; anyone found arguing that one is more correct than the other will be in danger of having their license to use the library revoked.

Leaky Abstractions

As Joel Spolsky wrote over 20 years ago:

All non-trivial abstractions, to some degree, are leaky.

It would be (perhaps) nice if using Cello prevented you from needing to know anything about the JUCE ValueTree API, but that’s not the case. There are some use cases where Cello can only give you a raw ValueTree to work with, or you’ll need a ValueTree to work with some other API. There are some methods in the Cello API that are just a trivial layer over the ValueTree API. A certain amount of time spent reading the ValueTree API docs will not be wasted.

Availablility, etc.

  • Available on github
  • Documentation extracted via Doxygen from the source also available on github
  • Released under the MIT License
  • Distrubuted as a JUCE module. Drop it into a project and start using it.
  • Works with C++17 or later

Share via BlueskyLinkedInMastodonEmailRedditX


Reading Time

~8 min read

Published

Cello

Category

Cello

Tags

Find Me: