入力・出力・状態|論理・アルゴリズム・情報の基礎

このページはプロモーション(広告)を含みます。

論理・アルゴリズム・情報の基礎(初級・読む25分+演習35分)

この授業のゴール

仕事でどう使うか

「同じデータを入れたのに、昨日と今日で結果が違う」という問い合わせは、実務で最も多いものの1つです。原因の多くは、手順が入力以外の何か(前回の実行で残った値、書き換えられたデータ)に影響されていることです。入力・出力・状態を区別できると、再現できない不具合を整理できます。

路線 この授業の使いどころ
DS(データサイエンティスト) ノートブックを上から順に実行し直したら結果が変わる、という問題を、前のセルが残した状態として説明する。欠損と0の扱いで平均が変わる
ML(機械学習エンジニア) 前処理が元のデータを書き換えていないか確認する。学習用と評価用で、同じ処理を同じ状態で適用する
DX(DX推進・AI企画) 業務システムの状態(受付→承認→完了)と、状態を変える出来事(イベント)を表にして要件定義に書く
RS(研究者) 解析手順の入力・出力・乱数の状態を記録し、他の人が同じ結果を再現できるようにする

この授業のゴールは、手順を見たときに、「この結果は入力だけで決まるのか、それとも状態に左右されるのか」を問えるようになることです。

前提

次の言葉と操作を使います。不安があれば先に戻ってください。

コードは、画面内の 実行 ボタンで動かせます。実行前に結果を予想してください。

身近な状況:同じ3,000円の買い物なのに、2回目は500円高い

ネットショップが、「500円引きクーポン」を1人1回だけ使えるようにしました。担当者が次のプログラムを書きました。

used_coupon = False          # クーポンを使ったか(この値は、関数の外に残る)

def price_with_coupon(price):
    global used_coupon       # 関数の外の変数 used_coupon を書き換える宣言
    if not used_coupon:
        used_coupon = True
        return price - 500
    return price

print(price_with_coupon(3000))
print(price_with_coupon(3000))

1回目は2,500円、2回目は3,000円になります。入力はどちらも3,000円で同じなのに、出力が違います。理由は、関数の外にある used_coupon という値が、1回目の呼び出しで書き換えられ、2回目に影響したからです。

「1人1回」という仕様どおりに動いているなら、これは正しい動作です。しかし、この関数をテストするときや、別の人の注文に使うときは、前に誰が使ったかによって結果が変わってしまいます。同じ入力に同じ出力を返す関数ではないため、結果の再現も確認もしにくくなります。

この授業では、手順の中の「入力」「出力」「状態」を分けて見る方法を学びます。

直感的な説明:自動販売機は、お金・商品・在庫で動く

自動販売機で考えます。

同じ入力(120円を入れて、お茶のボタンを押す)でも、在庫が0なら飲み物は出ません。出力は、入力だけでなく状態にも左右されます。そして、1回買うと在庫が1本減ります。出力のほかに、状態も変わります。

プログラムの関数にも、2種類があります。

状態を持つこと自体は悪くありません。注文の受付から配送までの流れは、状態の変化そのものです。大切なのは、状態がどこにあり、何が書き換えるのかを説明できることです。

たとえ話の限界:自動販売機の状態は目に見えますが、プログラムの状態(変数、書き換えられたリスト)は見えません。見えないものは、追跡表のように書き出して確かめます。

正確な定義:入力・出力・状態・副作用・純粋関数

用語を、正確に定義します。

print と return の違いを、実行して確認します。print は画面に文字を出す副作用で、値を返しません。

def double_print(x):
    print(x * 2)

def double_return(x):
    return x * 2

a = double_print(5)
b = double_return(5)
print('a =', a, type(a).__name__)
print('b =', b, type(b).__name__)

a は None(型名 NoneType)、b は10(型名 int)です。double_print の結果は、画面に出ただけで、変数に入っていません。続けて a + 1 を計算すると、エラーになります。

0と欠損(None)の区別:0は「0という値がある」、None は「値がない・わからない」です。たとえば売上が「0円」の日と「入力されていない」日は別の意味なので、計算でも区別します。

手順を設計するときの整理表を作ると、見落としが減ります。

確認する点 問い
入力 型は? 単位は? 0や空や欠損が来たらどうなる?
出力 型は? 返し忘れ(None)はない?
状態 手順の外に残る値は何か? 誰が書き換える?
副作用 引数を書き換えていないか? 画面やファイルに出していないか?

段階付き例題:ポイントカードを、書き換えずに更新する

問題:ポイントカードがあり、購入金額100円ごとに1ポイントが付きます(100円未満は切り捨て)。関数 add_points(card, amount) を作り、元のカードを書き換えずに、新しいカードを返してください。

