Rust Monthly Topics

GTKオブジェクトの利用 ~gtk-rsでデスクトップアプリ開発をはじめよう[中編]

前回に続き、gtk-rsを使ったデスクトップアプリ開発を取り上げていきます。前回、サンプルアプリケーションで基本の形を見てきました。GTKは作り込まれたライブラリですので、関連する概念が多く、アプリケーションを作るまでの道程は遠めです。今回は、前回の記事でも時折説明なしに出てきたGTKのオブジェクト指向のシステムや関連する概念を押さえておきましょう。

前回と同様に⁠本記事で登場するコードは以下に置いておきます⁠
https://github.com/KeenS/gtk-examples

GTKのアーキテクチャ

GTKはいくつかのライブラリに分割されており、それらの上にGTKが作られています。プログラマは時にはGTKだけでなく、その下にあるライブラリも使うことになります。

GTKのアーキテクチャ
図 GTKのアーキテクチャ

https://www.gtk.org/docs/architecture/ より引用

中でもGLibはGTKの基礎となるライブラリです。独自のオブジェクト指向システム(GObject)をはじめとして、多くのものを提供しています。GTKのウィジェットなどもGObjectで作られているのでGObjectの基本を押さえましょう。

GObject

GObjectはクラスベースの単一継承のオブジェクト指向をサポートした型システムです。ベースクラスはGObjectで、GTKのウィジェットもGObjectのサブクラスになっています。少し変わっているのは、Cの型システムを拡張しているわけではなく、データとしてクラスやインスタンスを表現している点です。静的にクラスが定義されるC++やJavaよりは動的にいろいろ変わるRubyやPythonなどに似ています。

Rustから扱うにあたっては以下の点で注意が必要です。

  1. クラスや継承などの概念を用いている
  2. サブクラス判定やメソッドの探索を動的に(実行時に)行っている
  3. クラスを表わす構造体とインスタンスを表わす構造体に分かれる
  4. オブジェクトはGLib独自の参照カウント方式によるメモリ管理がされる

模範的なRustとは大きく異なる設計を要求するため、Rustから自然に扱うには大量の橋渡しをするコードが必要になります。幸いにもgtk-rsではこれらの扱いを大幅に簡略化してくれるマクロを提供しているので、ある程度のボイラープレート(定型表現)は避けられますが、それでも時には心を無にして書くコードがあります。

これらを念頭の置きつつ、これからgtk-rsを使ううえで意識しておく必要のあるメモリ管理、サブクラス、プロパティ、シグナルの4つの概念について学びます。

メモリ管理

GObjectは独自のメモリ管理システムを持っています。中身は参照カウントで、ミュータブルにアクセスできます。RustでいうところのRc<RefCell<T>>のような扱いが可能というわけです。実際、GObjectのオブジェクトはCloneなどのRustのトレイトも実装しています。

例を見てみます。解放処理(参照のデクリメント)はRustがやってくれるので、参照カウントの挙動を実感できるのは相互参照でしょう。公式ドキュメントには、これに相当する例があるので動かしてみましょう。プロジェクトを用意します。

$ cargo new gtk-memory-management
$ cd gtk-memory-management
$ cargo add gtk4@0.6.6 --rename gtk

2つのボタンで1つの数値を操作するGUIアプリケーションを作りたいのですが、以下のコードはコンパイルが通りません。

use std::{cell::Cell, rc::Rc};

use gtk::{glib, prelude::*, Application, ApplicationWindow};

const APP_ID: &str = "com.github.keens.gtk-examples.memory-management";

fn main() -> glib::ExitCode {
    let app = Application::builder().application_id(APP_ID).build();

    app.connect_activate(build_ui);

    app.run()
}

fn build_ui(application: &Application) {
    // ボタンを2つ作る
    let button_increase = gtk::Button::builder()
        .label("Increase")
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();
    let button_decrease = gtk::Button::builder()
        .label("Decrease")
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();

    // 2つのボタンから変更する数字を用意する
    let number = Rc::new(Cell::new(0));

    // コールバックを接続する
    // ボタンをクリックすると `number` ともう1つのボタンのラベルを変える
    button_increase.connect_clicked(move |_| {
            // 数字を増やす
            number.set(number.get() + 1);
            // 他方のボタンのラベルを更新する
            button_decrease.set_label(&number.get().to_string());
    });
    button_decrease.connect_clicked(move |_| {
            // 数字を減らす
            number.set(number.get() - 1);
            // 他方のボタンのラベルを更新する
            button_increase.set_label(&number.get().to_string());
    });

    // ボタンをgtk_boxに追加する
    let gtk_box = gtk::Box::builder()
        .orientation(gtk::Orientation::Vertical)
        .build();
    gtk_box.append(&button_increase);
    gtk_box.append(&button_decrease);

    // ウィンドウを作る
    let window = ApplicationWindow::builder()
        .application(application)
        .title("My GTK App")
        .child(&gtk_box)
        .build();

    // ウィンドウを表示する
    window.present();
}

