Skip to content

Radical Reduction of Complexity by Contracts

Published: at 02:30 PM

Translates:

複雑さとは

ここでは複雑さを量的複雑度と質的複雑度の総称とします。 量的複雑度として循環的複雑度または認知的複雑度を採用します。 ここでは単に条件構文の数が多いならば複雑度が高いという認識で十分です。

質的複雑度には明確な指標はありませんが、例えば人類は4以上の数を数えられないというような、 リサーチや経験に基づくものを指すことにします。

例えば以下のようなコードは両方の意味で複雑度が高いといえます。

class SubscriptionRegistrationService(
    private val crmClient: CRMClient,
    private val dbAccess: DatabaseAccess
) {
    fun registerNewSubscription(customerId: String?, subscriptionType: String, durationMonths: Int, planPrice: Double): Result<String> {
        var effectiveCustomerId = customerId
        if (customerId == null || !crmClient.validateCustomerId(customerId)) {
            effectiveCustomerId = crmClient.issueNewCustomerId()
            crmClient.createCustomerProfile(effectiveCustomerId, newClientName())
        }

        if (!crmClient.isCustomerActive(effectiveCustomerId!!)) {
            crmClient.activateCustomer(effectiveCustomerId)
        }

        if (!dbAccess.isValidSubscriptionType(subscriptionType)) {
            return Result.failure(RegistrationError.InvalidSubscriptionType)
        }

        val validDurationMonths = if (durationMonths > 0) durationMonths else 12

        val validPlanPrice = if (!dbAccess.isValidPlanPrice(subscriptionType, planPrice)) {
            dbAccess.getDefaultPlanPrice(subscriptionType)
        } else {
            planPrice
        }

        if (dbAccess.doesSubscriptionExistForCustomer(effectiveCustomerId, subscriptionType)) {
            return Result.failure(RegistrationError.SubscriptionAlreadyExists)
        }

        val isRegisteredSuccessfully = dbAccess.registerSubscription(effectiveCustomerId, subscriptionType, validDurationMonths, validPlanPrice)
        return if (isRegisteredSuccessfully) {
            Result.success("subscribed")
        } else {
            Result.failure(RegistrationError.RegistrationFailed)
        }
    }

    private fun newClientName(): String {
        val now = LocalDateTime.now()
        val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm")
        val formattedDate = now.format(formatter)
        return "New Client $formattedDate"
    }
}

上記のコードですべての条件をカバーする必要があると仮定すると (100% MCC)、 テスト数が文字通り指数関数で増加することになります。品質の担保にはテストの一定の充足は必要不可欠であることを考えれば、 ごく小規模のプロジェクトの場合でもこのテストケースの増加に対応し続けることは現実的に不可能であるため、この問題は重大です。

また言うまでもありませんが、このようなコードは当然仕様の変更や不具合の修正に対しても重篤な影響を及ぼします。

契約プログラミングによる分割統治

オブジェクト指向プログラミング、とくに契約プログラミングでは呼び出し側と呼び出される側を利用者と供給者とみなし、 供給者がアウトプット (事後条件) するための (強い) 事前条件を利用者に求めます。

契約プログラミングの発想を取り入れることでプログラムは極端に単純化します。

class SubscriptionRegistrationService(
    private val subscriptionService: SubscriptionService,
    private val subscriptions: Subscriptions // equivalent to DatabaseAccess
) {
    // this class method requires clients to satisfy preconditions through parameter types
    fun registerNewSubscription(customer: Customer, subscription: Subscription): Result<Unit> = runCatching {
        subscriptionService.assertUniequeSubscriptionFor(customer, subscription).getOrThrow()
        subscriptions.registerSubscription(subscription).getOrThrow()
    }
}

// this is a interface because it may need to access data stores
interface SubscriptionService {
    fun assertUniequeSubscriptionFor(customer: Customer, subscription: Subscription): Result<Unit>
}

