チュートリアル:ValueTreeクラス
アプリケーションでデータを効果的に管理するためにValueTreeクラスを使用する方法を学びます。
レベル: 中級
プラットフォーム: Windows, macOS, Linux, iOS, Android
クラス: ValueTree, var, Identifier
はじめに
ValueTreeクラスはJUCEの秘密兵器です。アプリケーションの内部の複雑さを大幅に簡素化し、従来は受動的なデータモデルを実行時操作の能動的な参加者に変える力を持っています。データとGUIのインターフェース、データ変更の自動アンドゥとリドゥの提供など、開発のより面倒な側面を処理し、データの普遍的なコンテナも提供します。また、本質的にシリアライズ可能であり、エクスポートとインポートプロセスを非常に簡単にします。
あなたはそれらについて知らないかもしれませんし、あるいは単にその可能性に気づいていないかもしれません。このガイドは、それらを使用するために知っておく必要があるすべてをカバーすることを目的としています。
3つの重要なクラス
ValueTreeクラスは実際にはアンサンブルであり、それらを操作する際には、少なくとも3つの重要なクラスと常にやり取りすることになります。これらはValueTreeクラス自体、varクラス、およびIdentifierクラスです。これらに遭遇せずにValueTreeオブジェクトで意味のあることを行うことは不可能なので、それらをどのように(そしてなぜ)使用すべきかを理解することが有用です。したがって、そのようなデータを操作する実際の実践について議論する前に、以下について理解しておく必要があります:
ValueTreeクラスの概要
ValueTreeクラスは究極のコンテナクラスであり、あらゆる種類の情報を保持できます。これがこの型の明らかな役割かもしれませんが、それには多くの他の側面があり、単なる構造的な役割を超えています。
データストレージ
ValueTreeオブジェクトは柔軟で多目的なデータオブジェクトです。基本的な_型_名を持ち、任意の名前付きプロパティのセットを保持できます。これは一種の_ユニバーサル構造体_のようなものです;どのような(またはどれだけの)データを保持すべきかを事前に定義する必要はなく、実行時に好きなように使用できます。
説明のために、1つのオブジェクト(ここでは_疑似データ_として示されています)は以下の情報を保持するかもしれません:
Pet
Name = "Fluffmuff"
Animal = "Cat"
Size = 2.4
名前が示すように、ValueTreeオブジェクトはツリー構造のノードとしても機能できます。名前付きプロパティとともに、任意の数の子ノードを含むことができます(そして、それ自体を親ノードに追加することもできます)。
Pet
Name = "Fluffmuff"
Animal = "Cat"
Size = 2.4
Accessories
Collar
Colour = "Pink"
Camera
HasFlash = false
Capacity = 32
これらの構造はXML形式で形成されるものと非常に似ています;個々のValueTreeノードは、名前、プロパティ、子を持つXmlElementオブジェクトに非常に似ています。大きな違いは、プロパティがXML形式で使用されるすべてをテキストで表現するのではなく、実際の型付きデータとして格納されることです。これは、より複雑な型のデータを保持でき、より効率的にアクセスでき、一般的にデータモデル(およびシリアライゼーションだけでなく)での使用により適していることを意味します。実際、任意のValueTreeノードからXMLテキスト(または特別なバイナリ形式)を自動的に生成し、後でその構造を復元することが可能です。
参照カウント
各ノードは参照カウントされているため、ライフタイムの管理が簡単です。データ自体は実際には隠された共有インスタンスに格納されており、ValueTreeクラスは軽量な参照保持ラッパーインターフェースにすぎません。
値で素早く渡すことができ、ポインタを直接使用する必要がありません;関数からValueTreeオブジェクトを返してもデータはコピーされず、参照のみがコピーされるため、インターフェースがシンプルかつ安全になります。
自分で削除することを心配する必要はありません;どこかで使用されなくなるとすぐに自動的に破棄されます。これは、まだ使用しているかもしれないノードデータが誤って削除されることがないことも意味し、非同期UIがダングリングポインタに遭遇しないことを保証するのに特に便利です。
シンプルなインターフェース
プロパティと子を操作するためのシンプルで普遍的なインターフェースを持っています。汎用的であることで、型や構成に関係なくコンテンツにアクセスするための単一のインターフェースを持つことができます。共通のプロパティ型としてユニバーサルなvarクラスを使用することで、さまざまな入力を受け取ることができます。これらのプロパティを名前で格納および取得するためにIdentifierクラスを使用することで、データを整理するための直感的な方法を提供します。
アンドゥとリドゥ
このような簡潔なコントロールセットを持つことで、アンドゥの組み込みサポートを提供できます;コンテンツを変更するすべての関数には事前定義されたアンドゥ可能なアクションが組み込まれているため、アプリケーションでそのような機能を提供するにはUndoManagerオブジェクトを供給するだけです。まだ納得していない場合、これはデータモデルでValueTreeオブジェクトを使用する非常に説得力のある理由です。
通知
もう1つの非常に強力な機能は、ValueTreesがコンテンツが変更されたときに通知を送信する機能です;これは、特にUIを最新の状態に保つために、巨大な実用的簡素化を提供します。例えば、ノードのコンテンツを表示するために使用されるComponentオブジェクトは、データが編集されたことを確認するたびに単にリフレッシュできます ---ValueTree::Listenerサブクラスとして実装するだけです。
まとめ
ValueTreeクラスは、アプリケーションデータモデルのための一種の奇跡のクラスのようなもので、アプリケーションの内部がどのように組み合わさるかを簡素化するための豊富な機能を簡単に提供します。
varクラスの概要
varクラスは、さまざまな型のデータを保持するためのユニバーサルな_バリアント_クラスです。その機能により、JSONデータ構造を表現するのに適しています。
従来、コード内の任意の変数がどのような型のデータを格納できるかを事前に決定する必要がありました(例えば、整数を保持したい場合はintを選択し、その変数はそれにしか使用できません)。しかし、varを使用すれば、そのような決定をする必要はありません。さまざまな型と互換性があるからです。
これは汎用的なカメレオン変数のようなもので、基本的な数値(intまたはdouble)、テキスト(Stringオブジェクト)、bool値を表現でき、void状態も持てます(0やfalseは概念的に_nothing_とは異なる場合があるため)。また、ReferenceCountedObjectクラスから派生した任意のクラスへのポインタも保持できます(これは想像できるあらゆる種類の複雑なデータで構成できます)。それだけでは不十分かのように、単一のvarオブジェクトは複数のvarオブジェクトの配列としても使用できます!
この多様性により、汎用コンテナ(ValueTreeクラスなど)での使用に理想的であり、そのインターフェースはvarで保持できる限り、何を与えるか(または期待するか)について気にする必要がないという贅沢を得られます。基本型には暗黙的なキャスト(およびオーバーロードされた代入演算子)があり、コードでのやり取りがシンプルになります。現在の値の文字列表現を自動的に返すこともできます(非テキスト型を含む)。これらの贅沢がすべて適用されない唯一の場所は、varオブジェクトがReferenceCountedObjectオブジェクトを保持している場合です。これらは未知の型なので、自分でキャストする必要があります(正しい型でない場合はnullptrを返すため、dynamic_castで安全にこれを行うことができます)。
ValueTreeオブジェクトでは、すべてのプロパティはvarオブジェクトとして保持されます。この多目的クラスをプロパティ型として選択することで、それらが何であるかに関係なく、アクセスするための単一の関数セットを持つことが可能です。int値用に1つの関数を使用し、テキスト用に別の関数を使用する必要はありません;そのような関数はすべてvarオブジェクトを扱うことで統一できます。
Identifierクラスの概要
このクラスは、データを識別するために使用される人間が読める_キー_として意図されています。
本質的に、Identifierオブジェクトは文字列です。Stringオブジェクトから割り当てることができ、内容をStringオブジェクトとして取得することもできます。ValueTreeクラスのコンテキストでは、ノードの型名を指定するためと、各プロパティに一意のラベルを付けるために使用されます。
なぜStringクラスを使用しないのか?
汎用目的のStringクラスの代わりに特殊なクラスを使用する2つの主な理由があります。
- 制限された文字セットを強制できる:まず、このクラスは有効なキーを構成できる文字に制限を強制します;英数字と特殊文字
_-:#$%のみを許可します。これは少しひどいように聞こえるかもしれませんが、同じ制限を持つ他のシステム(例えば、XML形式やスクリプト)との互換性を確保することが可能になります。 - 目的に最適化できる:2番目の(しかし最も重要な)理由は、それらをどのように使用したいかに由来します。それらは任意のサイズのリストから単一のアイテムを識別するためのキーとして機能するため、それらで実行される最も一般的な操作は比較です。しかし、Stringオブジェクトの比較はかなり遅い場合があります。実際にテキストをチェックする必要があり、最初の異なる文字を見つけたときにのみ、2つのStringオブジェクトが同じではないと確信できます。ほとんど同じ文字列の場合、ほとんどの文字をチェックすることになる場合があります(一致する文字列の場合はすべての文字がテストされます)。単一の比較では、それは許容できるかもしれませんが、1つの文字列をリストと比較したい場合(キーとして使用するときに行うように)、全体のビジネスには長い時間がかかる可能性があります。
特別なクラスを使用することで、素早い比較を可能にするように最適化されていることを確認できます。この最適化(および制限された文字セット)のため、一般的なテキスト処理に使用することを意図していませんが、文字列のようなデータをキーとして使用するのに完璧に適しています。
なぜ比較が速いのか?
Identifierクラスの最適化について知っておくと便利です。一方では、コードが各テストですべての文字を密かにチェックしていないことを安心させてくれます。しかし、より重要なのは、多くの最適化と同様にコストがかかるため、問題にならないようにそれがどこにあるかを知っておくと役立ちます。
これを知らなくてもIdentifierを完全に使用することは可能ですが、トレードオフがどこで発生するかをよりよく理解できます。
この最適化の詳細は変わる可能性があります(これはクラスの副次的な知識です)が、実装への影響が変わる可能性は低いです。
String比較の特殊なケース
2つのStringオブジェクトが同じであることを知るには、実際にはそれらが異なっていないことを証明する必要があります。たまたま、Stringクラスは参照カウントされたテキストを保持しているため、2つのStringオブジェクトが実際には同じデータを指している可能性があります;その場合、すぐに発見できます(両方とも同じアドレスを保持しているため)。
これらの特殊なケースははるかに高速ですが、Stringクラスはそれらが発生した場合にのみそれらを利用できます。アドレスが同じでない場合、内容が同等でないことを証明するわけではないため、文字をチェックする必要があります。
特殊なケースの活用
Identifierクラスはこの動作を活用し、すべての同等のIdentifierオブジェクトが常に同じデータを指していることを保証できるようにします。最適化の方法により、異なるIdentifierオブジェクトの文字ごとの一致は、異なるメモリアドレスに対しては存在しません。これは、同一文字列の_特殊なケース_が同等である唯一の方法になり、したがって異なることを証明することは、異なるアドレスを保持していることを発見するのと同じくらい簡単になります。
しかし、これは魔法ではありません。コストは単に他の場所に移動しているだけです。
この動作を強制するために、すべてのIdentifierオブジェクトは単一の隠された一意の文字列のグローバルプールを共有します。実行時に使用されるすべてのIdentifierオブジェクトによって保持される文字列はこのプールに格納されます。StringオブジェクトからIdentifierオブジェクトを割り当てると、プールがチェックされて同等のコピーがすでに含まれているかどうかが確認されます。含まれている場合は、代わりにそれが使用されます;含まれていない場合は、新しいStringオブジェクトが追加され、次のIdentifierオブジェクトがそれを探す準備が整います。
コスト
したがって、StringオブジェクトからIdentifierオブジェクトをインスタンス化することは、その操作の中で最もコストがかかる部分です。存在したら、常にそれからコピーすれば、二度とそのコストを払う必要はありません。時には避けられないことがあります(具体的には、入力からIdentifierオブジェクトを取得する場合、または通常のStringオブジェクトに保持されているデータから)が、これはそれらを比較するよりもはるかに頻繁に発生することではありません。ベアのStringオブジェクトから割り当てるたびに(データから、またはコード内のリテラルとして)、最適化を強制するためにプールチェックが必要です。代わりに別の既存のIdentifierオブジェクトから割り当てる場合、そのようなチェックがすでに行われていることが保証されます。
コストを最小限に抑える方法
良い戦略は、起動時にいくつかの簡単にアクセスできるIdentifierインスタンスを初期化することです。コードからは、リテラル文字列の代わりにこれらを使用でき、それらからの追加のルックアップペナルティは発生しません。グローバルにアクセス可能な名前空間に入れたり、ファイル静的インスタンスを使用したり、静的クラスメンバーを使用して整理するのに役立てることもできます。
基本的なValueTreeクラスの使用法
varとIdentifierクラスに加えて、後で見るように、ValueTreeオブジェクトと一緒に使用される他のクラスが実際には多くあります。しかし、これらはValueTreeクラスのほとんどの基本的な操作に必要です。
3つの重要なクラスについて学んだら、基本を理解する良い立場にいるはずです。
これはValueTreeクラスのすべての重要なコア関数を紹介する基本ガイドです。コードでそれらを使い始めるために必要なすべての機能をカバーしています。
ValueTreeオブジェクトの作成
スタック上に(またはクラスのメンバーとして)オブジェクトを作成するだけでValueTreeオブジェクトを簡単に使用できます。
無効なValueTreeオブジェクト
ValueTreeオブジェクトはデフォルトで無効です。つまり、_デフォルトコンストラクタ_で初期化されたValueTreeオブジェクトはデータをまったく持ちません。Identifier(または既存の有効なValueTreeオブジェクト)で明示的に初期化しない限り、何も保持しません;空の殻です。
juce::ValueTree myNode; // このオブジェクトは無効です - ノードデータを保持していません
これはnullポインタに似ています(実際、内部的にはまさにそれです)が、固有の危険はありません。有効なノードが割り当てられるまで、何も参照しません。ここでの主な違いは、インターフェース関数を引き続き使用できることですが、(操作するものがないため)ほとんど効果がありません。
無効なノードでアクセス関数を「誤って」呼び出しても安全ですが、あまり意味がありません!
ノードが無効かどうかを確認する方法
コードでは、ValueTreeが空かどうかを確認するためにメンバー関数ValueTree::isValid()を呼び出すことができます:
if (myNode.isValid())
{
// 無効なノードではここには到達しません
}
有効なValueTreeオブジェクト
有効なValueTreeオブジェクトを作成する主な方法は、有効なIdentifierオブジェクトで初期化することです。これは新しいノードに_名前_を付けるために使用され、ノードの型を表します。
static juce::Identifier myNodeType ("MyNode"); // Identifierを事前に作成
juce::ValueTree myNode (myNodeType); // これは「MyNode」型の有効なノードです
Identifierオブジェクトを明示的に提供する代わりに文字列リテラルを使用できますが、可能な場合は既存のIdentifierインスタンスを使用する方が良い習慣です(詳細についてはIdentifierクラスの概要を参照)。
有効なノードが型を持つことを要求することで、どのようなデータを含むべきかを示すメカニズムがあります。逆に、ValueTreeオブジェクトに直面したとき(例えば、関数に供給されるパラメータとして)、その型を確認して、中に何を期待するかを判断できます ---ValueTree::getType()関数を使用します。
void foo (const juce::ValueTree& someNode)
{
if (someNode.getType() == myNodeType)
{
// 「MyNode」として作成されたノードの場合、ここに到達します
}
}
ノードが作成されると、その型を変更することはできません。これは意図的であり、そのような情報はオブジェクトのプロパティではなく、オブジェクトの基本的な本質であるためです。
有効なValueTreeオブジェクトの共有
ValueTreeを初期化する3番目の方法は、コピーコンストラクタを介して、既存のValueTreeオブジェクトを提供することです。
[1] : juce::ValueTree myNode (myNodeType); // 「MyNode」型の新しいノードを作成
[2] : juce::ValueTree sameNode (myNode); // このオブジェクトはmyNodeと同じデータを指します
これにより、既存のオブジェクトと_同じノードデータ_を参照するオブジェクトが生成されます。
ここでコピーされるのは参照のみであることを理解することが重要です;両方のオブジェクトは元のノードデータの同じ基礎インスタンスにアタッチされています。どちらか一方に(共有ノードが有効な場合)変更を加えると、他方の検査時にも見つかります。
代入演算子を使用しても同じ結果(ノードデータの共有)を達成できます。
[3] : juce::ValueTree otherNode (myNodeType); // これは2番目の(新しい)「MyNode」ノードを作成します...
[4] : otherNode = sameNode; // ...しかしオブジェクトは今最初のインスタンスを指しています
ここで質問があります:
行[3]で作成されたノードインスタンスは、行[4]で置き換えられるとどうなりますか?
最終的に、3つの変数すべてが同じ最初のインスタンスを指しています。2番目のインスタンスを参照するValueTreeオブジェクトはなくなったため、破棄されます。
ValueTreeオブジェクトが再割り当てされる(またはスコープ外になる)たびに、指していた基礎データは参照を失います。そのデータが他の場所に保持されていない場合、自動的に破棄されます。これは、データがいつでも自己破壊する可能性のある世界で操作しているかのように怖く聞こえるかもしれません;実際には、ノードを誤って失う可能性は非常に低いです!それは非常に堅牢なシステムです。さらに、データが重要であれば、実際にはすでにどこかに保存していることがわかるでしょう;望むように直感的です。
これは、オーディオコールバックなどのリアルタイムコードで注意する必要があることも意味します。オーディオコールバックでValueTreeオブジェクトを再割り当てすると、以前に割り当てられたデータへの参照がなくなった場合、不注意にdelete演算子を呼び出す可能性があります。
UndoManagerクラスについてのメモ
ValueTreeクラスのすべてのアクセス関数は、UndoManagerオブジェクトの使用方法を理解しています。適切なUndoableActionオブジェクトが事前に準備されており;呼び出しでUndoManagerオブジェクトへのポインタを提供するだけで、変更の操作可能なアンドゥ可能なレコードが自動的に追加されます。
これを管理するために知っておくべきことはそれほど多くありませんが、チュートリアル:ValueTreeでUndoManagerを使用するでより詳しく説明しています。
今のところ、nullptr値を提供することで履歴を保存せずに関数を使用します。コンテンツを変更する関数を説明するときは、少なくともUndoManagerオブジェクトへのnullポインタを与える必要があることを前提とします;もちろん例ではnullptr値が見えます。
リスナーとしての通知の受信
ValueTreeにリスナーとして登録すると、データが変更されたときに同期的に通知を受けることができます。リスナーへのポインタはValueTreeインスタンスに保持されるため、通常は登録する前にValueTreeのコピーを取ることが最善です:
struct ExampleListener : public juce::ValueTree::Listener
{
ExampleListener (juce::ValueTree v)
: tree (v)
{
tree.addListener (this);
}
juce::ValueTree tree;
};
その後、通知を受けたい動作に応じて、以下のコールバック関数を実装できます:
- valueTreePropertyChanged():ValueTreeまたはその子でプロパティの変更が発生したときに呼び出されます。
- valueTreeChildAdded():ValueTreeまたはその子に子ノードが追加されたときに呼び出されます。
- valueTreeChildRemoved():ValueTreeまたはその子から子ノードが削除されたときに呼び出されます。
- valueTreeChildOrderChanged():ValueTreeまたはその子で子ノードが並べ替えられたときに呼び出されます。
- valueTreeParentChanged():ValueTreeが親ノードに追加または削除されたときに呼び出されます。
コールバックはツリー階層を上向きに伝播するため、親ノードへのリスナーも子からプロパティ変更コールバックを受け取り、したがって型とプロパティ名をチェックする必要があります。
コールバックは同期的であるため、タイムクリティカルなアプリケーションではAsyncUpdaterを使用してプロパティの変更を処理する必要があります。
基本的なプロパティアクセス
プロパティの設定
ValueTree::setProperty()関数は、有効なノードにプロパティを設定する1つの直接的な方法です。設定したいプロパティの名前を指定するためのIdentifierオブジェクトと、それが取るvar値を提供する必要があります:
static juce::Identifier propertyName ("name");
myNode.setProperty (propertyName, "Fluffmuff", nullptr);
プロパティの取得
プロパティを取得する2つの基本的な方法があります。各アプローチでは、要求している名前付きプロパティを指定するためのIdentifierを提供する必要があります。
ValueTree::getProperty()関数を使用する場合:
juce::String name (myNode.getProperty (propertyName));
または添字演算子(つまり、ValueTree::operator[]関数)を使用する場合:
name = myNode[propertyName];
上記の両方の行は、name変数に同じ結果を格納します。これらのプロパティはサポートされている任意のvar型を保持できることを覚えておいてください。したがって、以下のコードも有効です:
static juce::Identifier propertySize ("size");
myNode.setProperty (propertySize, 2.4, nullptr);
double size = myNode[propertySize];
ここでは、double値を使用してプロパティが同じ方法で設定され、そのように簡単に読み返されます。既存のプロパティを異なる型の値で置き換えることも可能です。
ValueTreeノードに個々の値を格納するだけ(それ以上複雑なことはなく)したい場合は、開始するために十分な知識があります!
プロパティについて調べる
構造体を使用している場合、これらのメンバー変数を直接設定または取得する機能しかありません。ValueTreeノードでは、(実行時に)どのような_変数_があるかを調べることもできます。
ValueTree::getNumProperties()関数は、ノードが持つプロパティの数を教えてくれます:
int numProperties = myNode.getNumProperties();
ValueTree::getPropertyName()関数は、指定された位置のプロパティの名前を示すIdentifierオブジェクトを返します。これをValueTree::getNumProperties()関数と組み合わせて使用することで、以前に何であるかを知らなくてもプロパティを反復処理することが可能です。
for (int i = 0; i < numProperties; ++i)
{
juce::Identifier name (myNode.getPropertyName (i));
// …
}
ValueTree::hasProperty()関数は、特定の名前付きプロパティがノードに設定されているかどうかを単純に教えてくれます:
if (myNode.hasProperty (nameProperty))
{
// プロパティが見つかりました
}
これらの関数はリフレクションと呼ばれるものを可能にし、プログラムがオブジェクトの性質を検査できます。操作するオブジェクトに直面したとき、コードは必ずしもそれを使用できるようにするための定義(例えば、クラスヘッダー)を必要としません;どのようなメンバーがあるかを確認し、適切であれば使用できます。
基本的な子アクセス
より複雑な構造を作成したい場合は、これらのオブジェクトを階層のノードとして使用し始めたいと思うでしょう。
子の追加
ノードは多数のプロパティをアタッチできますが、配列のように多数の子ノードも含めることができます。
子として追加したいノードがある場合、追加したいノードでValueTree::addChild ()関数を呼び出すだけです。当然、新しい子ノードを渡しますが、どこに行くべきかも指定する必要があります。特定の位置に挿入する必要がない場合は、-1を指定して単に最後に配置できます。
juce::ValueTree childNode (myNodeType);
myNode.addChild (childNode, -1, nullptr);
ここで、childNodeノードに保持されているデータはmyNodeに保持されているデータに属します。
この_所属_は追加の参照でもあることに注意してください。childNode変数を再割り当てしても、既存のデータは失われません。
childNode = juce::ValueTree (myNodeType);
myNode.addChild (childNode, -1, nullptr);
直接のスコープにそのインスタンスを直接指すValueTreeオブジェクトがなくなっても、元のノード内で維持されています。
子の取得
子ノードを取得するいくつかの方法があります。任意のタスクでどれを選択すべきかは、選択した構造がどのように整理されているかによって異なります。
それらすべてで、子に対応しないリクエストは無効なオブジェクトを返します。
ValueTree::getChild()関数は、ノードの内部リスト内の指定された位置に現在存在する子を返します。常に最後に追加している場合、0は最初に入れたものに対応します:
childNode = myNode.getChild (0);
ValueTree::getChildWithName()関数は、ノード型として指定された名前識別子を持つ最初の子を返します:
childNode = myNode.getChildWithName (myNodeType);
ValueTree::getChildWithProperty()関数は、名前付きプロパティが指定された値に設定されている最初の子を返します:
childNode = myNode.getChildWithProperty (nameProperty, "Fluffmuff");
最後の2つは、他の必要なメソッドと同様に、最初のものを使用して書くことができます;他の基準(例えば、一致するノード型とプロパティ値の組み合わせ)で子を取得する独自の関数を書くのは簡単です。
ValueTree::getParent()関数を使用して、任意のノードの現在の所有者を取得することもできます:
ValueTree parent (childNode.getParent());
まとめ
このチュートリアルでは、ValueTreeクラスと関連クラスの詳細な概要を提供しました。特に、以下のことができるはずです:
- varとIdentifierクラスの重要性を理解する。
- ValueTreeオブジェクトを作成して渡す方法。
- ValueTreeオブジェクトのプロパティを追加およびアクセスする方法。
- ValueTreeオブジェクトに子ノードを追加およびアクセスする方法。