ステップ1:入力と出力を決める。入力:card(辞書:owner は str、points は int)、amount(int:購入額・円)。出力:更新後の新しいカード(辞書)。

ステップ2:計算式を決める。付与ポイントは、amount を100で割った商です。Python では amount // 100 と書き、小数点以下を切り捨てた整数になります。たとえば 1250 // 100 は12、99 // 100 は0です。

ステップ3:書き換えずに返す。dict(card) で辞書のコピーを作り、コピーのほうの points を更新して返します。

def add_points(card, amount):
    new_card = dict(card)                          # コピーを作る
    new_card['points'] = card['points'] + amount // 100
    return new_card

card0 = {'owner': 'A', 'points': 20}
card1 = add_points(card0, 1250)
card2 = add_points(card1, 99)
print(card0)
print(card1)
print(card2)

ステップ4:追跡表で確認する。

呼び出し 入力のカード 購入額 付与 出力のカード
add_points(card0, 1250) points 20 1,250 12 points 32
add_points(card1, 99) points 32 99 0 points 32

card0 は20のまま変わりません。入力を書き換えていないので、card0 をもう一度使って別の計算をしても、結果が変わりません。

ステップ5:意味を言葉に戻す。「99円の買い物ではポイントが付かない」「元のカードは、更新の記録を残すため変えない」という2つの文が、出力と一致しています。状態の変化を、「古い状態から新しい状態を作る」形で書くと、各時点の状態が全部残り、後から追跡できます。

操作して理解する:注文の状態遷移を表にして、許されない出来事を見つける

注文には状態(ordered:注文済み、paid:支払済み、shipped:発送済み、delivered:配達済み、cancelled:取消、refunded:返金済み)があり、出来事(pay:支払い、ship:発送、deliver:配達、cancel:取消、refund:返金)で状態が変わります。許される組み合わせを表(辞書)にすると、許されない出来事を見つけられます。

TRANSITIONS = {
    ('ordered', 'pay'): 'paid',
    ('ordered', 'cancel'): 'cancelled',
    ('paid', 'ship'): 'shipped',
    ('paid', 'refund'): 'refunded',
    ('shipped', 'deliver'): 'delivered',
}

def next_status(status, event):
    return TRANSITIONS.get((status, event))     # 表に無い組み合わせは None

status = 'ordered'
for event in ['pay', 'ship', 'deliver', 'cancel']:
    new = next_status(status, event)
    print(f'{status:10} --{event}--> {new}')
    if new is not None:
        status = new
print('最終状態:', status)

実行する前に、最後の cancel がどうなるかを予想してください。答え:配達済み(delivered)の注文は、取消の出来事を受け付ける行が表に無いので、None が返り、状態は変わりません。最終状態は delivered です。

次に、自分で書き換えてみましょう。

  1. イベントを ['pay', 'refund', 'ship'] にすると、最終状態は何ですか。
  2. 表に ('shipped', 'refund'): 'refunded' を加えると、配送中の返金も扱えます。この変更で、何が起きやすくなるか考えてください。

答えは、(1) refunded(paid→refunded の後、ship は表に無いので無視される)、(2) 発送済みの商品の返金を許すと、商品の回収という別の業務が必要になる。状態遷移の表は、業務ルールそのものです。

注意:None を返して「許されない」を表す設計では、呼び出す側が None の確認を忘れて status = next_status(status, event) と書くと、状態が None に上書きされて失われます。上のコードのように確認しても、許されない出来事は黙って無視されるだけなので、気づきにくくなります。実務では、許されない出来事をエラーにして止めるか、ログに残す設計がよく使われます。

発展:状態を隠す設計・再現性・欠損の扱い

状態を引数に出す:関数が外部の変数に依存する代わりに、状態を引数と戻り値で受け渡す設計にすると、同じ入力に同じ出力を返す純粋な関数になります。例題の add_points がその形です。テストは簡単になりますが、状態の受け渡しを呼び出す側が管理する必要があります。

乱数の状態:乱数(ランダムな値)を使う手順では、乱数生成器が内部に状態を持ちます。同じ結果を再現するには、シード(乱数の出発点)を固定します。機械学習や統計の実験では、シードを記録しておくことが再現性の基本です。

ノートブックの隠れた状態:ノートブックは、セルの実行順序によって変数が残ります。セルを飛ばして実行したり、上のセルを書き換えたのに下のセルを再実行しなかったりすると、画面に見える順序と実行の順序が食い違います。結果を共有する前に、最初から全セルを順に実行し直して、同じ結果になるか確認します。

