C# Splitの処理を高速化
##概要
C# で文字分割を大量(秒間20万回くらいを数時間)に行う必要があったが、Split関数がどうも遅かったので、お手製で文字列分割することで、少しは高速になったのでその話です。
この記事で、C#の文字列操作の高速化の記事があったので、私も自分メモ的に残そうと思い投稿です。
世の中にはより早い方法があるらしいのですが、ここが私の限界でした。。。。
もっと早いのあるよって方がいたら、教えてください。
##前提条件
何分割するか決まっている場合にのみ、正しく動作します。
下のほうに記載したソースの場合、4個つのカンマで構成される文字のみ正しく動作するということです。
こんな感じのデータ
"12345, D, 555555, 115151, EEEEEE"
debugビルドだと、普通のSplitのほうが早くなります。
releaseビルドでやる必要があります。
##環境
paiza.io を使っています。
C#をブラウザですぐに実行できるので、さっと処理を確かめたい時に便利です。
実行時間が2秒以内という制約があるので、2秒以内に収まる範囲の処理件数で、1回しか計測しません。
※マシンスペックにより、チューニングの仕方も違ったりするのでしょうが。。。
だいたいのロジックを確認するだけなら、これでもいいはず。。。
####環境による違いについて
環境によりけりっぽいです。
@nogic1008 さんからコメントのある通り、Try.NETでは、普通のスプリットの方が早いです。
高速化くしたかった環境が .net4.5.2だったのですが、
そこでは、お手製splitの方が高速でした。
##結果
200万回処理したときに、約300ミリ早くなりました。
loop count:2000000
nomalSplit:00:00:01.0473945
fastSplit:00:00:00.7556135
##文字分割を高速化した処理部分
###●処理の流れ
以下の流れで、処理しています。
1.区切り文字の位置を配列に保存
2.区切り文字の位置を利用し、Substringで文字抽出
###●試行錯誤する中での覚書
※いつか、全部比較版を載せたいところですが、、、、
・findより、微妙にループし区切り文字の位置を配列に入れたほうが早かった気がします
・i++するより、i = i + 1したほうが、ほんと微妙に早かった気がします。 ←大嘘でした。すみません。
・以下のほうが、StringBuilderに格納していき、ToStringするより早かったと思います。
private static string[] fastSplit(string str)
{
//結果を格納するための配列を生成する
var splitedStr = new string[COL_NUM];
//区切り文字を見つけた件数
var count = 0;
//区切り文字の位置を格納する配列
var posi = new int[COUNT_OF_DELIMITER];
//文字列をchar配列でループして、一文字ずつ区切り文字であるか判定
//区切り文字であるなら、区切り文字の位置を保存
for (var i = 0; i < str.Length; i = i + 1)
{
if (str[i] == DELIMITER)
{
posi[count] = i;
if (count == COUNT_OF_DELIMITER - 1)
break;
count = count + 1;
}
}
//-----------------------------------------------------
//Substringで文字列をスプリットしていく
//1つ目の引数は、開始位置(0始まり)
//2つ目の引数は、文字数
//-----------------------------------------------------
//※区切り文字を「カンマ」として説明を記載
//・1列目のデータは、以下で抽出
// 開始位置:0
// 文字数:最初のカンマの位置(最初のカンマの位置=最初の文字数になる)
splitedStr[0] = str.Substring(0, posi[0]);
//・2列目のデータは、以下で抽出(3列目以降も同じ感じ)
// 開始位置:最初のカンマの位置 + 1
// 文字数:次のカンマの位置 - 最初のカンマの位置 - 1
splitedStr[1] = str.Substring(posi[0] + 1, posi[1] - posi[0] - 1);
splitedStr[2] = str.Substring(posi[1] + 1, posi[2] - posi[1] - 1);
splitedStr[3] = str.Substring(posi[2] + 1, posi[3] - posi[2] - 1);
//・最後のデータは、以下で抽出
// 開始位置:最後のカンマの位置
// 文字数:指定なし(最後まで)
splitedStr[4] = str.Substring(posi[3] + 1);
return splitedStr;
}
##さらなる高速化
ありがたいことに、コメントに高速化の案を頂いたので、
それらを比較してみました。
paiza.ioの環境で、比較しているので、お使いの環境によっては、
必ずしも、比較結果通りになるとは限らないようです。
###結果
paiza.ioの環境では、@albireo さんから提案頂いた方法が一番速かったです。
下に記載していますが、「ローカルのVisualStudio2017 .Net Core 2.1 での計測」した場合も、
@albireo さんから提案頂いた方法が一番速かったです。
Loop Count:200000
nomalSplit:00:00:00.1090672
nomalSplit:00:00:00.1054340
fastSplit:00:00:00.0765732
fast2Split:00:00:00.0724998
nomalSplit2:00:00:00.1015977
@albireo さん作成 ソース「fast2Split」
「区切り文字を見つけたらいったん配列に位置を格納して後でSubstringで切り出すより、区切りを見つけたらその場で切り出した方が効率よくないかな?」
という発想の様で、確かに速くなりました!
private static string[] fast2Split(string str)
{
//結果を格納するための配列を生成する
var splitedStr = new string[COL_NUM];
//区切り文字を見つけた件数
var count = 0;
//切り出し開始位置
var start = 0;
//文字列をchar配列でループして、一文字ずつ区切り文字であるか判定
//区切り文字であるなら、区切り文字の位置を保存
for (var i = 0; i < str.Length; i++)
{
if (str[i] == DELIMITER)
{
//Substringで文字列をスプリットしていく
splitedStr[count] = str.Substring(start, i - start);
if (count + 1 == COUNT_OF_DELIMITER)
{
//最後のデータは、以下で抽出
splitedStr[count + 1] = str.Substring(i + 1);
break;
}
start = i + 1;
count++;
}
}
return splitedStr;
}
@muniel さん作成のソース 「nomalSplit2」
「.net core 2.1」 だと、普通にSplit速い、
paramsで毎回配列が作られるのを防ぐと速いってこと、みたいです。
paiza.ioや、私のローカルマシン(.net core 2.1)では、
あまり効果はなかったですが、特定の環境だと速いようです。
static readonly char[] DELIMITERS = new char[] { DELIMITER };
private static string[] nomalSplit2(string str)
{
return str.Split(DELIMITERS);
}
##ローカルのVisualStudio2017 .Net Core 2.1 での計測
@muniel さんから、paiza.ioは、Mono環境なので、Split処理が遅いのでは?
と教えて頂いたので、ローカルマシンの「.Net Core 2.1」でも計測しました。
結論から言うと、私のローカルマシンでは、Releaseビルドの場合、お手製Splitのほうが早かったです。
※Debugビルドの場合は、お手製ビルドの方が遅いですが、それは.net4.5.2とかでも同じでした。
###計測方法について
@nogic1008 さんより、教えて頂いたので、以下の方法で計測します。
①計測前に1回処理を回す。
②10回くらい計測して、平均、最大、最少を出す。
デバッグモードだとお手製Splitの方が遅いので、
デバッグ、リリースの2種類で計測します。
###ローカルのVisualStudio2017 .Net Core 2.1 結果
####Releaseモード
【平均部門】
1位:fast2Split 295ミリ
2位:fastSplit 360ミリ
3位:nomalSplit 453ミリ
4位:nomalSplit2 457ミリ
【最大部門】
1位:fast2Split 345ミリ
2位:fastSplit 396ミリ
3位:nomalSplit 530ミリ
4位:nomalSplit2 541ミリ
【最小部門】
1位:fast2Split 227ミリ
2位:fastSplit 265ミリ
3位:nomalSplit 350ミリ
4位:nomalSplit2 385ミリ
Version: 4.0.30319.42000
---------------------------Check---------------------------
nomalSplit:12345
nomalSplit:D
nomalSplit:555555
nomalSplit:115151
nomalSplit:EEEEEE
fastSplit:12345
fastSplit:D
fastSplit:555555
fastSplit:115151
fastSplit:EEEEEE
fast2Split:12345
fast2Split:D
fast2Split:555555
fast2Split:115151
fast2Split:EEEEEE
---------------------------WarmUp--------------------------
nomalSplit:00:00:00.7582664
fastSplit:00:00:00.4453710
fast2Split:00:00:00.3145498
nomalSplit2:00:00:00.5279528
---------------------------Start---------------------------
Loop Count:2000000
1回目
nomalSplit:00:00:00.4922244
fastSplit:00:00:00.3587043
fast2Split:00:00:00.3316316
nomalSplit2:00:00:00.4347266
2回目
nomalSplit:00:00:00.4510801
fastSplit:00:00:00.3966643
fast2Split:00:00:00.2736605
nomalSplit2:00:00:00.4440113
3回目
nomalSplit:00:00:00.4323691
fastSplit:00:00:00.3753283
fast2Split:00:00:00.3134344
nomalSplit2:00:00:00.4772646
4回目
nomalSplit:00:00:00.4941949
fastSplit:00:00:00.3753482
fast2Split:00:00:00.3459410
nomalSplit2:00:00:00.5416430
5回目
nomalSplit:00:00:00.3982180
fastSplit:00:00:00.3892855
fast2Split:00:00:00.3071757
nomalSplit2:00:00:00.4579428
6回目
nomalSplit:00:00:00.4836517
fastSplit:00:00:00.3604454
fast2Split:00:00:00.2599656
nomalSplit2:00:00:00.3852338
7回目
nomalSplit:00:00:00.3505502
fastSplit:00:00:00.2651866
fast2Split:00:00:00.2270998
nomalSplit2:00:00:00.4426847
8回目
nomalSplit:00:00:00.4628723
fastSplit:00:00:00.3566661
fast2Split:00:00:00.2776025
nomalSplit2:00:00:00.4873345
9回目
nomalSplit:00:00:00.4371866
fastSplit:00:00:00.3544766
fast2Split:00:00:00.3323241
nomalSplit2:00:00:00.4397582
10回目
nomalSplit:00:00:00.5304038
fastSplit:00:00:00.3707582
fast2Split:00:00:00.2867147
nomalSplit2:00:00:00.4599572
nomalSplit--------------------------------
平均(ミリ秒): 453.27511
最大(ミリ秒): 530.4038
最少(ミリ秒): 350.5502
fastSplit--------------------------------
平均(ミリ秒): 360.28635
最大(ミリ秒): 396.6643
最少(ミリ秒): 265.1866
fast2Split--------------------------------
平均(ミリ秒): 295.55499
最大(ミリ秒): 345.941
最少(ミリ秒): 227.0998
nomalSplit2--------------------------------
平均(ミリ秒): 457.05567
最大(ミリ秒): 541.643
最少(ミリ秒): 385.2338
####Debugモード
Version: 4.0.30319.42000
Is DEBUG
---------------------------Check---------------------------
nomalSplit:12345
nomalSplit:D
nomalSplit:555555
nomalSplit:115151
nomalSplit:EEEEEE
fastSplit:12345
fastSplit:D
fastSplit:555555
fastSplit:115151
fastSplit:EEEEEE
fast2Split:12345
fast2Split:D
fast2Split:555555
fast2Split:115151
fast2Split:EEEEEE
---------------------------WarmUp--------------------------
nomalSplit:00:00:00.9739630
fastSplit:00:00:00.8629389
fast2Split:00:00:00.7798239
nomalSplit2:00:00:00.5045547
---------------------------Start---------------------------
Loop Count:2000000
1回目
nomalSplit:00:00:00.5979602
fastSplit:00:00:00.7363702
fast2Split:00:00:00.6911465
nomalSplit2:00:00:00.5020473
2回目
nomalSplit:00:00:00.6521156
fastSplit:00:00:00.7206363
fast2Split:00:00:00.6235076
nomalSplit2:00:00:00.5577866
3回目
nomalSplit:00:00:00.4520174
fastSplit:00:00:00.7953817
fast2Split:00:00:00.6193398
nomalSplit2:00:00:00.5030806
4回目
nomalSplit:00:00:00.5479323
fastSplit:00:00:00.8133679
fast2Split:00:00:00.7045433
nomalSplit2:00:00:00.5573005
5回目
nomalSplit:00:00:00.5690903
fastSplit:00:00:00.6965763
fast2Split:00:00:00.7562962
nomalSplit2:00:00:00.6061496
6回目
nomalSplit:00:00:00.5495139
fastSplit:00:00:00.7080167
fast2Split:00:00:00.6088300
nomalSplit2:00:00:00.5475067
7回目
nomalSplit:00:00:00.5417170
fastSplit:00:00:00.7547760
fast2Split:00:00:00.6409854
nomalSplit2:00:00:00.6064478
8回目
nomalSplit:00:00:00.5446050
fastSplit:00:00:00.7173445
fast2Split:00:00:00.6388982
nomalSplit2:00:00:00.5174052
9回目
nomalSplit:00:00:00.5890863
fastSplit:00:00:00.8855495
fast2Split:00:00:00.6835102
nomalSplit2:00:00:00.4958474
10回目
nomalSplit:00:00:00.5653307
fastSplit:00:00:00.7017383
fast2Split:00:00:00.7640299
nomalSplit2:00:00:00.6352337
nomalSplit--------------------------------
平均(ミリ秒): 560.93687
最大(ミリ秒): 652.1156
最少(ミリ秒): 452.0174
fastSplit--------------------------------
平均(ミリ秒): 752.97574
最大(ミリ秒): 885.5495
最少(ミリ秒): 696.5763
fast2Split--------------------------------
平均(ミリ秒): 673.10871
最大(ミリ秒): 764.0299
最少(ミリ秒): 608.83
nomalSplit2--------------------------------
平均(ミリ秒): 552.88054
最大(ミリ秒): 635.2337
最少(ミリ秒): 495.8474
##ソース(全部)
###paiza.io
多分、paiza.ioに貼り付ければ、そのまま動くはず。。。
using System.Text;
using System;
public class Split{
public static void Main(){
//スプリットの対象文字
var splitTargets = new string[]{"12345,D,555555,115151,EEEEEE"};
//正しくスプリットできているかチェックする
Console.WriteLine("---------------------------Check---------------------------");
Check("nomalSplit", nomalSplit, splitTargets);
Check(" fastSplit", fastSplit, splitTargets);
Check(" fast2Split", fast2Split, splitTargets);
//スプリットの速度計測
Console.WriteLine("---------------------------Start---------------------------");
Console.WriteLine("Loop Count:{0}", LOOP_COUNT);
Sokutei(" nomalSplit", nomalSplit, splitTargets);
Sokutei(" fastSplit", fastSplit, splitTargets);
Sokutei(" fast2Split", fast2Split, splitTargets);
Sokutei("nomalSplit2", nomalSplit2, splitTargets);
}
//スプリットの検証用メソッド
private static void Check(string name, Func<string, string[]> splitFunc, string[] splitTargets)
{
foreach(var text in splitTargets)
{
var splitedValues = splitFunc(text);
foreach(var val in splitedValues)
{
Console.WriteLine("{0}:{1}", name, val);
}
}
}
//速度図るためのカウント
private const int LOOP_COUNT = 200000;
//スプリットの速度計測用メソッド
private static void Sokutei(string name, Func<string, string[]> splitFunc, string[] splitTargets)
{
var sw = new System.Diagnostics.Stopwatch();
sw.Start();
for (int i = 0; i < LOOP_COUNT; i++)
{
foreach(var text in splitTargets)
{
splitFunc(text);
}
}
sw.Stop();
Console.WriteLine("{0}:{1}", name, sw.Elapsed.ToString());
}
//区切り文字
private const char DELIMITER = ',';
//何個に区切るか
private const int COL_NUM = 5;
//区切り文字の数
private const int COUNT_OF_DELIMITER = COL_NUM - 1;
//通常のスプリット
private static string[] nomalSplit(string str)
{
return str.Split(DELIMITER);
}
//お手製スプリット
private static string[] fastSplit(string str)
{
//結果を格納するための配列を生成する
var splitedStr = new string[COL_NUM];
//区切り文字を見つけた件数
var count = 0;
//区切り文字の位置を格納する配列
var posi = new int[COUNT_OF_DELIMITER];
//文字列をchar配列でループして、一文字ずつ区切り文字であるか判定
//区切り文字であるなら、区切り文字の位置を保存
for (var i = 0; i < str.Length; i = i + 1)
{
if (str[i] == DELIMITER)
{
posi[count] = i;
if (count == COUNT_OF_DELIMITER - 1)
break;
count = count + 1;
}
}
//-----------------------------------------------------
//Substringで文字列をスプリットしていく
//1つ目の引数は、開始位置(0始まり)
//2つ目の引数は、文字数
//-----------------------------------------------------
//※区切り文字を「カンマ」として説明を記載
//・1列目のデータは、以下で抽出
// 開始位置:0
// 文字数:最初のカンマの位置(最初のカンマの位置=最初の文字数になる)
splitedStr[0] = str.Substring(0, posi[0]);
//・2列目のデータは、以下で抽出(3列目以降も同じ感じ)
// 開始位置:最初のカンマの位置 + 1
// 文字数:次のカンマの位置 - 最初のカンマの位置 - 1
splitedStr[1] = str.Substring(posi[0] + 1, posi[1] - posi[0] - 1);
splitedStr[2] = str.Substring(posi[1] + 1, posi[2] - posi[1] - 1);
splitedStr[3] = str.Substring(posi[2] + 1, posi[3] - posi[2] - 1);
//・最後のデータは、以下で抽出
// 開始位置:最後のカンマの位置
// 文字数:指定なし(最後まで)
splitedStr[4] = str.Substring(posi[3] + 1);
return splitedStr;
}
private static string[] fast2Split(string str)
{
//結果を格納するための配列を生成する
var splitedStr = new string[COL_NUM];
//区切り文字を見つけた件数
var count = 0;
//切り出し開始位置
var start = 0;
//文字列をchar配列でループして、一文字ずつ区切り文字であるか判定
//区切り文字であるなら、区切り文字の位置を保存
for (var i = 0; i < str.Length; i++)
{
if (str[i] == DELIMITER)
{
//Substringで文字列をスプリットしていく
splitedStr[count] = str.Substring(start, i - start);
if (count + 1 == COUNT_OF_DELIMITER)
{
//最後のデータは、以下で抽出
splitedStr[count + 1] = str.Substring(i + 1);
break;
}
start = i + 1;
count++;
}
}
return splitedStr;
}
static readonly char[] DELIMITERS = new char[] { DELIMITER };
private static string[] nomalSplit2(string str)
{
return str.Split(DELIMITERS);
}
}
###ローカルのVisualStudio2017 .Net Core 2.1
using System.Text;
using System;
using System.Collections.Generic;
using System.Linq;
public class SplitSpeed
{
public static void Main()
{
//スプリットの対象文字
var splitTargets = new string[] { "12345,D,555555,115151,EEEEEE" };
//環境
Console.WriteLine("Version: {0}", Environment.Version.ToString());
#if DEBUG
Console.WriteLine("Is DEBUG");
#endif
//正しくスプリットできているかチェックする
Console.WriteLine("---------------------------Check---------------------------");
Check("nomalSplit", nomalSplit, splitTargets);
Check(" fastSplit", fastSplit, splitTargets);
Check(" fast2Split", fast2Split, splitTargets);
//温める
Console.WriteLine("---------------------------WarmUp--------------------------");
Sokutei(" nomalSplit", nomalSplit, splitTargets);
Sokutei(" fastSplit", fastSplit, splitTargets);
Sokutei(" fast2Split", fast2Split, splitTargets);
Sokutei("nomalSplit2", nomalSplit2, splitTargets);
//合計のリセット
ResultDic = new Dictionary<string, List<TimeSpan>>();
//スプリットの速度計測
Console.WriteLine("---------------------------Start---------------------------");
Console.WriteLine("Loop Count:{0}", LOOP_COUNT);
for (var i = 1; i <= 10; i++)
{
Console.WriteLine("{0}回目", i);
Sokutei(" nomalSplit", nomalSplit, splitTargets);
Sokutei(" fastSplit", fastSplit, splitTargets);
Sokutei(" fast2Split", fast2Split, splitTargets);
Sokutei("nomalSplit2", nomalSplit2, splitTargets);
}
foreach(var results in ResultDic)
{
Console.WriteLine("{0}--------------------------------", results.Key);
Console.WriteLine("平均(ミリ秒):{0,12}", results.Value.Average(x => x.TotalMilliseconds));
Console.WriteLine("最大(ミリ秒):{0,12}", results.Value.Max(x => x.TotalMilliseconds));
Console.WriteLine("最少(ミリ秒):{0,12}", results.Value.Min(x => x.TotalMilliseconds));
}
Console.ReadLine();
}
//スプリットの検証用メソッド
private static void Check(string name, Func<string, string[]> splitFunc, string[] splitTargets)
{
foreach (var text in splitTargets)
{
var splitedValues = splitFunc(text);
foreach (var val in splitedValues)
{
Console.WriteLine("{0}:{1}", name, val);
}
}
}
private static Dictionary<string, List<TimeSpan>> ResultDic = new Dictionary<string, List<TimeSpan>>();
//速度図るためのカウント
private const int LOOP_COUNT = 2000000;
//スプリットの速度計測用メソッド
private static void Sokutei(string name, Func<string, string[]> splitFunc, string[] splitTargets)
{
System.Threading.Thread.Sleep(10);
var sw = new System.Diagnostics.Stopwatch();
sw.Start();
for (int i = 0; i < LOOP_COUNT; i++)
{
foreach (var text in splitTargets)
{
splitFunc(text);
}
}
sw.Stop();
var elapsed = sw.Elapsed;
//結果表示
Console.WriteLine("{0}:{1}", name, elapsed.ToString());
//結果の保存
List<TimeSpan> results;
if (ResultDic.TryGetValue(name, out results) == false)
{
results = new List<TimeSpan>();
ResultDic.Add(name, results);
}
results.Add(elapsed);
}
//区切り文字
private const char DELIMITER = ',';
//何個に区切るか
private const int COL_NUM = 5;
//区切り文字の数
private const int COUNT_OF_DELIMITER = COL_NUM - 1;
//通常のスプリット
private static string[] nomalSplit(string str)
{
return str.Split(DELIMITER);
}
//お手製スプリット
private static string[] fastSplit(string str)
{
//結果を格納するための配列を生成する
var splitedStr = new string[COL_NUM];
//区切り文字を見つけた件数
var count = 0;
//区切り文字の位置を格納する配列
var posi = new int[COUNT_OF_DELIMITER];
//文字列をchar配列でループして、一文字ずつ区切り文字であるか判定
//区切り文字であるなら、区切り文字の位置を保存
for (var i = 0; i < str.Length; i = i + 1)
{
if (str[i] == DELIMITER)
{
posi[count] = i;
if (count == COUNT_OF_DELIMITER - 1)
break;
count = count + 1;
}
}
//-----------------------------------------------------
//Substringで文字列をスプリットしていく
//1つ目の引数は、開始位置(0始まり)
//2つ目の引数は、文字数
//-----------------------------------------------------
//※区切り文字を「カンマ」として説明を記載
//・1列目のデータは、以下で抽出
// 開始位置:0
// 文字数:最初のカンマの位置(最初のカンマの位置=最初の文字数になる)
splitedStr[0] = str.Substring(0, posi[0]);
//・2列目のデータは、以下で抽出(3列目以降も同じ感じ)
// 開始位置:最初のカンマの位置 + 1
// 文字数:次のカンマの位置 - 最初のカンマの位置 - 1
splitedStr[1] = str.Substring(posi[0] + 1, posi[1] - posi[0] - 1);
splitedStr[2] = str.Substring(posi[1] + 1, posi[2] - posi[1] - 1);
splitedStr[3] = str.Substring(posi[2] + 1, posi[3] - posi[2] - 1);
//・最後のデータは、以下で抽出
// 開始位置:最後のカンマの位置
// 文字数:指定なし(最後まで)
splitedStr[4] = str.Substring(posi[3] + 1);
return splitedStr;
}
private static string[] fast2Split(string str)
{
//結果を格納するための配列を生成する
var splitedStr = new string[COL_NUM];
//区切り文字を見つけた件数
var count = 0;
//切り出し開始位置
var start = 0;
//文字列をchar配列でループして、一文字ずつ区切り文字であるか判定
//区切り文字であるなら、区切り文字の位置を保存
for (var i = 0; i < str.Length; i++)
{
if (str[i] == DELIMITER)
{
//Substringで文字列をスプリットしていく
splitedStr[count] = str.Substring(start, i - start);
if (count + 1 == COUNT_OF_DELIMITER)
{
//最後のデータは、以下で抽出
splitedStr[count + 1] = str.Substring(i + 1);
break;
}
start = i + 1;
count++;
}
}
return splitedStr;
}
static readonly char[] DELIMITERS = new char[] { DELIMITER };
private static string[] nomalSplit2(string str)
{
return str.Split(DELIMITERS);
}
}
##まとめ
ちょっとの差でも、回数多いとダメージでかいですよね。。。
何百万回と動作すると、少しずつ誤差が出ていく。。。
そして、「C#ってこんな言語だったっけ?」って思いました。。。
もっと早くなる方法があったら、教えてください。。。
本筋とは関係ないですが、paiza.ioのように、
すぐに動作を確認できる仕組みがあるって、ありがたいです。
ものすごく便利になったなと思いました。