Zenn
🥂

ベタなforよりbetterなfor

に公開2

最近リファクタしたコードを紹介します。

unsigned int login_user_id_list[MAX_USER_ID_COUNT];
int i = 0;
for (i < MAX_USER_ID_COUNT; i++)
{
    if (login_user_id_list[i] == INVALID_USER_ID)
    {
        continue;
    }
    if (login_user_id_list[i] == user_id)
    {
        break;
    }
}

if (i == MAX_USER_ID_COUNT)
{
    // ユーザーがログインしていないケース
}
else
{
    // ユーザーがログインしているケース
}

コメントを付けたのは私です。本当はコメントもなかったので、初めて見た時に一瞬「...?」ってなって、理解するまでちょっと時間かかりました。
最終的に私が提案したコードは以下のようになっていました。

#include <algorithm>

const auto found = std::find(std::begin(login_user_id_list), std::end(login_user_id_list), user_id);
if (found == std::end(login_user_id_list) || *found == INVALID_USER_ID)
{
    // ユーザーがログインしていないケース
}
else
{
    // ユーザーがログインしているケース
}

std::findの引数はイテレータになっていって、生配列の場合はstd::begin()std::end()で作成していますが、本当はbegin()end()というメンバー関数が用意されてあるコンテナとセットで使うのがお勧めします。
例えば、std::listを使った場合は、std::findのコールが以下のようになります。

std::list<unsigned int> login_user_id_list(MAX_USER_ID_COUNT);
const auto found = std::find(login_user_id_list.begin()), login_user_id_list.end(), user_id);

別にforループを使っても問題はないですが、std::findのような特化した関数がある場合は、そちらを使った方がコードが分かりやすくなると思います。
forループは万能で何にでも使えるからこそ、実際に何をやっているかを理解するまで全部一通り読まないといけないため、時間がかかります。

でもコードを書いている時は、まずforループを書きたくなる気持ちはよく分かります。私も大体forループから始めると思います。プログラマーはみんなforループに慣れているので、こういうコードは追いやすいかもしれないですが、場合によって分かりづらかったりします。
それに、forループはインデックスを扱ったりするため、ミスの余地もそれになりにあります。まずはforループを書いて、これはいけるなって分かったら、次のステップで、コードをもうちょっと分かりやすくしてみるという順番でもいいと思います。

stdライブラリは<algorithm>というヘッダーによくあるforループのパターンの代わりに使える関数をたくさん用意しています。

std::findの他にも

  • std::for_each
    • 全ての要素に対して処理を行う
  • std::all_of
    • 全ての要素が条件を満たしているかを調べる
  • std::any_of
    • どれかの要素が条件を満たしているかを調べる
  • std::none_of
    • 全ての要素が条件を満たしていないかを調べる
  • std::count
    • 指定された値である要素の数を数える
  • std::count_if
    • 条件を満たしている要素の数を数える
  • std::replace
    • 指定された値と一致する要素を指定された値に置き換える
  • std::fill
    • 指定された値で出力の範囲に書き込む

などあります。

全部はここで紹介できないですが、std::for_eachを代表として紹介しておきます。
std::for_eachforループをイテレータと関数で再現している関数です。std::for_eachのためにわざわざ関数を定義するのは手間なので、大体ラムダ式関数とセットで使います。

一番最初のリファクタしたforループをstd::for_eachで再現しようとすると、forstd::for_eachの違いが分かりやすくなります。

std::for_each(std::begin(login_user_id_list), std::end(login_user_id_list), [&](unsigned int current_id) {
    if (current_id == -1)
    {
        return;
    }
    if (current_id == user_id)
    {
        i = current_id;
    }
});

if (i == MAX_USER_ID_COUNT)
{
    // ユーザーがログインしていないケース
}
else
{
    // ユーザーがログインしているケース
}

