7
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Google Apps ScriptAdvent Calendar 2020

Day 15

GAS の各種イテレーターを for ... of で使える反復可能オブジェクトにする

Last updated at Posted at 2020-12-15

2020年のビッグニュースの一つとして、GAS で新しい JavaScript エンジンである V8 ランタイム(以下 V8)がサポートされたことでしょう。

V8 によって ES2015 以降の構文が(試した限りでは ES2019 の構文まで)使えるようになったため、モダンな文法を使ってプログラムを読みやすくすることが容易にできるようになりました。従来だと Clasp でローカルにソースコードを持ってきてローカルでは TypeScript で……という手段で ES2015 以降の文法を書くことができましたが、GAS 自体で公式サポートされることで楽が出来るしカジュアルに書きたい人と共同開発しやすいしと嬉しいことばかりです。

そんな ES2015 以降の文法で目を引くのがいわゆる for...of 文でしょう。

for...of 文とは

for...of 文と呼ばれるものは、従来からある for 文を書きやすくしたものです。

従来の for 文だと

var numbers = [200, 380, 400, 100, 80];
var sum = 0;
for (var i = 0; i < numbers.length; i++) {
    sum += numbers[i];
}
console.log("sum is " + sum); // GAS V8 以前では Logger.log が一般的

と配列の添字を使って配列の各要素を取得していたものが、 for...of 文では配列の添字を使わない書き方ができます。

const numbers = [200, 380, 400, 100, 80];
let sum = 0;
for (const number of numbers) {
    sum += number;
}
console.log("sum is " + sum);

ES2015 では var の欠点を払拭している変数宣言構文 letconst が使えるようになったので、 var の代わりに合わせて使ってみます。sum だけは += で再代入が発生するので let で宣言していますが、他は再代入が発生しないので const を使っています。1

今回は繰り返す numbers は配列すなわち Array ですが、実はこの of の右側に置くことができるのは Array だけでなく反復可能オブジェクト (iterable object) を置くことができます。Arrayも反復可能オブジェクトの一つというわけですね。

反復可能オブジェクトはそのオブジェクトが意味的に要素の集まった何かと解釈できるものと言えそうです。このあたりは、JavaScript の準公式ドキュメントとも言える MDN Web Docs のページが詳しいので参照してみて下さい。

まさに要素の集まりを表すと言える MapSet の他、String も「一文字ずつ」という意味で反復可能オブジェクトとして振る舞うようです。

反復可能オブジェクトは英語 iterable object の和訳ですが、iterator はカタカナ語「イテレーター」としてもよく知られています2。そして GAS の一部のオブジェクトにはイテレーターを提供するものもありますが、for...ofof の右側に置くことはできないようです。

次の節では、GASで出てくるイテレーターを for...of で使えるようにする方法を見ていきたいと思います。

GAS の XxxIterator を反復可能オブジェクトにする

GAS は日々拡張を繰り返していますが、「Advanced Google service」を除いた基本的な「Google Workspace service」の中でイテレーター XxxIterator 的なものは Drive ServiceFileIteratorFolderIterator のみのようです(この記事を書く前はもっと多いと思っていました)。

実際の使用例を FileIterator を例に見てみます。

const FOLDER_ID = "自分が読み取り権限のあるフォルダのフォルダID";
function listdir() {
  const folder = DriveApp.getFolderById(FOLDER_ID);
  const files = folder.getFiles(); // FileIterator
  while (files.hasNext()) {
    const file = files.next();
    console.log(file.getName());
  }
}

FileIterator も FolderIterator も以下のような性質を持つオブジェクトです。

  • 次にあたる要素があれば真偽値 Boolean を返す hasNext メソッドの実行結果は true、そうでない場合は false を返す
  • hasNext メソッドの実行結果が true であれば、next メソッドを一回実行して次に当たる要素を表すオブジェクトを得る
    • FileIterator.prototype.nextFile オブジェクト、FolderIterator.prototype.nextFolder オブジェクトを返す
    • hasNext メソッドの実行結果が false のときに next メソッドを実行すると例外が発生する
  • なお、FileIterator も FolderIterator も、for...of のof の右側に置くと反復可能オブジェクトではないという例外が発生する

返す値が違うだけで、hasNextnext の挙動はほぼ同じと言えましょう。なので動的言語らしくダックイピング的に FileIterator と FolderIterator を区別することなく反復可能オブジェクトにしてみましょう。

配列に入れる

単純な発想ですが、事前に全ての結果を集めた配列を返すことで反復可能オブジェクトとする方法もあるでしょう。もっとエレガントな解法は後述しますが、対比のため配列に入れる作戦も見ていきましょう。

