6
3

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 1 year has passed since last update.

GDSC JapanAdvent Calendar 2023

Day 17

ネストされたforループの可読性を向上させるリファクタリング案

Last updated at Posted at 2023-11-30

はじめに

Pythonを用いて解説しますが、どの言語にも通底するお話を書きます

forネスト、無くしたいですよね。

コードレビューやCodeClimateでよく「可読性が低い」として改善を求められますね。

しかし、直せと言われたって、直すアイデアはすぐには出てこない。
そこで、汎用性のあるリファクタリングアイデアを2つ持ってきましたのでご覧ください

そもそもなんでforネストは読みにくいの?

持論ですが、恐らくforのネスト自体は悪くないんですよ。

例えば$1000 \times 1000 \times 1000$の立方体空間内のRGB値の合計を求めるスクリプトを、あえてネストして書くと

rgb_sum.py
for x in range(1001):
  for y in range(1001):
    for z in range(1001):
      r, g, b += get_rgb(x, y, z)

反発が出そうなのであえて「読みやすい」とは言いませんが、しかし言うほど読みにくくないですよね?
forがネストされているものの、実質的な処理は3つ目のforにしかありませんから、「ああ、考えられうるxyzの組み合わせでイテレーションしてるんやね」とただちに分かるのです。

ではこれは?
設定はなんでもいいですが、例えば個別指導の教師を割り当てるプログラムとしましょう

teacher_allocation.py
for student for students:
  if not student.is_coming_today():
    continue

  all_allocated = True
  for subject in student.select_subjects_today():
    allocated = False
    for teacher in teachers:
      if not teacher.is_coming_today():
        continue
        
      if teacher.is_available(subject):
        student.allocate(subject, teacher)
        teacher.allocate(subject, student)
        allocated = True
        break
    if not allocated:
        all_allocated = False
  if not all_allocated:
      student.tell_not_all_allocated()

これは別に解読不可能だからはないものの、どこか心理的に「読みたくないな」と思いますよね。

前者と後者の違いは:

  1. 最後だけでなく途中のfor文にも処理がある
  2. フラグ処理やbreak / continue によるループ制御処理が話をややこしくしている
  3. 複数の責務が混在している

ことではないでしょうか。

人間がfor文を読むとき、「どのforが重なっているのか」「今はどこのforの話か」を念頭に置きながら読んでいると思います。

  • 1について、読む場所によって「どのforが重なっているのか」がチラチラ変わったら、脳が疲れ、「読みたくない」という心理を生む。
  • 2について、break / continueに遭遇する度に「今はどこのforの話か」が切り替わるので、脳が疲れ、「読みたくない」という心理を生む。
  • 3について、ただでさえ面倒な構文をもっと肥大化(fat)にされたら、脳が疲れ、「読みたくない」という心理を生む。

ことが言えるかと思います。

これを解消するべく、リファクタリング案を2つもってきました。

forの中身を関数化する

常套手段ですね。すぐ思いつくんじゃないですか。
前述の問題点1, 2を読みやすくする手段ですね。

しかし不慣れだと、「このforの中にあるbreak / continue / フラグどうすればいいんだろ…」と迷うかもしれません。
breakcontinueはそのまま保持し、フラグ変数は返り値として返すことで解決します。

こちらが冒頭の例です。
即興につき関数名はあまりよろしくないかもしれませんのでご了承を。

teacher_allocation.py
def allocate_students_teachers():
    for student in students:
        if not student.is_coming_today():
            continue
        all_allocated = _allocate_teachers_for_student(student)
        if not all_allocated:
            student.tell_not_all_allocated()

def _allocate_teachers_for_student(student):
    all_allocated = True
    for subject in student.select_subjects_today():
        allocated = _allocate_teacher_for_student_subject(student, subject)
        if not allocated:
            all_allocated = False
    return all_allocated

def _allocate_teacher_for_student_subject(student, subject):
    for teacher in teachers:
      if teacher.is_available(subject):
        student.allocate(subject, teacher)
        teacher.allocate(subject, student)
        return True
    return False

ちゃんとコメントをつけてあげれば、可読性がけっこうよくなったと思えそうじゃないですかね。

しかし、デメリットとして、得たいデータによっては、引数・返り値が多くなり、単純な記述量が多くなることが指摘されます。

先に候補者リストを作って一重forにする

自分で思いついた手法です。根本的解決にはなりませんが、読みやすくはなります。
全探索をしないといけないときに有効です。

例えば立方体空間内で、何らかの条件に合致した座標について、なんかするとき(設定は適当です)

full_search.py
for x in range(1001):
    if not nanka_joken_x(x):
        continue
    for y in range(1001):
        if not nanka_joken_y(y):
            continue
        for z in range(1001):
            if not nanka_joken(z):
                continue
            nanka_shori0(x, y, z)
            nanka_shori1(x, y, z)
            if nanka_joken0(x, y, z):
                nanka_shori2(x, y, z)

このように

  • 探索する点についてのリスト(イテレーター)を作り、
  • そのリストについて一重forで処理部分を記述

をしてみることができます。

full_search.py
#処理部分
for x, y, z in compute_candidates():
    nanka_shori0(x, y, z)
    nanka_shori1(x, y, z)
    if nanka_joken0(x, y, z):
        nanka_shori2(x, y, z)

#候補者リスト
def compute_candidates():
    return ((x, y, z)
            for x in range(1001)
            if nanka_joken_x(x)
            for y in range(1001)
            if nanka_joken_y(y)
            for z in range(1001)
            if nanka_joken_z(z))

このように、forネストの元凶である探索部分を先に済ませ、候補者リストcompute_candidates()とし、処理部分は候補者リストについてforeach すると。

見ての通り、根本的な解決にはなっていません。compute_candidates()内は3重ループのままです。
しかし、compute_candidates()内は「候補を探索しているんやね」と念頭に置けるので意識的には読みやすく(候補者リストを作るだけであればfor内は複雑になるとは考え難い)、本質部分ともいえる処理部分は各段に読みやすくなりました。

処理部分を狙った場所(関数化しない場合もする場合も)に配置したいときに一考の価値があるのではないでしょうか。

最後に

みなさんにもリファクタリング案があれば、ぜひお教えください。

いいね頂けると泣きながら喜びます><

6
3
6

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
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?