JavaScriptのショートサーキット評価

ほとんどのプログラミング言語には、ANDORを表す論理演算子が用意されている。
そして、左辺を評価した時点で論理式の結果が確定した場合には右辺の評価を行わないことを、ショートサーキット評価(短絡評価)という。
例えば、A AND Bという論理式があった場合、Afalseなら、その時点で式全体の結果はfalseで確定するため、Bがどうであるかについてはチェックしない。

JavaScript論理和演算子||論理積演算子&&も、ショートサーキット評価を行う。
これを利用することで、コードを簡略化したり、パフォーマンスを向上させたりすることが出来る。

論理演算子の挙動

ショートサーキット評価を利用するためにはまず、論理演算子がどのように動くのかを理解しないといけない。
なお、以下の説明は、どのような結果を生むのかについての説明であり、内部的な挙動については正確ではない可能性がある。

まずは論理和演算子||から。
左のオペランドを真偽値に型変換して評価し、それがtrueだった場合は左オペランドを、falseだった場合は右オペランドを、返す。
オペランドを返す場合、右オペランドは評価自体を行わない。

let value;
console.log(value || 'valueが未定義です。'); // valueが未定義です。
value = 7;
console.log(value || 'valueが未定義です。'); // 7

論理積演算子&&はその逆で、左オペランドfalseだった場合は左オペランドを返し、trueだった場合は右オペランドを返す。
オペランドを返す場合、右オペランドは評価自体を行わない。

let value;
console.log(value && 'valueが未定義です。'); // undefined
value = 7;
console.log(value && 'valueが未定義です。'); // valueが未定義です。

具体的な使い方

ショートサーキット評価を利用することで、条件分岐を簡単に書くことが出来る。

以下は、&&を使った書き方。

function checkValue(arg){
  if(typeof arg === 'number'){ console.log(`${arg}は数値型です。`); }; // 1は数値型です。
  typeof arg === 'number' && console.log(`${arg}は数値型です。`);  // 1は数値型です。
};

checkValue(1);

checkValue()で定義している2つの文は、どちらも同じ意味である。
論理式 && 文は、if(論理式){文};と同じ動きをする。
だがこのような書き方は、あまり見かけない。

よく使われるのは、以下の書き方である。

const useValue = inputtedValue || 10;

useValueを定義しているが、inputtedValueが存在すればその値を、存在しなければ10を、代入している。

複数の論理和演算子

同じ演算子が並んでいる場合、左から順番に評価していく。
そのため、論理和演算子が複数並んでいたときは、左から順番に評価していき、trueに型変換できるものが出た時点で、それを返す。
trueに型変換できるものがなかった場合は、一番右のオペランドを返す。

let hoge, fuga, muu;
console.log(hoge || fuga || muu); // undefined
fuga = 5;
console.log(hoge || fuga || muu); // 5
hoge = 7;
console.log(hoge || foo || muu); // 7

ちなみに、最後の論理式でfooという未定義の変数を使っており、本来ならエラーになる。
だがこの論理式では、hogeを評価した時点で論理式の評価は止まるので、エラーは出ない。このことからも、論理和演算子ではtrueに型変換できるものが出た時点で評価が終わるということを、確認できる。

簡略化を行うべきか

このように、ショートサーキット評価を利用することで、コードを簡略化できる。
だがこれに対しては、批判的な意見も存在する。可読性が落ちるというのが、その理由である。
確かにやり過ぎれば、可読性は落ちるだろう。
だが例えば、先程の複数の論理和演算子などは、むしろ可読性が向上すると思う。

useData = person.address || person.city || person.country;
if(person.address) {
  useData = person.address;
} else if(person.city) {
  useData = person.city;
} else {
  useData = person.country;
};

どちらも同じように動作するが、前者の書き方を知っていると、後者は冗長に感じる。

ショートサーキット評価とパフォーマンス

ショートサーキット評価を上手く使えば、可読性だけでなく、パフォーマンスも向上する。

let value;
function checkFunc(arg){
  console.log('checkFuncを呼び出しました。');
  return arg;
};

// コードA
// 関数を呼び出さない。
value && checkFunc(true) ? console.log('ok') : console.log('ng') ;

// コードB
// 関数を呼び出してしまう。
checkFunc(true) && value ? console.log('ok') : console.log('ng') ;

上記のコードでは、valuecheckFunc(true)が共にtrueとして評価できる場合にokを表示する。そうでない場合はngを表示する。
この例ではvalueundefinedなので、ngが表示される。
それはコードAでもコードBでも変わらない。
&&の左右を入れ替えただけなのだから、最終的な結果はAもBも同じである。

だがパフォーマンスは異なる。
Aの場合、左オペランドfalseなので、その時点で、つまりcheckFunc(true)を呼び出すことなく、論理式は終了する。
Bでは、左オペランドcheckFunc(true)なので、必ずこの関数が呼び出されてしまう。