欠損の扱いは仕様で決める:欠損を0として扱うか、除いて計算するか、別の値で埋めるかは、指標の定義で決めます。どれを選んでも、選んだ理由と影響を記録してください。欠損を0にすると平均が下がることは、このあとの失敗3で確かめます。

同時実行:同じ状態を複数の処理が同時に書き換えると、順序によって結果が変わります。この授業の範囲を超えますが、業務システムで「二重登録」「在庫のずれ」が起きる原因の代表例です。

よくある失敗

失敗1:引数のリストを書き換えてしまい、2回目の結果が変わる

消費税10%を加える関数を、引数のリストを直接書き換える形で書いた例です。

def add_tax_wrong(prices):
    for i in range(len(prices)):
        prices[i] = prices[i] * 110 // 100     # 引数のリストを直接書き換える
    return prices

def add_tax_right(prices):
    return [p * 110 // 100 for p in prices]    # 新しいリストを作る

cart = [1000, 2000]
print(add_tax_wrong(cart))
print(add_tax_wrong(cart))
print('cart =', cart)

cart2 = [1000, 2000]
print(add_tax_right(cart2))
print(add_tax_right(cart2))
print('cart2 =', cart2)

誤った関数は、1回目が [1100, 2200]、同じ cart で2回目を呼ぶと [1210, 2420] になります。税込みが二重にかかり、cart 自体も書き換わっています。正しい関数は、何回呼んでも [1100, 2200] で、cart2 は元のままです。

直し方:入力のリストは書き換えず、新しいリストを作って返す。関数の中で prices[i] = ... や prices.append(...)、prices.sort() のような書き換えを見つけたら、引数を壊していないか確認します。

失敗2:デフォルト引数のリストが、呼び出しをまたいで共有される

cart=[] をデフォルト値にした関数は、予想外の状態を持ちます。

def add_item(item, cart=[]):
    cart.append(item)
    return cart

def add_item_right(item, cart=None):
    if cart is None:
        cart = []
    cart.append(item)
    return cart

print(add_item('りんご'))
print(add_item('みかん'))
print(add_item_right('りんご'))
print(add_item_right('みかん'))

誤った関数は、1回目は ['りんご']、2回目は ['りんご', 'みかん'] になります。2回目の呼び出しで cart を渡していないのに、1回目のりんごが残っているためです。デフォルト値のリストは、関数を定義したときに1度だけ作られ、以後の呼び出しで共有されます。

直し方:デフォルト値は None にし、関数の中で if cart is None: cart = [] と、呼び出しごとに新しいリストを作ります。詳しくは 「デフォルト引数」(有料版) で扱います。

失敗3:欠損(None)を0として扱い、平均が下がる

3日間の売上のうち、2日目は「入力されていない」(欠損)です。これを0にして平均を出した例です。

sales = [1000, None, 3000]       # None は「未入力」を表す
as_zero = [0 if v is None else v for v in sales]
print('欠損を0として平均:', round(sum(as_zero) / len(as_zero), 1))
known = [v for v in sales if v is not None]
print('欠損を除いて平均  :', round(sum(known) / len(known), 1))

欠損を0にすると、平均は1,333.3円。除いて計算すると2,000円です。実際に売上が0円だった日ならば、0を含めるのが正しいのですが、単に未入力だった日を0にすると、平均を実際より低く見せてしまいます。

直し方:0と欠損の意味を、データ辞書や指標の定義で決める。欠損を除く場合は、除いた件数も一緒に報告する(3件中2件で計算した、と書く)。

まとめ

用語

状態(state)

手順の呼び出しをまたいで残る値。外部の変数、リスト、ファイル、データベースの内容など。

副作用(side effect)

戻り値以外に起きる変化。引数の書き換え、画面への出力、外部の変数の更新など。

純粋関数(pure function)

同じ入力に必ず同じ出力を返し、副作用がない関数。

戻り値(return value)

関数が return で呼び出し元に返す値。return が無ければ None。

状態遷移(state transition)

出来事(イベント)によって、状態が別の状態に変わること。許される組み合わせを表で表す。

よくある質問

状態を持つ手順は避けるべきですか?

避ける必要はありません。注文の流れや在庫など、業務は状態の変化そのものです。大切なのは、状態がどこにあり、何がそれを書き換えるのかを説明でき、検査できることです。計算部分は純粋な関数にして、状態の更新は少数の場所にまとめると管理しやすくなります。

副作用は、すべて悪いものですか?

いいえ。画面への表示、ファイルへの保存、データベースの更新は、副作用がなければ成り立たない仕事です。問題になるのは、意図せず呼び出し元のデータが書き換わる場合です。副作用がある処理は、名前や説明で明示し、計算の部分と分けて書きます。

リストを書き換える `sort()` と、新しいリストを返す `sorted()` は、どう使い分けますか?

list.sort() は元のリストを並べ替え、None を返します。sorted(list) は元を変えず、並べ替えた新しいリストを返します。元の順序をあとで使うなら sorted を使います。x = x.sort() と書くと、x が None になる誤りが典型です。

0と欠損は、実務でどう区別して記録しますか?

データ辞書に「0は〜を意味する」「空欄は未入力を意味する」と書きます。システムによっては、欠損が空文字や -1 や 9999 のような特別な値で入ることがあります。集計の前に、値の分布を見て、特別な値が混じっていないか確認します。

ノートブックで結果が再現しないとき、何を確認しますか?

まず、カーネルを再起動して全セルを上から順に実行し直します。次に、乱数のシードが固定されているか、読み込むデータが同じか、ライブラリの版が同じかを確認します。セルの実行順や、前の実行で残った変数が原因のことが多いです。

状態遷移の表は、どこで使われますか?

注文、申請、チケットなど、状態が変わる業務で使います。状態の一覧と、許される出来事の組み合わせを表にすると、要件定義でのレビューが簡単になり、実装やテストの確認項目にもなります。許されない組み合わせの扱い(エラーか、無視か)も決めておきます。

確認問題

問1 「純粋関数」の説明として、最も適切なのはどれか。

  1. ① print を一度も使わない関数
  2. ② とても短い関数
  3. ③ 同じ入力には必ず同じ出力を返し、副作用がない関数
  4. ④ 引数が1つだけの関数
解答と解説

正解:③

純粋関数は、出力が入力だけで決まり、外部の状態を読み書きしない関数です。検査ケースで確かめやすい性質があります。

問2 関数の中で、引数として受け取ったリストに prices[0] = 0 と代入した。呼び出し元のリストはどうなるか。

  1. ① 呼び出し元のリストも書き換わる
  2. ② 呼び出し元のリストは変わらない
  3. ③ エラーになる
  4. ④ リストが空になる
解答と解説

正解:①

リストは、引数として渡しても同じリストを指します。関数の中での書き換えは、呼び出し元にも反映されます。

問3 def f(x): print(x * 2) と定義し、r = f(5) を実行した。r の値はどれか。

  1. ① 10
  2. ② '10'
  3. ③ 5
  4. ④ None
解答と解説

正解:④

f は print で画面に出すだけで、return を書いていないので None を返します。

問4 同じ入力を与えても、呼び出すたびに出力が変わる関数がある。最も疑うべきものはどれか。

  1. ① 入力の型が int であること
  2. ② 関数の外にある状態(変数)を読み書きしていること
  3. ③ 関数の名前が長いこと
  4. ④ 戻り値があること
解答と解説

正解:②

入力が同じなのに出力が変わるなら、入力以外の値(状態)が影響しているはずです。

問5 売上 [1000, None, 3000] の平均を、欠損(None)を0として計算した値と、除いて計算した値の組み合わせは、どれか。

  1. ① 約1333円と1333円
  2. ② 2000円と2000円
  3. ③ 約1333円と2000円
  4. ④ 2000円と約1333円
解答と解説

正解:③

0として計算すると 4000÷3 で約1333円、除くと 4000÷2 で2000円です。欠損の扱いで平均が変わります。

問6 def add(x, box=[]): box.append(x); return box を、add(1)、add(2) の順に呼ぶと、2回目の戻り値はどれか。

  1. ① [1, 2]
  2. ② [2]
  3. ③ [1]
  4. ④ エラーになる
解答と解説

正解:①

デフォルト値のリストは、定義時に1度だけ作られ、呼び出しをまたいで共有されます。2回目は1回目の要素が残っています。

次に学ぶこと

次は 2進数・ビット・容量 で、コンピュータが値を2進数(0と1)で持つしくみと、容量の見積もりを学びます。この授業の「型」と「0と欠損」の話が、そのまま活きます。

関数の戻り値と副作用は 「戻り値と副作用」(有料版)、デフォルト引数の落とし穴は 「デフォルト引数」(有料版) で詳しく扱います。この授業の確認問題だけでモジュールの習得とはしません。ON06 の総合テストと、会員割引の条件を表で検証する自力課題で判定します。

キャリア学習:データサイエンティスト・機械学習エンジニア・DX推進・研究者を目指す人の学習アプリ。164モジュール・984授業を、ブラウザで動くSQLとPythonの演習で。基礎と各分野の最初のモジュールは無料。全部は2,980円の買い切り。

アプリを無料で開く有料版と無料版の違い