0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

@Sendableなクロージャについて

Last updated at Posted at 2024-10-27

はじめに

swift concurrencyの対応をしている中で、以下のようなワーニングが出たので、その対応方針について検討していたら@Senbableなクロージャってなんだ?となったので調べてみました。

Main actor-isolated property 'completion' can not be referenced from a Sendable closure; this is an error in the Swift 6 language mode

まず上記のワーニングで怒られているのは、Sendableでないプロパティ'completion'がSendableなクロージャでキャプチャして参照してはいけないというものです。

コードの内容

具体的なコードとしては以下です。

@MainActor
class SingleSelectImagePickerWrapper: AssetPickable {

	// MARK: - private property

	private var completion: (Result<UIImage, Error>) -> Void = { _ in }
	private var isSelected = false

	// MARK: - public method

	func showPicker(_ target: UIViewController, selectionLimit: Int = 1,
	                assetType: AssetType) async throws -> UIImage {

		let picker =
			PHPickerViewController(configuration: createConfiguration(selectionLimit: selectionLimit,
			                                                          assetType: assetType))
		picker.delegate = self
		target.present(picker, animated: true)

		return try await withCheckedThrowingContinuation { [picker, weak self] continuation in

			guard let self else {

				print("Not found ImagePickerWrapper.")
				return
			}

			completion = { result in

				print("Finish Picker Image.")

				switch result {

				case let .success(image):
					continuation.resume(returning: image)

				case let .failure(error):
					continuation.resume(throwing: error)
				}

				DispatchQueue.main.async {

					// 結果を受けたらPickerは戻す
					picker.dismiss(animated: true)
				}
			}
		}
	}
}

// MARK: - PHPickerViewControllerDelegate extension

extension SingleSelectImagePickerWrapper: PHPickerViewControllerDelegate {

	func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {

		// すでに選択済みの場合は、選択イベントを無視する
		if isSelected { return }
		isSelected = true

		guard let itemProvider = results.first?.itemProvider else {

			completion(.failure(AssertPickError.notFoundItemError))
			return
		}

		guard itemProvider.canLoadObject(ofClass: UIImage.self) else {

			completion(.failure(AssertPickError.failLoadItem))
			return
		}

		itemProvider.loadObject(ofClass: UIImage.self) { [completion] image, error in

			if let error {

				completion(.failure(error)) // ワーニング出力箇所
				return
			}

			guard let image = image as? UIImage else {

				completion(.failure(AssertPickError.notFoundItemError)) // ワーニング出力箇所
				return
			}

			completion(.success(image)) // ワーニング出力箇所
		}
	}
}

上記コードは、アルバムから画像を選択できるピッカーを表示し、選択された画像をUIImageとして取得するということを行なっています。
その中のUIImageとして取得する部分にあたるloadObjectのクロージャでキャプチャしたcompletionを参照したところでワーニングが表示されます。

loadObjectの定義を見ると、クロージャの型定義に@Sendableの記載があります。

    @available(iOS 11.0, *)
    open func loadObject(ofClass aClass: any NSItemProviderReading.Type, completionHandler: @escaping @Sendable ((any NSItemProviderReading)?, (any Error)?) -> Void) -> Progress

@Sendableなクロージャについて考える

Sendableはプロトコルであり、classやstructなどに適合させてSendableな型を定義していましたが、クロージャの型をプロトコルに準拠させることはできないので、@Sendableという書き方でクロージャをSendableに適合させる方法を提供してくれているみたいです。

そもそもSendableとは?

公式のドキュメントでは以下のように説明されています。

データ競合のリスクを招かずに、任意の同時コンテキスト間で値を共有できるスレッドセーフな型。

ドキュメントにも記載のある通り、データ競合が起こらないことを保証する型というのがSendable型になります。

データ競合(data race)とは?

複数のスレッドから共有した可変の状態(classの変数など)に少なくとも1つ以上の書き込みが含まれるアクセスを同時に行われることを指します。
データ競合が起きるとさまざまな意図しない挙動が起きうることになります。
例えばアプリがクラッシュするなどです。