data class Customer(val cusdata class Customer(val customerId: String, val active: Boolean, val name: String) {
    init {
        // invariants of "Customer" entity
        require(customerId.isNotBlank()) { "Customer ID cannot be blank." }
        require(active) { "The customer must be active." }
        require(name.isNotBlank()) { "Customer name cannot be blank." }
    }
}

class Subscription(
    val customerId: String,
    val subscriptionType: String,
    val durationMonths: Int,
    val planPrice: Double
) {
    companion object {
        private val validSubscriptionTypes = setOf("Monthly", "Annual", "Trial")
    }

    init {
        // invariants of "Subscription" entity
        require(customerId.isNotBlank()) { "Customer ID cannot be blank." }
        require(subscriptionType in validSubscriptionTypes) { "Invalid subscription type." }
        require(durationMonths > 0) { "The subscription duration must be at least 1 month." }
        require(planPrice > 0) { "The plan price must be a positive number." }
    }
}

これは分割統治 (Divide et Impera) の具体的な方法の一つといえます。 テストは、各エンティティごとに個別に管理できるようになりました。 契約という概念を導入することで、各エンティティの責任が明確化され、仕様の変更にも対応しやすくなりました。 また、手続きに埋め込まれた複数のエンティティは、不変条件を維持したままクラス・インスタンスとして再利用できるようになりました。 局所的には100%のカバレッジを達成するために必要なテストケースの数は変わりませんが、 エンティティを再利用できるようになったことで、アプリケーション全体で必要なテストケースの数は大幅に削減されます。

構造化プログラミングによる分割統治は機能しないのか

ここでは便宜上、データとそれに対する処理を別々に管理するプログラミング手法を構造化プログラミングとします[Ivar 1992]。 ソフトウェアに複雑性をもたらしているものは、少なくともここで議論している範囲では、 整数や文字列などのプリミティブ型がある論理的な領域において取りうる値のバリデーションです。

オブジェクト指向プログラミングであればこれらの値をバリデーションとともにカプセル化した状態で利用者から供給者に対して 受け渡すことが可能ですが、構造化プログラミングでは値のアクセス範囲を制限しないため、値が常にバリデートされている状態 (値の不変条件) をなんらかの方法で担保する必要があります。

契約プログラミングを提唱した Bertrand の書籍 [Bertrand 1997] ではクラスベースの静的型付け言語を想定していますが、 例えば JS のようにイベント駆動で開発ができる言語であれば、プリミティブオブジェクトにレシーバーを付与してイベントを発火させる などでしょうか。この場合ランタイムでしかその妥当性を確認できないというデメリットがありますが、 言語に機能上も慣習上も値のアクセス範囲を制限する手段がないとき、公開ライブラリのAPIを変更したくないとき、 オブジェクト指向開発を強制させたくない場合などには有効です。

// Define a simple object that represents a book
let uncertainBook = {
  title: "Effective JavaScript",
  year: 2021,
  author: "David Herman"
};

// Define a handler object that represents invariants.
// This will be called when the proxied object mutates.
const handler = {
  get(target, property, receiver) {
    console.log(`Accessing property ${property}`);
    return Reflect.get(...arguments);
  },

  set(target, property, value) {
    // Example validation: Ensure 'year' is a valid number
    if (property === 'year' && (typeof value !== 'number' || value < 1900 || value > new Date().getFullYear())) {
      throw new Error('Year must be a valid number between 1900 and the current year.');
    }

    // Example validation: Ensure 'title' and 'author' are non-empty strings
    if ((property === 'title' || property === 'author') && (!value || typeof value !== 'string')) {
      throw new Error(`${property.charAt(0).toUpperCase() + property.slice(1)} must be a non-empty string.`);
    }

    console.log(`Setting property ${property} to ${value}`);
    return Reflect.set(...arguments);
  }
};

// Wrap the book object with a Proxy to enforce the above defined behavior
const eligibleBook = new Proxy(uncertainBook, handler);

// Test the Proxy behavior
try {
  eligibleBook.title = "JavaScript: The Good Parts"; // Should work
  eligibleBook.year = 2008; // Should work
  // eligibleBook.year = "2020"; // Should throw an error due to invalid type
  // eligibleBook.author = ""; // Should throw an error due to empty string
} catch (e) {
  console.error(e.message);
}

契約違反の後始末

ユーザーの入力が契約違反を引き起こした場合、一番簡単なものだと、 REST API サーバーであればロジカルエラーとして処理のコールスタックを伝播し、最終的に HTTP Response でエラーコードとして React や Swift で構築されたクライアントに通知されることになるでしょう。 なおこのケースはあくまでも説明のために引き合いに出しただけで、実際にはそのような契約違反はクライアントか プレゼンテーション層の責務として入力値がバリデーションされそもそも発生しないようにするべきです。

そのコンポーネントの直接の利用者、つまりあなたの同僚や同僚が書いたコードが契約違反を引き起こした場合は簡単です。 契約違反はコンパイル時に静的にエラーを発生させ、不正なコードが意図せず紛れ込むことは許しません。 動的型付け言語の場合は自動テストまたは手動テスト時にランタイムエラーが発生することで契約違反が発覚します。

このときあまりにも事前条件を複雑にしすぎると誰も利用できないコンポーネントとなり、 他の開発者が迂回経路を探し始めるため注意が必要です。

強い事前条件と弱い事前条件

強い事前条件とは利用者に厳格な前提条件を求めることです。典型的にはメソッドや関数を呼び出すための引数のオブジェクトの初期化が難しくなります。 反対に弱い事前条件とは、例えば単にプリミティブ型 String である事のみを求めるということです。

強い事前条件は供給者の内部の複雑性を低くして品質を向上させる一方で、 前述のように最悪の場合利用者にとってアクセス (呼出) 不能となり、実質的に機能しません。

弱い事前条件にした場合は本来は不正な値を渡された場合のフォールバック処理など、暗黙的な挙動が多くなりがちです。 動的型付け言語では型による制約を施すことができないため、すべての処理が最大限弱い事前条件を前提にせざる得ません。

弱い事前条件を求められる場合、本来あるべき値のバリデーションなどを一連の処理の先頭にまとめるのが一般的です。 このような弱い事前条件を基礎としたプログラミングを防御的プログラミングと呼びます。

以下は利用者に単に二つのオブジェクトを要求するという、非常に弱い事前条件の例です。

function registerNewSubscription(customer, subscription) {
  // guard clauses
  if (!customer?.customerId?.trim()) throw new Error("Customer ID cannot be blank.");
  if (!customer?.active) throw new Error("The customer must be active.");
  if (!customer?.name?.trim()) throw new Error("Customer name cannot be blank.");
  if (!subscription?.subscriptionType || !validSubscriptionTypes.includes(subscription.subscriptionType)) {
    throw new Error("Invalid subscription type.");
  }
  if (!(subscription?.durationMonths > 0)) throw new Error("The subscription duration must be at least 1 month.");
  if (!(subscription?.planPrice > 0)) throw new Error("The plan price must be a positive number.");

  // original obligation
  dbAccess.registerSubscription(
    customer.customerId,
    subscription.subscriptionType,
    subscription.durationMonths,
    subscription.planPrice
  );
}

議論していない考慮すべき事項