実践Flutter

Flutterでスマホを中心にアプリ制作していきます。

実践Dart:クラス

はじめに

本記事ではDart言語のクラスについて述べていきます。

Dartはオブジェクト指向言語ですので、その中心になっているのはクラスの記述になります。

Dartは数十年の伝統のある言語と比べると非常に若くモダンな言語ですが、言語仕様としてはそこまで変わったところがなく、クラス表記に関してもよき伝統を受け継いだ、落ち着いた作りになっています。





クラスの定義

基本の形

クラス定義は基本的にJavaやC#と同様、メンバ変数とメソッド定義という形になっています:

class Car{
  String name = "";
  String engine = "";
  
  Car(String name, String engine){
    this.name = name;
    this.engine = engine;
  }
  
  void printName(){
    print(name+" "+engine);
  }
}

void main(){
  Car c = Car("Prius", "Hybrid");
  c.printName();
}

Dartに存在するすべてのものは何らかのクラスのインスタンスです。すべてのクラスはObjectクラスの子クラスです。

上記c.printName()はCarクラスのインスタンスcのメソッドprintName()を呼び出しています。「a.b」の表記がインスタンスaの中のbを呼び出すという意味になります。

ここで「カスケード表記(cascade notation)」というものがあり、同じインスタンスに連続してアクセスする場合の表記を簡潔にできます:

c.printName();
c.printName();
c.printName();

//上記と同じ。カスケード演算子(..)を用いた記述。
c..printName()
 ..printName()
 ..printName();

カスケード中のメソッドに返却値があった場合はすべて捨てられますがメソッドのボディはすべて実行されます。

インスタンスを作成するとき、コンストラクタの呼び出しに続けていくつかのプロパティの値を代入していくときに、非常によく用いられる表記です。


import

他のファイルや公式で用意されているライブラリに定義されているクラスを使用する場合、import宣言をします。

公式のビルトインライブラリを使用する場合は、"dart:"を先頭につける形でimportします:

import 'dart:core';
import 'dart:async';
import 'dart:math';

といった形です。例えばmathであればこちら公式ドキュメントの冒頭にimportの仕方が書いてあります。他のライブラリも同様です。

サードパーティのライブラリからimportする場合は、"package:"を先頭につける形でimportします:

import 'package:path/to/lib/file/dartfile.dart';

packageに続いてimportしたいdartファイルへの相対パスを記載します。

今後、Flutterに関連する記事のなかでGetXやHiveなど定番パッケージをimportしていきますので、具体的にはそちらの冒頭の記述を御覧ください。


アクセス制限

クラスのメンバをライブラリ外からアクセスさせないためには、変数名の先頭にアンダースコア"_"を付けます

//ファイル「car.dart」に定義されたCarクラス
class Car {
  String name = "";
  String _engine = "";
}

//ファイル「main.dart」に定義された関数
import 'package:car.dat';

void main(){
  Car c = Car();
  print(c.name);
  print(c._engine);   //NG。これはコンパイルが通らない。
}

同様にしてメソッド名やクラス名の前にアンダースコアをつけると、他のファイルから参照できなくなります。

ただしクラス定義と使用する関数が同じファイルにある場合、先頭にアンダースコアをつけてもアクセス可能ですので、いわゆるprivateとは違います

他言語にあるprotected(そのクラスを継承するサブクラスにのみアクセスを許可する)に相当する仕掛けはDartにはありません。

基本的に煩雑な記述を避けるポリシーのあるDartでは、アクセス制限の記述はなるべく少なくなるようにデザインされています。また必要以上に制限せず、支障がない限りpublicを推奨して柔軟性の確保を重視しています。


コンストラクタ

クラスのインスタンスを返す特別なメソッドがコンストラクタです。下記のようにクラスと同名で定義します:

class Car{
  String _name = "";
  String _engine = "";
  
  Car(String name, String engine){
    this._name = name;
    this._engine = engine;
  }
}

コンストラクタを呼び出すときは、他の言語のようにnewを使うこともできますが、Dart2からはnewを省略できます