実際、このコードをコンパイルしようとすると以下のようなエラーメッセージが出ます。

error[E0382]: borrow of moved value: `button_decrease`
  --> gtk-memory-management/src/main.rs:44:5
   |
27 |       let button_decrease = gtk::Button::builder()
   |           --------------- move occurs because `button_decrease` has type `gtk4::Button`, which does not implement the `Copy` trait
...
40 |       button_increase.connect_clicked(move |_| {
   |                                       -------- value moved into closure here
41 |           number.set(number.get() + 1);
42 |           button_decrease.set_label(&number.get().to_string());
   |           --------------- variable moved due to use in closure
43 |       });
44 | /     button_decrease.connect_clicked(move |_| {
45 | |         number.set(number.get() - 1);
46 | |         button_increase.set_label(&number.get().to_string());
47 | |     });
   | |______^ value borrowed here after move

何やら所有権に関連したエラーが出ているようですね。問題があるのは以下の部分です。

let number = Rc::new(Cell::new(0));

button_increase.connect_clicked(move |_| {
    number.set(number.get() + 1);
    button_decrease.set_label(&number.get().to_string());
});
button_decrease.connect_clicked(move |_| {
    number.set(number.get() - 1);
    button_increase.set_label(&number.get().to_string());
});

numberbutton_decreasebutton_increaseなどの値を複数箇所で所有しようとしているので、所有権検査で弾かれてしまいます。Rcで包んでいるnumberやGObjectであるButtonCloneが使えるので安直には以下のようにすればよさそうです。

let number = Rc::new(Cell::new(0));

// クロージャにムーブするために事前にcloneしておく
let number_clone = number.clone();
let button_decrease_clone = button_decrease.clone();
let button_increase_clone = button_increase.clone();
button_increase.connect_clicked(move |_| {
    number_clone.set(number_clone.get() + 1);
    button_decrease_clone.set_label(&number_clone.get().to_string());
});
button_decrease.connect_clicked(move |_| {
    number.set(number.get() - 1);
    button_increase_clone.set_label(&number.get().to_string());
});

しかし、これではコードが汚なくなりますし、何よりbutton_increasebutton_decreaseで相互参照(循環参照)してしまっているので、メモリが正しく解放されません。

ウィジェット同士の相互参照
図 ウィジェット同士の相互参照

循環参照をうまく扱うにはRc::downgradeを呼んで適切な箇所で弱参照にすればいいのでした。ところが、そうするとさらにコードが散らかってしまいます。

glibではこういうときのためにclone!マクロを用意しています。clone!マクロを使えば、参照カウントのクローンや弱参照/強参照の管理を宣言的に行えます。以下のように使います。

button_increase.connect_clicked(glib::clone!(@strong number, @weak button_decrease =>
    move |_| {
        number.set(number.get() + 1);
        button_decrease.set_label(&number.get().to_string());
}));
button_decrease.connect_clicked(glib::clone!(@weak button_increase =>
    move |_| {
        number.set(number.get() - 1);
        button_increase.set_label(&number.get().to_string());
}));

