はじめに (対象読者・この記事でわかること)

この記事は、Flutter開発中に「あれ?なんでこの変数は更新されないんだろう?」と疑問に思ったことがあるプログラミング初心者の方、またはDartの変数の挙動について深く理解したい方を対象としています。

この記事を読むことで、Flutterの関数に引数を渡した際に、int型のようなプリミティブ型が更新されず、List型のようなコレクション型が更新されるという一見不思議な現象の背後にある理由を明確に理解できます。具体的には、プログラミングにおける「値渡し」と「参照渡し」の概念、そしてDart言語が採用しているオブジェクトの受け渡しメカニズムについて学べます。さらに、int型のような変数を関数内で安全かつ意図通りに更新するための具体的な解決策と実践的なアプローチがわかります。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 * Flutter/Dartの基本的な構文(変数宣言、関数定義、main関数など) * クラスとオブジェクトの基本的な概念 * 関数の基本的な呼び出し方と引数の渡し方

Flutterの変数の不思議?int型とList型の挙動の違い

FlutterでUIを構築し、ビジネスロジックを実装する中で、あなたは次のような状況に遭遇したことはありませんか?ある変数を関数に渡してその関数内で変更を加えたにもかかわらず、関数呼び出し後に関数外でその変数を参照すると、まるで何も変更されていなかったかのように元の値のままだった、と。

特に、この現象がint型のような数値型では発生するのに、List型のようなコレクション型では意図通りに更新される、という点が混乱を招くことがあります。

例えば、次のようなコードを想像してみてください。