Flutterではコンストラクタを大量に組み合わせてGUIパーツのコードを構築していくため、newをつけない記法が推奨です。

//newをつけた場合。他言語でおなじみの記法
Car c = new Car("Prius", "Hybrid");

//Flutterではnewをつけない方が普通。
Car c = Car("Prius","Hybrid");

コンストラクタの定義において、インスタンス変数の値を設定するときに推奨されている形として下記の「initializing formal」があります:

class Car{
  String _name;
  String _engine;
  
  Car(this._name, this._engine);
}

コンストラクタのパラメタ表記が「this.XX(XXはパラメタ名)」になっている形です。こうするとパラメタ指定された値がthis.XXに代入されるようになります。

コンストラクタのコードといえば、仮引数で受けて変数を代入するだけというのが定番です。そんなときはこの表記法がぴったりはまります。

「initializing formal」の形式を採用している場合でも、値の代入に加えてコンストラクタ処理を続けて書くことが可能です。

class Car{
  String _name;
  String _engine;
  
  Car(this._name, this._engine){
    print("Car was made by name "+_name);
  }
}

 

初期化リスト

コンストラクタの型宣言の後に初期化のリストをつけることができます。コンストラクタのあとのコロン(:)に続く部分が初期化リストです:

class Car{
  String _name = "";
  String _engine = "";
  
  Car(String n, String e):_name = n, _engine=e;
}

Initializing formal形式だと直接引数にクラスのメンバ変数名を記載することになり、必要以上に外部にクラス内部の実装を晒すことになるため、これを回避するために一旦別名で受けたのが上記の例になります。

上記のコードの例では一旦仮引数「n」及び「e」で受けていますので、クラスの外部からはこれらの名前しか公開されず、nameやengineの名前は見えなくなります。

また次節の名前付きコンストラクタと組み合わせて、デフォルト値を設定するなどに使うことができます。


名前付きコンストラクタ

コンストラクタに名前を付けて、バリエーションを増やすことができます:

class Car{
  String _name;
  String _engine;
  
  Car(String n, String e):_name = n, _engine=e;
  
  //名前付きコンストラクタの定義
  Car.yaris(String e):_name = "Yaris", _engine=e;
  
  printCar(){
    print(_name+" "+_engine);
  }
}

void main(){
  Car c = Car("Prius", "Hybrid");
  c.printCar();   //Prius Hybrid
  
  //名前付きコンストラクタの呼び出し
  Car y = Car.yaris("Normal");
  y.printCar();   //Yaris Normal
}

上記のコンストラクタのうち、「.yaris」がついているものが名前付きコンストラクタです。名前がついている以外、特に使い方に違いはありません。

様々なバリエーションのコンストラクタを名前に意図を込めて用意できるため、非常に有用な機能です。


コンストラクタのリダイレクト

コンストラクタを複数用意するとき、処理が重複するケースに既存のコンストラクタを再利用することができます:

class Car{
  String _name;
  String _engine;
  
  Car(String n, String e):_name = n, _engine=e;
  
  //this()によって別のコンストラクタを再呼び出し
  Car.yaris(String e):this("Yaris", e);
  
  printCar(){
    print(_name+" "+_engine);
  }
}

Car.yarisのコンストラクタのところで、thisという形でCarコンストラクタを呼び出しています。

バリエーションがあるとはいえ、コンストラクタの処理はそれぞれでそこまで変わらないことが多いです。共通の処理はベースのコンストラクタに寄せて、変わる部分だけをそれぞれの名前付きコンストラクタに記述するのはよい設計です。


factoryコンストラクタ

有名なデザインパターンの一つにファクトリパターンというものがあります。これはインスタンスを生成するファクトリと呼ばれるメソッドを用意する設計パターンです。

単なるコンストラクタと違うのは、必ずしも新しいインスタンスを用意するのではなく、必要に応じて新しいものを作ったり、既存のものを返したり、あるいは必要に応じてサブクラスのインスタンスを返すというものです。