類似した概念に競合状態(data condition)がありますが別物です。
下記記事でどう違うのか大変わかりやすく説明されていました。

Sendableについて詳細に説明しようとすると、膨大な長さの記事になりそうなのでここでは、Sendableに適合した型ではデータ競合が発生しないということだけ抑えておけば良いと思います。
(Sendableについての詳細はまた別記事書きたいです)

上記を踏まえて@Sendableなクロージャがどういうものか見る

先ほどSendableな型ではデータ競合が起こらないと説明しました。
よって、@Sendableなクロージャでもデータ競合が起きないということが保証されています。
ではデータ競合が起きうるクロージャというのはどういうクロージャでしょうか?

例えば以下のようなcompletionクロージャはデータ競合が起きそうです。
クロージャ内でキャプチャしているcounterが可変状態を持っており、複数スレッドでcompletionが実行されると、同時にcounterが書き込まれる可能性があります。

class Counter {
  var value = 0
}

var counter = Counter()

var completion: () -> Void = {
  counter.value += 1
}

DispatchQueue.global.async {
  completion()
}

DispatchQueue.global.async {
  completion()
}

可変状態を持つクラスではなく定数のみのclassやstructなどSendableなインスタンスをキャプチャする分にはデータ競合は起きません。

ワーニングの対応方法

ここまでわかると最初に記載したワーニングの対応方法が見えてきました。
キャプチャしているcompletionプロパティがSendableつまりデータ競合を起こさないことを保証していれば、loadObjectのクロージャにある@Sendableの要件を満たせそうです。

修正したコードが以下になります。

@MainActor
class SingleSelectImagePickerWrapper: AssetPickable {

	// MARK: - private property

	private var completion: @Sendable (Result<UIImage, Error>) -> Void = { _ in } // Sendableに適合させる
	private var isSelected = false

	// MARK: - public method

	func showPicker(_ target: UIViewController, selectionLimit: Int = 1,
	                assetType: AssetType) async throws -> UIImage {

		let picker =
			PHPickerViewController(configuration: createConfiguration(selectionLimit: selectionLimit,
			                                                          assetType: assetType))
		picker.delegate = self
		target.present(picker, animated: true)

		return try await withCheckedThrowingContinuation { [picker, weak self] continuation in

			guard let self else {

				print("Not found ImagePickerWrapper.")
				return
			}

			completion = { result in

				print("Finish Picker Image.")

				switch result {

				case let .success(image):
					continuation.resume(returning: image)

				case let .failure(error):
					continuation.resume(throwing: error)
				}

				DispatchQueue.main.async {

					// 結果を受けたらPickerは戻す
					picker.dismiss(animated: true)
				}
			}
		}
	}
}

// MARK: - PHPickerViewControllerDelegate extension

extension SingleSelectImagePickerWrapper: PHPickerViewControllerDelegate {

	func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {

		// すでに選択済みの場合は、選択イベントを無視する
		if isSelected { return }
		isSelected = true

		guard let itemProvider = results.first?.itemProvider else {

			completion(.failure(AssertPickError.notFoundItemError))
			return
		}

		guard itemProvider.canLoadObject(ofClass: UIImage.self) else {

			completion(.failure(AssertPickError.failLoadItem))
			return
		}

		itemProvider.loadObject(ofClass: UIImage.self) { [completion] image, error in

			if let error {

				completion(.failure(error))
				return
			}

			guard let image = image as? UIImage else {

				completion(.failure(AssertPickError.notFoundItemError))
				return
			}

			completion(.success(image))
		}
	}
}

これで最初のワーニングは表示されなくなりました。

おわり

いかがだったでしょうか。
swift concurrencyのワーニング対応でswift concurrencyについての理解がかなり深まったように思います。
上記を調べる上で以下動画が大変助けになりましたので、参考資料として貼っておきます。

0
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?