const FOLDER_ID = "自分が読み取り権限のあるフォルダのフォルダID";
function listdir() {
  const folder = DriveApp.getFolderById(FOLDER_ID);
  const files = folder.getFiles();
  for (const file of iterator2arrayGAS(files)) {
    console.log(file.getName());
  }
}

function iterator2arrayGAS(iteratorGAS) {
  const elements = [];
  while(iteratorGAS.hasNext()) {
    elements.push(iteratorGAS.next());
  }
  return elements;
}

最初にエレガントな解法を思いつければよいのですが、問題解決をしたいけれど使える時間に限りがある場合は、こういう既知の内容で解決することも立派な解決方法です。

この配列に入れる作戦の唯一心配なところは、hasNext を途方も無い回数呼んでも false とならず、結果の配列の要素が限りなくなる可能性を考えたときでしょうか。世間でよくある RDBMS の SELECT 結果を全件配列で受け取らずイテレーター的手法を使うのも、膨大な数を一気に取得する可能性を考慮するとメモリや時間的なコストが非現実的となる場合があるからでしょう。

筆者は GAS のフォルダ直下に置けるファイルの最大数を知りませんが、Google Drive のフォルダ直下に置けるファイル数の上限がよく知られていないこと、GASの「6分の壁」を考えると、配列に入れようが後述のエレガントな解答であろうが膨大なファイル数である可能性を排除できない場合は問題が発生する可能性があります。その点については別途考察しましょう。

反復処理プロトコルに沿って書いてみる

反復可能オブジェクトを自作するには、反復処理プロトコルに沿うと良いでしょう。

詳細は上記 MDN Web Docs の解説記事を見ていただくことにして、先ほどの listdir 関数を反復処理プロトコルに従って書き換えてみます。

const FOLDER_ID = "自分が読み取り権限のあるフォルダのフォルダID";
function listdir() {
  const folder = DriveApp.getFolderById(FOLDER_ID);
  const files = folder.getFiles();
  for (const file of iterableGAS(files)) {
    console.log(file.getName());
  }
}

function iterableGAS(iteratorGAS) {
  return {
    next: function() {
      const done = !iteratorGAS.hasNext();
      const value = done ? undefined : iteratorGAS.next();
      return {value: value, done: done};
    },
    [Symbol.iterator]: function() { return this; }
  };
}

上記 MDN Web Docs の解説記事を読んでなんとなく理解できるのであれば、上記書き換えコードの iterableGAS 関数のやろうとしていることもわかるかと思います。もしわからなくても大丈夫です、きっと興味を持って勉強を続けていればいつか必ずわかります。

ジェネレーターに沿って書いてみる

ES2015 以降には for...of などと合わせてジェネレーター関数というものも導入されました。

function 宣言ではなく function* 宣言。この function* 宣言で定義されたジェネレーター関数は、実行結果として定義内の return 結果を返すのではなく、定義に即した Generator オブジェクトを返します。この Generator オブジェクトは反復可能プロトコルに準拠したものとなります。

正確性より直感を重視して解説すると、通常の function 宣言で定義された関数は、一度の実行で一箇所の return 結果を返すものですが、function* 宣言で定義されたジェネレーター関数は、一度の実行で yield に出会うまでブロック内実行を進めそこの yield で指定された値を返し一時停止、次の値を要求されたらさらに次に yield に出会うまで……を return に出会うか関数定義の末尾に至る(結果的に暗黙で return undefined する)まで続ける、といったニュアンスになっています。全部の値を集めた配列を返すのと違い、逐次返却するのでメモリ的に優しいことと、終わりがないデータにも対応可能な点などの違いがあります。

説明ばかりしてもわかりづらいと思うので、listdir 関数をジェネレーターに沿って書き換えてみたいと思います。

const FOLDER_ID = "自分が読み取り権限のあるフォルダのフォルダID";
function listdir() {
  const folder = DriveApp.getFolderById(FOLDER_ID);
  const files = folder.getFiles();

  for (const file of generatorGAS(files)) {
    console.log(file.getName());
  }
}

function* generatorGAS(iteratorGAS) {
  while(iteratorGAS.hasNext()) {
    yield iteratorGAS.next();
  }
}

反復処理プロトコルを直接書いて return とオブジェクトが入り乱れていた iterableGAS と比べると、generatorGAS の定義は非常にシンプルになりました。むしろ最初に whilehasNextnext をで直接書いていたものと見かけほぼ同じ構造になりつつ、そのコードをまるごと別関数(ジェネレーター関数ですが)に分けることができました。