このように、重い処理を右オペランドに配置することで、無用な呼び出しを避け、必要なときにのみ呼び出すように出来る。

上記の例ではcheckFunc()はログを表示した後に引数を返すだけだが、ここでそれなりに重い処理を行っていた場合、パフォーマンスには差が生じる。

以下の例で、それを確認できる。

let value;
function checkFunc(arg){
  for(let i=0; i < 100; i++){
    1 + 1;
  };
  return arg;
};

const start = new Date();
for(let i=0; i < 1000000; i++){
  value && checkFunc(true) ? 1 : 0 ;  // checkFunc(true)は呼ばれない
};
console.log(new Date() - start);  // 18 〜 21
let value;
function checkFunc(arg){
  for(let i=0; i < 100; i++){
    1 + 1;
  };
  return arg;
};

const start = new Date();
for(let i=0; i < 1000000; i++){
  checkFunc(true) && value ? 1 : 0 ;  // checkFunc(true)を必ず呼び出す
};
console.log(new Date() - start);  // 240 〜 260

実際の秒数はもちろん環境によって異なるが、私の環境では、前者は18〜21ミリ秒、後者は240〜260ミリ秒、といったところだった。
つまり、10倍以上の差が出ていることが分かる。

参考資料

JestでReactのテストをする(6) 非同期のテスト

経験を重ねることでテストの書き方にも少しずつ慣れてきたのだが、非同期のテストには未だに苦手意識がある。
今回書いた内容についても、これがベストプラクティスだとは思わない。
実戦でやろうとすると、いろいろと課題が出てくると思う。
ただ、自分なりに少しは取っ掛かりを掴めた気がする。

なお、Promiseを使ってサンプルやテストを書いた。
Promiseについてはこちらを参照。
Promiseによる非同期処理の書き方

テスト対象のコードを書く

まず、テストの対象となるコードを書く。
作るのは、非同期でAPIから情報を取ってきて、それをビューに反映させるコンポーネント
ただ、今回重要なのは非同期ということなので、APIとのやり取りは実際には行わない。

const usersData = [
  {'name':'Bob', 'country':'America'},
  {'name':'Cate', 'country':'England'},
  {'name':'Bob', 'country':'Canada'},
  {'name':'Taro', 'country':'Japan'}
];
function searchUsername(name){
  return new Promise((resolve, reject)=>{
    let result = [];
    usersData.forEach(i=>{
      if(i.name === name){ result.push(i) };
    });
    if(result.length === 0){
      reject(new Error('該当するユーザーは存在しません。'));
    } else {
      resolve(result);
    };
  });
};
export function inquireDatabase(name, callback){
  return searchUsername(name)
    .then(result => {callback(result);})
    .catch(result => {callback(result);});
};

usersDataオブジェクトに、ユーザーの情報が入っている。
そしてその中身を検索して、該当するユーザーの情報を非同期で返すのが、searchUsername()
そしてそれをラップして、指定したコールバック関数を非同期で実行するのが、inquireDatabase()

例えば、inquireDatabase('John', myFunc)とすれば、Johnというユーザーの情報を探し、その結果を引数としてmyFuncを実行してくれる。

これらのコードを利用したReactのコンポーネントが、以下。

export default class SearchBox extends React.Component {
  constructor(props){
    super(props);
    this.state = {result: ''};
  };
  showUsersList(received){
    if(received instanceof Error){
      this.setState({result: received.message});
    } else {
      let array = received.map(obj=>{
        let data = [];
        Object.keys(obj).forEach(elem=>{
          data.push(obj[elem]);
        });
        return data.join(' ');
      });
      this.setState({result: array.join(', ')});
    };
  };
  render(){
    return(
      <div>
        <input type="text" onKeyUp={e=>{
          if(e.keyCode !== 13){ return; };
          inquireDatabase(e.target.value, this.showUsersList.bind(this));
        }}></input>
        <div>{this.state.result}</div>
      </div>
    );
  };
};

テキストボックスのonKeyUpで、inquireDatabase()を呼び出している。
得られた結果を引数にしてshowUsersList()を呼び出し、これにより、画面上の表示が変更される。

f:id:numb_86:20170205160607p:plain:w500

このサンプルではローカルのオブジェクトを検索しているが、実際の開発では、APIに問い合わせて検索を行うイメージ。

テストを書いてみる

inquireDatabase()をテストしていく。
これは非同期で動くので、これまでのような書き方だと、失敗する。

例えば、下記のテストは通らない。

describe('basic', ()=>{
  let mock;
  beforeEach(()=>{ mock = jest.fn(); });
  test('called', ()=>{
    inquireDatabase('Taro', mock);
    expect(mock).toHaveBeenCalled();
  });
});

inquireDatabase()のコールバック関数としてmockを設定しているのだが、mockが実行されたかテストすると、実行されていないと判定される。
これはもちろん、非同期で実行されるからだ。mockが呼び出される前に次の行のexpect()が実行されてしまうため、「mockは実行されていないよ」と判断されてしまう。