button_increaseで指定している@strong number,@weak button_decrease =>という指定はnumberを強参照(普通のcloneでクローンし、button_decreaseを弱参照cloneしたあとにdowngradeでクローンするという意味です。こうすることで循環参照を回避できます。

ウィジェット同士の相互参照を弱参照にした
図 ウィジェット同士の相互参照を弱参照にした

ところで弱参照の値にアクセスするには、一度upgradeできるか試してみて、失敗したら処理をやめるということが必要ですが、それもclone!マクロが内部で相当するコードを生成してくれています。ですので参照の違いを意識することなくクロージャを書けるようになっています。

サブクラス

GObjectは継承ベースのオブジェクト指向フレームワークなので、アプリケーションを作るときは自然とサブクラスを作ることになります。サブクラスの作り方を見てみましょう。gtk-subclassクレートを作ります

$ cargo new gtk-subclass
$ cd gtk-subclass
$ cargo add gtk4@0.6.6 --rename gtk

すでに出てきたButtonクラスのサブクラスを作ってみます。

Buttonクラスの継承関係
図 Buttonクラスの継承関係

https://docs.gtk.org/gtk4/class.Button.htmlより引用

先ほど説明したとおり、GObjectではクラスやインスタンスを表わす構造体が登場します。GObjectの流儀に沿ってそれらをまとめて定義するコードと、それをRustらしくラップするコードに分けて記述します。

完成形は以下のようなディレクトリ構成になります。

.
├── Cargo.lock
├── Cargo.toml
└── src
    ├── custom_button
    │   ├── imp.rs
    │   └── mod.rs
    └── main.rs

まずは実体となる方のコードです。custom_button/imp.rsに以下のようなコードを書きます。

use gtk::glib;
use gtk::subclass::prelude::*;

#[derive(Default)]
pub struct CustomButton;

// サブクラスを定義するときの主要なトレイト
#[glib::object_subclass]
impl ObjectSubclass for CustomButton {
    const NAME: &'static str = "MyGtkAppCustomButton";
    // 後で定義する
    type Type = super::CustomButton;
    type ParentType = gtk::Button;
}

// GObjectのサブクラスが実装するトレイト
impl ObjectImpl for CustomButton {}

// Widgetのサブクラスが実装するトレイト
impl WidgetImpl for CustomButton {}

// Buttonのサブクラスが実装するトレイト
impl ButtonImpl for CustomButton {}

ここでキーとなるのが#[glib::object_subclass]です。

クラスを作るうえで構造体に求められるフィールドのようなものはありません。代わりにObjectSubclassを実装することで継承関係を表現します。ObjectSubclass はここでの実装よりもう少し大きな定義をもつトレイトですが、#[glib::object_subclass]アトリビュートをつけることで定型文を埋めてくれるので記述は少なくなります。

#[glib::object_subclass]アトリビュートはObjectSubclassのメンバを補完してくれるだけでなく、他にもいくつかのトレイトの実装を生成します。

一方、#[glib::object_subclass]でカバーできない部分もあり、3つのトレイトの実装は手動で与えています。これらはObjectWidgetButtonのサブクラスを定義するのに必要なトレイトの実装です。Rustには継承がないのでトレイトを使ってRustの型システムの上でのサブクラス関係やオーバーライドを実現します。今回は特にオーバーライドをしないので空の実装を与えます。

これらのトレイトはRustの型システム上でもトレイトの継承関係にあるのでRustのコードからも自然に扱えます。トレイトの継承関係を見るとオブジェクトの継承関係と一致していることがわかります。

// それぞれのトレイトの宣言
pub trait ObjectImpl: ObjectSubclass + ObjectImplExt {
    // ...
}

pub trait WidgetImpl: WidgetImplExt + ObjectImpl {
    // ...
}

pub trait ButtonImpl: ButtonImplExt + WidgetImpl {
    // ...
}

次に、これをラップするコードです。ライブラリの外部から呼び出す際にはこちらのコードを使います。custom_button/mod.rsに以下を書きましょう。

// impの方にpubはつけずにプライベートモジュールとする
mod imp;

use glib::Object;
use gtk::glib;

glib::wrapper! {
    pub struct CustomButton(ObjectSubclass<imp::CustomButton>)
        @extends gtk::Button, gtk::Widget,
        @implements gtk::Accessible, gtk::Actionable, gtk::Buildable, gtk::ConstraintTarget;
}

impl CustomButton {
    pub fn new() -> Self {
        Object::builder().build()
    }

    pub fn with_label(label: &str) -> Self {
        Object::builder().property("label", label).build()
    }
}

glib::wrapperマクロで一気にいろいろなコードを生成してしまいます。マクロにはラップする型とすべての親クラスと実装するインターフェースを渡してあげます。これで親クラスやインターフェースのメソッドを継承したかのように呼び出せます。本来は祖先クラスやインターフェースは親クラスからわかるので、もう少し記述が少なくなると嬉しいのですが、これがRustの中からできる限界のようです。我慢してすべて手書きしましょう。

最後に、メソッドではない関連関数を実装しています。メソッドは継承できますが、関連関数はそうはいかないので手で書きます。今回はnewwith_labelの関連関数を定義しましょう。Buttonには他にも関連関数がありますが、さしあたってこれだけ実装すれば困らないでしょう。

今回作ったサブクラスはほとんど継承元であるButtonと同じように使えます。main.rsに以下を書きましょう。

mod custom_button;

use gtk::prelude::*;
use custom_button::CustomButton;

fn main() {
    let application =
        gtk::Application::new(Some("com.github.gtk-rs.examples.basic"), Default::default());
    application.connect_activate(build_ui);
    application.run();
}

fn build_ui(application: &gtk::Application) {
    let window = gtk::ApplicationWindow::new(application);

    window.set_title(Some("First GTK Program"));
    window.set_default_size(350, 70);

    // CustomButtonを使う
    let button = CustomButton::with_label("Click me!");
    button.connect_clicked(|_| {
        println!("Clicked!");
    });

    window.set_child(Some(&button));
    window.show();
}

これでGTKの主要な機能の1つ、クラスと継承が扱えました。

property

次にプロパティについてです。プロパティはGTKが提供するオブジェクトの状態へアクセスする手段です。APIを通して値の読み書きができます。プログラム内から扱う他、UIの部品を作るときなどにも参照する重要な機能です。

例を見てみましょう。CustomButtonを定義するときに、特に構造体のフィールドは定義しませんでした。しかし、Buttonを継承しているのでButtonが提供しているプロパティへアクセスできます。実は、すでにButtonのプロパティへアクセスしています。with_label関連関数の定義を振り返ってみましょう。

impl CustomButton {
    // ...

    pub fn with_label(label: &str) -> Self {
        Object::builder().property("label", label).build()
    }
}

Objectのビルダーを通してlabelプロパティに対して&strの値を設定していますね。

作ったオブジェクトのプロパティを扱うには継承したproperty/set_propertyメソッドを使います。

let button = CustomButton::with_label("Click me!");
// `property` で値を取得
assert_eq!(button.property::<String>("label"), "Click me!");

// `set_property` で値を設定
button.set_property("label", "Changed");
assert_eq!(button.property::<String>("label"), "Changed");

propertyで値を取得します。このとき、取得する型は正しく指定する必要があります。

今回はGTKのButtonのドキュメントからUTF-8文字列であることがわかるのでStringを指定しています。

GTKの文字列からRustの文字列への変換はgtk-rsがやってくれます。もっと一般に、リターン型を指定するときはFromValueを実装している型を使います。

同様にset_propertyで値を設定します。property同様に、渡す値は Valueへ変換できる型を使います。

素のプロパティは以上のようなしくみですが、API上はそのラッパを通して触ることが多いです。例えばButtonでは、これらのプロパティにアクセスするlabel/set_labelメソッドを実装しているので、プロパティを直接触る必要はありません。

プロパティを自分で定義するにはderiveマクロのPropertiesを使います。

CustomButtonclicked_countというプロパティを生やしてみましょう。custom_button/imp.rsを編集します。空だったCustomButton構造体にclicked_countフィールドを追加し、Propertiesderiveします。

use gtk::prelude::*;
use gtk::glib::Properties;
use std::cell::Cell;

#[derive(Default, Properties)]
#[properties(wrapper_type = super::CustomButton)]
pub struct CustomButton {
    #[property(get, set)]
    clicked_count: Cell<u32>,
}

Properties導出マクロはさまざまな使い方があります。基本は#[properties]属性マクロの引数にwrapper!で作った型を指定しつつ、プロパティにしたいフィールドに#[property]属性マクロをつけます。今回の例では素直にclicked_countフィールドから、それを操作するclicked_count()/set_clicked_count()メソッドを生成しています。このように、メソッドを生成するためには、フィールドの型はPropertyを実装していなければなりません。

このあと、Objectから継承したプロパティ関連のメソッドをオーバーライドする必要があります。

// GObjectのサブクラスが実装するトレイト
impl ObjectImpl for CustomButton {
    fn properties() -> &'static [glib::ParamSpec] {
        Self::derived_properties()
    }
    fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
        self.derived_set_property(id, value, pspec)
    }
    fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value {
        self.derived_property(id, pspec)
    }
}

