The cello::Value
class acts as a proxy to a property in a ValueTree that’s designed to operate as much as possible like a regular variable of the property’s type, plus some additional magic thrown in as well. Internally, Value objects store a reference to the cello::Object
that owns them, and which wraps the ValueTree that’s holding their property, and the juce::Identifier
that names it. All interactions with that property use the Object
and ValueTree APIs behind the scenes, but that’s not something that you’ll typically need to think about.
A Value
object can store any data type that:
- can be represented using a
juce::var
, either directly as a type thatvar
supports, or that has a template speciazlization for thejuce::VariantConverter
struct to define a mechanism to convert the data type into avar
representation. - has an
operator !=
defined; we use this to handle property updates correctly.
Warning
This document may be out of date with the current state of the released library; always check the latest source.
Construction
The Value
object is designed to always exist in the context of being a member variable of an Object
, since the model we’re following here is to attempt to make our Object
as much like a regular C++ object with member variables as we can. The Value
class is a template, and as such the declaration of a Value
must include the type it is templated on.
The constructor for Value
is this:
/**
* @brief Construct a new Value object
*
* @param data The cello::Object that owns this Value
* @param id Identifier of the data
* @param initVal default initialized state for this value.
*/
Value (Object& data, const juce::Identifier& id_, T initVal = {})
As discussed in the overview and goals sections, an important goal for this project was being able to be confident that all of the Value
s in any Object
we create exist before we attempt to access them, and that they are created with a known state at creation time.
The following lines in the Value
constructor make sure that this happens:
if (!object.hasattr (id))
object.setattr<T> (id, initVal);
When the Value
is being constructed, it:
- checks the owning object to see whether it already has a property that uses its identifier.
- if not, it adds a new property for its identifer and sets it to the specificied initial value.
In the Object
section of this document, there’s a discussion of the difference between an Object
that creates a new ValueTree
and one that wraps an existing one. When an Object
wraps a ValueTree
and that tree contains this property already, this bit of code is skipped.
Typically, we declare and define Values
directly in the class or struct declaration as members, using code like:
struct MyCelloStruct : public cello::Object
{
static const inline juce::Identifier fooId { "foo" };
cello::Value<int> foo { *this, fooId, 100 };
};
That’s a bit noisy, and since making our code less repetitive and verbose was a design goal, we define a preprocessor macro to streamline this, which can be written in this simpler form:
MAKE_VALUE_MEMBER (int, foo, 100);
…which expands to exactly the code previously shown. In general, I avoid creating macros without careful thought and time spent looking for better solutions; in this case I was unable to come up with anything better.
Set a Value
Once you’ve created a Value
as part of an Object
, changing the value that the property stores works exactly as you’d expect it to – assign a new value of the correct type to it:
myObject.val = newValue;
// or if you prefer to make it look like a function call:
myObject.val.set (newValue);
Any attempt to update the Value
with data of the wrong type will be caught by the compiler.
Both of these do exactly the same thing, and include some hidden features internally to help you protect yourself.
Undo/Redo
If the Object
to which this Value
belongs is configured to manage undo/redo operations, all operations that modify the Value
will automatically become undoable. The underlying ValueTree API requires that the developer manage this manually at the point of every function call that modifies a tree. Mixing calls that sometimes do and sometimes don’t pass in a juce::UndoManager
will result in a system that behaves unpredicably to a user.
Making things even easier, by configuring the top-most Object
in a hierarchy to be undoable, all child Object
s in that hierarchy will use the same UndoManager
.
onSet()
Validator
You may (optionally) define a validation function that will be called every time this Value
is updated. Its signature is:
using ValidatePropertyFn = std::function<T (const T&)>;
so, it accepts a value of type T
by reference and returns a T
by value. You may do anything you like in this function, but obvious things include:
- perform assertions for debug-time sanity checks
- ensure that class invariants are maintained
- perform range validation
For example, assume that we have a Value
of type float
and its value must be restricted to the range between 0.0 and 1.0:
// create and assign the validator function
myObject.restrictedFloatValue.onSet = [] (const float& v) { return std::clamp (v, 0,f. 1.f); };
// try to set an out of range value
myObject.restrictedFloatValue = 300.3f;
// the value afterwards will be 1.f, maintaining the range.
An interesting side effect of the onSet
(and onGet
, discussed below) facility is that we no longer need our Value
members to be private
to maintain encapsulation; whatever implementation details we might have wanted to put inside of member functions can be placed in these validator functions, and client code using our Object
s can write to and read from our Value
s as if they were plain old data, making code that works with our Object
s much cleaner. Elsewhere in this doc there’s a discussion of the “Uniform Access Principle” (link TKTK) and the benefits that it can bring your code.
onPropertyChange()
Notifications
Writing code that can easily be notified of changes in app state without requiring tight coupling of (e.g.) app logic and UI components is one of the more powerful aspects of working with ValueTrees in general, and the design of Object
s and Value
s is designed to remove all of the friction that comes along with using ValueTrees directly – you don’t need to implement a ValueTree::Listener
class and then write and maintain a lengthy tree of if
/else
logic to handle individual property changes.
Instead, you can just pass a PropertyUpdateFn
callable to a Value
or an Object
and directly insert logic to execute upon a property change. The signature of that function is:
using PropertyUpdateFn = std::function<void (const juce::Identifier&)>;
If you assume that you’ve created this:
struct UpdateDemo : public cello::Object
{
MAKE_VALUE_MEMBER (int, updateVal, {});
};
…then it can be used like this:
UpdateDemo ud;
ud.updateVal.onPropertyChange ([&ud] (const juce::Identifier&)
{
std::cout << "updateVal = " << ud.updateVal.get ();
});
ud.updateVal = 100;
…and the property update callback will write to stdout:
updateVal = 100;
This trivial example is clear, but only hints at the power of the technique. Once you’re passing your application state as a hierarchy of Object
s, any code anyplace in your app can listen for any state changes that are pertinent to it. On a recent project I worked on, we were asked to add analytics tracking after the app had long since entered a maintenance phase. The typical approach you’ll see to adding analytics would be to find the spots in your code where interesting things happen and insert calls to generate analytics events to send to a server someplace. Instead, we added a single new source file that wrapped a bunch of Object
s in the hierarchy and added property change listeners to any Value
we wanted to be able to track on the server. It was totally non-intrusive and painless.
Get a Value
Retrieving the current value of a Value
is as simple as setting it was:
operator T
// compiler will reject if the types don't match
// because of context, this calls the Value's operator T()
// to return the referenced value.
const int update { ud.updateVal };
// you can also force this with a cast:
const auto update2 { static_cast<int> (ud.updateVal) };
T get()
// ...or if you prefer to use auto, use get(), which returns the
// correct (templated) type for the Value being retrieved.
const auto update3 { ud.updateVal.get () };
Avoid the easy mistake that I make when using auto
here—it’s easy to get so used to the fact that a Value
operates so much like its template type that you forget that it isn’t really an instance of that type. If you write this code:
auto updateError { ud.updateVal };
…the type of updateError
is not int
, it’s cello::Value<int>
, so any downstream code you write attempting to use the updateError
variable as an integer will refuse to compile, probably leaving you scratching your head for a minute.
onGet()
Validator
Cello also offers the option to provide a validation function that will be executed when a value is returned from a Value
. It has the same signature as the onSet
function has, std::function<T (const T&)>
. You can do anything useful in this function: assertions, logging, validating and correcting ranges, even computing new values for a property when they’re accessed instead of returning the value stored in the underlying tree.
Forcing Updates
The normal behavior of ValueTrees is to only send a property change notification when the valus of a property actually changes. There may be valid cases where your code wants to send change notifications to listeners even if the value remains constant. In this case, you can set a Value
to perform forced updates any time it’s set:
UpdateDemo ud;
// set the 'updateVal` Value to always send change notifications.
ud.updateVal.forceUpdate (true);
ud.updateVal = 1;
ud.updateVal = 1;
// at this point, any property change listeners will have executed twice.
We also provide a small utility class that lets you enable this behavior and then turn it back off when your code exits the current scope:
UpdateDemo ud;
{
// creating this object sets ud.updateVal to perform forced updates.
ScopedForceUpdater sfu { ud.updateVal };
ud.updateVal = 1;
ud.updateVal = 1;
// any property change listeners will execute twice.
}
// on exiting the scope, forced updates are turned off for the updateVal Value
ud.updateVal = 1;
// the value didn't change, so the update listeners will not be executed.
Excluding Listeners
It can be useful to prevent a listener to an Object
or Value
to be omitted from the list of listeners receiving property update callbacks—consider the case where we set a Value that we’re also receiving these updates. It’s very easy to create a situation where an infinite loop of updates happens.
To set this behavior for an individual Value
, pass a pointer to the ValueTree::Listener
object to Value::excludeListener()
(and don’t forget that our Object
class is a ValueTree::Listener
).
This can be controlled at the level of individual Value
s, or for an entire Object
.
Creating Variant Converters
the juce::VariantConverter
structure is a template that can be specialized for any type to define a mechanism by which a data type may be converted into a juce::var
and then back into the original type. The struct declares two member functions that must be overloaded:
// convert a var back into the desired data type
static T juce::VariantConverter<T>::fromVar (const juce::var& v);
// convert a value from its native data type into a var.
static var juce::VariantConverter<T>::toVar (const T& t);
A common technique when creating these converters is to serialize your data in and out of a string representation, like:
namespace juce
{
/**
* @brief A variant converter template specialization for
* std::complex<float> <--> & juce::var.
* VariantConverter structs need to be in the juce namespace for
* them to work properly.
*
* We round-trip the data through a string that uses a ':' as the delimiter
* between the values.
*/
template <> struct VariantConverter<std::complex<float>>
{
static std::complex<float> fromVar (const var& v)
{
const auto tokens { juce::StringArray::fromTokens (v.toString (), ":", "") };
if (tokens.size () == 2)
return { tokens[0].getFloatVal (), tokens[1].getFloatVal () };
jassertfalse;
return {};
}
static var toVar (const std::complex<float>& val)
{
juce::String s { val.real () };
s << ":" << juce::String { val.imag () };
return s;
}
};
} // namespace juce
A less well-known technique (I first saw it on the JUCE forum in a post by matkatmusic) is to take advantage of the fact that an Array<var>
is a var
. If each element of your data type can itself be represented as a var
, you can skip the string conversion:
namespace juce
{
/**
* @brief A variant converter template specialization for
* std::complex<float> <--> & juce::var.
* VariantConverter structs need to be in the juce namespace for
* them to work properly.
*/
template <> struct VariantConverter<std::complex<float>>
{
static std::complex<float> fromVar (const var& v)
{
if (const auto* array = v.getArray (); array != nullptr && array->size () == 2)
return { array->getUnchecked (0), array->getUnchecked (1) };
jassertfalse;
return {};
}
static var toVar (const std::complex<float>& val)
{
Array<var> array;
array.set (0, val.real ());
array.set (1, val.imag ());
return array;
}
};
} // namespace juce
The downside of this approach that leads me to use it more rarely than I would like is that the JUCE internals that are used when converting a ValueTree to text (e.g. using the toXmlString()
method of ValueTrees, or when persisting an Object
to file as XML) will not work correctly, and will assert when you’re debugging.
When your Value
s are working with types that aren’t directly representable as var
s, the get and set mechanisms inside the Value
class will invoke the appropriate VariantConverter
automatically; once you’ve written the converter for a type, you don’t need to think about it again.
Arithmetic Operators
To make Value
s holding values of arithmetic types behave more naturally, the Value
class includes a set of operators that implement all of the in-place operations: +=
, -=
, *=
, /=
, pre- and post-increment ++
, pre- and post-decrement --
.
Using any of these will update the Value
and trigger any proprty change callbacks:
UpdateDemo ud;
ud.updateVal++;
++ud.updateVal;
ud.updateVal += 1000;
ud.updateVal -= 100;
ud.updateVal--;
--ud.updateVal;
Note that the behavior of the post-increment and post-decrement operators isn’t 100% as the language spec would have us do things: because this type relies on an underlying ValueTree object to provide the actual data storage, the idea of ‘returning a copy of this object in its original state’ doesn’t work. Instead, we return an instance of the T
data type itself.
Cached Values
As an optimization, the Value
class also provides an inner utility class Value::Cached
that listens for changes to a Value
and stores its last/current value in the templated data type.
This can be useful as an optimization when:
- The
Value
has anonGet()
validation function that is computationally expensive or takes a problematic amount of time. - The
Value
is a complex data type that has an expensiveVariantConverter
.
If either or both of these cases are in effect and the underlying value is needed frequently, being able to minimize those calls could be extremely helpful.
Note that this is read-only; you cannot update the Value
that is being cached here.
You can create an instance of this class in one of two ways:
Using auto
In a context where you can declare auto
variables, you can just do auto myCached { val.getCached () };
Using Complete Type
To declare a member variable of this type in a class declaration, you need to specify the full type of the CachedValue
and also initialize it.
struct CachedDemo
{
UpdateDemo ud;
cello::Value<int>::Cached cachedValue { ud.updateVal };
};
This is noisy and brittle – will break if the template type used for updateVal
changes. We provide a macro to clean this up:
#define CACHED_VALUE(name, value)
will create a Value::Cached
variable named name
, connected to and initialized by the Value
object.
As with the Value
object itself, you may retrieve the current value using either operator T()
or its get()
method.