Swiftにおけるfinal修飾子とその強制

公開:2020/09/20
更新:2020/09/20
11 min読了の目安(約6800字TECH技術記事

はじめに

Swiftには他の言語と同じようにfinal修飾子があります.
この修飾子はLanguage Guideに次のような説明がされています.

You can prevent a method, property, or subscript from being overridden by marking it as final. Do this by writing the final modifier before the method, property, or subscript’s introducer keyword (such as final var, final func, final class func, and final subscript).

Any attempt to override a final method, property, or subscript in a subclass is reported as a compile-time error. Methods, properties, or subscripts that you add to a class in an extension can also be marked as final within the extension’s definition.

You can mark an entire class as final by writing the final modifier before the class keyword in its class definition (final class). Any attempt to subclass a final class is reported as a compile-time error.

https://docs.swift.org/swift-book/LanguageGuide/Inheritance.html

要約すると,この修飾子を用いることにより,プロパティやメソッドのオーバーライドおよびクラスの継承を禁止することができます.

継承を禁止する2つの利点

さて,このfinal修飾子を使う利点とはなんでしょうか.
私は大きく分けて次の2つが挙げられると考えています.

  1. パフォーマンスの向上
  2. 変更の影響範囲を限定する

パフォーマンスの向上

これは,Appleの公開しているブログにも言及されています.

The final keyword is a restriction on a class, method, or property that indicates that the declaration cannot be overridden. This allows the compiler to safely elide dynamic dispatch indirection. For instance, in the following point and velocity will be accessed directly through a load from the object’s stored property and updatePoint() will be called via a direct function call. On the other hand, update() will still be called via dynamic dispatch, allowing for subclasses to override update() with customized functionality.

https://developer.apple.com/swift/blog/?id=27

final修飾子を使用してあるクラスの継承を禁止すると,そのクラスのプロパティやメソッドをDynamic Dispatchを介さずに直接読み込めるようです.
その結果,パフォーマンスが向上すると述べられています.

より具体的に考えてみましょう,
例えば,次のように,AnimalクラスとCatクラスがあるとします.
Animalクラスは継承可能となっているため,animal.eat()にて実際に呼び出されるのはAnimalクラスかそのサブタイプ(Catクラス)のeat()となります.
よって,コンパイル時に実際に何が呼ばれるのかを判定する処理(*1)が必要になります.

class Animal {
  func eat() { 
    print("eat")
  }
}

class Cat: Animal {
  override func eat() { 
    super.eat()
    print("cat food")
  }
}

let animal: Animal = Cat()
animal.eat()

しかし,Animalクラスにfinal修飾子が付いていたらどうでしょうか.
その場合はanimal.eat()で呼び出されるのはAnimalクラスのeat()になることが約束されます.
よって,コンパイル時に直接Animalクラスの`eat()を直接アクセスすれば良くなるため,パフォーマンスが向上します.

変更の影響範囲を限定する

final修飾子を使用することは,そのクラスの変更による影響を最小限にすることに繋がります.
結果的に,意図せずバグを生んでしまったり,コードの可読性を向上させることに寄与します.

先述したCatとAnimalを例にとります.
CatはAnimalを継承し,そのメソッドをオーバーライドし,その中でAnimalのメソッドを呼び出しています.
そのため,Animalのメソッドに変更を加えた場合,Catのメソッドの振る舞いが変わります.
つまり,これはAnimalに変更を加える度に,そのサブタイプが破壊されていないか確認する必要があることを示しています.
こういった,不用意に受ける影響の確認と修正を要されるくらいなら,継承させる必要がないクラスにはfinal修飾子を付けた方が良いかもしれません.

final修飾子を強制する

先述したようなfinal修飾子による恩恵を最大限受けるために,継承されていないクラスにはfinal修飾子を付けましょう.
しかし,いくら意識したところで,プログラミング中のトライ&エラーの過程でfinal修飾子のことをすっかり忘れてしまうかもしれません.
もしくは,チームにそういった意識が根付いていないかもしれません.
どうにか,機械的に解決(final修飾子を強制)できないでしょうか?

この方法には次の2つがあると思います.

  • 静的解析ツールを使用して,継承していないクラスを指摘する.
  • 静的解析ツールやフォーマッターを活用して,継承されていないクラスに対して自動的にfinal修飾子を付ける

今回はDangerを用いて,前者の解決方法を試みました.

Dangerプラグインの作成

継承されていないクラスを検知し,PRのコメントにてそれを指摘するDangerプラグインを作成しました.
なお,このプラグインが検知できるのは次のケースです.

  • 既にあるrclassへ付けてあったfinalが誤って外された時
  • 新しく定義したタイプにfinalが必要な時
  • 消去したタイプの親タイプにfinalが必要な時

普段rubyを書き慣れていないため,読みにくいのはご容赦下さい🙏🙏
(また,何か例外があればご指摘頂けると嬉しいです)

module Danger
  class ForceSwift < Plugin
    Class = Struct.new(:parent_type, :self_type, :is_final?)
    LinePosition = Struct.new(:file_path, :line_number)

    def warn_if_needed(target_paths)
      all_classes = []
      modified_classes = []
      deleted_classes = []
      @position = {}

      diff_swift_files = (git.modified_files + git.added_files)
                         .filter { |file| File.extname(file) == '.swift' }
                         .map { |file| git.diff_for_file(file).patch }

      project_files = target_paths
                      .flat_map { |root| Dir[File.join("#{root}/**", '*.swift')] }

      diff_swift_files.each do |text|
        text.split("\n").each do |line|
          case line
          when /^\+/
            add_class_to_list(line, modified_classes)
          when /^\-/
            add_class_to_list(line, deleted_classes)
          end
        end
      end

      project_files.each do |path|
        line_number = 0
        File.open(path)  do |file|
          file.each_line do |line|
            line_number += 1
            is_success = add_class_to_list(line, all_classes)
            @position[all_classes.last.self_type] = LinePosition.new(path, line_number) if is_success
          end
        end
      end

      modified_classes
        .each { |a_class| warn_if_final_is_needed(a_class, all_classes) }
      deleted_classes
        .flat_map { |a_class| parents(a_class, all_classes) }
        .each { |a_class| warn_if_final_is_needed(a_class, all_classes) }
    end

    private

    def children(reference_class, all_classes)
      all_classes.filter { |a_class| a_class.parent_type.include?(reference_class.self_type) }
    end

    def parents(reference_class, all_classes)
      all_classes.filter { |a_class| reference_class.parent_type.include?(a_class.self_type) }
    end

    def warn_if_final_is_needed(a_class, all_classes)
      children = children(a_class, all_classes)
      is_leaf = children.empty?
      if is_leaf && !a_class.is_final?
        warn(
          "おそらく#{a_class.self_type}にはfinal修飾子が必要です.",
          file: @position[a_class.self_type].file_path,
          line: @position[a_class.self_type].line_number
        )
      else
        children.each { |node| warn_if_final_is_needed(node, all_classes) }
      end
    end

    def add_class_to_list(line, list)
      class_difinication_match = line.match(/class (.+) {/)
      return false unless !class_difinication_match.nil? && !class_difinication_match[1].nil?

      is_final = !line.match(/.*final.+class/).nil?
      hierarchy_match = class_difinication_match[1].match(/(.+): (.+)/)
      if !hierarchy_match.nil? && !hierarchy_match[2].nil?
        parents = hierarchy_match[2].split(',').map { |line| line.strip }
        list << Class.new(parents, hierarchy_match[1], is_final)
      else
        list << Class.new([], class_difinication_match[1], is_final)
      end
      true
    end
  end
end

このプラグインを使用すると次のように,Botが指摘してくれます.

まとめ

  • final修飾子を付けることには2つのメリットがある
  • 付けるのを忘れがちならDangerプラグインを使うなどの解決方法がある

注釈

(*1) たぶんこれがDynamic Dispatch

参考文献