Qt の model/view フレームワーク は、2005 年に Qt 4 に追加された大きな機能のひとつで、従来のアイテムベースのリスト、テーブル、ツリーウィジェットに代わり、より汎用的な抽象化を提供します。
このフレームワークの中心に位置するのが QAbstractItemModel で、UI コンポーネントにデータを提供するための仮想インターフェースを実装者に提供します。
QAbstractItemModel は Qt Core モジュール の一部であり、Qt Quick のアイテムビューがデータを読み書きする際のインターフェースとしても機能します。
Qt にはいくつかのシンプルで便利なモデル実装が含まれています。たとえば QStringListModel、ほかのモデルに対して変換ビューを提供するさまざまな プロキシモデル、さらにより高度な QSqlQueryModel などです。
組み込みモデルで対応できないものについては、QAbstractItemModel をゼロから実装できます。単純な値のリストのような用途では繰り返し的な作業にとどまりますが、すぐにかなり複雑な作業になることもあります。これは、QAbstractItemModel の抽象化が「テーブルの木構造」として極めて汎用的であるためです。
理論的には、テーブルの各行は列ごとに異なる種類のデータを持つことができ、各セルがデータ階層全体の親になることもあり、ツリーの各レベルがまったく異なる次元や基盤となるデータ構造を持つことも可能です。
しかし実際には、そのような柔軟性を必要とするデータ構造はまれであり、ビューは一般的に一貫したレイアウトを前提としています。したがって、ツリーは多くの場合、単に行の階層構造にすぎず、各レベルで同じ列セットを持つことが多いのです。
C++ 側では、この 20 年間で多くの変化がありました。2005 年当時の C++ 標準テンプレートライブラリ (STL) には、提供されるデータ構造やプロトコルが限られており、しかもそれらの多くは各種ツールチェーン(しばしば独自仕様のものも含む)で信頼して動作するものではありませんでした。
今日では、固定サイズや可変サイズのデータ構造が揃い、主要なコンパイラすべてでしっかりサポートされています。標準化されたイテレータ API とそれを操作するアルゴリズムがあり、C++20 ではそれらの基本原則をさらに一般化する ranges や views が導入されました。また、クラス・テンプレート引数推論(CTAD)や、コンパイル時に特定のデータ構造の機能を検出できるさまざまなメタプログラミングのツールやパターンも利用可能です。
こうした最新のプログラミング手法を備え、かつ「QAbstractItemModel で可能なことすべてが必ずしも有用とは限らない」という前提(もちろん QAbstractItemModel を奪うわけではありません!)のもとで、私たちは「任意の C++ の range を Qt のモデル/ビュー フレームワークに公開できる汎用アイテムモデル」を実装できるかどうかを試みました。特に、Qt Widgets や Qt Quick のアイテムビューで利用できるようにすることを目指したのです。
先にお伝えすると・・・:もし Qt 6.10 のベータ版を触ったことがあるなら、このアイデアを実装した新しいクラス QRangeModel に気付いたかもしれません。
コードは以下のようにシンプルです:
int main(int argc, char **argv)
{
QApplication app(argc, argv);
std::vector<int> data = { 1, 2, 3, 4, 5 };
QRangeModel model(data);
QListView view;
view.setModel(&model);
view.show();
Qt Quick の UI が例えば次のような場合:
Window {
id: window
visible: true
width: 500
height: 500
required property AbstractItemModel model
ListView {
id: list
anchors.fill: parent
model: window.model
delegate: Text {
required property string display
width: list.width
text: display
}
}
}
同じモデルを利用します:
QQmlApplicationEngine engine;
engine.setInitialProperties({
{
"model", QVariant::fromValue(&model)
}
});
engine.loadFromModule("Main", "Main");
return app.exec();
}
これらを組み合わせると、2 つのウィンドウが表示され、それぞれに 5 つの数字を持つリストが表示されます。
Qt 6.10 ベータ版には、この仕組みを実際に試せるマニュアルテストが qtbase/tests/manual/corelib/itemmodels/qrangemodel に含まれており、この例だけでなく多くのことを確認できます。
ここまでのところ、特に目新しい点はなさそうに思えます。これまでに特別なことといえば、std::vector に対して一行のコードも書かずにモデルを使える、ということくらいです。
しかし、先ほどの例で作成したウィジェット UI に注目すると、アイテムをダブルクリックして編集できることに気づくはずです。そしてそのとき、Qt Quick の UI 側にも新しい値が反映されます。ここまでは特に驚くことではありません。これはどの QAbstractItemModel に対しても、モデル/ビュー フレームワークが提供してくれる標準的な機能だからです。
ところが、上記の main() 関数内にある data 変数を確認すると、依然として元の 5 つの数値が保持されています。これは、QRangeModel を data のコピーでインスタンス化したためです。
ここで、これを変更してみましょう。
QRangeModel model(&data); // raw pointer
または
QRangeModel model(std::ref(data)); // reference wrapper
これで、UI を通じてモデルを変更すると、main 関数のスタック上にある data インスタンス自体が変更されるようになります。つまり、C++ のデータと、ウィジェットおよび Qt Quick の UI の間で、わずか 1 行のコードで双方向の連携が実現できるのです。
さらに、データ型として std::vector<QString> や QList<double> を使えるという事実は、QRangeModel が持つ可能性を示唆しています。
QStringList data = {"one", "two", "three"};
QRangeModel model(&data);
QList や std::vector、std::list など、QVariant が文字列に変換できるあらゆる型を利用できます。もし挑戦的に試してみたいなら、C++20 の range を使うこともできます。
auto square = [](int i) { return i * i; };
QRangeModel model(std::views::iota(1, 5) | std::views::transform(square));
残念ながら、std::views::filter のようないくつかの view はここでは利用できません。C++20 ではすべての view に std::cbegin と std::cend が用意されているわけではなく、C++23 でそれらが追加されても、const filter view 上を反復処理することはできないからです。
単一行の値を扱うのは良い出発点です。しかし、例をもう少し拡張して、テーブルを表示してみましょう。
int main(int argc, char **argv)
{
QApplication app(argc, argv);
std::vector<std::vector<int>> data = {
{1, 2, 3, 4, 5},
{6, 7, 8, 9, 10},
{11, 12, 13, 14, 15},
};
QRangeModel model(&data);
QTableView view;
view.setModel(&model);
view.show();
QQmlApplicationEngine engine;
engine.setInitialProperties({
{
"model", QVariant::fromValue(&model)
}
});
engine.loadFromModule("Main", "Main");
return app.exec();
}
Window {
id: window
visible: true
width: 500
height: 500
required property AbstractItemModel model
TableView {
id: table
anchors.fill: parent
model: window.model
alternatingRows: true
rowSpacing: 5
columnSpacing: 5
clip: true
selectionModel: ItemSelectionModel {}
delegate: TableViewDelegate {}
}
}
ここでは 二次元ベクタ を使用しており、QRangeModel がそれをテーブルへと変換してくれます。そして、このデータ構造を ポインタとして渡しているため、エントリを対話的に変更することができます。実際、もしユーザーが行や列を挿入・削除・移動できる UI を QAbstractItemModel API を使って実装したなら、その操作に応じてデータ構造のエントリも挿入・削除・移動されることになります。
しかし、もし範囲を const オブジェクト として渡した場合は、変更はできなくなります。
const std::vector<std::vector<int>> data = { ~~~ };
QRangeModel model(data); // const is also maintained when passing by value
この場合、UI からの編集はできず、setData や moveRows といったプログラムによる呼び出しも失敗します。
std::vector や QList のような 可変長データ構造 だけでなく、QRangeModel は 固定長のデータ構造(配列やタプルなど)にも対応しています:
std::array data = {1, 2, 3, 4, 5};
例えばタプルを使うと:
std::vector<std::tuple<int, QString>> data = {
{1, "eins"},
{2, "zwei"},
{3, "drei"},
{4, "vier"},
{5, "fünf"},
};
QRangeModel model(std::ref(data));
2 列を持つテーブルモデルができます。1 列目は数値のリスト、2 列目はその数値のドイツ語名です。これらのエントリは編集したり、行を追加・削除・移動したりできますが、列構造そのものは コンパイル時に決まる定数 のため、変更できません。
ただし std::tuple は扱いがやや煩雑です。特に独自の構造体を使う場合、C++ のタプルプロトコルを実装しなければなりません。そこで登場するのが Qt のメタオブジェクトシステム です。
struct Value
{
Q_GADGET
Q_PROPERTY(int display MEMBER m_display)
Q_PROPERTY(QString toolTip MEMBER m_toolTip)
public:
int m_display;
QString m_toolTip;
};
std::vector<Value> data = {
{1, "eins"},
{2, "zwei"},
{3, "drei"},
{4, "vier"},
{5, "fünf"},
};
QRangeModel model(&data);
このモデルでもエントリや行を変更することは可能ですが、列構造は依然として固定です(プロパティの数や順序は定数であり、constexpr 定数ではありませんが変更できません)。
プロパティ名には理由があります。もしこのガジェットをテーブル構造に置くか、あるいは QRangeModel::RowOptions を特殊化してこのガジェットを マルチロールアイテム としてタグ付けすれば(詳細はドキュメント参照)、このアイテムはマルチロールアイテムになります:
std::vector<std::vector<Value>> data = {
~~~
};
QRangeModel model(&data);
これにより、C++ 側で構築したデータ構造を、Qt Quick のアイテムビューから名前付きの必須プロパティで完全にアクセス可能 にすることが非常に簡単になります。
さらに、マルチロールアイテムの範囲は、Qt::ItemDataRole、int、または QString から QVariant へのマッピングを持つ 連想コンテナ を使っても実現可能です。
QList<QMap<Qt::ItemDataRole, QVariant>> data = {
{
{Qt::DisplayRole, 1},
{Qt::ToolTipRole, "eins"},
},
{
{Qt::DisplayRole, 2},
{Qt::ToolTipRole, "zwei"},
},
};
QRangeModel model(&data);
最後に、親子行の階層をたどるためのヘルパー関数をいくつか利用することで、QRangeModel は C++ のデータ型を ツリー構造 として表現することもできます。これは少し複雑になるため、詳細はドキュメントを参照してください。
QRangeModel は、コンストラクタに渡された範囲の型に基づいて、コンパイラが QAbstractItemModel の実装を生成できるように設計されています。コンストラクタ自体はテンプレートですが、QRangeModel 自身はテンプレートクラスではありません。QAbstractItemModel のサブクラスとして定義されています。
C++ では、ポリモーフィック型のサブクラスをテンプレートとして作ることは推奨されていません。これは仮想テーブルが弱くなるためで、特にライブラリコードでは dynamic_cast がライブラリ境界を越えて失敗したり、コードが大幅に膨らむ原因になります。
その代わりに、QRangeModel の実装では高度な 型消去(type erasure)技法 を使用しています。興味のある読者は、タプルの要素にランタイムインデックスでアクセスする方法や、独自の擬似仮想テーブル経由で関数呼び出しをルーティングする方法、さらには範囲・行・値(参照、ポインタ、スマートポインタ、通常の値、いずれも const 可能)を扱うためのヘルパー群など、興味深いアプローチを見つけられるでしょう。
標準的な C++ データ構造に対して任意の形の QAbstractItemModel を動作させる場合の主な欠点は、データ構造に直接加えた変更が抽象アイテムモデルに反映されない ことです。
例えば、ビューで表示されているベクタの値を変更した場合、その変更をビューに通知する必要があります。単純に data[5] = 6 と代入するだけでは通知されませんし、std::vector に直接フックして変更を検知する方法もありません。最悪の場合、QPersistentModelIndex が更新されない/無効化されないことでクラッシュにつながることもあります。そこで QRangeModel の次のステップとして、便利なアダプタクラス を用意する計画があります。このアダプタクラスは、範囲を変更するための C++ ライクな API を提供しつつ、内部では QAbstractItemModel プロトコルを介してビューに変更を通知し、永続的インデックスの更新も保証する、というものです。
さらにもう一つの大きな課題は、QObject インスタンスを保持する範囲 を扱いやすくすることです。これらの QObject のプロパティ変更通知シグナルを対応する QAbstractItemView::dataChanged() シグナルに接続することで、C++ 側のデータと Qt Quick ビューの統合がさらに簡単になります。
範囲のヘッダデータを設定できる機能は便利かもしれません。QRangeModel ではすでに、タプルの場合は要素の型名、ガジェット行の場合は対応するプロパティ名をデフォルトとして実装しています。QAbstractItemModel は便利に setHeaderData() を提供していますが、特に処理は行いません。水平ヘッダについては、コンパイル時に解決できる方法を提供しつつ、セッターには妥当なデフォルト実装を用意できるかもしれません。
QRangeModel を使うのに C++17 以上があれば十分ですが、最新の C++ 機能のサポートも続けていきたいと考えています。例えば、一部の std::ranges には std::size が実装されておらず、std::views::iota(0) のような無限範囲では終端イテレータを求める操作が高コストです。こうした場合、QAIM::fetchMore を使えば、終端をチャンク単位で探索できます。また、std::views::filter などを const-correctness を損なわずに動作させたいと考えています。ただしこれには少なくとも C++23 が必要です。C++23 では std::views::filter に cbegin() が追加されますが、const view ではまだ呼び出せません。さらに C++26 のリフレクション機能を使えば、構造体のサポートが現状よりずっと簡単になり、タプルプロトコルを実装する必要もなくなるでしょう。
Qt 6.10 では、標準的な C++ データ構造を独自に QAbstractItemModel を実装せずに Qt Quick や Qt Widgets の UI に統合できます。次に同じような QAbstractItemModel を実装する必要はなく、コンパイラに任せることも可能です。ただし、もし C++ の範囲を使ってみて「動くはずなのに動かない」と思った場合は、ぜひフィードバックをお寄せください。