【Dart vs Swift】文字列結合のパフォーマンス比較:「不変性」と「コピーオンライト」の哲学
はじめに
こんにちは、株式会社メンバーズ Cross Application カンパニーの田原です。
皆さん突然ですが、アニメ『涼宮ハルヒの憂鬱』をご存知でしょうか?作中で繰り返される悪夢のような夏休み「エンドレスエイト」は、8月17日から31日までの15日間を無限(15,000回以上)に繰り返すというもので、テレビ放送でも実際に8話に渡ってほぼ同じ内容のエピソードが繰り返されました。
DartやSwiftを使っている開発者の方へ。本記事では、このエンドレスエイトのループをモチーフに、DartとSwiftという2つの言語における文字列結合のパフォーマンスを比較します。この比較を通して、「不変性(Immutable)」と「コピーオンライト(Copy-on-Write)」いう、言語の設計思想がパフォーマンスにどう影響するかを解説します。この記事が、皆さんの開発におけるパフォーマンスチューニングの一助となれば幸いです。
計測方法:エンドレスエイトの反復
エンドレスエイトのループを再現するため、8月17日から8月31日までの日付を文字列として格納し、これを何度も繰り返し結合する処理を実装します。
Dart:不変性がもたらす文字列結合の遅さ
シンプルな実装(String結合)
まずはシンプルな実装を試みます。
import 'dart:core';
// 『涼宮ハルヒの憂鬱』における「エンドレスエイト」の日付リスト
const List<String> endlessEightDates = [
'8月17日',
'8月18日',
'8月19日',
'8月20日',
'8月21日',
'8月22日',
'8月23日',
'8月24日',
'8月25日',
'8月26日',
'8月27日',
'8月28日',
'8月29日',
'8月30日',
'8月31日',
];
// 通常実装(Stringの結合)
String endlessEight_normal(int count) {
String result = '';
for (int i = 0; i < count; i++) {
for (String date in endlessEightDates) {
result += date;
}
}
return result;
}この実装では、result += date;という処理がループの中で何度も繰り返されます。一見シンプルに見えますが、DartのStringが「不変(Immutable)」であることが大きな問題を引き起こします。
文字列が不変であるということは、一度生成された文字列は内容を変更できないことを意味します。そのため、+=演算子を使って文字列を結合するたびに、以下の非効率なステップが繰り返されます。
結合後の新しい文字列のためのメモリ領域が確保される。
元の
resultとdateの内容が、新しいメモリ領域にすべてコピーされる。result変数が、新しく作成された文字列を指すように更新される。
ループが進むにつれてresultの文字列はどんどん長くなるため、メモリの確保とデータコピーのコストが指数関数的に増大します。その結果、全体の実行時間はO(n²)に近づいてしまいます。
計測用のコードも追加します。
void main() {
const int count = 15000;
print('🗓️ エンドレスエイトの回数: $count');
print('---');
// 通常実装の計測
final stopwatchNormal = Stopwatch()..start();
endlessEight_normal(count);
stopwatchNormal.stop();
final normalTime = stopwatchNormal.elapsedMilliseconds;
print('通常実装の実行時間: ${normalTime}ms');
}実行結果は以下のようになります。
🗓️ エンドレスエイトの回数: 15000
---
通常実装の実行時間: 38411ms夏休みを15,000回繰り返すのに、38.4秒というかなりの時間がかかりました。この結果は、DartのStringの不変性がもたらすパフォーマンスの問題を如実に示しています。
最適化実装(StringBuffer)
このパフォーマンス問題を解決するために、Dartが提供するStringBufferを使用します。StringBufferは可変(Mutable)なデータを扱うためのクラスです。
// 最適化実装(StringBuffer)
String endlessEight_optimized(int count) {
final buffer = StringBuffer();
for (int i = 0; i < count; i++) {
for (String date in endlessEightDates) {
buffer.write(date);
}
}
return buffer.toString();
}StringBufferは、内部に可変長のバッファを持っており、文字列の追加は既存のバッファに直接行われます。これにより、不要なメモリの再確保やデータコピーが防がれ、実行速度は線形的(O(n))に収まります。
比較計測用のコードを修正します。
// main関数(比較)
void main() {
const int count = 15000;
// 通常実装の計測
final stopwatchNormal = Stopwatch()..start();
endlessEight_normal(count);
stopwatchNormal.stop();
final normalTime = stopwatchNormal.elapsedMilliseconds;
// 最適化実装の計測
final stopwatchOptimized = Stopwatch()..start();
endlessEight_optimized(count);
stopwatchOptimized.stop();
final optimizedTime = stopwatchOptimized.elapsedMilliseconds;
print('🗓️ エンドレスエイトの回数: $count');
print('---');
print('通常実装の実行時間: ${normalTime}ms');
print('最適化実装の実行時間: ${optimizedTime}ms');
print('---');
if (optimizedTime > 0) {
final performanceRatio = normalTime / optimizedTime;
print('最適化実装は通常実装の約${performanceRatio.toStringAsFixed(1)}倍高速です。');
} else {
print('🤔:最適化実装が非常に高速で、計測限界を下回った可能性があります。');
}
}実行結果は以下のようになります。
🗓️ エンドレスエイトの回数: 15000
---
通常実装の実行時間: 38577ms
最適化実装の実行時間: 6ms
---
最適化実装は通常実装の約6429.5倍高速です。最適化により夏休みは6ミリ秒で終わりました。通常実装と比べると、約6,500倍という圧倒的なパフォーマンス差が生まれました。
計算量の違い: O(n²) vs O(n)
この非効率性は、プログラミングにおける「計算量」の観点から見ると、より明確になります。
通常実装(String結合)
ループが1回進むごとに、resultの文字列はどんどん長くなります。このため、新しいメモリ領域の確保とデータコピーにかかる時間は、文字列の長さに比例して増えていきます。全体のループ回数もn、文字列の結合コストも徐々にnに比例していくため、全体の計算量はO(n²)(二次曲線)に近づいてしまいます。この急激なコスト増加が、15,000回という大きな回数になったときに、実行時間を爆発的に増加させました。
最適化実装(StringBuffer)
StringBufferは、内部的に可変長のバッファを持っており、文字列の追加は基本的にこのバッファに直接行われます。これにより、不要なメモリの再割り当てとデータコピーを防ぎます。
厳密には、バッファに文字列を追加する際にバッファサイズを超える場合には、より大きな新しいメモリ領域の確保とデータコピーが必要になります。この時には追加の処理コストが生じますが、メモリ割り当ての発生回数を抑えるための指数関数的成長の仕組みにより、多数の追加操作を行った場合の平均コストは定数時間(O(1))に収まります。
このため、ループの回数nにこの定数コストを掛け合わせることで、全体の計算量はO(n)(線形)に収まります。ループの回数が多くなっても、処理時間は緩やかにしか増加しません。
ここで、「指数関数的成長」とは、バッファが一杯になった時に、現在のサイズの2倍のサイズで新しいバッファを確保する仕組みです。これにより、バッファの再割り当てというコストの高い処理はごくまれにしか発生しません。ほとんどのappend操作は非常に高速なO(1)で完了するため、多数の文字列追加を行った場合の平均コストは定数時間に収まります。
このO(1)の平均コストは、償却定数時間と呼ばれます。
StringBufferの内部バッファの仕組みは隠蔽されており、直接コードを確認することはできません。しかし、この「指数関数的成長」というパフォーマンス哲学は、他のDartライブラリの実装にも共通して見られます。
例えば、FlutterのChangeNotifierクラスがその好例です。このクラスのaddListenerメソッドは、リスナー(関数の配列)がいっぱいになった時、配列のサイズを2倍に増やし、既存のリスナーを新しい配列にコピーする処理を含んでいます。
@override
void addListener(VoidCallback listener) {
// 配列が一杯になったら
if (_count == _listeners.length) {
// 現在のサイズの2倍の新しい配列を作成
final List<VoidCallback?> newListeners = List<VoidCallback?>.filled(
_listeners.length * 2,
null,
);
// 既存のリスナーを新しい配列にコピー
for (int i = 0; i < _count; i++) {
newListeners[i] = _listeners[i];
}
_listeners = newListeners;
}
// リスナーを追加
listeners[count++] = listener;
}この実装は、StringBufferが文字列の断片に対して行っている処理とまったく同じだと考えられます。どちらも、要素を繰り返し追加する際に、非効率なメモリの再確保を最小限に抑えることを目的としています。
このことから、Dartという言語は、このようなデータ構造の効率的な管理を、開発者が意識的に行うことを前提とした思想を持っていることがわかります。これが、Dartの通常実装と最適化実装の間で、約6,500倍という圧倒的なパフォーマンス差が生まれた根本的な理由です。
参考: 償却解析
Swift:コピーオンライトで効率化する戦略
次に、同じ処理をSwiftで実装し、同様の計測を行います。
import Foundation
// 『涼宮ハルヒの憂鬱』における「エンドレスエイト」の日付リスト
let endlessEightDates = [
"8月17日",
"8月18日",
"8月19日",
"8月20日",
"8月21日",
"8月22日",
"8月23日",
"8月24日",
"8月25日",
"8月26日",
"8月27日",
"8月28日",
"8月29日",
"8月30日",
"8月31日"
]
func endlessEight(count: Int) -> String {
var result = ""
for _ in 0..<count {
for date in endlessEightDates {
result += date
}
}
return result
}
Dartのシンプルな実装と同様に、ここでも+=演算子を使って文字列を結合します。
計測用のコードも追加します。
func measureAndPrint() {
let count = 15000
print("🗓️ エンドレスエイトの回数: \(count)")
print("---")
let startTimeNormal = Date()
_ = endlessEight(count: count)
let endTimeNormal = Date()
let normalTime = endTimeNormal.timeIntervalSince(startTimeNormal) * 1000 // ミリ秒に変換
print("実行時間: \(Int(normalTime))ms")
}
measureAndPrint()計測結果は以下のようになります。
🗓️ エンドレスエイトの回数: 15000
---
実行時間: 7ms驚くべきことに、Dartの通常実装が38秒以上かかったのに対し、Swiftではわずか7ミリ秒で夏休みが終了しました。これは、DartのStringBufferを使った最適化実装と同等の速度です。
この結果は、Swiftの言語設計が持つ重要な特性によるものです。
SwiftのStringの特性
SwiftのStringは値型(Value semantics)として振る舞いますが、その裏側では「コピーオンライト(Copy-on-Write)」という戦略を用いてデータを効率的に管理しています。
値型としての振る舞い:
Stringを別の変数に代入したり、関数に渡したりすると、あたかも新しいインスタンスが丸ごとコピーされたかのように振る舞います。コピーオンライトの実装: しかし、実際のデータはすぐにコピーされません。代わりに、複数の
Stringインスタンスが同じデータのバッファ(メモリ上の保存場所)を共有します。
このバッファの共有により、データの変更がない限り、メモリの無駄なコピーは発生しません。
O(n)のコストが発生するタイミング
データが実際にコピーされるのは、文字列の内容を変更する(mutating)最初の操作時です。この時、共有していたバッファが複製され、新しいインスタンスが独自のバッファを持つことになります。このコピー操作には、文字列の長さに比例したコスト、つまりO(n)の時間とスペースがかかります。
ここでのnは文字列の長さを表します。これは、データのコピーに必要な時間が、文字列の文字数に比例するためです。
バッファの再割り当てと指数関数的成長
文字列に文字を追加し続けると、いずれバッファの容量が一杯になります。その場合、Swiftはより大きな新しいバッファを割り当て、そこに既存のデータを移動させます。
この時、バッファのサイズを指数関数的に成長させる戦略が取られます。これはDartのStringBufferで解説したのと同様の仕組みです。
この戦略により、多数のappend操作を行った場合、その平均コストは定数時間になります。つまり、個々のappend操作にはコストのかかる再割り当てが含まれることがあっても、全体として見れば非常に効率的である、ということです。
コピーオンライトと自動最適化
要するに、SwiftのStringは、+=やappendによる文字列結合が頻繁に発生する場面で、不必要なメモリコピーを避け、効率的なデータ管理を実現しています。
最初の変更時: 共有バッファからのコピーが発生し、O(n)のコストがかかる可能性があります。
連続した追加: バッファの指数関数的成長により、多数の
append操作の平均コストは定数時間に抑えられます。
参考:String.swift
まとめ:DartとSwiftの設計思想を比較してわかる最適化の哲学
今回の検証は、同じ「文字列結合」という単純な処理でも、言語の特性や設計思想によって、パフォーマンスが全く異なることを示しています。
Dartでは、文字列の不変性から生じる非効率なメモリ確保に対し、開発者が
StringBufferという適切なデータ構造を明示的に選択することが求められます。一方、Swiftでは、コピーオンライトの仕組みが効率的な文字列結合を実現します。これにより、開発者は特別な配慮をせずとも高いパフォーマンスを享受できます。
どちらの言語も最終的なパフォーマンスは同等ですが、そのアプローチは異なります。Dartは「開発者による明示的な最適化」を、Swiftは「標準ライブラリによる効率的な実装」を重視するという、それぞれの哲学の違いを物語っています。
この違いを理解することは、パフォーマンスが求められる場面で、どのデータ構造やアルゴリズムを選択すべきかを判断する上で非常に重要です。
この記事が、皆さんの日々の開発におけるパフォーマンスチューニングの一助となれば幸いです。
そして、この記事はエンドレスエイトという思わせぶりなテーマに始まりながら、その要素が極めて希薄な、ただの「こじつけ」記事になってしまったことを深くお詫び申し上げます。
最後までお読みいただき、ありがとうございました。
この記事を書いた人
What is BEMA!?
Be Engineer, More Agile