膨大な数の処理が必要なときの対処法

今回の for...of と反復可能オブジェクトの話題とは直接関係はありませんが、 FileIterator や FolderIterator で膨大な数の処理が必要で、実際に行いたい処理を入れても6分に収まらない場合はどうすればいいでしょう。

FileIterator のドキュメントを読むと getContinuationToken というメソッドが見つかります。このメソッドは String として継続トークンを返し、その継続トークン continuationTokenDriveApp.continuFileIterator に引数として渡すことで、getContinuationToken を取得した時点の FileIterator オブジェクトを返してくれるということです。

筆者の環境では、このような膨大な数……というサンプルが無く、そもそも処理対象ディレクトリに6分の壁を意識させるほどのファイル数があるフォルダも無いので課題解決意欲がわかないこともあり、このあたりは実験していません。とはいえ、そういう問題に行き当たったときに継続トークンが取れることを思い出せれば良いでしょう。

配列に入れるサンプル iterator2arrayGAS では、 最初に全件取ってしまうので getContinuationToken メソッドは無意味です、その後に課題となっている処理を行っている最中に時間切れ寸前……というときに files はもう全て取り尽くして next を呼べないイテレーターだからです。この意味でも、配列に入れるサンプルは分が悪いと言えます。

反復可能プロトコル iterableGAS やジェネレーター関数 generatorGAS のサンプルでは、getContinuationToken を呼ぶ意味があります。for...of ブロック中で時間切れ間近となった場合、ブロックを break で出た後で const token = files.getContinuationToken(); として String token の結果を適当なスプレッドシートのセルやスクリプトプロパティなどの永続的な場所に書いて次の実行時の起点とすることができるでしょう。もちろん、永続的な場所に書くだけの時間的余裕は持ちましょう。

そうはいっても配列が欲しいとき

膨大な数のことなどを想定して反復可能オブジェクトを返すようにした場合も、「今回の場合は膨大な数なんてありえないし Array.prototype.map で一気に処理したい」といった要望はよくあるでしょう。

そういうときは、反復可能オブジェクトを Array.from に引数として与えることで、反復可能オブジェクトを配列に変換することができます。

const FOLDER_ID = "自分が読み取り権限のあるフォルダのフォルダID";
function listdir() {
  const folder = DriveApp.getFolderById(FOLDER_ID);
  const files = folder.getFiles();
  const itr   = generatorGAS(files); // 反復可能オブジェクト、iteratorGAS でも同じ意味の反復可能オブジェクトを得られる
  const all_files_text = Array.from(itr).map( file => file.getName() + "\n" ).join("");
  console.log(all_files_text);
}

function* generatorGAS(iteratorGAS) {
  while(iteratorGAS.hasNext()) {
    yield iteratorGAS.next();
  }
}

GAS の Advanced Google service の list メソッドを反復可能オブジェクトにする

GAS には Advanced Google service というものもあり、有効化までひと手間必要ですが、最初から有効な Google Workspace service では使えない Google サービスへのアクセス API を使うこともできます。

ただ、Advanced Google service は RESTful API のアクセス手順をそのまま持ってきた API デザインとなっており、Google Workspace service の XxxApp を起点にしたあの感触とは結構違ったものとなっています。

具体的には、今回のイテレーターのような全件返さないものは、イテレーターというよりページャのような返却方法を取っています。

例えば Google Tasks を操作する Tasks Service を見ると、 Tasks.Tasklists.listTasks.Tasks.list といった list メソッドが各ページを返す役割となります。筆者は Google Tasks で自分にフィットしたタスク管理をしようと Google Tasks API を操作した経験を元に書いていますが、他の Advanced Google service でも list メソッドが各ページを返すという構造となっているようです。

Tasks API のリファレンスドキュメント から Tasks.Tasks.list の解説 を見ると、以下のようになっていることがわかります。

  • タスクリストのタスクリストID tasklistId を第一引数に指定した const tasks = Tasks.Tasks.list(tasklistId) という呼び出して、指定タスクリストに所属するタスク群の1ページ目のオブジェクト tasks が手に入る
    • 第2引数に検索オプションを指定できる、詳細はリファレンスドキュメント参照
  • 上記で取得した1ページ目のオブジェクトのキーは kind etag items そして nextPageToken
    • items は1ページ目のタスク群
    • nextPageToken は次のページを指定するトークンで、次のページが存在しない場合には存在しない
  • 次のページを取得する場合は nextPageToken を確保しておいて、第2引数の検索オプション内で pageToken プロパティとして const tasks = Tasks.Tasks.list(tasklistId, {pageToken: nextPageToken}); とするとよい