Dart
void updateMyNumber(int number) { number = 100; // この関数内で値を変更 } void updateMyList(List<int> list) { list.add(100); // この関数内でリストに要素を追加 } void main() { int myInt = 10; List<int> myList = [1, 2, 3]; print('更新前: myInt = $myInt, myList = $myList'); // myInt = 10, myList = [1, 2, 3] updateMyNumber(myInt); updateMyList(myList); print('更新後: myInt = $myInt, myList = $myList'); // 結果はどうなるでしょう? }

このコードを実行すると、myInt10のままですが、myList[1, 2, 3, 100]に更新されているはずです。なぜこのような違いが生まれるのでしょうか?これはFlutterやDartに限らず、多くのプログラミング言語で共通する変数やオブジェクトの「受け渡し方」のメカニズムが関係しています。この一見不思議な挙動は、プログラミングにおける「値渡し(pass by value)」と「参照渡し(pass by reference)」という重要な概念、そしてDart言語の具体的な実装方法を理解することで明確になります。

このセクションでは、まず問題の現象を明確にし、次のセクションでそのメカニズムと解決策を深掘りしていきます。この知識は、意図しないバグを防ぎ、より堅牢なFlutterアプリケーションを構築するために不可欠です。

Dartにおける「参照の値渡し」の真実とint/Listの挙動

ここからが本題です。Dart言語における値の受け渡しメカニズムを深く掘り下げ、なぜint型が更新されずList型が更新されるのかを具体的に見ていきましょう。

問題の再現と確認

まずは、先ほど提示したコードを実際に実行して、その挙動を確認します。

Dart
// main.dart void modifyInt(int value) { value = value + 1; // 関数内で値をインクリメント print('関数内でのint値: $value'); // 11 } void addToList(List<int> list) { list.add(4); // 関数内でリストに要素を追加 print('関数内でのList: $list'); // [1, 2, 3, 4] } void main() { int myNumber = 10; List<int> myList = [1, 2, 3]; print('--- 関数呼び出し前 ---'); print('myNumber: $myNumber'); // 10 print('myList: $myList'); // [1, 2, 3] print('\n--- modifyInt関数呼び出し ---'); modifyInt(myNumber); // myNumberを引数として渡す print('\n--- addToList関数呼び出し ---'); addToList(myList); // myListを引数として渡す print('\n--- 関数呼び出し後 ---'); print('myNumber: $myNumber'); // 期待値: 11, 実際: 10 print('myList: $myList'); // 期待値: [1, 2, 3, 4], 実際: [1, 2, 3, 4] }

実行結果:

--- 関数呼び出し前 ---
myNumber: 10
myList: [1, 2, 3]

--- modifyInt関数呼び出し ---
関数内でのint値: 11

--- addToList関数呼び出し ---
関数内でのList: [1, 2, 3, 4]

--- 関数呼び出し後 ---
myNumber: 10
myList: [1, 2, 3, 4]

ご覧の通り、modifyInt関数内でvalue11になったにもかかわらず、関数呼び出し後のmyNumberは元の10のままです。一方で、addToList関数内でlistに要素を追加すると、関数呼び出し後のmyListは正しく更新されています。この挙動の違いが、多くの開発者を悩ませる原因となるのです。

Dartにおける「参照の値渡し (Pass by Object Reference Value)」

この現象を理解するためには、プログラミング言語における変数とメモリの関係、そして関数の引数がどのように渡されるかを知る必要があります。

多くのプログラミング言語では、「値渡し (pass by value)」と「参照渡し (pass by reference)」という概念があります。 * 値渡し: 引数として渡された変数の「値そのもの」がコピーされて関数に渡されます。関数内でそのコピーされた値を変更しても、元の変数の値には影響しません。 * 参照渡し: 引数として渡された変数の「メモリ上のアドレス(参照)」が関数に渡されます。関数内でそのアドレスが指すメモリ上の値を変更すると、元の変数の値も変更されます。

Dart言語は、厳密には「参照の値渡し (pass by object reference value)」を採用しています。 これは少し紛らわしい表現ですが、オブジェクト指向言語の特性を理解すると納得できます。

Dartでは、intStringListなど、すべてがオブジェクトです。変数は、そのオブジェクトそのものを格納しているわけではなく、オブジェクトがメモリ上のどこにあるかを指し示す「参照(reference)」を保持しています。

関数に引数を渡すとき、この「参照の値」がコピーされて渡されます

int型の場合: 不変 (Immutable) なオブジェクトと参照のコピー

intdoubleboolStringのようなプリミティブ型に見えるものは、Dartでは実際には不変 (Immutable) なオブジェクトです。

  1. int myNumber = 10; の宣言時、メモリ上に10という値を持つintオブジェクトが作成され、myNumberはそのオブジェクトへの参照を保持します。
  2. modifyInt(myNumber); で関数を呼び出す際、myNumberが保持する「10というintオブジェクトへの参照」がコピーされ、modifyInt関数の引数valueに渡されます。
  3. 関数内で value = value + 1; と実行されると、Dartは新しいintオブジェクト(11という値を持つ)をメモリ上に作成します。そして、value変数は、この新しい11のオブジェクトへの参照を保持するように変わります。
  4. しかし、関数外のmyNumberは、依然として元の10のオブジェクトへの参照を保持したままです。

つまり、modifyInt関数内でのvalueの変更は、新しいintオブジェクトを参照するようにvalue自体の参照を切り替えているだけであり、myNumberが参照している元の10のオブジェクトには何の影響も与えていないのです。不変オブジェクトの特性上、元の10のオブジェクト自体を変更することはできません。

List型の場合: 可変 (Mutable) なオブジェクトと参照のコピー

ListMapなどのコレクション型は、可変 (Mutable) なオブジェクトです。

  1. List<int> myList = [1, 2, 3]; の宣言時、メモリ上に[1, 2, 3]という要素を持つListオブジェクトが作成され、myListはそのオブジェクトへの参照を保持します。
  2. addToList(myList); で関数を呼び出す際、myListが保持する「[1, 2, 3]というListオブジェクトへの参照」がコピーされ、addToList関数の引数listに渡されます。
  3. この時点で、関数外のmyListも、関数内のlistも、同じListオブジェクトをメモリ上で指し示しています。
  4. 関数内で list.add(4); と実行されると、listが指し示すメモリ上の元のListオブジェクト自体に要素4が追加されます。
  5. 関数終了後も、myListは変更された同じListオブジェクトを指し示しているため、[1, 2, 3, 4]という更新された値が反映されているのです。

まとめると、どちらの型も「参照の値渡し」ですが、不変なint型の場合は関数内で新しいオブジェクトを参照し直すため元の変数に影響がなく、可変なList型の場合は関数内で元のオブジェクトを直接操作するため変更が反映される、という違いが生じるわけです。

ハマった点やエラー解決

この挙動を理解していないと、「なぜかint変数だけが更新されない!バグだ!」と誤解し、多くの時間を費やしてしまうことになります。特に、複数の関数やWidget間で状態を共有しようとしたときに、この「参照の値渡し」のメカニズムを把握していないと、意図しない挙動に悩まされることになります。これはエラーではなく、Dart言語の仕様によるものなので、エラーメッセージが出るわけでもありません。

解決策

では、int型のような不変な変数を関数内で更新したい場合、どのようにすれば良いのでしょうか?いくつかの一般的な解決策があります。

1. 関数からの戻り値として新しい値を返す

最もシンプルで直接的な方法です。関数内で計算された新しい値を戻り値として返し、呼び出し側でその値を元の変数に再代入します。

Dart
int modifyIntAndReturn(int value) { value = value + 1; // 新しい値を計算 return value; // その新しい値を返す } void main() { int myNumber = 10; print('関数呼び出し前のint値: $myNumber'); // 10 myNumber = modifyIntAndReturn(myNumber); // 戻り値をmyNumberに再代入 print('関数呼び出し後のint値: $myNumber'); // 11 }

2. コールバック関数を利用する (Flutter Widget間での状態更新に有効)

特にFlutterで親Widgetから子Widgetの値を更新したい場合などに有効なパターンです。子Widgetから親Widgetにイベントを通知し、親Widgetで状態を更新します。

Dart
// 仮に子Widgetから通知されるコールバック関数を定義 typedef IntUpdater = void Function(int newValue); void myButtonAction(int currentValue, IntUpdater updater) { int newValue = currentValue + 5; // 新しい値を計算 updater(newValue); // コールバック関数を呼び出し、新しい値を渡す } void main() { int myCounter = 0; // コールバック関数の実装 void updateCounter(int newValue) { myCounter = newValue; // 呼び出し側で変数を更新 print('カウンターが更新されました: $myCounter'); } print('初期カウンター: $myCounter'); // 0 myButtonAction(myCounter, updateCounter); // myCounter: 5 myButtonAction(myCounter, updateCounter); // myCounter: 10 print('最終カウンター: $myCounter'); // 10 }

3. FlutterのStatefulWidgetとsetStateを活用する

FlutterアプリケーションでUIの状態(int値など)を更新する最も基本的な方法は、StatefulWidgetとそのsetStateメソッドを使用することです。setState内部で変数を変更することで、Widgetが再構築され、UIに更新が反映されます。

Dart
import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Counter App', theme: ThemeData(primarySwatch: Colors.blue), home: const CounterScreen(), ); } } class CounterScreen extends StatefulWidget { const CounterScreen({super.key}); @override State<CounterScreen> createState() => _CounterScreenState(); } class _CounterScreenState extends State<CounterScreen> { int _counter = 0; // 状態として持つint型の変数 void _incrementCounter(int valueToAdd) { setState(() { _counter += valueToAdd; // setState内で_counterを更新 }); print('現在のカウンター: $_counter'); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Flutter Counter')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( '現在のカウント:', style: TextStyle(fontSize: 24), ), Text( '$_counter', // _counterの値を表示 style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold), ), const SizedBox(height: 30), ElevatedButton( onPressed: () => _incrementCounter(1), // 1を加えて更新 style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 15), textStyle: const TextStyle(fontSize: 20), ), child: const Text('1 カウントアップ'), ), const SizedBox(height: 20), ElevatedButton( onPressed: () => _incrementCounter(5), // 5を加えて更新 style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 15), textStyle: const TextStyle(fontSize: 20), ), child: const Text('5 カウントアップ'), ), ], ), ), ); } }

この例では、_counterというint型の変数を_CounterScreenStateの内部に持ち、_incrementCounter関数内でsetStateを呼び出して_counterを更新しています。これにより、_counterの変更がUIに反映されます。

4. より高度な状態管理ソリューションの利用

アプリケーションが大規模になるにつれて、setStateだけでは状態管理が複雑になることがあります。そのような場合は、以下のような状態管理ソリューションを検討します。 * Provider: Flutterの公式推奨パッケージの一つで、シンプルかつ強力な状態管理を提供します。 * Riverpod: Providerの安全でテストしやすい代替として人気があります。 * Bloc/Cubit: イベント駆動型で、複雑なビジネスロジックと状態変化を管理するのに適しています。 * ValueNotifier/ChangeNotifier: シンプルな状態を監視・通知するためのDart標準の仕組み。

これらのソリューションは、変数の更新を特定の場所で一元的に管理し、変更を監視しているWidgetに自動的に通知する仕組みを提供します。

まとめ

本記事では、Flutterにおけるint型とList型が関数に渡された際の挙動の違い、そしてその背後にある「参照の値渡し」というDartのメカニズム を解説しました。

  • int型が更新されない理由: Dartではすべての変数がオブジェクトへの「参照」を保持しており、関数に引数を渡すとその参照がコピーされます。intは不変オブジェクトであるため、関数内でvalue = value + 1;とすると、新しいintオブジェクトが作成され、引数のvalueがその新しいオブジェクトの参照を持つようになります。元の変数は引き続き以前のオブジェクトを参照しているため、関数外では値が更新されません。
  • List型が更新される理由: Listは可変オブジェクトであるため、関数内でlist.add(4);のように要素を追加すると、引数のlistと元の変数が参照している「同じListオブジェクト」自体が変更されます。そのため、関数外でも更新が反映されます。
  • int型を更新するための解決策:
    1. 関数から新しい値をreturnし、呼び出し側で再代入する。
    2. コールバック関数を利用して、呼び出し側で更新処理を実行する。
    3. FlutterのStatefulWidgetsetStateを使って状態を管理する。
    4. Providerなどの状態管理ソリューションを導入する。

この記事を通して、Flutter開発で遭遇しがちな変数の更新に関する「なぜ?」が解消され、Dartの変数の扱い方に対する理解が深まったことでしょう。この知識は、意図しないバグを防ぎ、より予測可能で堅牢なアプリケーションを構築する上で非常に役立ちます。

今後は、より複雑な状態管理ソリューション(ProviderやRiverpodなど)を学習し、大規模なアプリケーションで効率的に状態を管理する方法についても深掘りしていくと良いでしょう。

参考資料