シリーズ最終回となる第3回では、コードの再構成とテスト設計について解説します。第1回で学んだ命名と美しさ、第2回で学んだ制御フローの改善に続き、今回はより大きな視点でコードを整理する方法を学びます。
適切な問題の分離、一度に1つのことを行う設計、そして読みやすくテストしやすいコードの書き方を習得することで、大規模なプロジェクトでも保守性の高いコードを書けるようになります。
無関係の下位問題を抽出する
汎用的なコードを分離する
プロジェクト固有のビジネスロジック1と、汎用的なユーティリティ機能2を分離することで、コードの再利用性と可読性が向上します。
# === 悪い例:距離計算のロジックが混在 ===
def find_nearest_store(user_location, stores):
nearest = None
min_distance = float('inf')
for store in stores:
# 距離計算のロジックが混在している
dx = store.x - user_location.x
dy = store.y - user_location.y
distance = math.sqrt(dx * dx + dy * dy)
if distance < min_distance:
min_distance = distance
nearest = store
return nearest
# === 良い例:距離計算を別関数に抽出 ===
def calculate_distance(point1, point2):
"""2点間のユークリッド距離を計算"""
dx = point1.x - point2.x
dy = point1.y - point2.y
return math.sqrt(dx * dx + dy * dy)
def find_nearest_store(user_location, stores):
"""最寄りの店舗を検索"""
return min(stores, key=lambda store: calculate_distance(user_location, store))
純粋なユーティリティコード
汎用的な処理は独立したユーティリティ関数として実装します。
# === ユーティリティモジュール: utils/text.py ===
def remove_html_tags(text):
"""HTMLタグを除去して純粋なテキストを返す"""
return re.sub(r'<[^>]+>', '', text)
def truncate_text(text, max_length, suffix='...'):
"""
テキストを指定長で切り詰める
Args:
text: 対象テキスト
max_length: 最大文字数
suffix: 省略記号(デフォルト: '...')
"""
if len(text) <= max_length:
return text
return text[:max_length - len(suffix)] + suffix
# === メインコードでの使用 ===
from utils.text import remove_html_tags, truncate_text
def format_article_preview(article):
"""記事のプレビューを生成"""
clean_text = remove_html_tags(article.content)
preview = truncate_text(clean_text, max_length=200)
return preview
プロジェクト固有の機能の分離
プロジェクト固有の機能も、関連性の低い処理は別関数に分離します。
# === 悪い例:認証とビジネスロジックが混在 ===
def process_order(user_id, items):
# 認証処理が混在
if not user_id:
return {"error": "ユーザーIDが必要です"}
user = get_user(user_id)
if not user or not user.is_active:
return {"error": "無効なユーザー"}
# 在庫チェックとビジネスロジックが混在
for item in items:
stock = check_inventory(item.id)
if stock < item.quantity:
return {"error": f"{item.name}の在庫が不足しています"}
# 注文処理
order = create_order(user, items)
return {"success": True, "order_id": order.id}
# === 良い例:関心事を分離 ===
def validate_user(user_id):
"""ユーザーの妥当性を検証"""
if not user_id:
raise ValueError("ユーザーIDが必要です")
user = get_user(user_id)
if not user or not user.is_active:
raise ValueError("無効なユーザー")
return user
def check_items_availability(items):
"""商品の在庫を確認"""
for item in items:
stock = check_inventory(item.id)
if stock < item.quantity:
raise ValueError(f"{item.name}の在庫が不足しています")
def process_order(user_id, items):
"""注文を処理する(メインロジックのみ)"""
try:
user = validate_user(user_id)
check_items_availability(items)
order = create_order(user, items)
return {"success": True, "order_id": order.id}
except ValueError as e:
return {"error": str(e)}
一度に1つのことを
タスクを小さく分割する
コードが行っているタスクをすべて列挙し、それぞれを独立した関数に分割します。
# === 悪い例:すべてを1つの関数で処理 ===
def process_user_registration(user_data):
# バリデーション
if not user_data.get('email'):
return {"error": "メールアドレスが必要です"}
if '@' not in user_data['email']:
return {"error": "無効なメールアドレス"}
if len(user_data.get('password', '')) < 8:
return {"error": "パスワードは8文字以上必要です"}
# パスワードハッシュ化
salt = generate_salt()
hashed_password = hash_password(user_data['password'], salt)
# データベース保存
user = User(
email=user_data['email'],
password=hashed_password,
salt=salt
)
db.save(user)
# ウェルカムメール送信
send_welcome_email(user.email)
# ログ記録
logger.info(f"新規ユーザー登録: {user.email}")
return {"success": True, "user_id": user.id}
# === 良い例:各タスクを独立した関数に分割 ===
def validate_registration_data(user_data):
"""登録データのバリデーション"""
if not user_data.get('email'):
raise ValueError("メールアドレスが必要です")
if '@' not in user_data['email']:
raise ValueError("無効なメールアドレス")
if len(user_data.get('password', '')) < 8:
raise ValueError("パスワードは8文字以上必要です")
def create_user_account(email, password):
"""ユーザーアカウントの作成"""
salt = generate_salt()
hashed_password = hash_password(password, salt)
user = User(
email=email,
password=hashed_password,
salt=salt
)
db.save(user)
return user
def send_registration_notification(user):
"""登録通知の送信"""
send_welcome_email(user.email)
logger.info(f"新規ユーザー登録: {user.email}")
def process_user_registration(user_data):
"""ユーザー登録の統合処理"""
try:
# 1. バリデーション
validate_registration_data(user_data)
# 2. アカウント作成
user = create_user_account(
user_data['email'],
user_data['password']
)
# 3. 通知送信
send_registration_notification(user)
return {"success": True, "user_id": user.id}
except ValueError as e:
return {"error": str(e)}
複数の処理を明確に分離
データの変換と処理を明確に分離することで、各ステップが独立してテスト可能になります。
# === 悪い例:データ取得と処理が混在 ===
def generate_monthly_report(year, month):
report_data = {}
# データ取得と処理が混在
sales = db.query(f"SELECT * FROM sales WHERE year={year} AND month={month}")
total = 0
for sale in sales:
total += sale.amount
if sale.category not in report_data:
report_data[sale.category] = 0
report_data[sale.category] += sale.amount
# フォーマット処理も混在
report = f"月次売上レポート ({year}年{month}月)\n"
report += f"総売上: {total:,}円\n\n"
report += "カテゴリ別売上:\n"
for category, amount in sorted(report_data.items()):
percentage = (amount / total) * 100
report += f" {category}: {amount:,}円 ({percentage:.1f}%)\n"
return report
# === 良い例:各処理を分離 ===
def fetch_monthly_sales(year, month):
"""月次売上データを取得"""
return db.query(
"SELECT * FROM sales WHERE year=? AND month=?",
[year, month]
)
def calculate_sales_summary(sales):
"""売上データを集計"""
summary = {
'total': 0,
'by_category': {}
}
for sale in sales:
summary['total'] += sale.amount
category = sale.category
if category not in summary['by_category']:
summary['by_category'][category] = 0
summary['by_category'][category] += sale.amount
return summary
def format_sales_report(year, month, summary):
"""売上レポートをフォーマット"""
lines = [
f"月次売上レポート ({year}年{month}月)",
f"総売上: {summary['total']:,}円",
"",
"カテゴリ別売上:"
]
for category, amount in sorted(summary['by_category'].items()):
percentage = (amount / summary['total']) * 100
lines.append(f" {category}: {amount:,}円 ({percentage:.1f}%)")
return '\n'.join(lines)
def generate_monthly_report(year, month):
"""月次レポートを生成"""
# 1. データ取得
sales = fetch_monthly_sales(year, month)
# 2. 集計
summary = calculate_sales_summary(sales)
# 3. フォーマット
return format_sales_report(year, month, summary)
コードに思いを込める
プログラムの動作を簡単な言葉で説明する
コードを書く前に、その動作を簡単な言葉で説明することで、より自然な実装が可能になります。
# 動作の説明:
# 「与えられたテキストから、最も頻出する単語トップ10を見つける。
# ただし、一般的な単語(a, the, is など)は除外する」
def find_top_words(text, top_n=10):
"""
テキスト内の頻出単語トップNを取得
一般的な単語(ストップワード)は除外される
"""
STOP_WORDS = {
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by',
'for', 'from', 'has', 'he', 'in', 'is', 'it', 'its',
'of', 'on', 'that', 'the', 'to', 'was', 'will', 'with'
}
# 単語を抽出(小文字化、英数字のみ)
words = re.findall(r'\b[a-z]+\b', text.lower())
# ストップワードを除外してカウント
word_counts = Counter(
word for word in words
if word not in STOP_WORDS
)
# 頻出順にトップNを返す
return word_counts.most_common(top_n)
ライブラリを知る
Pythonの標準ライブラリ3や主要なサードパーティライブラリ4を知ることで、車輪の再発明を避けられます。
# === 悪い例:自前で実装 ===
def parse_csv_file(filename):
data = []
with open(filename, 'r') as f:
lines = f.readlines()
headers = lines[0].strip().split(',')
for line in lines[1:]:
values = line.strip().split(',')
row = {}
for i, header in enumerate(headers):
row[header] = values[i]
data.append(row)
return data
# === 良い例:標準ライブラリを活用 ===
import csv
def parse_csv_file(filename):
with open(filename, 'r', newline='') as f:
return list(csv.DictReader(f))
# === その他の便利な標準ライブラリの活用例 ===
from itertools import groupby, chain
from collections import defaultdict, Counter
from functools import lru_cache
import json
# グループ化
def group_by_category(items):
# 自前実装の代わりに
return {
key: list(group)
for key, group in groupby(
sorted(items, key=lambda x: x.category),
key=lambda x: x.category
)
}
# キャッシュ
@lru_cache(maxsize=128)
def expensive_calculation(n):
"""計算結果を自動的にキャッシュ"""
return sum(i ** 2 for i in range(n))
# データ構造
def count_words_by_length(words):
"""単語を長さごとにカウント"""
length_counts = defaultdict(list)
for word in words:
length_counts[len(word)].append(word)
return dict(length_counts)
短いコードを書く
必要のない機能は実装しない(YAGNI原則)
YAGNI(You Aren't Gonna Need It)原則5に従い、現時点で必要ない機能は実装しません。
# === 悪い例:過度に多機能なクラス ===
class UserAnalytics:
def __init__(self):
self.login_times = []
self.page_views = {}
self.click_events = []
self.scroll_depth = []
self.session_duration = []
self.browser_info = []
self.device_types = []
self.referrer_sources = []
# ... 実際には使われない多くのメトリクス
def track_everything(self, event):
# すべてを追跡する複雑なロジック
if event.type == "login":
self.login_times.append(event.timestamp)
elif event.type == "page_view":
# ... 100行以上の未使用コード
# ...
# === 良い例:実際に必要な機能のみ実装 ===
class UserAnalytics:
def __init__(self):
self.login_count = 0
self.last_login = None
def track_login(self, timestamp):
"""ログイン回数と最終ログイン時刻を記録"""
self.login_count += 1
self.last_login = timestamp
コードを小さく保つ
機能を最小限に抑え、共通処理を積極的に抽出します。
# === 悪い例:重複したバリデーション処理 ===
def validate_email_for_registration(email):
if not email:
return False, "メールアドレスが必要です"
if '@' not in email:
return False, "無効なメールアドレス形式"
if email.count('@') != 1:
return False, "無効なメールアドレス形式"
local, domain = email.split('@')
if not local or not domain:
return False, "無効なメールアドレス形式"
if '.' not in domain:
return False, "無効なドメイン"
return True, ""
def validate_email_for_password_reset(email):
if not email:
return False, "メールアドレスが必要です"
if '@' not in email:
return False, "無効なメールアドレス形式"
if email.count('@') != 1:
return False, "無効なメールアドレス形式"
# ... 同じ処理の繰り返し
# === 良い例:共通バリデーション関数 ===
def is_valid_email(email):
"""
メールアドレスの基本的な形式をチェック
Returns:
bool: 有効な場合True
"""
if not email or email.count('@') != 1:
return False
local, domain = email.split('@')
return (
local and
domain and
'.' in domain and
not domain.startswith('.') and
not domain.endswith('.')
)
def validate_email_for_registration(email):
if not email:
return False, "メールアドレスが必要です"
if not is_valid_email(email):
return False, "無効なメールアドレス形式"
return True, ""
テストと読みやすさ
テストを読みやすくする
テストコード6も本番コード同様、読みやすく保守しやすくする必要があります。
# === 悪い例:意味不明な変数名とテスト名 ===
def test_user_system():
u = User("a@b.c", "p")
assert u.e == "a@b.c"
assert u.p == "p"
u.p = "newp"
assert u.p == "newp"
# === 良い例:説明的な変数名とテスト名 ===
def test_user_creation():
"""ユーザー作成時の初期値を検証"""
# Arrange
email = "test@example.com"
password = "secure_password"
# Act
user = User(email=email, password=password)
# Assert
assert user.email == email
assert user.password == password
def test_password_update():
"""パスワード更新機能を検証"""
# Arrange
user = User(email="test@example.com", password="old_password")
new_password = "new_secure_password"
# Act
user.update_password(new_password)
# Assert
assert user.password == new_password
エラーメッセージを読みやすくする
テストが失敗した時のメッセージを明確にすることで、デバッグが容易になります。
# === 悪い例:エラーメッセージが不明確 ===
def test_discount_calculation():
assert calc(100, 0.1, True, False, "PREMIUM") == 85
# === 良い例:明確なエラーメッセージ ===
def test_discount_calculation():
# Arrange
base_price = 100
discount_rate = 0.1
is_member = True
is_sale_period = False
member_type = "PREMIUM"
expected_price = 85
# Act
actual_price = calculate_discount(
base_price=base_price,
discount_rate=discount_rate,
is_member=is_member,
is_sale_period=is_sale_period,
member_type=member_type
)
# Assert with detailed message
assert actual_price == expected_price, (
f"割引計算が正しくありません。"
f"期待値: {expected_price}円, "
f"実際: {actual_price}円, "
f"条件: 基本価格={base_price}, 割引率={discount_rate}, "
f"会員={is_member}, セール期間={is_sale_period}"
)
テストの適切な入力値を選択する
テストデータは単純で理解しやすいものを選びます。
# === 悪い例:複雑なテストデータ ===
def test_sort_users():
users = [
User("zzz@example.com", 42, "2021-03-15T14:23:45.123Z"),
User("aaa@example.com", 31, "2020-11-28T09:15:33.456Z"),
User("mmm@example.com", 28, "2022-01-07T18:45:12.789Z"),
# ... 大量の複雑なデータ
]
sorted_users = sort_users_by_email(users)
# 検証が困難
# === 良い例:シンプルで分かりやすいテストデータ ===
def test_sort_users():
# Arrange: アルファベット順に並べやすい単純なデータ
users = [
User(email="charlie@example.com", age=30),
User(email="alice@example.com", age=25),
User(email="bob@example.com", age=28),
]
# Act
sorted_users = sort_users_by_email(users)
# Assert: 期待される順序が明確
expected_order = ["alice@example.com", "bob@example.com", "charlie@example.com"]
actual_order = [user.email for user in sorted_users]
assert actual_order == expected_order
テスト容易性(Testability)
依存性の注入(Dependency Injection)
外部依存を注入可能にすることで、テスト時にモック7やスタブ8を使用できるようになります。
# === 悪い例:テストが困難な設計 ===
class EmailService:
def send_notification(self, user_id):
# データベースに直接依存
user = database.get_user(user_id) # グローバルなdatabase
# 外部APIに直接依存
smtp = smtplib.SMTP('smtp.gmail.com', 587)
smtp.send_mail(user.email, "通知")
# ログファイルに直接書き込み
with open('/var/log/email.log', 'a') as f:
f.write(f"Sent to {user.email}\n")
# === 良い例:依存性を注入可能な設計 ===
class EmailService:
def __init__(self, user_repository, email_client, logger):
self.user_repository = user_repository
self.email_client = email_client
self.logger = logger
def send_notification(self, user_id):
# 注入された依存を使用
user = self.user_repository.get_user(user_id)
self.email_client.send(user.email, "通知")
self.logger.info(f"Sent to {user.email}")
# === テストコード ===
from unittest.mock import Mock
def test_send_notification():
# モックを作成
mock_repo = Mock()
mock_repo.get_user.return_value = User(email="test@example.com")
mock_email = Mock()
mock_logger = Mock()
# 依存性を注入してテスト
service = EmailService(mock_repo, mock_email, mock_logger)
service.send_notification(user_id=123)
# 検証
mock_email.send.assert_called_once_with("test@example.com", "通知")
mock_logger.info.assert_called_once()
Pythonにおけるプライベートメソッドとテスト容易性
Pythonには厳密なアクセス修飾子9がないため、プライベートメソッドのテストについて独特のアプローチがあります。
# === プライベートメソッドの扱い ===
class OrderProcessor:
def process_order(self, order):
"""公開メソッド"""
if not self._validate_order(order):
raise ValueError("無効な注文")
total = self._calculate_total(order)
discount = self._apply_discount(total, order.customer)
return self._finalize_order(order, total - discount)
def _validate_order(self, order):
"""慣習的プライベートメソッド(_で開始)"""
return order.items and all(item.quantity > 0 for item in order.items)
def __calculate_tax(self, amount):
"""名前マングリングされるメソッド(__で開始)"""
return amount * 0.1
# === アプローチ1:公開APIを通じてテスト ===
def test_process_order_with_invalid_order():
processor = OrderProcessor()
invalid_order = Order(items=[]) # 無効な注文
# プライベートメソッドは公開APIを通じて間接的にテスト
with pytest.raises(ValueError, match="無効な注文"):
processor.process_order(invalid_order)
# === アプローチ2:複雑なロジックは別クラスに抽出 ===
class OrderValidator:
"""テスト可能な独立したクラス"""
def validate(self, order):
return order.items and all(item.quantity > 0 for item in order.items)
class PriceCalculator:
"""テスト可能な独立したクラス"""
def calculate_total(self, order):
return sum(item.price * item.quantity for item in order.items)
def apply_discount(self, total, customer):
if customer.is_premium:
return total * 0.1
return 0
class OrderProcessor:
def __init__(self, validator=None, calculator=None):
self.validator = validator or OrderValidator()
self.calculator = calculator or PriceCalculator()
def process_order(self, order):
if not self.validator.validate(order):
raise ValueError("無効な注文")
total = self.calculator.calculate_total(order)
discount = self.calculator.apply_discount(total, order.customer)
return self._finalize_order(order, total - discount)
テストダブルの活用
Pythonの動的型付けを活かして、様々なテストダブル10を簡単に作成できます。
# === 様々なテストダブルの例 ===
# 1. シンプルなスタブ
class StubDatabase:
def get_user(self, user_id):
return User(id=user_id, email="stub@example.com")
# 2. モックオブジェクト(手動実装)
class MockEmailClient:
def __init__(self):
self.sent_emails = []
def send(self, to, subject, body):
self.sent_emails.append({
'to': to,
'subject': subject,
'body': body
})
def assert_email_sent(self, to, subject):
for email in self.sent_emails:
if email['to'] == to and email['subject'] == subject:
return True
raise AssertionError(f"Email to {to} with subject '{subject}' not sent")
# 3. フェイクオブジェクト(簡易実装)
class FakeCache:
def __init__(self):
self._data = {}
def get(self, key):
return self._data.get(key)
def set(self, key, value, ttl=None):
self._data[key] = value
def delete(self, key):
self._data.pop(key, None)
# === テストでの使用例 ===
def test_user_service_with_test_doubles():
# Arrange
stub_db = StubDatabase()
mock_email = MockEmailClient()
fake_cache = FakeCache()
service = UserService(
database=stub_db,
email_client=mock_email,
cache=fake_cache
)
# Act
service.register_user("test@example.com", "password")
# Assert
mock_email.assert_email_sent("test@example.com", "Welcome!")
assert fake_cache.get("user:test@example.com") is not None
ファイルシステムのテスト
ファイル操作のテストでは、一時ファイルやモックを活用します。
# === ファイル操作のテスト可能な設計 ===
import tempfile
from pathlib import Path
class FileProcessor:
def __init__(self, base_path=None):
self.base_path = Path(base_path) if base_path else Path.cwd()
def process_csv(self, filename):
"""CSVファイルを処理"""
file_path = self.base_path / filename
with open(file_path, 'r') as f:
return self._parse_csv(f)
def _parse_csv(self, file_obj):
"""ファイルオブジェクトからCSVを解析"""
import csv
return list(csv.DictReader(file_obj))
# === テストコード ===
def test_process_csv():
# 一時ディレクトリを使用
with tempfile.TemporaryDirectory() as tmpdir:
# テストデータの準備
csv_content = "name,age\nAlice,25\nBob,30"
csv_file = Path(tmpdir) / "test.csv"
csv_file.write_text(csv_content)
# テスト実行
processor = FileProcessor(base_path=tmpdir)
result = processor.process_csv("test.csv")
# 検証
assert len(result) == 2
assert result[0]['name'] == 'Alice'
assert result[0]['age'] == '25'
# === StringIOを使った単体テスト ===
from io import StringIO
def test_parse_csv_unit():
# ファイルシステムを使わずにテスト
csv_content = StringIO("name,age\nAlice,25\nBob,30")
processor = FileProcessor()
result = processor._parse_csv(csv_content)
assert len(result) == 2
assert result[0]['name'] == 'Alice'
まとめ
全3回にわたって、Pythonを通じてリーダブルコードの実践的なテクニックを学びました。最終回では以下の重要な概念を解説しました:
- 無関係の下位問題を抽出する - 汎用コードとビジネスロジックの分離
- 一度に1つのことを - タスクの明確な分割と独立した処理
- コードに思いを込める - 簡潔な説明から始まる設計
- 短いコードを書く - YAGNI原則と必要最小限の実装
- テストと読みやすさ - 保守しやすいテストコードの作成
- テスト容易性 - 依存性の注入とPython特有のテスト手法
これらの原則を日々の開発に適用することで、以下のような効果が期待できます。
- 保守性の向上 - 6ヶ月後の自分や他の開発者が理解しやすい
- バグの削減 - 明確な構造により問題を発見しやすい
- 開発速度の向上 - 再利用可能なコンポーネントの蓄積
- チーム生産性の向上 - コードレビューとコラボレーションの効率化
- テストの信頼性向上 - テストしやすい設計により網羅的なテストが可能
リーダブルコードの実践は、一朝一夕には身につきません。しかし、意識的に練習を重ねることで、必ずあなたのコーディングスキルは向上します。今日から、一つずつこれらのテクニックを試してみてください。
"プログラムは人が読むために書かれるべきである。たまたまコンピュータが実行できるにすぎない。" - Harold Abelson
参考文献
- Dustin Boswell、Trevor Foucher著、角征典訳『リーダブルコード より良いコードを書くためのシンプルで実践的なテクニック』オライリー・ジャパン、2012年
- Robert C. Martin著、花井志生訳『Clean Code アジャイルソフトウェア達人の技』アスキー・メディアワークス、2009年
- Kent Beck著、長瀬嘉秀、永田渉訳『テスト駆動開発』オーム社、2017年
- Michael Feathers著、ウルシステムズ株式会社監訳『レガシーコード改善ガイド』翔泳社、2009年
-
ビジネスロジック - アプリケーションの中核となる業務規則や計算処理。ドメイン固有の知識を含む部分。 ↩
-
ユーティリティ機能 - 特定の業務に依存しない汎用的な処理。文字列操作、日付計算、ファイル操作など。 ↩
-
標準ライブラリ - Pythonに最初から含まれているモジュール群。追加インストール不要で使用できる。 ↩
-
サードパーティライブラリ - Python公式以外が開発したライブラリ。pipでインストールして使用する。 ↩
-
YAGNI原則 - "You Aren't Gonna Need It"の略。将来必要になるかもしれない機能を予測して実装しないという原則。 ↩
-
テストコード - プログラムが正しく動作することを検証するためのコード。ユニットテスト、統合テストなど。 ↩
-
モック - テスト用の偽オブジェクト。メソッド呼び出しを記録し、期待通りに呼ばれたか検証できる。 ↩
-
スタブ - テスト用の偽オブジェクト。事前に定義した値を返すだけのシンプルな実装。 ↩
-
アクセス修飾子 - public、private、protectedなど、メンバーへのアクセス範囲を制御する仕組み。Pythonでは慣習的に実現。 ↩
-
テストダブル - テスト時に本物のオブジェクトの代わりに使用する偽オブジェクトの総称。モック、スタブ、フェイクなどを含む。 ↩