反復可能プロトコルで書くよりジェネレーター関数の方が楽でもあるので、今回は最初からジェネレーター関数で書いてみましょう。

あと、RESTful API に最小限のメソッドしか無いので後々の拡張を想定して、これも ES2015 で導入された class 構文を使って書いてみましょう。

class PagesIterator {
  /**
   * constructor
   * 1st argument is callback. It is given only 1 argument as page token.
   * It must return object that have items property (required as Array) and nextPageToken property (optional, if next page is exist).
   * @param {function} token => token ? NEXT_PAGE_RETRIEVE_CODE : FIRST_PAGE_RETRIEVE_CODE
   * @return {PagesIterator}
   */
  constructor(callback) {
    this.callback = callback;
    const res = this.callback();
    this.current_response = res;
    this.safe_counter = 0;
  }
  /**
   * Retrieve next page if it is exist
   * This method is almost internal for @@iterator method.
   * @return {bool} retrieve success or fail
   */
  retrieve() {
    if ( !this.current_response.nextPageToken ) {
      return false;
    }
    const res = this.callback(this.current_response.nextPageToken);
    this.current_response = res;
    return res.items.length > 0;
  }
  /**
   * Iterator for this class and instance
   * This class'es instance can puts iterable context, e.g. for ( const item of THIS ) { ... }
   */
  * [Symbol.iterator]() {
    while(true) {
      this.safe_counter++;
      for ( const obj of (this.current_response.items||[]) ) {
        yield obj;
      }
      if ( !this.retrieve() ) break;
      if ( this.safe_counter > PagesIterator.SAFE_LIMIT ) {
        console.error("safe counter over: break");
        break;
      }
    }
  }
}
PagesIterator.SAFE_LIMIT = 20;

* [Symbol.iterator]() { ... } 部分が反復可能プロトコルでジェネレーター関数を指定している部分。class 構文中でメソッド定義する場合には function キーワードが無くせるので、それに伴って function* キーワードも単独の * になるところは注意点です。

1要素ずつ反復していくのですが、取得したページを全件取り出したときに nextPageToken があれば次のページを取り出すと言ったまとまった処理をする必要があり、この処理を retrieve というメソッドで抜き出せるだけでもジェネレーター関数内の定義が少なくなってスッキリします。

ジェネレーター関数の定義もそこそこ多いように見えますが、while(true){...} 無限ループを使っていることによる不安解消のため、ループ最大数を PagesIterator.SAFE_LIMIT 回に制限する安全が半分程度を占めています。

この PagesIterator クラスの使い方は constructor のコメントにも書かれている通り

const TASKLIST_ID = "タスク群を取り出したいタスクリストのID";
const pages = new PagesIterator(
    token => Tasks.Tasks.list(TASKLIST_ID, token ? {...optionalArgs, pageToken: token} : optionalArgs
);

と言ったように、コンストラクタ new PagesIterator の第1引数にコールバック関数を指定、そのコールバック関数は nextPageToken がある場合に第1引数としてそれを取り、ない場合は1ページ目のオブジェクトを返すようにします。

PagesIterator では、コンストラクタがコールバック関数を受け取るようにして hasNext next のときのような単純なダックタイピングができないことを吸収していますが、コールバック関数の返り値のオブジェクトが items プロパティとして配列を持っていることを想定しているので、半分程度は内部構造に依存したコードになっています。であれば list メソッドとその第1引数、第2引数の構造も PagesIterator に任せてもいいのではと、書いた後で考えたりもしましたが、どこまでやるかは色々な方針があるでしょう。retrieve メソッドへの分離ができなくてもいいのであれば、class 構文に頼ること無く generatorGAS 関数のような単一の関数に処理を収めることもできるでしょう。

PagesIterator のまとまったコードは xtetsuji/gas-xtutils に置いてありますので、興味があったら参考にしてみて下さい。

  1. fornumber もブロックスコープごとに const 宣言されるのでブロック中で再代入しなければ問題ありません。また sum だけ let なのが気持ち悪いということでしたら、for すら使わず Array.prototype.reduce を使って const sum = numbers.reduce((sum, number) => sum + number); と言った書法がおなじみです。

  2. 動詞 「反復する」iterate の名詞形 iterator を計算機用語として和訳すると「反復子(はんぷくし)」といった言葉になると思うのですが、筆者が触れた多くの場面で「iterator」「イテレーター」と英語やカタカナ語のままで扱われている印象があります。

7
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?