通常コンストラクタというと新しいインスタンスを生成する機能までは確定なのが普通ですが、factoryパターンを言語レベルで用意しているのは面白い言語デザインですね。

シングルトンパターンも、常に同じインスタンスを返すという意味で、factoryコンストラクタを用いて実装するのが自然です。

class Car{
  String _name;
  String _engine;
  
  //factoryメソッド
  factory Car(String n, String e){
    //何らかの形でCarのインスタンスを返す処理
  }

  Car.normal(){
    //通常のコンストラクタをfactoryとは別に用意。
    //factoryの中で新しいインスタンスを生成するか、あるいは
    //キャッシュから使用するインスタンスを選択して返します。
  }

factoryパターン、あるいはfactoryメソッドパターンはデザインパターンのなかでも非常に有用なものです。

DartかJavaかといった言語は関係なく、オブジェクト指向設計のための重要な指針になりますのできちんと理解して使えるようになることを強く勧めします。

インスタンス変数とクラス変数

クラスにまつわる変数は大きく2種類あります。

種類 意味
インスタンス変数 各インスタンスに属する変数。インスタンス毎に異なる。プロパティ、属性値、属性、メンバとも呼ばれる。
クラス変数 クラスに共通する変数。static修飾子をつけるのでスタティック変数とも呼ばれる。

下記コードのように、インスタンス変数は通常の変数定義表記、クラス変数は「static」修飾子を付けて定義する:

class Car{
  //インスタンス変数
  var km = 10000;
  
  //インスタンス変数
  final power = 10;
  
  //クラス変数。
  static var tire = 4;
}

void main(){
  var c = Car();

  //インスタンス変数へのアクセス。インスタンス名.変数名
  print(c.km);      //10000

  //クラス変数へのアクセス。クラス名.変数名
  print(Car.tire);  //4
}

クラス変数はクラスに共通する値、シングルトンパターンのシングルトン、factoryパターンのインスタンスプールなどをストアするために使います。

変な使い方をするとバグの温床になりますので、インスタンス変数とはしっかり区別するのはもちろん、変に乱用しないように注意する必要があります。


const修飾子にまつわる注意

クラスの定義の中でconst修飾子の使い方には注意が必要です。

constはコンパイル時に確定する定数を表すものなので、メモリイメージの生成が実行時になされるインスタンス変数には使えません。インスタンスの生成はコンストラクタで行われて、これは実行時に生成されるということですからね。

一方で、クラスに属するクラス変数に関してconst修飾子は使用可能です。

class Car{
  //コンパイルNG。インスタンス変数としてconstは使用不可。
  const km = 10000;
  
  //コンパイルOK。インスタンス変数としての定数はfinal。
  final power = 10;
  
  //クラス変数としてのconstはOK。
  static const tire = 4;
}

そもそもコンパイル時に決まるような定数の場合、クラス変数にしたほうが自然な場合が多いので、この制約は特には困らないのが普通かもしれません。

constが付けられないと起こられたときは、クラス変数にすべきではないかと考えてみるのも一つの手です。


constコンストラクタ

Flutterでよく出てくる重要概念にconstコンストラクタがあります。これはコンストラクタが生成するインスタンスが不変であることを宣言し、コンパイラに最適化を促すものです。

不変なインスタンスにするためには、すべてのインスタンス変数をfinalにする必要があります。このときコンストラクタにconst修飾子をつけることが可能になり、不変なインスタンスを生成することを宣言できます。

class Car{
  final String _name;
  final String _engine;
  
  //コンストラクタにconst修飾子
  const Car(String n, String e):_name = n, _engine=e; 
}

こうした上で、コンストラクタ呼び出し時にもconst修飾子をつける必要があります:

//const修飾子が必要
final c = const Car("Prius", "Hybrid");

//const宣言した場合は、自動的にconstコンストラクタが呼ばれる
const d = Car("Prius", "Hybrid");

//両方constをつけるのは冗長(コンパイルは通るが警告)
const e = const Car("Prius", "Hybrid");

const宣言したリストのなかのコンストラクタも自動的にconstコンストラクタが呼び出されます:

class Car{
  final String _name;
  final String _engine;
  
