
宣言的なコードで開発を高速化する
こんにちは。リテールアプリ共創部マッハチームのきんじょーです。
マッハチームでは小売業界のお客様に向け、新規アプリ開発の立ち上げを専門に行っています。
新規開発の立ち上げ時には、短期間で高品質なアプリケーションを開発することが求められます。
マッハチームのメンバーで開発速度向上に関する知見を共有するブログの連載を始めました。
今回は私から、第3弾として普段の開発で読みやすく変更しやすいコードを書くために心がけていることをご紹介します。
メンバーによる過去投稿も是非読んでください!
手続き型、宣言的とは
「手続き型」「宣言的」はプログラミングのアプローチの一つです。
手続き型のコードは「どのように処理するか」に焦点を当て、処理手順を詳細に記述します。変数の状態を変更しながら、ステップバイステップで目的を達成します。
宣言的なコードは「何をしたいか」に焦点を当て、処理の意図を明確に表現します。手順よりも結果や目的を重視し、より読みやすく理解しやすいコードになるため筆者は好んで使用しています。
条件判定
「ユーザーがプレミアム機能にアクセスできるか」を判定する処理を例に、手続き型と宣言的アプローチの違いをTypeScriptのコードで見てみましょう。
まず、宣言した変更可能な変数の状態を更新する手続き型コードを書いてみます。
let canAccessPremiumFeature = false;
// データベースからユーザー情報を取得
const user = await getUserById(userId);
// プレミアムプランかをチェック
if (user.plan === "premium") {
canAccessPremiumFeature = true; // 1回目の変更
}
// 支払い状況をチェック
if (user.hasOverdue) {
canAccessPremiumFeature = false; // 2回目の変更
}
// アカウント状態をチェック
if (!user.isActive) {
canAccessPremiumFeature = false; // 3回目の変更
}
こちらは極端な例ですが、変更可能な変数canAccessPremiumFeature
が複数箇所で更新されており、最終的な値がどこで決まったかわかりにくくなっています。
何か不具合が発生して調査をするにしても、どこでどのように値が更新されたかがわからず、調査が困難になります。
次に、同じく手続き型ですが関数に切り出して早期リターンを利用する方法でコードを改善します。
function canAccessPremiumFeature(user: User): Promise<boolean> {
// プレミアムプランかをチェック
if (user.plan !== "premium") return false;
// 支払い状況をチェック
if (user.hasOverdue) return false;
// アカウント状態をチェック
if (!user.isActive) return false;
return true;
}
変更可能な変数canAccessPremiumFeature
を定義しなくて済むため、変数の中身を覚えておく必要がなくなりコードの認知負荷が下がります。
最後に、宣言的アプローチで書き直したコードです。
// データベースからユーザー情報を取得
const user = await getUserById(userId);
// プレミアム機能にアクセスできるかを判定
const canAccessPremiumFeature =
user.plan === "premium" &&
user.isActive &&
!user.hasOverdue;
canAccessPremiumFeature
をconstで定義し変更不可にできました。
これにより変数の中身を覚えておく必要がなくなり認知負荷が下がるとともに、どこかで意図せずcanAccessPremiumFeature
が更新される可能性がなくなるため、バグの混入を防ぐことができます。
プレミアム機能にアクセスする条件が1箇所にまとまり、一目瞭然になりました。
配列操作
次に、配列から「偶数のみを抽出して2倍にする」という処理を例に見てみましょう。
まず、手続き型で書いたコードです。
result
という空配列を定義し、ループを回して条件をチェックして、配列に追加する処理を記述しています。
// 手続き型
const numbers = [1, 2, 3, 4, 5, 6];
let result = [];
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] % 2 === 0) {
result.push(numbers[i] * 2);
}
}
次に、宣言的アプローチで書いたコードです。
JavaScriptの配列には、関数型プログラミングの考え方を取り入れたイミュータブルで宣言的に要素を扱うメソッドが用意されています。
filter
メソッドで偶数を抽出し、map
メソッドで2倍にする処理を記述しています。
// 宣言的
const numbers = [1, 2, 3, 4, 5, 6];
const result = numbers
.filter(n => n % 2 === 0) // 偶数を抽出
.map(n => n * 2); // 2倍にする
手続き型コードでは「ループを回して、条件をチェックして、配列に追加する」という処理の手順を記述していますが、宣言的なコードでは「偶数を抽出して2倍にする」という処理の意図が明確に表現されています。
手続き型のコードではnumbers配列をforループで1回だけ走査していますが、宣言的なコードではfilterメソッドとmapメソッドでnumbers配列を2回走査しており、処理効率は手続き型コードの方が良さそうに見えます。
しかし、私の経験では多くの場合、定数倍のループ回数の違いがパフォーマンスに深刻な影響をもたらすことはなく、読みやすさや保守性を重視してまずは宣言的アプローチを採用することが多いです。
手続き型コードを宣言的にリファクタリングする
最後に、手続き型で書かれた注文データから請求情報を生成する処理を、宣言的にリファクタリングします。
おおまかな処理の流れは以下です。
- 注文データからステータスが確定したものを抽出
- 各注文の小計を計算
- プレミアムユーザーの場合は割引を適用
- 請求書情報を生成
まず、手続き型で書いたコードです。
// ECサイトで注文データから請求書情報を生成する処理
interface Order {
id: string;
userId: string;
items: Array<{ productId: string; name: string; price: number; quantity: number }>;
status: 'pending' | 'confirmed' | 'cancelled';
isPremiumUser: boolean;
createdAt: Date;
}
function generateInvoices(orders: Order[]) {
// ステータスが確定した注文を抽出
let validOrders = [];
for (let i = 0; i < orders.length; i++) {
if (orders[i].status === 'confirmed') {
validOrders.push(orders[i]);
}
}
// 各注文の小計を計算
let invoices = [];
for (let order of validOrders) {
let subtotal = 0;
for (let item of order.items) {
subtotal += item.price * item.quantity;
}
// プレミアムユーザーの場合は割引率を判定
let discount = 0;
if (order.isPremiumUser) {
if (subtotal >= 10000) {
discount = 0.2;
} else {
discount = 0.1;
}
}
// 請求書情報を生成
let total = subtotal * (1 - discount);
invoices.push({
orderId: order.id,
userId: order.userId,
subtotal: subtotal,
discount: discount,
total: total
});
}
return invoices;
}
徐々に宣言的にリファクタリングしていきましょう。
ステータスが確定した注文はfilterメソッドで抽出できます。
// 手続き型
let validOrders = [];
for (let i = 0; i < orders.length; i++) {
if (orders[i].status === 'confirmed') {
validOrders.push(orders[i]);
}
}
// 宣言的
const validOrders = orders.filter(order => order.status === 'confirmed');
小計の計算で行っている以下の処理はreduceメソッドで簡潔に書くことができます。
// 手続き型
let subtotal = 0;
for (let item of order.items) {
subtotal += item.price * item.quantity;
}
// 宣言的
const subtotal = order.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
割引率の判定のif文も三項演算子で簡潔に書くことができます。
三項演算子のネストは好みが分かれるので、ここはヘルパー関数を切り出すのも良いですね。
// 手続き型
let discount = 0;
if (order.isPremiumUser) {
discount = subtotal >= 10000 ? 0.2 : 0.1;
}
// 宣言的
const discount = order.isPremiumUser
? subtotal >= 10000 ? 0.2 : 0.1
: 0;
// ヘルパー関数を用意する場合
function calculateDiscountRate(isPremium: boolean, subtotal: number): number {
if (!isPremium) return 0;
return subtotal >= 10000 ? 0.2 : 0.1;
}
これらの処理をmapメソッドで処理すると、最終的に以下のようなコードになります。
function generateInvoices(orders: Order[]) {
return orders
.filter(order => order.status === 'confirmed') // ステータスが確定した注文を抽出
.map(order => {
const subtotal = order.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const discountRate = order.isPremiumUser
? subtotal >= 10000 ? 0.2 : 0.1
: 0;
return {
orderId: order.id,
userId: order.userId,
subtotal,
discount: discountRate,
total: subtotal * (1 - discountRate),
};
});
}
コードの量が減るだけでなく、意図が明確で読みやすいコードになりました。
変更可能な変数を使用しないため、変数の中身に関する認知負荷を下げることができ、意図せぬバグの混入を防ぐことができます。
また、filterで処理したステータス確認済み注文がそのままmapメソッドに渡されるので、間に不具合が混入する可能性もありません。
mapは渡された配列の要素数と同じ数の新しい配列を返すため、配列にpushし忘れで要素数が変わってしまうような不具合も発生しません。
まとめ
今回は、普段実装する際に気を付けていることとして、変更可能な変数を利用せず宣言的にコードを書くTipsをご紹介しました。
コードは書いた後よりも読まれる時間の方が長く、読みやすく変更しやすいコードを積み重ねていくことで、品質と開発速度向上の両立に繋げられると考えています。
この記事がどなたかの役に立つと幸いです。
以上。リテールアプリ共創部のきんじょーでした。