forループのcontinuestd::for_eachではreturnになりますが、forループのbreakstd::for_eachでは再現できないです。
std::for_eachは実際に各要素で同じことを行いたい場合に使うため、早くループからリターンすることはできないです。そのため、このstd::for_eachの使い方では、user_idを見つけたとしても、ループし続けます。
やはりこのforループ場合はstd::find_ifが一番適切だと思います。

forループとstd::for_eachのどちらが読みやすいかってなると好みの問題になりますが、<algorithm>にある関数の大半はラムダ式関数とセットで使うため、こういう書き方になると思います。

よく書いているforループがあったら、<algorithm>にそのユースケースに特化した関数がないかを見てみるといいと思います。


|cpp記事一覧へのリンク|

Discussion

dameyodamedamedameyodamedame

正直この粒度であれば何でもいいと思いますが、可読性はforループの方が高いと思います。
C++は記述が長ったらしくなることが多く、読みにくい場合が多いので。
後は速度面の比較もあった方がいいと思いますよ。
コード違いますが、簡単に比較したものを載せておきます。

#include <cstddef>
#include <vector>
#include <iostream>
#include <cassert>
#include <algorithm>

#define ANKERL_NANOBENCH_IMPLEMENT
#include "nanobench.h" // https://raw.githubusercontent.com/martinus/nanobench/v4.3.11/src/include/nanobench.h

constexpr static int INVALID_USER_ID = -1;
constexpr static int NOT_FOUND = -1;

int original(int user_id, const int* login_user_id_list, std::size_t size) {
    if (user_id == INVALID_USER_ID) return NOT_FOUND;
    size_t i = 0;
    for (; i < size; i++) {
        if (login_user_id_list[i] == user_id) {
            break;
        }
    }
    return i == size ? NOT_FOUND : i;
}

int refactored(int user_id, const int* login_user_id_list, std::size_t size) {
    if (user_id == INVALID_USER_ID) return NOT_FOUND;
    const auto last = login_user_id_list + size;
    const auto found = std::find(login_user_id_list, last, user_id);
    return found == last ? NOT_FOUND : static_cast<int>(found - login_user_id_list);
}

int main() {
    constexpr static std::size_t COUNT = 1024 * 1024 * 10;
    std::vector<int> login_user_id_list(COUNT);
    int inc_id = 0;
    for (auto& id: login_user_id_list) id = inc_id++;
    ankerl::nanobench::Bench().run("original", [&] {
        assert(original(COUNT-1, login_user_id_list.data(), login_user_id_list.size()) == COUNT-1);
    });
    ankerl::nanobench::Bench().run("refactored", [&] {
        assert(refactored(COUNT-1, login_user_id_list.data(), login_user_id_list.size()) == COUNT-1);
    });
}

// $ g++ -Wall -pedantic -O2 -g for_vs_find.cpp -o for_vs_find
// $ ./for_vs_find    
// Warning, results might be unstable:
// * CPU frequency scaling enabled: CPU 0 between 1,550.0 and 3,200.0 MHz
// * CPU governor is 'schedutil' but should be 'performance'
// 
// Recommendations
// * Use 'pyperf system tune' before benchmarking. See https://github.com/psf/pyperf
// 
// |               ns/op |                op/s |    err% |     total | benchmark
// |--------------------:|--------------------:|--------:|----------:|:----------
// |        4,899,369.00 |              204.11 |    2.4% |      0.06 | `original`
// |        3,751,882.00 |              266.53 |    3.7% |      0.04 | `refactored`
// $ 

最後に、既存のコードをリファクタリングをする場合は不具合混入の可能性もあるので、メリットとデメリットをよく考えて行った方がいいと思います。開発中など品質を取る前ならいいのですが…。

スペース・ソルバ株式会社スペース・ソルバ株式会社

コメントありがとうございます。パフォーマンスの観点は確かに大事だと思います。今後の参考にいたします。ありがとうございました。

ログインするとコメントできます