  const Car(String n, String e):_name = n, _engine=e;
  
  void printCar(){
    print(_name+" "+_engine);
  }  
}

void main(){
  //OK
  final list = [const Car("Prius","Hybrid"), const Car("Yaris","Normal")];

  //OK
  const list2 = [Car("Prius","Hybrid"), Car("Yaris","Normal")];
 
  //冗長
  const list3 = [const Car("Prius","Hybrid"), const Car("Yaris","Normal")];
 

  list[0].printCar();
}

使用する方ではconst修飾子の付け方が様々あって面倒くさそうですが、このあたりはIDEがconstをここにつけるべき、ここは付けられないなどかなり具合良く警告を出して対案を示してくれますので、それに従えば基本的にOKです。

自前のクラスについてconstになりうる、constとして使用すべきという場合はしっかりインスタンス変数をfinal宣言、コンストラクタにconstを付けて、constコンストラクタを使えるようにしておきましょう。


GetterとSetter

GetterとSetterとはインスタンスの値を取得するもの、および設定するものです。それぞれ通常は取得、設定のメソッドを用意します。

なぜこうするかと言うと、直接パブリックな値としてアクセスを許可してしまうと、予期せぬタイミングで値が変えられてしまったり、値の取得に際してはクラスの実装側の都合で何らかの手続や改変を行うようにしたい場合にこれを無視されてしまったりする可能性があるからです。

必ず値の取得や設定にはこのメソッドを通過してくださいね、という関所を設けるのが、オブジェクト指向の基本的な設計パターンになっています。

Dartではインスタンスの保持する値を参照・設定するためのGetter、Setterに、それぞれgetとsetのキーワードがあり、これを用いて参照、代入を自然な形で表現できます。普通のオブジェクト指向言語では特別なキーワードがなく、通常のメソッドでこれらを定義しますが、Dartは簡便にGetter、Setterを実装できる仕組みを用意しているというわけです。

class Car{
  String _name;
  String _engine;
  
  Car(this._name, this._engine);
  
  //Getter
  String get name => _name;
  String get engine => _engine;
  
  //Setter
  set name(String n){
    if(n.isEmpty){
      _name = "Blank";
    }else{
      _name = n;
    }
  }
  
  //単に代入処理だけなら非推奨
  set engine(String e) => _engine = e;
  
  void printCar(){
    print(_name+" "+_engine);
  }  
}

void main(){
  var c = Car("Prius", "Hybrid");
  c.printCar();
  //Getterの利用。通常の変数参照の形。
  print(c.name);
  
  //Setterの利用。通常の変数代入の形。
  c.name = "Yaris";
  c.engine = "Normal";
  c.printCar();
}

Getterはgetキーワードを付加して変数値を返す関数、Setterはsetキーワードを付加して値を設定する関数を定義します。

使用する時はGetterの方は単純にインスタンス変数を参照する形Setterでは変数に値を代入する形で表現することができます。ここが単純にメソッドでGetter、Setterを実現している言語との違いです。メソッド呼び出しの形をわざわざ書かなくてよく、自然で簡潔な記述になります。

ただし特にSetterについて、単に無条件で代入するのであればsetを利用するのではなく、単にインスタンス変数をpublicにすることが推奨されています。(より簡潔に書ける方を推奨するポリシーがあるため)

またfinalのインスタンス変数もgetを設定することは推奨されません。

なんでもかんでもGetter、Setterを黙って用意する、というわけではなく、必要がなければpublicにしなさいよ、というわけですね。

将来的に改変する可能性があり、再利用性の観点から予めSetter、Getterを用意すべきと判断した場合は、用意してOKです。しかし呼び出し側の記述は結局変わりませんので、再利用性の観点でいうと特に途中でクラス側が方針を変えても問題ありません。


演算子のオーバーロード

Setter、Getterが一般の代入と同様の文法で記載できることと同様、その他の演算子も一般のクラスを対象にプリミティブ型のような形で書くスタイルを推奨しています。例えばJavaでいうequals()メソッドも演算子==をオーバーロードする形が推奨です。

class Car{
  String _name;
  String _engine;
  