少し面倒な手順が挟まりますが、概ね素直にプロパティを定義できます。

Signal

シグナルは何かイベントが起きたときに、登録しておいたコールバックを呼び出すしくみです。

例を見てみましょう。これまたシグナルもHello Worldのときにすでに扱っています。

fn build_ui(application: &gtk::Application) {
    // ...

    button.connect_clicked(|_| {
        println!("Clicked!");
    });

    // ...
}

connect_clickedメソッドを通してclickedシグナルにコールバックを登録しています。これもプロパティと同様にラッパが用意されている形です。素のシグナルは以下のように使われます。

// ボタンの "clicked" シグナルに接続する
button.connect_closure(
    "clicked",
    false,
    closure_local!(move |button: Button| {
        // ボタンがクリックされたらラベルを "Hello World!" に設定する
        button.set_label("Hello World!");
    }),
);

シグナルも自分で定義して送出できますが、本記事では扱わないので詳しくはシグナルのドキュメントを参照してください。

まだまだあるGTKの機能

今回はアプリケーションを作るための最低限の機能だけを扱いましたが、他にもいろいろな機能があります。設定を扱ったりウィンドウの状態を保存したりUIをXMLで定義したりとさまざまなことができますし、GTKの周辺ライブラリもまだまだあります。⁠こんなことできないの?」と思ったら調べてみましょう。

まとめ

今回はGTKの機能とgtk-rsを通したそれらの使い方をみてきました。次回、今までの知識を使ってアプリケーションを作ってみましょう。

前回と同様に⁠本記事で登場するコードは以下に置いておきます⁠
https://github.com/KeenS/gtk-examples

おすすめ記事

記事・ニュース一覧