このような事態を避け、非同期の関数を上手くテストできるようにすることが、この記事の目的である。

setTimeout()を使う

要はコールバックが呼び出されるまでテストを実行しなければいいのだから、setTimeout()で適当に待機することで、先程のテストは通るようになる。

inquireDatabase('Taro', mock);
setTimeout(()=>{
  expect(mock).toHaveBeenCalled();
}, 1000);

だがこれは、スマートではないだろう。
何秒待てばいいかは事前には分からないし、テストによって異なる。
こういう形ではなく、コールバックが呼び出されたことを検知してテストを実行したほうがいい。
Promiseを使うことで、それが可能になる。

Promiseを使う

以下のように書くと、上手く動いてくれる。

return inquireDatabase('Taro', mock)
  .then(() => expect(mock).toHaveBeenCalled());

inquireDatabase()then()をつなげ、そのなかでexpect()を呼び出している。
こうすることで、コールバックの実行が終わった時点でテストを実行してくれる。

returnも重要である。
これがあるから、expect()は実行される。
もしこのreturnがなかったら、then()の呼び出しを待つこと無くプログラムは進んでいき、そのままテストが終わってしまう。

mockが意図した引数を受け取れていることも、確認できる。

return inquireDatabase('Taro', mock)
  .then(() => expect(mock).toHaveBeenCalledWith([{'name':'Taro', 'country':'Japan'}]));

Promiseオブジェクトでのみ、可能

このようなテストを書けるのは、テスト対象であるinquireDatabase()が、Promiseオブジェクトを返すからである。

export function inquireDatabase(name, callback){
  return searchUsername(name)
    .then(result => {callback(result);})
    .catch(result => {callback(result);});
};

だからこそ、then()をつなげていくことが出来る。
テストを上手くやるためにはテストしやすいコードを書くべき、というのはよく言われるが、それは非同期でも同じということだ。

ここまでのまとめ

ここまで書いたテストコードをまとめたものを、以下に載せておく。

describe('basic', ()=>{
  let mock;
  beforeEach(()=>{ mock = jest.fn(); });
  test('not to be called', ()=>{
    inquireDatabase('Taro', mock);
    expect(mock).not.toHaveBeenCalled();
  });
  test('setTimeout', ()=>{
    inquireDatabase('Taro', mock);
    setTimeout(()=>{
      expect(mock).toHaveBeenCalled();
    }, 1000);
  });
  test('Promise', ()=>{
    return inquireDatabase('Taro', mock)
      .then(() => expect(mock).toHaveBeenCalled());
  });
  test('Promise argument', ()=>{
    return inquireDatabase('Taro', mock)
      .then(() => expect(mock).toHaveBeenCalledWith([{'name':'Taro', 'country':'Japan'}]));
  })
});

複数のexpect()を実行

上記のコードだと、1回のtest()で1度しかexpect()を実行していない。
これを複数回実行することは、もちろん可能である。
方法は2つ。

まず先に、コードを載せておく。

describe('multiple', ()=>{
  let mock;
  beforeEach(()=>{ mock = jest.fn(); });
  test('single', ()=>{
    return inquireDatabase('Taro', mock)
      .then(()=>{
        expect(mock).toHaveBeenCalled();
        expect(mock).not.toHaveBeenCalledTimes(2);
        expect(mock).toHaveBeenCalledWith([{'name':'Taro', 'country':'Japan'}]);
      });
  });
  test('chain', ()=>{
    return inquireDatabase('Sam', mock)
      .then(() => expect(mock).toHaveBeenCalled())
      .then(() => expect(mock).toHaveBeenCalledWith(new Error('該当するユーザーは存在しません。')))
      .then(() => expect(mock).toHaveBeenCalledTimes(1))
      .then(() => inquireDatabase('Cate', mock))
      .then(() => expect(mock).toHaveBeenCalledWith([{'name':'Cate', 'country':'England'}]))
      .then(() => expect(mock).toHaveBeenCalledTimes(2));
  });
  test('combine', ()=>{
    return inquireDatabase('Taro', mock)
      .then(()=>{
        expect(mock).toHaveBeenCalled();
        expect(mock).toHaveBeenCalledTimes(1);
        expect(mock).toHaveBeenCalledWith([{'name':'Taro', 'country':'Japan'}]);
      })
      .then(() => inquireDatabase('Cate', mock))
      .then(() => expect(mock).toHaveBeenCalledTimes(2));
  });
});

1つ目は、単純にthen()のなかで複数のexpect()を実行するということ。難しいことは何もない。
もう一つは、次々にthen()をつなげていき、そのなかでexpect()を実行する方法。これでも問題ない。

両方を組み合わせることも、もちろん可能。

どういう方法でもいいと思うが、then()をつなげていく場合、途中で再び関数を呼び出せるという利点がある。
上記のコードではinquireDatabase('Cate', mock)がそれにあたる。

参考資料