  Car(this._name, this._engine);
  
  //+演算子のオーバーロード
  Car operator +(Car other)=>Car(_name+"+"+other.name, _engine+"+"+other.engine);

  //==演算子のオーバーロード(のオーバーライド)
  @override
  bool operator ==(Object other){
    if(identical(this, other)) return true;
    if(other is Car){
      return _name==other.name && _engine==other.engine;
    }else{
      return false;
    }
  }
  
  //==演算子をオーバーライドする場合、hashCodeもオーバーライド
  @override
  int get hashCode => _name.hashCode+_engine.hashCode;
  
  //Getter
  String get name => _name;
  String get engine => _engine;
  
  //Setter
  set name(String n){
    if(n.isEmpty){
      _name = "Blank";
    }else{
      _name = n;
    }
  }
  
  //単に代入処理だけなら非推奨
  set engine(String e) => _engine = e;
  
  void printCar(){
    print(_name+" "+_engine);
  }  
}

void main(){
  var p = Car("Prius", "Hybrid");
  var y = Car("Yaris", "Normal");
  var z = Car("Yaris", "Normal");
  
  //+演算子を試す
  var py = p+y;
  py.printCar();   //Prius+Yaris Hybrid+Normal

  //pとyは等しい?
  print(p==y);   //false
  
  //yとzは等しい?
  print(y==z);   //true

}

operatorキーワードに演算子を入れて、あとの返り値型と引数の書き方、ボディの書き方は通常の関数と同様です。

演算子==のオーバーロードは全ての親クラスであるObjectクラスに定義されているので、これをオーバーライドする形になります。

演算子==をオーバーライドするときはhashCodeをオーバーライドするようにという警告が出ますので、そちらも記載しています。hashCodeについては別のところで書こうと思いますがJavaなど他言語で定番のものです。

==以外の演算子をオーバーロードする機会はそうそうないとは思いますが、書き方のスタイルを抑えておけば難しいことはあまりありません。




おわりに

オブジェクト指向言語の根幹であるクラス周りの記法についてまとめました。

Dartでは、それぞれ伝統的な記法に則りつつも、良いプラクティスやデザインパターンを取り入れて、より簡潔に、洗練された記述を目指した作りになっていると思います。

オブジェクト指向が広く知れ渡ったのはC言語の後継版として作られたC++の普及、そしてJavaの普及の時代からですので、四半世紀前というところですね。

設計手法に改良を重ねられつつ、いまでもプログラミング言語の根幹に位置しているのはすごいことです。

オブジェクト指向の何が素晴らしいかというと一言では言い表せませんが、ソフトウェアを作る際に具体的なパーツをイメージしやすいところに感覚的な良さを感じます。ハードウェアであれば、コンデンサやらチップやら、物理的に目に見えてわかりやすいわけですが、ソフトウェアの概念はなかなか掴みづらいですからね。

しかしオブジェクト指向によって、各クラスのインスタンスがメモリ空間に存在する物理的パーツとして協力しあって機能を実現していくということが非常によくイメージできます。

とりわけFlutterの世界では、GUIのパーツや機能がそれぞれ非常にわかりやすくオブジェクトとして登場し、組み合わさって動く姿がほぼほぼ可視化されて見えますので、直接的にオブジェクト指向にマッチしていると言えます。

また感覚的ではなく、具体的にオブジェクト指向の凄みを知りたければ「デザインパターン」を勉強してみて下さい。真にこれを理解できると、達人たちの深い洞察とオブジェクト指向の深さが理解できると思います。ちょっとそこまで理解するには情報工学の学部卒業レベルの知見は必要かもしれませんので万人向けではないかもしれません。

f:id:linkedsort:20211106201045j:plain