Amazon製 Server-side Swift フレームワーク smoke-framework について
ライブラリなしでメディアアプリでよく見る無限スクロールするタブの動きを実装したUIサンプルの紹介
1. はじめに
皆様お疲れ様です。Swift AdventCalendarの2日目を担当させて頂きます、fumiyasac(Fumiya Sakai)と申します。何卒よろしくお願い致します。
結構前にも何度かメディアアプリで活用することができそうな動きを実現するためのサンプル実装をご紹介してきましたが、今回はUICollectionViewとUIPageViewControllerを利用してかつUIライブラリを使わない方針で、メディアアプリっぽい無限スクロール型のタブからカテゴリーを選択する動きとを自作してみたサンプルを作成しました。以前にも先人のエンジニアの方々が取り組んでいたことをブログ記事等でも拝見していたので、丁度良い機会でしたので改めて取り組んでみることにしました。
Githubでのサンプルコード:
※ 実際に公開しているリポジトリ内のコードでは、現在位置がわかるようにダミーのサンプルデータを表示するようにしています。
サンプルの全体的な動きの動画:
※ こちらのサンプルはPullRequestやIssue等はお気軽にどうぞ!
補足資料に関して:
今回の内容につきましては、ROPPONGI.swift 第6回 望年会にて登壇の際に利用した資料を下記のリンクにて共有しておりますので、こちらも是非ご活用頂けますと幸いです。
2. 今回の参考資料とサンプル概要について
★2-1. このサンプルを実装するにあたっての参考資料:
今回のサンプル実装にあたっては下記に紹介するTIPやの記事での実装を参考に取り入れました。
1.UICollectionViewにおいて画面上に見えているセルのインデックス値を取得する:
下記は現在画面上に表示されているUICollectionViewのセルを表示するためのTIPSは下記になります。今回のサンプルではスクロールが止まったタイミングでUICollectionViewで作成したタブ部分での位置補正とUIPageViewControllerでセットしているUIViewControllerにおいて該当するカテゴリーのインデックスに該当する記事一覧を表示しているUIViewControllerを表示するために利用しています。
var visibleIndexPathList: [IndexPath] = []
for cell in categoryScrollTabCollectionView.visibleCells {
if let visibleIndexPath = categoryScrollTabCollectionView.indexPath(for: cell) {
visibleIndexPathList.append(visibleIndexPath)
print("現在画面内に見えているセルのインデックス値:", visibleIndexPath)
}
}
2.UICollectionViewLayoutを利用するための参考リンク集:
UICollectionViewLayoutをカスタマイズすることによって、さらに複雑なUIレイアウトを実現するための参考にした記事は下記になります。
- UICollectionViewLayoutを利用するにあたってのはじめの一歩
- UICollectionViewのLayoutで悩んだら
- UICollectionViewFlowLayoutでセルの大きさやセル同士の間隔などを設定する
3.UICollectionViewやUIScrollViewを利用した無限スクロールの実装例:
メディア系のアプリでよくあるような無限スクロールをするUIを作る上で、その他参考になりそうな記事及び実装サンプルはこちらになります。
- UIPageViewControllerをつかって無限スクロールできるタブUIを実装してOSSとして公開しました
- 【Swift4】UICollectionViewを使ってカルーセルを実装してみた。【セルの装飾編】
- 無限スクロールするUICollectionView実装サンプル
- 無限スクロールするUIScrollView実装サンプル
★2-2. 今回のサンプルについて:
サンプルのキャプチャ画像:
環境やバージョンについて:
- Xcode10.1
- Swift4.2
- MacOS Mojave (Ver10.14)
3. 無限スクロールするタブによるカテゴリー選択部分を実装する部分に関する解説
ここからは無限スクロール型のタブをUIColletionViewの性質を利用して実装し、UIPageViewControllerの動きと連携する部分に関する部分に関して実装する上で押さえておくと良さそうなポイントを解説していきます。
★3-1: Storyboardの構成
Storyboardの構成に関しては下記のような形になります。おおもとの画面となるArticleViewController.swift
の上には2つのContainerViewがあり、
- 無限スクロールするUICollectionViewを配置している
CategoryScrollTabViewController.swift
を接続しているContainerView - カテゴリー別の記事一覧を表示する
CategoryScrollContentsViewController.swift
を表示するためのUIPageViewControllerを接続しているContainerView
という形となっています。
そしてCategoryScrollTabViewController.swift
では、UICollectionViewで表現しているタブ表示において、ユーザーが選択したカテゴリーに該当するカテゴリー別の記事一覧を表示するための処理を下記のようなProtocolを用意して橋渡しができるような形としておきます。
// カテゴリータブ操作時に実行されるプロトコル
protocol CategoryScrollTabDelegate: NSObjectProtocol {
// UIPageViewControllerで表示しているインデックスの画面へ遷移する
func moveToCategoryScrollContents(selectedCollectionViewIndex: Int, targetDirection: UIPageViewController.NavigationDirection, withAnimated: Bool)
}
そして、定義したProtocolと対応する実際の処理をArticleViewController.swift
に下記のような形で実装します。CategoryScrollTabViewController.swift
のUICollectionViewに配置しているセルが押下されたタイミングでCategoryScrollTabDelegate
に定義したメソッドを実行させることで、UIPageViewControllerの位置表示を変更するような形となっています。
class ArticleViewController: UIViewController {
// カテゴリーの一覧データ
private let categoryList: [String] = ArticleMock.getArticleCategories()
// 現在表示しているViewControllerのタグ番号
private var currentCategoryIndex: Int = 0
// ページングして表示させるViewControllerを保持する配列
private var targetViewControllerLists: [UIViewController] = []
// ContainerViewにEmbedしたUIPageViewControllerのインスタンスを保持する
private var pageViewController: UIPageViewController?
・・・(省略)・・・
}
// MARK: - CategoryScrollTabDelegate
extension ArticleViewController: CategoryScrollTabDelegate {
// タブ側のViewControllerで選択されたインデックス値とスクロール方向を元に表示する位置を調整する
func moveToCategoryScrollContents(selectedCollectionViewIndex: Int, targetDirection: UIPageViewController.NavigationDirection, withAnimated: Bool) {
// UIPageViewControllerに設定した画面の表示対象インデックス値を設定する
// MEMO: タブ表示のUICollectionViewCellのインデックス値をカテゴリーの個数で割った剰余
currentCategoryIndex = selectedCollectionViewIndex % categoryList.count
// 表示対象インデックス値に該当する画面を表示する
pageViewController!.setViewControllers([targetViewControllerLists[currentCategoryIndex]], direction: targetDirection, animated: withAnimated, completion: nil)
}
}
対応するデータに対応した画面表示のための実装と、無限スクロールするタブに関連するための実装を1つのViewControllerの中に押し込めてしまうと処理が煩雑になってしまいそうだったので、全体のUI構成要素の部品別に分割して処理の接続が必要な部分についてはProtocolを経由して処理の橋渡しができるようにしている点がポイントになるかと思います。
★3-2: 無限スクロールを伴うタブ型UI実装をするために必要なポイント解説①
次に無限スクロールを伴うタブ型UIを実装するにあたって必要な実装に関するポイントについて解説していきます。
今回のサンプルでは、UICollectionViewで作成しているタブについては現在位置に表示されているものが真ん中くるような形にしたかったので、下記のような形でUICollectionViewFlowLayoutクラスを継承したクラスを作成し、layoutAttributeプロパティを調節することで 「各々のセルがスクロールから止まる際に停止位置が中央に来るような調節」 を適用させています。
そして今回のサンプルで利用している無限スクロールを伴うタブ型UIに適用した、スクロールから止まる際に停止位置が中央に来るようにするUI実装をするためのCategoryScrollTabViewFlowLayout.swift
クラスのコードは下記ような形になります。
import UIKit
final class CategoryScrollTabViewFlowLayout: UICollectionViewFlowLayout {
// 参考1: 下記のリンクで紹介されていたTIPSを元に実装しました
// https://uruly.xyz/carousel-infinite-scroll-3/
// 参考2: UICollectionViewのlayoutAttributeの変更タイミングに関する記事
// https://qiita.com/kazuhiro4949/items/03bc3d17d3826aa197c0
// 参考3: UICollectionViewFlowLayoutのサブクラスを利用したスクロールの停止位置算出に関する記事
// https://dev.classmethod.jp/smartphone/iphone/collection-view-layout-cell-snap/
// 該当のセルのオフセット値を計算するための値(スクリーンの幅 - UICollectionViewに配置しているセルの幅)
private let horizontalTargetOffsetWidth: CGFloat = UIScreen.main.bounds.width - AppConstant.CATEGORY_CELL_WIDTH
// UICollectionViewをスクロールした後の停止位置を返すためのメソッド
// MEMO: UICollectionViewのLayoutAttributeを調整して、中央に表示されるように調整している
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
// 配置されているUICollectionViewを取得する
guard let conllectionView = self.collectionView else {
assertionFailure("UICollectionViewが配置されていません。")
return CGPoint.zero
}
// UICollectionViewのオフセット値を元に該当のセルの情報を取得する
var offsetAdjustment: CGFloat = CGFloat(MAXFLOAT)
let horizontalOffest: CGFloat = proposedContentOffset.x + horizontalTargetOffsetWidth / 2
let targetRect = CGRect(
x: proposedContentOffset.x,
y: 0,
width: conllectionView.bounds.size.width,
height: conllectionView.bounds.size.height
)
// 配置されているUICollectionViewのlayoutAttributesを元にして停止させたい位置を算出する
guard let layoutAttributes = super.layoutAttributesForElements(in: targetRect) else {
assertionFailure("配置したUICollectionViewにおいて該当セルにおけるlayoutAttributesを取得できません。")
return CGPoint.zero
}
for layoutAttribute in layoutAttributes {
let itemOffset = layoutAttribute.frame.origin.x
if abs(itemOffset - horizontalOffest) < abs(offsetAdjustment) {
offsetAdjustment = itemOffset - horizontalOffest
}
}
return CGPoint(
x: proposedContentOffset.x + offsetAdjustment,
y: proposedContentOffset.y
)
}
}
今回の実装ではUICollectionViewの位置を中央に寄せる処理だけのシンプルなものになりますが、UICollectionViewLayoutやUICollectionViewFlowLayoutを継承したクラスを活用することによって、UICollectionViewのレイアウトをカスタマイズすることによって、より多彩な表現を実現することができるので工夫次第では様々な動きを実現できます。
★3-3: 無限スクロールを伴うタブ型UI実装をするために必要なポイント解説②
そして無限スクロールを伴うタブ型UIを実装するにあたって、重要な部分となる 「UICollectionViewCellの配置個数やインデックス値と連動したUIScrollViewDelegateとの処理」 について紹介できればと思います。
下図のような形で実際のカテゴリーの数の4倍した個数のセルを配置しておき、UICollectionViewCellの選択されているインデックスの初期値は実際のカテゴリー数の2倍の数に相当するような形にしておきます。スクロールした際に発動するUIScrollViewDelegateのfunc scrollViewDidScroll(_ scrollView: UIScrollView)
を利用してスクロールをした際に指定したX軸方向のオフセット値のしきい値を超えた場合には位置を調節し、その際に見えているUICollectionViewCellのインデックス値が指定した範囲内に収まるような形にしています。
そして今回のサンプルで利用している無限スクロールを伴うタブ型UIを実現するための、無限スクロールを実行するためにUIScrollViewDelegateを利用した処理及びUICollectionViewに配置したセルのインデックス値を調整とするための処理をまとめたコードは下記のような形になります。
スクロールが停止した際に加えて、UICollectionViewに配置しているセルがタップされた場合やArticleViewController.swift
に配置したUIPageViewControllerを操作した際には、scrollToItem(at indexPath: IndexPath, at scrollPosition: UICollectionView.ScrollPosition, animated: Bool)
メソッドが実行されて現在位置が変更されるので、その際にも該当のインデックス値がしきい値を超えないようにする配慮が必要になる点に注意してください。
class CategoryScrollTabViewController: UIViewController {
// CategoryScrollTabDelegateプロトコル
weak var delegate: CategoryScrollTabDelegate?
// カテゴリーの一覧データ
private let categoryList: [String] = ArticleMock.getArticleCategories()
// ボタン押下時の軽微な振動を追加する
private let buttonFeedbackGenerator: UIImpactFeedbackGenerator = {
let generator: UIImpactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
return generator
}()
// MEMO: UICollectionViewの一番最初のセル表示位置に関する設定
// 参考: https://www.101010.fun/entry/swift-once-exec
private lazy var setInitialCategoryScrollTabPosition: (() -> ())? = {
// 押下した場所のインデックス値を持っておくために、実際のタブ個数の2倍の値を設定する
currentSelectIndex = self.categoryList.count * 2
//print("初期表示時の中央インデックス値:", currentSelectIndex)
// 変数(currentSelectIndex)を基準にして位置情報を更新する
updateCategoryScrollTabCollectionViewPosition(withAnimated: false)
return nil
}()
// 配置したセル幅の合計値
private var allTabViewTotalWidth: CGFloat = 0.0
// 現在選択中のインデックス値を格納する変数(このクラスに配置しているUICollectionViewのIndex番号)
private var currentSelectIndex = 0
@IBOutlet weak private var selectedCatogoryUnderlineWidth: NSLayoutConstraint!
@IBOutlet weak private var categoryScrollTabCollectionView: UICollectionView!
// MARK: - Computed Properties
// MEMO:
// ここでは無限スクロールができるように予め、(実際の個数 × 4)のセルを配置している
// またscrollViewDidScroll内の処理で所定の位置で調整をかけるので実際のUICollectionViewCellのインデックス値の範囲は下記のようになる
// Ex. タブを6個設定する場合 → 6 ... 19が取り得る範囲となる
// 表示するカテゴリーの個数を元にしたインデックスの最大値
// 例. カテゴリーが6個の場合は5となる
private var targetContentsMaxIndex: Int {
return categoryList.count - 1
}
// 実際に配置したUICollectionViewCellが取り得るインデックスの最大値
// 例. カテゴリーが6個の場合は19となる
private var targetCollectionViewCellMaxIndex: Int {
return categoryList.count * 4 - targetContentsMaxIndex
}
// 実際に配置したUICollectionViewCellが取り得るインデックスの最小値
// 例. カテゴリーが6個の場合は6となる
private var targetCollectionViewCellMinIndex: Int {
return categoryList.count
}
// MARK: - Override
override func viewDidLoad() {
super.viewDidLoad()
setupCategoryScrollTabCollectionView()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// MEMO: この部分は一番最初に起動した時だけ発火するようにする
setInitialCategoryScrollTabPosition?()
}
// MARK: - Function
// 親(ArticleViewController)のUIPageViewControllerのスクロール方向を元にUICollectionViewの位置を設定する
// MEMO: このメソッドはUIPageViewControllerを配置している親(ArticleViewController)から実行される
func moveToCategoryScrollTab(isIncrement: Bool = true) {
// UIPageViewControllerのスワイプ方向を元に、更新するインデックスの値を設定する
var targetIndex = isIncrement ? currentSelectIndex + 1 : currentSelectIndex - 1
// 取りうるべきインデックスの値が閾値(targetCollectionViewCellMaxIndex)を超えた場合は補正をする
if targetIndex > targetCollectionViewCellMaxIndex {
targetIndex = targetCollectionViewCellMaxIndex - targetContentsMaxIndex
currentSelectIndex = targetCollectionViewCellMaxIndex
}
// 取りうるべきインデックスの値が閾値(targetCollectionViewCellMinIndex)を下回った場合は補正をする
if targetIndex < targetCollectionViewCellMinIndex {
targetIndex = targetCollectionViewCellMinIndex + targetContentsMaxIndex
currentSelectIndex = targetCollectionViewCellMinIndex
}
// 押下した場所のインデックス値を持っておく
currentSelectIndex = targetIndex
//print("コンテンツ表示側のインデックスを元にした現在のインデックス値:", currentSelectIndex)
// 変数(currentSelectIndex)を基準にして位置情報を更新する
updateCategoryScrollTabCollectionViewPosition(withAnimated: true)
// 「コツッ」とした感じの端末フィードバックを発火する
buttonFeedbackGenerator.impactOccurred()
}
// MARK: - Private Function
// UICollectionViewに関する設定
private func setupCategoryScrollTabCollectionView() {
categoryScrollTabCollectionView.delegate = self
categoryScrollTabCollectionView.dataSource = self
categoryScrollTabCollectionView.registerCustomCell(CategoryScrollTabViewCell.self)
categoryScrollTabCollectionView.showsHorizontalScrollIndicator = false
// MEMO: タブ内のスクロール移動を許可する場合はtrueにし、許可しない場合はfalseとする
categoryScrollTabCollectionView.isScrollEnabled = true
}
// 選択もしくはスクロールが止まるであろう位置にあるセルのインデックス値を元にUICollectionViewの位置を更新する
private func updateCategoryScrollTabCollectionViewPosition(withAnimated: Bool = false) {
// インデックス値に相当するタブを真ん中に表示させる
let targetIndexPath = IndexPath(row: currentSelectIndex, section: 0)
categoryScrollTabCollectionView.scrollToItem(at: targetIndexPath, at: .centeredHorizontally, animated: withAnimated)
// UICollectionViewの下線の長さを設定する
let categoryListIndex = currentSelectIndex % categoryList.count
setUnderlineWidthFrom(categoryTitle: categoryList[categoryListIndex])
// 現在選択されている位置に色を付けるためにCollectionViewをリロードする
categoryScrollTabCollectionView.reloadData()
}
// スクロールするタブの下にある下線の幅を文字の長さに合わせて設定する
private func setUnderlineWidthFrom(categoryTitle: String) {
// 下線用のViewに付与したAutoLayoutの幅に関する制約値を更新する
let targetWidth = CategoryScrollTabViewCell.calculateCategoryUnderBarWidthBy(title: categoryTitle)
selectedCatogoryUnderlineWidth.constant = targetWidth
UIView.animate(withDuration: 0.36, animations: {
self.view.layoutIfNeeded()
})
}
// UIPageViewControllerを動かす方向を受け取ったインデックス値(indexPath.row)と現在のインデックス値(currentSelectIndex)を元に算出する
// MEMO: 親(ArticleViewController)のUIPageViewCotrollerの更新はCategoryScrollTabDelegateのメソッドを経由して実行する
private func getCategoryScrollContentsDirection(selectedIndex: Int) -> UIPageViewController.NavigationDirection {
// 下記の条件を満たす場合は例外的に進む方向とする
// 1. 引数で渡されたインデックス値:
// - selectedIndex が (targetCollectionViewCellMaxIndex - targetContentsMaxIndex) と等しい
// 2. 現在のインデックス値:
// - currentSelectIndex が targetCollectionViewCellMaxIndex と等しい
if selectedIndex == targetCollectionViewCellMaxIndex - targetContentsMaxIndex && currentSelectIndex == targetCollectionViewCellMaxIndex {
return UIPageViewController.NavigationDirection.forward
}
// 下記の条件を満たす場合は例外的に戻す方向とする
// 1. 引数で渡されたインデックス値:
// - selectedIndex が (targetCollectionViewCellMinIndex + targetContentsMaxIndex) と等しい
// 2. 現在のインデックス値:
// - currentSelectIndex が targetCollectionViewCellMinIndex と等しい
if selectedIndex == targetCollectionViewCellMinIndex + targetContentsMaxIndex && currentSelectIndex == targetCollectionViewCellMinIndex {
return UIPageViewController.NavigationDirection.reverse
}
// (現在のインデックス値 - 引数で渡されたインデックス値)を元に方向を算出する
if currentSelectIndex - selectedIndex > 0 {
return UIPageViewController.NavigationDirection.reverse
} else {
return UIPageViewController.NavigationDirection.forward
}
}
}
// MARK: - UICollectionViewDelegate
extension CategoryScrollTabViewController: UICollectionViewDelegate {}
// MARK: - UICollectionViewDataSource
extension CategoryScrollTabViewController: UICollectionViewDataSource {
// 配置するセルの個数を設定する
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
// MEMO: 無限スクロールの対象とする場合はタブ表示要素の4倍余分に要素を表示する
return categoryList.count * 4
}
// 配置するセルの表示内容を設定する
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCustomCell(with: CategoryScrollTabViewCell.self, indexPath: indexPath)
let targetIndex = indexPath.row % categoryList.count
let isSelectedTab = (indexPath.row % categoryList.count == currentSelectIndex % categoryList.count)
cell.setCategory(name: categoryList[targetIndex], isSelected: isSelectedTab)
return cell
}
// セル押下時の処理内容を記載する
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
// UIPageViewControllerを動かす方向を選択したインデックス値(indexPath.row)と現在のインデックス値(currentSelectIndex)を元に算出する
let targetDirection = getCategoryScrollContentsDirection(selectedIndex: indexPath.row)
// 押下した場所のインデックス値を現在のインデックス値を格納している変数(currentSelectIndex)にセットする
currentSelectIndex = indexPath.row
//print("タブ押下時の中央インデックス値:", currentSelectIndex)
// 変数(currentSelectIndex)を基準にして位置情報を更新する
updateCategoryScrollTabCollectionViewPosition(withAnimated: true)
// 算出した現在のインデックス値・動かす方向の値を元に、UIPageViewControllerで表示しているインデックスの画面へ遷移する
self.delegate?.moveToCategoryScrollContents(
selectedCollectionViewIndex: currentSelectIndex,
targetDirection: targetDirection,
withAnimated: true
)
// 「コツッ」とした感じの端末フィードバックを発火する
buttonFeedbackGenerator.impactOccurred()
}
}
// MARK: - UICollectionViewDelegateFlowLayout
extension CategoryScrollTabViewController: UICollectionViewDelegateFlowLayout {
// タブ用のセルにおける矩形サイズを設定する
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CategoryScrollTabViewCell.cellSize
}
}
// MARK: - UIScrollViewDelegate
extension CategoryScrollTabViewController: UIScrollViewDelegate {
// 配置したUICollectionViewをスクロールしている際に実行される処理
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// 表示したいセル要素のWidthを計算する
// MEMO: 実際の幅の値が欲しいのでUIScrollView内の幅を1/4したものになる
if allTabViewTotalWidth == 0.0 {
allTabViewTotalWidth = floor(scrollView.contentSize.width / 4.0)
}
// スクロールした位置が閾値を超えたら中央に戻す
if (scrollView.contentOffset.x <= allTabViewTotalWidth) || (scrollView.contentOffset.x > allTabViewTotalWidth * 3.0) {
scrollView.contentOffset.x = allTabViewTotalWidth * 2.0
}
}
// 配置したUICollectionViewをスクロールが止まった際に実行される処理
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// スクロールが停止した際に見えているセルのインデックス値を格納して、真ん中にあるものを取得する
// 参考: https://stackoverflow.com/questions/18649920/uicollectionview-current-visible-cell-index
var visibleIndexPathList: [IndexPath] = []
for cell in categoryScrollTabCollectionView.visibleCells {
if let visibleIndexPath = categoryScrollTabCollectionView.indexPath(for: cell) {
visibleIndexPathList.append(visibleIndexPath)
//print("現在画面内に見えているセルのインデックス値:", visibleIndexPath)
}
}
let targetIndexPath = visibleIndexPathList[1]
// ※この部分は厳密には不要ではあるがdelegeteで引き渡す必要があるので設定している
let targetDirection = getCategoryScrollContentsDirection(selectedIndex: targetIndexPath.row)
// 押下した場所のインデックス値を現在のインデックス値を格納している変数(currentSelectIndex)にセットする
currentSelectIndex = targetIndexPath.row
//print("スクロールが慣性で停止した時の中央インデックス値:", currentSelectIndex)
// 変数(currentSelectIndex)を基準にして位置情報を更新する
updateCategoryScrollTabCollectionViewPosition(withAnimated: true)
// 算出した現在のインデックス値・動かす方向の値を元に、UIPageViewControllerで表示しているインデックスの画面へ遷移する
self.delegate?.moveToCategoryScrollContents(
selectedCollectionViewIndex: currentSelectIndex,
targetDirection: targetDirection,
withAnimated: false
)
// 「コツッ」とした感じの端末フィードバックを発火する
buttonFeedbackGenerator.impactOccurred()
}
}
タブの動きを表現するためのCategoryScrollTabViewController.swift
に関しては後述するUIPageViewControllerDelegate
及びUIPageViewControllerDataSource
と連動する必要があるので全体的なコードは多くなってしまいますが、全体のコードの中でも特に内部で利用しているUICollectionViewCell
のインデックス値・配置位置等に関する調整に関する実装ポイントを下記にまとめました。
(実装ポイント1)インデックス値を調整するための実装:
(実装ポイント2)配置したUICollectionViewのoffset値を調整するための実装:
(実装ポイント3)UICollectionViewCellのインデックス値の変更の前後状態を元にUIPageViewControllerの動き方を決定するための実装:
(実装ポイント4)配置したUICollectionViewのスクロールが停止した際の表示位置を調整するための実装:
★3-4: メイン部分に配置したUIPageViewControllerと連携するために必要な実装
最後に記事一覧表示をするためのUIPageViewControllerを配置しているArticleViewController.swift
に関する部分をまとめておこうと思います。基本的にはカテゴリーに紐づくデータを表示するための画面(CategoryScrollContentsViewController.swift
)のインスタンスの一覧をまずはスワイプで無限スクロールできるような状態にしておき、ページが動いたタイミング(この場合はスワイプアニメーションに該当)に発動する処理func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool)
でにおいて完了したタイミングで、前述した無限スクロールするタブUIの位置変更を実行するupdateCategoryScrollTabPosition(isIncrement: true)
を実行するような形にしています。
これらの点を元に処理をまとめたコードは下記のような形になります。
class ArticleViewController: UIViewController {
// カテゴリーの一覧データ
private let categoryList: [String] = ArticleMock.getArticleCategories()
// 現在表示しているViewControllerのタグ番号
private var currentCategoryIndex: Int = 0
// ページングして表示させるViewControllerを保持する配列
private var targetViewControllerLists: [UIViewController] = []
// ContainerViewにEmbedしたUIPageViewControllerのインスタンスを保持する
private var pageViewController: UIPageViewController?
// MARK: - Override
override func viewDidLoad() {
super.viewDidLoad()
// MEMO: InterfaceBuilderでNavigationBarの背景色を#ff6060 / Trunslucentをfalseとする
setupNavigationBarTitle("サンプル記事一覧")
removeBackButtonText()
setupPageViewController()
}
// Segueに設定したIdentifierから接続されたViewControllerを取得する
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
switch segue.identifier {
// ContainerViewで接続されたViewController側に定義したプロトコルを適用する
case "CategoryScrollTabViewContainer":
let vc = segue.destination as! CategoryScrollTabViewController
vc.delegate = self
default:
break
}
}
// MARK: - Private Function
private func setupPageViewController() {
// UIPageViewControllerで表示させるViewControllerの一覧を配列へ格納する
let _ = categoryList.enumerated().map{ (index, categoryName) in
let sb = UIStoryboard(name: "Article", bundle: nil)
let vc = sb.instantiateViewController(withIdentifier: "CategoryScrollContents") as! CategoryScrollContentsViewController
vc.view.tag = index
vc.setDescription(text: categoryName)
vc.setArticlesByCategoryId(articles: ArticleMock.getArticlesBy(categoryId: index))
targetViewControllerLists.append(vc)
}
// ContainerViewにEmbedしたUIPageViewControllerを取得する
for childVC in children {
if let targetVC = childVC as? UIPageViewController {
pageViewController = targetVC
}
}
// UIPageViewControllerDelegate & UIPageViewControllerDataSourceの宣言
pageViewController!.delegate = self
pageViewController!.dataSource = self
// 最初に表示する画面として配列の先頭のViewControllerを設定する
pageViewController!.setViewControllers([targetViewControllerLists[0]], direction: .forward, animated: false, completion: nil)
}
// 配置されているタブ表示のUICollectionViewの位置を更新する
// MEMO: ContainerViewで配置しているViewControllerの親子関係を利用する
private func updateCategoryScrollTabPosition(isIncrement: Bool) {
for childVC in children {
if let targetVC = childVC as? CategoryScrollTabViewController {
targetVC.moveToCategoryScrollTab(isIncrement: isIncrement)
}
}
}
}
// MARK: - UIPageViewControllerDelegate
extension ArticleViewController: UIPageViewControllerDelegate {
// ページが動いたタイミング(この場合はスワイプアニメーションに該当)に発動する処理を記載するメソッド
// (実装例)http://c-geru.com/as_blind_side/2014/09/uipageviewcontroller.html
// (実装例に関する解説)http://chaoruko-tech.hatenablog.com/entry/2014/05/15/103811
// (公式ドキュメント)https://developer.apple.com/reference/uikit/uipageviewcontrollerdelegate
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
// スワイプアニメーションが完了していない時には処理をさせなくする
if !completed { return }
// ここから先はUIPageViewControllerのスワイプアニメーション完了時に発動する
if let targetViewControllers = pageViewController.viewControllers {
if let targetViewController = targetViewControllers.last {
// Case1: UIPageViewControllerで表示する画面のインデックス値が左スワイプで 0 → 最大インデックス値
if targetViewController.view.tag - currentCategoryIndex == -categoryList.count + 1 {
updateCategoryScrollTabPosition(isIncrement: true)
// Case2: UIPageViewControllerで表示する画面のインデックス値が右スワイプで 最大インデックス値 → 0
} else if targetViewController.view.tag - currentCategoryIndex == categoryList.count - 1 {
updateCategoryScrollTabPosition(isIncrement: false)
// Case3: UIPageViewControllerで表示する画面のインデックス値が +1
} else if targetViewController.view.tag - currentCategoryIndex > 0 {
updateCategoryScrollTabPosition(isIncrement: true)
// Case4: UIPageViewControllerで表示する画面のインデックス値が -1
} else if targetViewController.view.tag - currentCategoryIndex < 0 {
updateCategoryScrollTabPosition(isIncrement: false)
}
// 受け取ったインデックス値を元にコンテンツ表示を更新する
currentCategoryIndex = targetViewController.view.tag
}
}
}
}
// MARK: - UIPageViewControllerDataSource
extension ArticleViewController: UIPageViewControllerDataSource {
// 逆方向にページ送りした時に呼ばれるメソッド
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
// インデックスを取得する
guard let index = targetViewControllerLists.index(of: viewController) else {
return nil
}
// インデックスの値に応じてコンテンツを動かす
if index <= 0 {
return targetViewControllerLists.last
} else {
return targetViewControllerLists[index - 1]
}
}
// 順方向にページ送りした時に呼ばれるメソッド
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
// インデックスを取得する
guard let index = targetViewControllerLists.index(of: viewController) else {
return nil
}
// インデックスの値に応じてコンテンツを動かす
if index >= targetViewControllerLists.count - 1 {
return targetViewControllerLists.first
} else {
return targetViewControllerLists[index + 1]
}
}
}
// MARK: - CategoryScrollTabDelegate
extension ArticleViewController: CategoryScrollTabDelegate {
// タブ側のViewControllerで選択されたインデックス値とスクロール方向を元に表示する位置を調整する
func moveToCategoryScrollContents(selectedCollectionViewIndex: Int, targetDirection: UIPageViewController.NavigationDirection, withAnimated: Bool) {
// UIPageViewControllerに設定した画面の表示対象インデックス値を設定する
// MEMO: タブ表示のUICollectionViewCellのインデックス値をカテゴリーの個数で割った剰余
currentCategoryIndex = selectedCollectionViewIndex % categoryList.count
// 表示対象インデックス値に該当する画面を表示する
pageViewController!.setViewControllers([targetViewControllerLists[currentCategoryIndex]], direction: targetDirection, animated: withAnimated, completion: nil)
}
}
タブ型のUICollectionView
を実装しているCategoryScrollTabViewController.swift
とコンテンツを表示するためのUIPageViewController
があるArticleViewController.swift
におけるお互いの関係性と処理のキーポイントとなりそうな部分についてまとめたものが下図になります。
該当箇所の全体的なポイントをまとめた概略図:
4. その他今回の実装の中で取り入れた細かなTIPSに関する解説
このような形のUI実装については昨今のアプリにおいてはよく見かける形のものかと思いますが、より細かな部分を見ていくと様々な工夫やデザインが施されているものも数多くあります。今回はスクロールが停止したことをよりユーザーが検知しやすくなるような小さな工夫を加えてみましたので、その実装部分における簡単な解説になります。
★4-1: スクロール停止時に現在位置を示す下線部分が表示文字と同じ長さになるアニメーションを加える
無限スクロールするタブ型UI部分にはUICollectionViewの他にも、現在選択されているセルの文字の長さに応じて下線部分の長さが変化するようなアニメーションを加えています。まずは下図のような形でInterfaceBuilderに下線となるUIViewを配置した後にAutoLayoutの幅の制約をOutlet接続をしておきます。
次に下記のような形のコードで引数で渡された文字列とフォントから配置するラベルの幅を取得できるようにしておきます。
final class CategoryScrollTabViewCell: UICollectionViewCell {
・・・(省略)・・・
// MARK: - Class Function
// カテゴリー表示用の下線の幅を算出する
class func calculateCategoryUnderBarWidthBy(title: String) -> CGFloat {
// テキストの属性を設定する
var categoryTitleAttributes = [NSAttributedString.Key : Any]()
categoryTitleAttributes[NSAttributedString.Key.font] = UIFont(
name: AppConstant.CATEGORY_FONT_NAME,
size: AppConstant.CATEGORY_FONT_SIZE
)
// 引数で渡された文字列とフォントから配置するラベルの幅を取得する
let categoryTitleLabelSize = CGSize(
width: .greatestFiniteMagnitude,
height: AppConstant.CATEGORY_FONT_HEIGHT
)
let categoryTitleLabelRect = title.boundingRect(
with: categoryTitleLabelSize,
options: .usesLineFragmentOrigin,
attributes: categoryTitleAttributes,
context: nil)
return ceil(categoryTitleLabelRect.width)
}
・・・(省略)・・・
}
そして前述のラベルの幅を取得する処理とアニメーション処理を組み合わせた下記のようなメソッドを然るべきタイミングで実行するようにしています。
// selectedCatogoryUnderlineWidth: 下線表示となるUIViewに付与している幅の制約
// スクロールするタブの下にある下線の幅を文字の長さに合わせて設定する
private func setUnderlineWidthFrom(categoryTitle: String) {
// 下線用のViewに付与したAutoLayoutの幅に関する制約値を更新する
let targetWidth = CategoryScrollTabViewCell.calculateCategoryUnderBarWidthBy(title: categoryTitle)
selectedCatogoryUnderlineWidth.constant = targetWidth
UIView.animate(withDuration: 0.36, animations: {
self.view.layoutIfNeeded()
})
}
実行するタイミング:
- UIPageViewControllerのスワイプ移動が完了したタイミング
- 無限スクロールするタブ型UIのスクロールが停止したタイミング
- 無限スクロールするタブ型UIのセルをタップしたタイミング
※ 具体的な処理部分についてはCategoryScrollTabViewController.swift
の処理をご参考下さい。
★4-2: スクロール停止時に止まった事をユーザーに伝える「コツッ」となる端末フィードバックを加える
ユーザーによる移動処理が完了したことを視覚と合わせて、端末が微妙に震える(Haptic Feedback)の処理を触覚でも伝えるようにするために下記のようなコードを然るべきタイミングで実行するようにしています。
// ボタン押下時の軽微な振動を追加する
private let buttonFeedbackGenerator: UIImpactFeedbackGenerator = {
let generator: UIImpactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
return generator
}()
// impactOccurred()メソッドを実行することで「コツッ」とした感じの端末フィードバックを発火する
buttonFeedbackGenerator.impactOccurred()
実行するタイミング:
- UIPageViewControllerのスワイプ移動が完了したタイミング
- 無限スクロールするタブ型UIのスクロールが停止したタイミング
- 無限スクロールするタブ型UIのセルをタップしたタイミング
※ 具体的な処理部分についてはCategoryScrollTabViewController.swift
の処理をご参考下さい。
参考:
5. あとがき
今回のトピックに関しては特に目新しいものではないかもしれませんが、改めて自分でも実装してみる事で、実装を実現するために必要な技術や知識はもとよりカスタマイズする上での勘所を自分の中でも掴むことができたのは、とても良い体験になったと思います。また今回の実装については参考資料で書かれている記事における実装と異なる部分がありますが、実装方法については決して1通りとは限らないと思いますので今回の実装以外でより効率的または汎用性のある実装があるよ!という方はご教授頂けますと幸いに思いますm(_ _)m
見た目にも目を惹くようなワンポイントを含めたアニメーションやインタラクションを伴うUI実装の部分は、しばしば求められるものではないかもしれないが、実装の手段や道筋を知っておけば「アプリUIをさらにより良いものにする」ためのヒントやアイデアとして取り入れる事ができますし、同様の機能であっても差が生まれる部分にもなり得る場所だと思うので、同じトピックであっても探求は忘れないでいきたい所存です。
SwiftのGenericsとProtocolの実装
SwiftのGenericsとProtocolの実装について簡潔に解説する。
Apple Swift version 4.2.1 (swiftlang-1000.11.42 clang-1000.11.45.1)
Target: x86_64-apple-darwin18.2.0
ジェネリックな型の実行時表現
下記のtake
関数のX
型のように、ジェネリックな型があるとする。
func take<X>(_ x: X) { ... }
Swiftは静的な型システムを持っているが、ジェネリクスにおいてはどのような型が渡されても動作できなければならないので、コンパイラは型について抽象化されたコードを生成する。この抽象化はコンパイルステージにおいてLLVM-IRを生成する段階で実現されるため、LLVM-IRを観察する事で確認できる。
ジェネリクスを実現する生成コードの観察
上記のtake
関数であれば、下記のようなLLVM-IR関数が生成される。
define hidden swiftcc void @"$S1a4takeyyxlF"(
%swift.opaque* noalias nocapture,
%swift.type* %X) #0
X
型の引数x
は、%swift.opaque*
という型になっている。これはopaque pointerと言って、要するに参照先の型が不明なポインタの事である。加えて、%swift.type*
型の引数%X
が渡されている。%swift.type
はMetatypeという型を現す型の事だ。この関数が使用されるときには、引数はどんな型であってもそのポインタに変換される。そして、コンパイル時に解決された型パラメータの型のMetatypeが渡される。
Metatypeの説明
MetatypeはSwift言語においても直接取り扱う事ができるオブジェクトで、下記のように型名やtype(of:)
関数によって取得できる。
let intType: Int.Type = Int.self
let a: Int = 1
let aType: Any.Type = type(of: a)
また、staticメソッドなどはMetatypeに対するメソッドとして扱われている。
print(Int.bitWidth) // 64
シグネチャと生成コードの対応
ジェネリックな型を持つ引数がopaque pointerにコンパイルされることと、ジェネリックな型についてのMetatypeが渡されることは独立している。例えば、ジェネリックパラメータの数は変わらないまま、同じジェネリック型を持つ引数が増えた場合、opaque pointerは引数の数だけ渡されるが、Metatypeの数は変化しない。以下に例を示す。
func take<X>(_ x1: X, _ x2: X)
define hidden swiftcc void @"$S1a4takeyyx_xtlF"(
%swift.opaque* noalias nocapture,
%swift.opaque* noalias nocapture,
%swift.type* %X) #0
また、引数の表現を生成する処理と、ジェネリックシグネチャについての引数を追加する処理は独立している。例えば、ジェネリックではない引数が追加された場合、その引数を生成した後、ジェネリックパラメータについてのMetatypeの引数が末尾に生成される。以下に例を示す。
func take<X>(_ x1: X, _ x2: Int)
define hidden swiftcc void @"$S1a4takeyyx_SitlF"(
%swift.opaque* noalias nocapture,
i64,
%swift.type* %X) #0
ジェネリックな型に対する操作
ジェネリックな型の値に対しては、それの実行時の真の型に関わらず、コピーや破棄を行う事ができる。下記のコードでは、x
をa
にコピーする処理や、関数脱出時にa
を破棄する処理が行われる。
func take<X>(_ x: X) {
let a = x
}
このような、ジェネリックな型に対する処理は、真の型によってするべき処理が異なる。参照型の場合は、参照先のオブジェクトの参照カウンタを操作せねばならない。値型の場合は、そのサイズが型により異なるし、保持しているプロパティに対しても処理をしなければならない。例えば、値型がプロパティとして参照型を持っている場合、その値型がコピーされるときには、その参照型の参照先のオブジェクトの参照カウンタを増加させねばならない。
Value Witness Table
こうした処理内容は型によって異なるため、その処理を行う関数がMetatypeから取り出せるようになっている。具体的には、そうした操作をまとめたValue Witness Tableというテーブルがあり、Metatypeからその型を操作するためのValue Witness Tableが取り出せるようになっている。
プロトコルの実行時表現
Swiftではある型の特性を宣言するための言語機能としてprotocolがある。基本的な用途として、メソッドを所持している事を現す事ができる。以下に例を示す。
protocol P {
func proc1()
func proc2()
}
これをジェネリクスと合わせて使う事により、ジェネリックな型に対して、その型があるプロトコルに準拠する事を制約できる。
func take<X>(_ x: X) where X : P
プロトコル制約を実現する生成コードの観察
上記の関数をコンパイルして生成されるLLVM-IR関数は下記のようになる。
define hidden swiftcc void @"$S1a4takeyyxAA1PRzlF"(
%swift.opaque* noalias nocapture,
%swift.type* %X,
i8** %X.P) #0
ただのジェネリックパラメータだった場合と比べて、i8**
型の引数%X.P
が追加されている。これはX
型についての実行時の情報を表現するために追加された引数である。
シグネチャと生成コードの対応
準拠するプロトコルが増えたり、ジェネリックパラメータが増えた場合の例を示す。
func take<X, Y>(_ x: X, _ y: Y) where
X : P,
X : Q,
Y : P
このように、XはPに加えてQにも準拠させ、さらにPだけに準拠するYを追加したとする。すると、LLVM-IR関数は下記のようになる。
define hidden swiftcc void @"$S1a4takeyyx_q_tAA1PRzAA1QRzAaCR_r0_lF"(
%swift.opaque* noalias nocapture,
%swift.opaque* noalias nocapture,
%swift.type* %X,
%swift.type* %Y,
i8** %X.P,
i8** %X.Q,
i8** %Y.P) #0
まず、引数2つに対応するopaque pointerが2つ生成され、次に、XとYの実行時の型のMetatypeが生成され、最後に、それらのプロトコル準拠に対応する値が生成される。
Protocol Witness Table
ジェネリックパラメータがプロトコルに準拠する事に対応して渡されている%X.P
などの引数は、Protocol Witness Tableを示すポインタである。Protocol Witness Tableとは、あるジェネリック型の値が、そのプロトコルとして振る舞うときに必要な操作をまとめた関数テーブルである。
更に詳しく
より詳細に解説した際の発表資料
SwiftのGenericsとProtocolの実装
https://speakerdeck.com/omochi/swiftfalsegenericstoprotocolfalseshi-zhuang
AppleのSwift開発者が解説している動画
2017 LLVM Developers’ Meeting: “Implementing Swift Generics ”
https://www.youtube.com/watch?v=ctS8FzqcRug
SwiftのOptionalを理解する
iOSアプリケーション開発を主な業務としているが、チームの都合でObjective-Cを選択している。そんなSwiftに不慣れな自分にとって厄介なのはOptional。色んな場面で様々な形式で出てくるため混乱する。そこで自分自身がOptionalを習得するため、自分が見つけられたOptional関連のコードを飼料化してみた。
無
プログラミングが生まれた頃から、様々な方法で無を表現することが試みられている。
- Lispでは、無を表すものとしてnilを用意。
- C言語では、空ポインタとしてNULLマクロを定義。
- Objective-Cでは、空idとしてnilが用意され、画期的なのはnilにメッセージを送信しても無視されるだけでエラーにならない!
- SwiftのnilはObjective-Cとの互換。Cocoaフレームワークを利用するためか。
Lispは実装方法によるが内部ではnilを値と要素の二通りがあるようだ。
それと比較して、C言語は質実剛健。簡素で実用的だ。
var a : Int = 1
var b : Int? = 2
a = nil // エラー
b = nil // OK
b = Int(“abcd”) // nil
var c : Optional = 3 // パラメータ付き型指定
SwiftでOptionalといえばInt?と型に?がついた宣言ということになるが、厳密にはパラメータ付き型指定の糖衣構文とうことになる。Optional変数にはnilを代入することが出来るが、Optional出ない型とは異なる型ということになる。
開示(unwrap)
Optional変数に!をつけると、Optionalでない変数に変えられる。Optional変数がnilだったら実行時にエラーとなる。
C言語のポインターに近い挙動ということか。
var a : Int? = 1234
var b : Int = a - 2 // 型が異なるのでコンパイル・エラー
var b : Int = a! - 2 // 開示指定する
a = nil
b = a! - 2 // 実行時エラー
a = 5678
if a != nil {
print(“\(a!)”) // 開示指定が必要
}
print(String(describing: a)) // Debug目的で
オプショナル束縛構文 optional binding
C言語のNULLチェックをして利用するというパターン化されたコードをスマートにしたのが、オプショナル束縛構文 か?
var num : Int? = 1234
if let n = num {
print(“\(n)”)
}
if var n = Int(“1234”) {
n += 5678
print(“\(n)”)
}
if let n = Int(“1234”), let m = Int(“5678”) {
print(“\(n + m)”)
}
var a : Int? = 1
while let n = a {
a = nil
}
guard文
if分によってインデントが深くなることを避けるため、例えば、関数の先頭でNULLチェックをして、NULLだったら直ぐにreturnするというパターン化されたコードがあるが、これのために用意されたのが、guard文。オプショナル束縛構文の糖衣構文ということのようだ。
guard 条件 else { /* breakやreturn */ }
func demo(_ num:Int?) {
guard let n = num else { return }
print(“\(n)”) // 変数nが使える
}
nil合体演算子
三項演算子で値がnilなら指定した値を、nilでない場合はその値を返すというパターン化されたコードが必要になると思うが、これについても糖衣構文が用意されている。
let n : Int? = 1234
let m = (n != nil) ? n! : 0
let m = n ?? 0
let a : Int? = nil
let b : Int? = nil
let c : Int? = 3
let = a ?? b ?? c ?? 0 // cの値
inout引数
Swiftの関数は、C言語と同様に値渡しだが、C++の参照渡しに相当するのがinout引数。
ただ、実引数に&をつけることから、C言語のポインターの値渡しをポインターであることを隠蔽した構文ということかなと思う。
func demo(_ p: inout Int?) {
p = nil
}
var n: Int? = 1234
demo(&n)
print(n ?? “nil”)
func test(_ num: inout Int) {
num = 0
}
n = 5678
test(&n!) // nがnilだと実行時エラー
print(n ?? “nil”)
実引数が計算型プロパティだった場合は、関数内での変更はコピーに対して行われる。
有値オプショナル型 (IUO)
有値オプショナル型 (implicitly unwapped optional) は、オプショナル型だが、値が格納されていることが分かっている場合のための構文。
おそらく、Objective-C / Cocoa との互換性のためのもので、例えば、InterfaceBuilderのOutletなどで利用されいるようだ。
let n : Int! = 1234
print(“\(n)”) // 開示指定は不要
var m : Int! = nil
m += 5678 // 実行時エラー
print(“\(m)”)
失敗のあるイニシャライザ
自分の調査が足りなかったら申し訳ないで、Swiftが登場した当初、Optional型とはNSObjectを継承したクラスだったと思うが、言語的には曖昧だと思う。このOptional型の定義を厳密にするために用意されたのが、失敗のあるイニシャライザ ということか?
struct Demo {
var a = 0
init?(_ n:Int) {
if n < 0 {
return nil
}
a = n
}
init() {
a = 1234
}
}
var p: Demo = Demo()
var q: Demo? = Demo(5678)
キャスト演算子
Swiftの言語仕様書のOptionalの章に含まれるものではないようだが、Optionalの話で大事な構文がキャスト演算子だ。列挙してみる。
- 式 is T
- 型/プロトコルTなら真
- 式 as T
- 型/プロトコルTにキャスト
- 式 as? T
- 型/プロトコルTのオプショナルにキャスト
失敗した場合はnil - 式 as! T
- 型/プロトコルTにキャスト
失敗した場合は実行時エラー
オプショナルチェーン optional chaining
オプショナル束縛構文は、続けて記述できる。
// 辿っている途中でnilがあれば、
// そこで止まり全体でnilとなる。
if let name = who?.club?.teacher?.name {
print(name)
}
【関連情報】
Cocoa Advent Calendar 2018
Cocoa.swift 2019-01
Cocoa.swift
Cocoa勉強会 関東
Cocoa練習帳
Qiita
Better Swift
Better Swift
Swift Advent Calendar 2018 の 5 日目です。
折角の機会なので普段自分がよりよい Swift を書くためにやっていることを振り返って、まとめてみようと思います。
無意識にやっていることも多いと思うので、言語化できたら追記していきます。
※ iOS に依存した内容も少し含まれてますが、ご了承ください。
※ サンプルコードは Swift 4.2 (Xcode 10.1) です。
SwiftLint を入れる
https://github.com/realm/SwiftLint
Swift の linter です。
特にチームで開発する場合はコードレビューのコストを下げることができるので、できれば入れた方が良いです。
CI 環境が整っている場合は fastlane や Danger を組み合わせて CI 上で swiftlint を動かし、プルリク時に lint 系の指摘を自動でやってくれるような環境を整えると素敵です。
自分がプライベートの開発でも利用している .swiftlint.yml を置いておきます。参考にしたい方はどうぞ。
変更したら影響箇所がコンパイルエラーになるような実装を意識する
自分が Swift を好きな理由の一つが変更した部分に影響する箇所がコンパイルエラーになることです。
実行前にエラーになることで変更漏れなどのバグを大幅に減らすことができ、機能変更やリファクタリングなどが捗ります。
ただ、あまりにも自由に Swift を書いているとその恩恵を得られない実装になってしまうことがあり、非常にもったいないです。
思いつく全てのケースをあげているときりがないですが、いくつか代表的なものを紹介します。
Dictionary を避ける
Dictionary は非常に便利ですが、使う前にそれを専用の型にすることができないかを一度考えましょう。
Dictionary を使いたいケースはキーバリューを表現したい時だと思います。そのキーが (数が多すぎないレベルで) 有限であれば、struct
で実装した方が良いことが多いです。
String を避ける
ID を表す時などにそのまま String
を使いがちですが、これも struct
でその ID を表す型を作って利用した方が良いです。
struct UserID {
let rawValue: String
}
このようにすることで、どの場所でこの ID を表す文字列を利用しているかがわかりやすくなります。
また、色々な ID を取り扱っている場合、メソッドの引数で ID をたらい回しに受け渡す時に ID を使い間違えてしまうことも減らせます。(ID の型が異なるのでビルドエラーになります)
Optional を避ける
Swift の便利な機能の一つですが、使い方を誤ってしまうと可読性が落ちてしまいます。
例えば下記のような Optional です。
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let viewController = storyboard
.instantiateViewController(withIdentifier: "TopViewController") as? TopViewController
これは本当に nil
を許容すべきでしょうか?
また、少し Swift になれてくると guard let
などで安全にアンラップしようとしてしまうかもしれませんが、それも本当に必要でしょうか?
Storyboard から TopViewController
を取り出すこの実装は nil
にならないことを保証すべきです。nil になる場合は ID や型が間違っているということなので、アプリのリリース前に必ず直す必要があります。
// force cast を利用する
let viewController = storyboard
.instantiateViewController(withIdentifier: "TopViewController") as! TopViewController
もちろん、例えば自由入力欄のテキスト (String
) を Int
に変換したい場合は nil チェックをすべきなので、この部分は実装者 (or レビュワー) が適切な判断をする必要があります。
Optional は基本的には使う前にアンラップ処理を書かなければならずロジックを読む妨げになったり、実装を読んでいる人がいつ nil
になるんだろうと考えなければならなくなることもあります。
不要な Optional は避けたり、できるだけ早めにアンラップをしてアンラップ後の値を取り回すようにしましょう。
Enum
case の網羅
Swift の enum はかなり優れていますよね。switch 文で case を網羅していると、case が増えたときにその部分がビルドエラーになるのは素敵な機能の一つです。
この機能は if case
で分岐したり、switch 文で default:
を書いてしまうと機能しなくなってしまいます。これは非常にもったいないので case が増えた際に修正が必要になるかもしれない部分はできるだけ switch 文で case を網羅するように書いたほうが良いです。
enum ABTests {
case a
case b
case c
}
let experiment: ABTests = fetchExperiment()
switch experiment {
case .a: doSomething()
case .b, .c: break // 不要でもcaseを記載する
}
このようにすることで case d が増えたときに変更漏れを防ぐことができます。
case があまりにも多い時や case が増えても変更することがない時以外は case を網羅しておくほうが良いと思います。
また、何らかの条件に基づいて case を取得する実装の場合、switch 文で網羅できませんが、あえて何もしない switch 文を作って、強制的にビルドエラーにする方法も場合によっては便利です。
switch value {
case 1...10: return .case1
case 11...20: return .case2
default: return .case3
}
#if DEBUG // 念の為、本番ビルドには含めないようにする
switch value {
case .case1, .case2, .case3: break //MEMO: caseが増えたら↑を編集する
}
#endif
namespace
Swift で namespace を作りたいときは後述する Embedded Framework がおすすめですが、そこまで規模が大きくない場合は enum で namespace を作ると良いです。case のない enum はインスタンス化が出来ないため、予期せぬ使い方を防止できます。
enum Module {
static let value = "value"
struct Model {
...
}
}
struct と class の使い分け
Apple が良いドキュメントを提供しています。
https://developer.apple.com/documentation/swift/choosing_between_structures_and_classes
- Use structures by default.
- 基本的には struct を使う
- Use classes when you need Objective-C interoperability.
- Objective-C との互換性が必要な場合は class を使う
- Use classes when you need to control the identity of the data you're modeling.
- データの同一性を制御する必要があるときは class を使う
- 例) シングルトンや共通の設定を表現するときは class にする
- Use structures along with protocols to adopt behavior by sharing implementations.
- protocol で共通の実装をする場合は struct を使う
- → 共通実装を protocol で実現できる場合は struct にする。出来ない場合または継承が適している場合は class を使う
early return
if 文の入れ子はできるだけ避けて、guard 文などで early return するように意識したほうが良いです。
// if 文の Pyramids of doom
if condition1 {
// do something
if condition2 {
// do something 2
if condition3 {
// do something 3
}
}
}
↓
guard condition1 else { return }
// do something
guard condition2 else { return }
// do something 2
guard condition3 else { return }
// do something 3
early return を使う際に defer
が活かせるケースも多いです。適切に利用しましょう。
defer {
// 最後に実行したい処理
}
guard condition1 else { return }
// do something
guard condition2 else { return }
// do something 2
API のレスポンスは専用の型にする
API から返ってくる値を Dictionary などで取り扱うことは避け、専用の型にすべきです。型にすることで誤ったキーで値を取り出してしまうミスや利用時に値の型を意識する必要性が減ります。
また型にする際に、Codable
を利用すると良いです。JSONのデコードやレスポンスをキャッシュする際の取り回しが非常に楽になります。
struct Article: Codable {
let items: [Item]
struct Item: Codable {
let title: String
let content: String
let author: String
let publishDate: Date
}
}
.lazy
.flatMap
や .compactMap
などを使いこなせるようになったら、次は .lazy
をつけたほうが良いかを意識するようにすると良いです。
let selectedButton = array
.compactMap { $0 as? UIButton } // array.count の回数、flatMapの中身が実行されてしまう
.first { $0.isSelected }
↓
let selectedButton = array.lazy
.compactMap { $0 as? UIButton } // isSelected == true が見つかった段階で処理を切り上げてくれる
.first { $0.isSelected }
メソッド自体を .map に渡す
クロージャを引数に取る .map
などのメソッドにはメソッド自体を渡すことが出来ます。直接メソッドを渡すほうがスッキリとした味わいのコードになります。
func configure(_ view: ProductView) { ... }
let views: [ProductView] = ...
views.forEach(configure) // .forEach { configure($0) }
let intArray = ["1", "2", "🐱"].compactMap(Int.init) // Int のイニシャライザを compactMap に渡す
型の入れ子に extension を使う
深い Nested Type を作ると読みにくくなりがちです。その場合は extension を利用して入れ子にすることでできるだけフラットな記述をすることが出来ます。
struct Article {
let items: [Item]
}
extension Article {
struct Item {
let title: String
let category: Category
...
}
}
extension Article.Item {
enum Category {
case technology
...
}
}
Storyboard や xib から生成した View の初期化
Storyboard や xib から生成した View の場合はイニシャライザで必要なオブジェクトを渡すことが出来ません。そのため、生成後に外側から直接プロパティに値を入れてしまいたくなりますが、初期化方法がわかりにくくなるため、初期化用のメソッドを用意して生成時に外側からプロパティをあまり操作しないようにしましょう。
class Cell: UITableViewCell {
...
func configure(with value: Dependency) {
titleLabel.text = value.title
nameLabel.text = value.name
}
}
class ProfileView: UIView {
// static メソッドを作るとわかりやすい (できれば、protocol 化して I/F を揃えるようにしたい)
static func instantiate(with value: Dependency) -> ProfileView {
let view = Bundle.main.loadNibNamed("ProfileView", owner: nil, options: nil)[0] as! ProfileView
view.nameLabel = value.name
view.iconView.image = value.icon
...
return view
}
}
ドットで補完できるようにする
switch 文の case のように、型が推論できる場合は型名を省略してドットから書き始めることが出来ます。これを利用すると非常にコードが美しくなります。
extension Notification.Name {
static let didChangedTabBar = Notification.Name("didChangedTabBar")
}
NotificationCenter.default
.addObserver(self,
selector: #selector(didChangedTabBar),
name: .didChangedTabBar, // ドットで書き始められる (引数が要求している型が Notification.Name のため)
object: nil)
public extension UserDefaults {
public struct Key {
public let rawValue: String
}
}
public extension UserDefaults {
public func integer(for key: Key) -> Int {
return integer(forKey: key.rawValue)
}
public func set(_ value: Int, for key: Key) {
set(value, forKey: key.rawValue)
}
}
extension UserDefaults.Key {
static let viewCounter = UserDefaults.Key(rawValue: "viewCounter")
}
// より Swifty な UserDefaults に
let count = UserDefaults.standard.integer(for: .viewCounter)
UserDefaults.standard.set(count + 1, for: .viewCounter)
クロージャの即時実行
JavaScript ではおなじみですが、Swift でもクロージャの即時実行ができます。条件によって代入する値を変えたいときなどに利用すると便利です。
let value = {
switch condition {
case "dog": return 🐶
case "cat": return 🐱
default: return 😀
}
}()
Generics の型を型推論できるようにする
Generics の型パラメータの型を引数に取るメソッドを作る際に、デフォルト引数を設定すると戻り値の型で型推論させることもできるようになり、可読性が良くなります。
func value<T>(_ type: T.Type = T.self, forKey: String) -> T { ... }
// 型を記述して利用する
let intValue = value(Int.self, forKey: "age")
// 型推論を利用する
let user = User(name: "Taro", age: value(forKey: "age"))
Embedded Framework
コンポーネントごとに機能をまとめたり、レイヤードアーキテクチャを採用したい場合は Embedded Framework を導入すると実装が強制 (矯正) されて、良いです。
詳しくはこちらのリンクがとても参考になります。
Embedded Framework使いこなし術
Swift Extension
こちらに投稿した Extension 集がおすすめです。
使うと手放せなくなるSwift Extension集
ただ、Swift のExtension は便利ですが、闇雲に増やさないほうが良いです。グローバルなメソッドよりは型に制限がある分まだましですが、利用箇所が限定的であれば、よりスコープを狭めることをおすすめします。
スコープを狭める方法としては private extension と protocol がおすすめです。
// そのファイル内でしか利用しない extension には private をつける
private extension String {
func parseAsTime() -> Time? {
guard self.count == 4,
let hour = Int(self[0...1]),
let minute = Int(self[2...3]) else { return nil }
return .init(hour: hour, minute: minute)
}
}
// protocol を利用すると extension の影響範囲が明確になり、可読性が上がり、間違った利用を減らせる。
// 空の protocol を作る
protocol NibDesignable: AnyObject {} // @IBDesignable の View のみに適用したい機能
// その protocol を拡張して実装する
extension NibDesignable where Self: UIView {
private func loadNib() -> UIView {
let bundle = Bundle(for: type(of: self))
let nib = UINib(nibName: className, bundle: bundle)
return nib.instantiate(withOwner: self).first as! UIView
}
func setUpNib() {
let view = self.loadNib()
addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
leftAnchor.constraint(equalTo: view.leftAnchor),
rightAnchor.constraint(equalTo: view.rightAnchor),
topAnchor.constraint(equalTo: view.topAnchor),
bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}
protocol に stored property を持たせる
本当に必要なときしか利用しないほうが良いです。可読性が著しく落ちる場合があります。
protocol を利用して機能を実装しているときに値を専用のプロパティに保存したいケースがあります。その場合は、Objective-C の機能を利用して実装することが可能です。
protocol ViewCountable: AnyObject {}
private struct AssociatedKeys {
static var viewCounterKey: Void?
}
extension ViewCountable {
func viewCountUp() {
viewCounter += 1
}
private(set) var viewCounter: Int {
get {
return objc_getAssociatedObject(self, &AssociatedKeys.viewCounterKey) as? Int ?? 0
}
set {
objc_setAssociatedObject(self,
&AssociatedKeys.viewCounterKey,
newValue,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
}
class ViewController: UIViewController, ViewCountable {
func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.viewCountUp() // プロパティを実装していないが状態を更新できる
}
}
Kingfisher などの OSS のコードを参考にしてみると良いと思います。 objc_setAssociatedObject
で検索してみましょう。
ログの計測など、本質的でないロジックを隠したいときに限定的に利用するのがおすすめです。
コンパイラに優しいコードを書く事も必要
Swift のコンパイラは複雑な型推論が必要になるとビルドに時間がかかったり、そもそもビルドできなくなったりする時があります。慣れると「これはコンパイラがかわいそうだな」と思うようになり、適切に型を明示できるようになります(?)。慣れるまではどの実装のビルドに時間がかかっているのかをたまに確認するようにすると良いです。
ビルド時間の計測にはこちらのツールがオススメです。
https://github.com/RobertGummesson/BuildTimeAnalyzer-for-Xcode
小さなアプリであればビルド時間の影響は少ないですが、規模が大きくなってくるとかなり辛い問題になります。
こちらのツールで見つかったビルド時間の長い実装に型を明示したり、式を分割するなどしてコンパイラに優しい Swift のコードにしていきましょう。
コード生成
Swift はコード生成関係の言語機能が弱いです。そのため、どうしても冗長なコードを書く必要が出てきてしまいますが、サードパーティのツールを利用するとある程度コード生成を実現できます。
リソース関係の定数の自動生成は下記がオススメです。
自分で自動生成する内容をカスタマイズしたい場合は Sourcery がオススメです。
アナリティクス用のログ送信に必要な定数を生成したり、ユニットテスト用のモッククラスを自動生成したりなど、人類がやるべきでないことをツールに任せることが出来ます。
まとめ
変更箇所に影響がある部分をビルドエラーにできる Swift の良さを活かすためには実装者も Swift に歩み寄る必要があります。
今回紹介したような Tips を利用して、運用中にバグが生まれにくく、新規メンバーもコミットしやすくなるような状態を作っていきたいですね。
以上、Swift Advent Calendar 2018 の 5 日目でした。
RxSwiftとReSwiftで実装するMVVM+Reduxアーキテクチャ
現在開発中の個人アプリにRxSwiftとReSwiftを使ったMVVM+Reduxアーキテクチャを採用しています。
「MVVM+Reduxアーキテクチャ」といっても、特に新しいアーキテクチャを考えたわけではなく、MVVMとReduxを組み合わせてアプリを実装しているということです。
MVVMとReduxは解決しようとしている課題が異なるため、併用することが可能です。
本記事では、なぜMVVM+Reduxアーキテクチャを採用したのか、どのようにMVVM+Reduxアーキテクチャを実装しているのかについてご紹介します。
なぜMVVM+Reduxアーキテクチャを採用したのか
MVVMで開発して感じていた課題
会社では数人の開発者でiOSアプリの開発を行っており、アーキテクチャにはMVVMを採用しています。
規模としては小さいアプリなのですが、MVVMで1年ほど開発を行ってきて、ずっと感じていた課題があります。
それは、MVVMには複数画面間での状態の共有方法、状態変化の通知方法についてのルールがないことです。
いくつか具体的な例で考えてみましょう。
「商品一覧」を表示するタブと、「お気に入り商品一覧」を表示するタブがあるとします。
このとき、「商品一覧」画面でお気に入りに追加した商品を、「お気に入り商品一覧」画面に即時反映させるにはどうしたらよいでしょうか?「商品一覧」画面から「商品詳細」画面への遷移を実装することを考えてみます。
「商品詳細」画面を表示するためには、商品のIDや名前、画像のURLといったパラメータが必要です。
このとき、これらのパラメータは「商品詳細」画面のViewControllerに渡すべきでしょうか?ViewModelに渡すべきでしょうか?商品検索機能の実装について考えてみます。
「商品一覧」画面から「検索条件選択」画面に遷移し、検索条件を選択したら「商品一覧」画面に戻って商品を検索するとします。
このとき、選択した検索条件をどのようにして「商品一覧」画面に渡せばよいでしょうか?
いずれの課題にも解決方法はあります。
このケースでは、2つの画面が共通で参照するクラスを作成して、「商品一覧」画面で商品をお気に入りに追加したことを「お気に入り商品一覧」画面に通知させれば良いでしょう。
NotificationCenterを使ってもいいかもしれません。このケースでは、画面遷移の実装方法にも依りますがViewControllerに渡すのが一般的でしょう。
商品IDも状態の一部であり、ViewControllerがそれをプロパティとして保持しているのは理想的ではないと考えるのであれば、ViewModelに渡すのでもいいと思います。このケースでは、Delegateパターンがよく使われていると思います。
また、「商品一覧」画面用のViewModelを「検索条件選択」画面でも共有して、選択された検索条件をそのViewModelにセットするという方法もあります。
しかしながら、MVVMアーキテクチャではこうした複数の画面をまたいだ状態の扱い方に関しては定義がされていません1。
開発者それぞれが個々の画面を開発しているときは、Viewの状態はViewModelに持たせる、ViewとViewModelはデータバインディングで状態を双方向に通知するといった、一般的に知られるMVVMのルールをもとに開発していればよかったのですが、複数画面をまたいだ機能の開発となると参照できるルールがないため、実装方法は開発者それぞれに依存してしまうという状態が起きてしまいます。
実装方法を決めれば良いじゃないかと言われたらそれまでなのですが、iOSアプリ開発経験の少ないチームでスタートしたためプロジェクト開始当初からこうした事態を想定することができませんでした。
複数画面間での状態の共有方法、状態変化の通知方法についてもルールがほしい!ということで注目したのがReduxアーキテクチャです。
MVVMにReduxを加えることで課題を解決
状態の扱い方が開発者に依存してしまうということは、将来別の開発者がそのコードに変更を加える際に、どこで状態の受け渡しや変更が起きているかを把握しきれず、思わぬバグを生んでしまう可能性があるということです。
Reduxは状態の扱い方に関してルールを設け、アプリケーションの状態がどのように変化し、やり取りされるのかを予測可能にすることを目的としたアーキテクチャであり、まさに私が抱えていた課題を解決してくれるものだと思いました。
Reduxやその思想のもととなっているFluxについては、私は以下の記事や書籍で勉強をしました。
本記事ではReduxやFluxそのものの解説はしないので、詳しくはそちらをご参照ください。
MVVMとReduxはどちらもアプリケーションアーキテクチャパターンに分類されるものですが、PDS(Presentation-Domain-Separation)をその主目的とするMVVMと、アプリケーション全体の状態の扱い方に注目しているReduxとでは、解決しようとしている課題が異なるため併用が可能だと思っています。
本記事の後半では、私がMVVM+Reduxアーキテクチャをどのように実装しているかについてご紹介します。
Reduxを採用することによって状態の扱い方が統一され、読みやすく変更しやすいコードになることを感じていただければと思います。
RxSwiftとReSwiftを用いたMVVM+Reduxアーキテクチャの実装例
現在絶賛開発中の個人アプリは読書メモを取るためのアプリで、RxSwiftとReSwiftを使ってMVVM+Reduxアーキテクチャを実装しています。
ここからはそのアプリのコード(一部説明用に省略したり簡易化しています)を例に、MVVM+Reduxアーキテクチャの実装方法についてご紹介していきます。
なお、RxSwiftとReSwiftについてはすでに理解している前提で説明していきます。
ReSwiftを使ったReduxの実装部分は、さきほど挙げた以下の記事と書籍を参考にしています。
一覧画面から詳細画面への遷移
最初の例として、一覧画面から詳細画面へ遷移するコードを紹介します。
一つの本に対する読書メモをPost(メモを投稿するイメージなので)と表現しています。
State
Stateは基本的に画面単位で分割し、structをネストして構成しています。
トップレベルのStateであるAppState
は、「読書メモ一覧」画面のStateであるPostListState
を保持しています。
struct AppState: StateType {
var postListState = PostListState()
}
PostListState
は、画面に表示する読書メモデータ(posts
)を保持しています。
また、下層のStateとして「読書メモ詳細」画面のStateであるPostState
を保持しています。
// MARK: State
struct PostListState: StateType {
var posts = [Post]()
var postState = PostState()
}
// MARK: Action
extension PostListState {
enum Action: ReSwift.Action {
case updatePosts(posts: [Post])
}
}
// MARK: Reducer
extension PostListState {
static func reducer(action: ReSwift.Action, state: PostListState?) -> PostListState {
var state = state ?? PostListState()
if let action = action as? Action {
switch action {
case let .updatePosts(posts):
state.posts = posts
}
}
state.postState = PostState.reducer(action: action, state: state.postState)
return state
}
}
ViewModel
続いてViewModelです。
ViewModelはViewControllerからの入力イベントを受け取り、何らかの処理をしたあとその結果をStateに反映させる役割を担います。
「読書メモ一覧」画面用のViewModelであるPostListViewModel
は、以下の2つのことを行っています:
(1)一覧画面の表示イベント(viewWillAppear
)を受け取り、APIから読書メモデータを取得し、それをupdatePosts
Actionを通じて一覧画面のState(PostListState
)に反映
(2) 一覧画面上での行選択イベント(itemSelected
)を受け取り、その行に対応する読書メモデータをupdatePost
Actionを通じて「読書メモ詳細」画面のState(PostState
)に反映
ViewModelはまた、Stateの更新通知を受け取るStoreSubscriber役も担っています(3)。
ViewControllerをStoreSubscriberにすることもできますが、受け取ったStateをもとに何らかのロジックを適用したり、ビューでの表示用に加工したりといった処理はViewModelの責務なのでこのようにしています。
更新通知を受け取ったら、受け取った値をViewControllerに流します。
UIにバインドするためDriverやSignalとして公開するようにしています(4)。
class PostListViewModel {
// MARK: Injected properties
private let store: Store<AppState>
private let api: API
// MARK: Input streams
let viewWillAppear = PublishRelay<Void>()
let viewWillDisappear = PublishRelay<Void>()
let itemSelected = PublishRelay<Int>()
// MARK: Output streams
private let postsStream = BehaviorRelay<[Post]>(value: [])
private let errorsStream = PublishRelay<Error>()
// MARK: Private properties
private let disposeBag = DisposeBag()
init(store: Store<AppState>, api: API) {
self.store = store
self.api = api
viewWillAppear
.subscribe(onNext: { [unowned self] in
self.store.subscribe(self) { subcription in
subcription.select { state in state.postListState }
}
})
.disposed(by: disposeBag)
// (1)
viewWillAppear
.subscribe(onNext: { [unowned self] in
self.fetchPosts()
})
.disposed(by: disposeBag)
viewWillDisappear
.subscribe(onNext: { [unowned self] in
self.store.unsubscribe(self)
})
.disposed(by: disposeBag)
// (2)
itemSelected
.withLatestFrom(postsStream) { (index, posts) in posts[index] }
.subscribe(onNext: { [unowned self] post in
self.store.dispatch(PostState.Action.updatePost(post: post))
})
.disposed(by: disposeBag)
}
private func fetchPosts() {
api.fetchPosts()
.do(onError: { [unowned self] in self.errorsStream.accept($0) })
.subscribe(onNext: { [unowned self] in
self.store.dispatch(PostListState.Action.updatePosts(posts: $0))
})
.disposed(by: disposeBag)
}
}
// MARK: StoreSubscriber
// (3)
extension PostListViewModel: StoreSubscriber {
typealias StoreSubscriberStateType = PostListState
func newState(state: PostListState) {
postsStream.accept(state.posts)
}
}
// MARK: Output
// (4)
extension PostListViewModel {
var posts: Driver<[Post]> {
return postsStream.asDriver()
}
var errors: Signal<Error> {
return errorsStream.asSignal()
}
}
ViewController
最後にViewControllerを見ていきます。
ViewControllerの役割は非常にシンプルで、ViewModelから受け取った値を使ってビューの描画を行うことと、イベントをViewModelに入力することです。
ViewControllerには極力ロジックを持たせず、ViewModelとのデータバインディングの設定のみを行うようにしています。
class PostListViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
// MARK: Injected properties
var viewModel: PostListViewModelProtocol!
// MARK: Private properties
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
bind()
}
func bind() {
rx.viewWillAppear
.bind(to: viewModel.viewWillAppear)
.disposed(by: disposeBag)
rx.viewWillDisappear
.bind(to: viewModel.viewWillDisappear)
.disposed(by: disposeBag)
tableView.rx.itemSelected
.map { $0.row }
.bind(to: viewModel.itemSelected)
.disposed(by: disposeBag)
tableView.rx.itemSelected
.subscribe(onNext: { [unowned self] in
self.tableView.deselectRow(at: $0, animated: false)
// DIコンテナからPostViewControllerインスタンスを取得
let vc = self.resolve(PostViewController.self)!
self.present(vc, animated: true)
})
.disposed(by: disposeBag)
viewModel.posts
.drive(tableView.rx.items(
cellIdentifier: R.nib.postListCell.name,
cellType: PostListCell.self)
) { (_, element, cell) in
cell.configure(post: element)
}
.disposed(by: disposeBag)
}
}
画面遷移時の状態の受け渡しについて
一通りコードを見てきましたが、改めて一覧画面から詳細画面へ遷移する部分に着目して説明します。
画面遷移のトリガーとなるのは、一覧画面での行の選択イベントです。
選択された行の番号をViewModelに入力します。
tableView.rx.itemSelected
.map { $0.row }
.bind(to: viewModel.itemSelected)
.disposed(by: disposeBag)
ViewModelは行選択イベントを受け取ると、updatePost
ActionをDispatchして、「読書メモ詳細」画面用のStateであるPostState
を更新します。
itemSelected
.withLatestFrom(postsStream) { (index, posts) in posts[index] }
.subscribe(onNext: { [unowned self] post in
self.store.dispatch(PostState.Action.updatePost(post: post))
})
.disposed(by: disposeBag)
ViewControllerでは行選択イベント発火時に詳細画面を表示するという設定も行っていました。
ここで注目すべきは、詳細画面のViewControllerやViewModelに対して何もパラメータを渡していないことです。
tableView.rx.itemSelected
.subscribe(onNext: { [unowned self] in
self.tableView.deselectRow(at: $0, animated: false)
// DIコンテナからPostViewControllerインスタンスを取得
let vc = self.resolve(PostViewController.self)!
self.present(vc, animated: true)
})
.disposed(by: disposeBag)
本記事の前半で、詳細画面への遷移時にパラメータをどのように渡すべきかのルールがないという課題を挙げましたが、Reduxの導入によってこれが解決されたのです。
つまり、画面遷移時に次の画面のViewControllerやViewModelにパラメータを渡す必要がなくなったということです。
なぜなら、遷移前に詳細画面のStateにデータを渡しておくことによって、詳細画面のViewControllerやViewModelはデータをStoreから受け取ることができるからです。
データの受け渡しは必ずReduxのフローを経由して行うというルールがあることで、開発者ごとに実装がばらつくことがなくなり、やり取りされているデータの把握が容易になるので、将来コードに変更を加える際に思わぬバグを仕込んでしまう可能性を減らすことができます。
フィルタ条件選択画面で選択したフィルタを一覧画面に適用する
今度は少し複雑な例を取り上げます。
本アプリには、読書をしているときに疑問に感じたことをメモしておく機能があります。
さらに、その疑問にはカテゴリーやスターをつけることができ、それらでフィルタをすることができます。
フィルタを選択して適用する流れは以下のとおりです。
- 一覧画面でフィルタボタンをタップし、フィルタ選択画面を表示する
- フィルタ種別選択画面で種別(ここでは「スター」)を選択する
- フィルタ(ここでは「スター付き」)を選択する
- ×ボタンをタップしてフィルタを適用する
今回は3つの画面が登場します。
さらに、現在選択されているフィルタ、フィルタの一覧、選択されたフィルタなど、扱うStateもやや複雑です。
MVVM+Reduxアーキテクチャでどのように実装できるでしょうか?
State
「疑問一覧」画面のStateであるQuestionListState
は、疑問データ一覧(questions
)を保持しています。
また、下層のStateとして「フィルタ種別選択」画面のStateであるQuestionFilterListState
を保持しています。
// MARK: State
struct QuestionListState {
var questions = [Question]()
var questionFilterListState = QuestionFilterListState()
}
// MARK: Action
extension QuestionListState {
enum Action: ReSwift.Action {
case updateQuestions([Question])
}
}
// MARK: Reducer
extension QuestionListState {
static func reducer(action: ReSwift.Action, state: QuestionListState?) -> QuestionListState {
var state = state ?? QuestionListState()
if let action = action as? Action {
switch action {
case let .updateQuestions(questions):
state.questions = questions
}
}
state.questionFilterListState = QuestionFilterListState.reducer(action: action, state: state.questionFilterListState)
return state
}
}
「フィルタ種別選択」画面のStateであるQuestionFilterListState
は、現在選択されているフィルタ(categoryFilterId
とisStarredFilterId
)を保持しています。
また、下層のStateとして「フィルタ選択」画面のStateであるSelectQuestionFilterState
を保持しています。
// MARK: State
struct QuestionFilterListState {
var categoryFilterId = QuestionFilter.defaultCategoryId
var isStarredFilterId = QuestionFilter.defaultIsStarredId
var selectQuestionFilterState = SelectQuestionFilterState()
}
// MARK: Action
extension QuestionFilterListState {
enum Action: ReSwift.Action {
case selectCategoryFilter(Int)
case selectIsStarredFilter(Int)
}
}
// MARK: Reducer
extension QuestionFilterListState {
static func reducer(action: ReSwift.Action, state: QuestionFilterListState?) -> QuestionFilterListState {
var state = state ?? QuestionFilterListState()
if let action = action as? Action {
switch action {
case let .selectCategoryFilter(id):
state.categoryFilterId = id
case let .selectIsStarredFilter(id):
state.isStarredFilterId = id
}
}
state.selectQuestionFilterState = SelectQuestionFilterState.reducer(action: action, state: state.selectQuestionFilterState)
return state
}
}
「フィルタ選択」画面のStateであるSelectQuestionFilterState
は、フィルタ種別(filterType
)、フィルタ一覧(filters
)、現在選択されているフィルタ(selected
)を保持しています。
// MARK: State
struct SelectQuestionFilterState {
var filterType: FilterType?
var filters = [String]()
var selected: Int?
enum FilterType {
case category
case star
}
}
// MARK: Action
extension SelectQuestionFilterState {
enum Action: ReSwift.Action {
case selectFilterType(type: FilterType, filters: [String], selected: Int?)
}
}
// MARK: Reducer
extension SelectQuestionFilterState {
static func reducer(action: ReSwift.Action, state: SelectQuestionFilterState?) -> SelectQuestionFilterState {
var state = state ?? SelectQuestionFilterState()
if let action = action as? Action {
switch action {
case let .selectFilterType(type, filters, selected):
state.filterType = type
state.filters = filters
state.selected = selected
}
}
return state
}
}
ViewModel
ViewModelの基本的な形は、画面ごとに大きく変わることはありません。
ViewModelの役割、実装の構成については、一覧画面から詳細画面への遷移の項で説明した通りです。
ここでは先に3つのViewModelの実装を列挙して、状態変化の流れについては後述することとします。
class QuestionListViewModel {
// MARK: Injected properties
private let store: Store<AppState>
private let api: API
// MARK: Input streams
let viewWillAppear = PublishRelay<Void>()
let viewWillDisappear = PublishRelay<Void>()
// MARK: Output streams
private let questionsStream = BehaviorRelay<[Question]>(value: [])
private let errorsStream = PublishRelay<Error>()
// MARK: Private properties
private let disposeBag = DisposeBag()
private let categoryFilterStream = BehaviorRelay<Int>(value: nil)
private let isStarredFilterStream = BehaviorRelay<Int>(value: false)
init(store: Store<AppState>, api: API) {
self.store = store
self.firestoreApi = firestoreApi
viewWillAppear
.subscribe(onNext: { [unowned self] in
self.store.subscribe(self) { subcription in
subcription.select { state in state.postListState.postState.questionListState }
}
})
.disposed(by: disposeBag)
let queryParams = Observable.combineLatest(
categoryFilterStream,
isStarredFilterStream)
viewWillAppear
.withLatestFrom(queryParams)
.subscribe(onNext: { [unowned self] (category, isStarred) in
self.fetchQuestions(category: category, isStarred: isStarred)
})
.disposed(by: disposeBag)
viewWillDisappear
.subscribe(onNext: { [unowned self] in
self.store.unsubscribe(self)
})
.disposed(by: disposeBag)
}
private func fetchQuestions(category: Int, isStarred: Int) {
api.fetchQuestions(category: category, isStarred: isStarred)
.do(onError: { [unowned self] in self.errorsStream.accept($0) })
.subscribe(onNext: { [unowned self] in
self.store.dispatch(QuestionListState.Action.updateQuestions($0))
})
}
}
// MARK: StoreSubscriber
extension QuestionListViewModel: StoreSubscriber {
typealias StoreSubscriberStateType = QuestionListState
func newState(state: QuestionListState) {
questionsStream.accept(state.questions)
categoryFilterStream.accept(state.questionFilterListState.categoryFilterId)
isStarredFilterStream.accept(questionFilterListState.isStarredFilterId)
}
}
// MARK: Output
extension QuestionListViewModel {
var questions: Driver<[Question]> {
return questionsStream.asDriver()
}
var errors: Signal<Error> {
return errorsStream.asSignal()
}
}
class QuestionFilterListViewModel {
// MARK: Injected properties
private let store: Store<AppState>
// MARK: Input streams
let viewDidLoad = PublishRelay<Void>()
let itemSelected = PublishRelay<Int>()
// MARK: Output streams
private let filtersStream = BehaviorRelay<[Filter]>(value: [])
private let categoryIdStream = BehaviorRelay<Int>(value: QuestionFilter.defaultCategoryId)
private let isStarredIdStream = BehaviorRelay<Int>(value: QuestionFilter.defaultIsStarredId)
private let errorsStream = PublishRelay<Error>()
// MARK: Private properties
private let disposeBag = DisposeBag()
init(store: Store<AppState>) {
self.store = store
viewDidLoad
.subscribe(onNext: { [unowned self] in
self.store.subscribe(self) { subcription in
subcription
.select { state in state.postListState.postState.questionListState.questionFilterListState }
}
})
.disposed(by: disposeBag)
let filterIds = Observable.combineLatest(categoryIdStream, isStarredIdStream)
itemSelected
.withLatestFrom(filterIds) { (index, ids) in (index, ids.0, ids.1) }
.subscribe(onNext: { [unowned self] (index, category, isStarred) in
if index == 0 {
self.store.dispatch(
SelectQuestionFilterState.Action.selectFilterType(
title: QuestionFilter.categoryFilterLabel,
type: .category,
filters: QuestionFilter.categoryFilters,
selected: category))
} else {
self.store.dispatch(
SelectQuestionFilterState.Action.selectFilterType(
title: QuestionFilter.isStarredFilterLabel,
type: .star,
filters: QuestionFilter.isStarredFilters,
selected: isStarred))
}
})
.disposed(by: disposeBag)
}
deinit {
store.unsubscribe(self)
}
}
// MARK: StoreSubscriber
extension QuestionFilterListViewModel: StoreSubscriber {
typealias StoreSubscriberStateType = QuestionFilterListState
func newState(state: QuestionFilterListState) {
categoryIdStream.accept(state.categoryFilterId)
isStarredIdStream.accept(state.isStarredFilterId)
let categoryFilter = QuestionFilter.findCategoryFilterById(state.categoryFilterId)
let starFilter = QuestionFilter.findIsStarredFilterById(state.isStarredFilterId)
filtersStream.accept([
(label: QuestionFilter.categoryFilterLabel, filter: categoryFilter),
(label: QuestionFilter.isStarredFilterLabel, filter: starFilter)
])
}
}
// MARK: Output
extension QuestionFilterListViewModel {
var filters: Driver<[Filter]> {
return filtersStream.asDriver()
}
var errors: Signal<Error> {
return errorsStream.asSignal()
}
}
class SelectQuestionFilterViewModel {
// MARK: Injected properties
private let store: Store<AppState>
// MARK: Input streams
let viewDidLoad = PublishRelay<Void>()
let itemSelected = PublishRelay<Int>()
// MARK: Private properties
private let disposeBag = DisposeBag()
private let filterTypeStream = BehaviorRelay<SelectQuestionFilterState.FilterType?>(value: nil)
private let itemsStream = BehaviorRelay<[String]>(value: [])
private let checkedIndexStream = BehaviorRelay<Int?>(value: nil)
init(store: Store<AppState>) {
self.store = store
viewDidLoad
.subscribe(onNext: { [unowned self] in
self.store.subscribe(self) { subcription in
subcription.select { state in state.postListState.postState.questionListState.questionFilterListState.selectQuestionFilterState }
}
})
.disposed(by: disposeBag)
itemSelected
.withLatestFrom(filterTypeStream) { (index: $0, filterType: $1) }
.subscribe(onNext: { [unowned self] in
switch $0.filterType! {
case .category:
self.store.dispatch(QuestionFilterListState.Action.selectCategoryFilter($0.index))
case .star:
self.store.dispatch(QuestionFilterListState.Action.selectIsStarredFilter($0.index))
}
})
.disposed(by: disposeBag)
}
deinit {
store.unsubscribe(self)
}
}
// MARK: StoreSubscriber
extension SelectQuestionFilterViewModel: StoreSubscriber {
typealias StoreSubscriberStateType = SelectQuestionFilterState
func newState(state: SelectQuestionFilterState) {
filterTypeStream.accept(state.filterType)
itemsStream.accept(state.filters)
checkedIndexStream.accept(state.selected)
}
}
// MARK: Output
extension SelectQuestionFilterViewModel {
var items: Driver<[String]> {
return itemsStream.asDriver()
}
var checkedIndex: Driver<Int?> {
return checkedIndexStream.asDriver()
}
}
ViewController
ViewControllerは前述の通りViewModelから受け取った値のビューへの描画とViewModelへのイベントの入力だけを担います。
状態の扱いに関してViewControllerは何も関知しないため、コードの紹介は省略します。
フィルタ機能の状態変化の流れについて
ここからは、フィルタが選択され一覧画面に適用されるまでの、状態変化の流れに着目して見ていきます。
まず、「フィルタ種別選択」画面にて、「スター」フィルタが選択されたとします。
するとQuestionFilterListViewModel
は、selectFilterType
ActionをDispatchして、「フィルタ選択」画面のStateであるSelectQuestionFilterState
を更新します(1)。
ここではActionにフィルタ種別、フィルタ一覧、現在選択されているフィルタを渡しています。
コードは省略しますが、ViewControllerではフィルタ種別の選択と同時に「フィルタ選択」画面への遷移が行われています。
前述したとおり、ViewControllerは画面遷移に際して次の画面のViewControllerにパラメータを渡す必要はありません。
状態の受け渡しはViewModelとStoreで完結しており、ViewControllerはどのような状態がやり取りされているのか意識しなくて良いのです。
itemSelected
.withLatestFrom(filterIds) { (index, ids) in (index, ids.0, ids.1) }
.subscribe(onNext: { [unowned self] (index, category, isStarred) in
if index == 0 {
self.store.dispatch(
SelectQuestionFilterState.Action.selectFilterType(
type: .category,
filters: QuestionFilter.categoryFilters,
selected: category))
} else {
// (1)
self.store.dispatch(
SelectQuestionFilterState.Action.selectFilterType(
type: .star,
filters: QuestionFilter.isStarredFilters,
selected: isStarred))
}
})
.disposed(by: disposeBag)
続いて、「フィルタ選択」画面にてフィルタが選択される際の流れについて見ていきます。
ここでは「スター付き」というフィルタが選択されたとします。
するとSelectQuestionFilterViewModel
は、selectIsStarredFilter
ActionをDipatchして、「フィルタ種別」画面のStateであるQuestionFilterListState
を更新します(2)。
ここではActionに選択された行の番号、つまりフィルタIDを渡しています。
itemSelected
.withLatestFrom(filterTypeStream) { (index: $0, filterType: $1) }
.subscribe(onNext: { [unowned self] in
switch $0.filterType! {
case .category:
self.store.dispatch(QuestionFilterListState.Action.selectCategoryFilter($0.index))
case .star:
// (2)
self.store.dispatch(QuestionFilterListState.Action.selectIsStarredFilter($0.index))
}
})
.disposed(by: disposeBag)
フィルタが選択されると、「フィルタ種別選択」画面に戻ります。
ここで×ボタンをタップする「フィルタ種別選択」画面が閉じられ、「疑問一覧」画面が表示されます。
すると、QuestionListViewModel
にviewWillAppear
イベントが入力されます。
viewWillAppear
イベントをトリガーに、APIから疑問一覧データを取得します。
QuestionFilterListState
が保持するフィルタは先程選択したフィルタに更新されているので(3)、APIから「スター付き」の疑問一覧データが取得されることになります(4)。
let queryParams = Observable.combineLatest(
categoryFilterStream,
isStarredFilterStream)
viewWillAppear
.withLatestFrom(queryParams)
.subscribe(onNext: { [unowned self] (category, isStarred) in
// (4)
self.fetchQuestions(category: category, isStarred: isStarred)
})
.disposed(by: disposeBag)
...
func newState(state: QuestionListState) {
questionsStream.accept(state.questions)
// (3)
categoryFilterStream.accept(state.questionFilterListState.categoryFilterId)
isStarredFilterStream.accept(state.questionFilterListState.isStarredFilterId)
}
上記の流れを図で表すとこんな感じです。
Reduxによって状態変化の流れは常に一方向であり、複雑に入り組むことはありません。
仕事で開発しているアプリではReduxなしでこのようなフィルタ機能を実装していましたが、そのときはQuestionListViewModel
に相当するViewModelをフィルタ選択画面に渡していき、選択されたフィルタをそのViewModelにセットするというやり方をしていました。
これだとフィルタ選択画面では2つのViewModelが存在するような形になってしまい、他の画面との統一性がなくなってしまいました。
今回紹介した実装方法だと、画面が持つ機能によってViewControllerやViewModelの実装が大きく変わることがありません。
複雑な状態のやり取りが起きる画面であっても、状態変化が起きる場所は決まっているので、コードが非常に読みやすいです。
まとめ
MVVM+Reduxアーキテクチャにチャレンジした背景と、MVVM+Reduxアーキテクチャをどのように実装しているかについてご紹介しました。
実装方法については正直まだまだ試行錯誤の段階です。
今は単純に画面ごとに分割しているだけのStateツリーの構成の仕方にも改善の余地はあるだろうし、ActionCreator、Middlewareといった、まだ使用していないReduxのコンポーネントもあるので、より良いMVVM+Reduxアーキテクチャの実装方法があるはずです。
今後もMVVM+Reduxアーキテクチャについて色々と発信していく所存です。
非常に長くなってしまいましたが、最後までお読みいただきありがとうございました。
-
ネット上の様々なMVVMの記事を呼んだ上での私個人の理解です。 ↩
1週間でアプリのパフォーマンスを5倍に改善した話
Swift Advent Calendar 2018 の 7 日目です。
先日開発中のアプリのプロトタイプを完成させ、自信満々に仲間に見せたところ
「動いてるけど動作重いね、、」
と言われショックで2日間放心状態に陥りました。
しかし!そこから1週間集中的にパフォーマンス改善に取り組み
起動時間を大幅に改善することに成功しました。
今回はその時の取り組み、アホみたいに遅かった原因、結果どれくらい短縮できたのかをまとめました。
※当然ですが、効果は各プロジェクトの実装に完全に依るものです。あくまで一例として参考にしていただけたらと思います。
① TIME PROFILERを活用しよう( −5.6s )
まず、基本のキとして、Xcode Instrumentsの機能であるTIME PROFILERを使いました。
参考:XcodeのInstrumentsのTime Profilerを使って重たい処理を調べる
この機能でスレッドを展開し、時間を食ってる処理を探し出すことができます。
原因:Dateに生やしていたExtention
至極当たり前のことですが、ループ内でのインスタンス生成はアンチパターンです。
しかし、下記のような便利なExtentionをDateに生やしていることによりそこに気づけませんでした。
extension Date {
var calendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .current
calendar.locale = .current
return calendar
}
var zeroclock: Date {
return fixed(hour: 0, minute: 0, second: 0)
}
static var now: Date {
return Date()
}
static var today: Date {
return now.zeroclock
}
func fixed(year: Int? = nil, month: Int? = nil, day: Int? = nil, hour: Int? = nil, minute: Int? = nil, second: Int? = nil) -> Date {
let calendar = self.calendar
var comp = DateComponents()
comp.year = year ?? calendar.component(.year, from: self)
comp.month = month ?? calendar.component(.month, from: self)
comp.day = day ?? calendar.component(.day, from: self)
comp.hour = hour ?? calendar.component(.hour, from: self)
comp.minute = minute ?? calendar.component(.minute, from: self)
comp.second = second ?? calendar.component(.second, from: self)
return calendar.date(from: comp)!
}
}
こういったExtentionはとても便利ですが、実際に以下のように膨大な時間を食っていました。
func updateItems() {
items.forEach{
if $0.end.zeroclock < Date.today {
deleteItems($0)
}
}
}
お気づきでしょうか、この類の処理は配列の要素の数だけDate、DateComponents、Calendarの初期化を行なってしまっています。
この中でもDateComponents、Calendarの初期化は繰り返すことで膨大な時食い虫となります。
利用するDateComponents、Calendarはstaticで宣言しておくのが良いと思います。
extension DateFormatter {
static var Current:DateFormatter = {
let formatter = DateFormatter()
formatter.locale = .current
formatter.timeZone = .current
return formatter
}()
}
extension Calendar {
private static var Current: Calendar = {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .current
calendar.locale = .current
return calendar
}()
}
そもそもDateは絶対的な時間軸上のある一点を指すクラスです。
相対的な時間を扱う際にはCalendarクラスを扱うのが正しいのだと思います。
Dateクラスも毎回初期化する必要のない場合はループの外で初期化しましょう。
TIME PROFILERを用いて隠れていたアンチパターンを見つけ出すことができました。
要素数が数千個あったこともあり、一気にここで5.6秒の短縮に成功します。
WebサービスのAPIを使いこなそう( −2.2s )
WebサービスのAPIの仕様を理解し、それに適したロジック設計を行うことで、アプリのパフォーマンスを改善することができます。
今回は、Google Calendar APIの仕様を活かすことでパフォーマンスを改善しました。
ソースコードは割愛しますが、ユーザのカレンダーイベントをGoogle Calendarから取得する際に、当初は削除された予定、時間変更されたイベントをアプリに反映するため、当初は毎回特定期間の全てのカレンダーイベントを取得していましたが、これを
1. syncTokenを用いた差分のみの取得
2. showDeletedパラメータをセットすることで削除されたカレンダーイベントも取得
3. eventsIdを用いてローカルDBのカレンダーイベントを更新し、利用
のように変更することでデータ取得を高速にすることができました。
Google APIは特にどれも強力な機能をもっているので、しっかりドキュメントを読み込み、
それに合わせたロジック設計を行うことでパフォーマンス改善が見込めます。
データは適切なタイミングで用意しよう( −2.1s )
起動時に複数の重たい処理を走らせると当然起動時間を食ってしまうことになります。
「今表示している画面に必要な情報」を考え、適宜処理することでパフォーマンス改善につながります。
今回はGoogleカレンダーから空き時間を取得するアプリだったため、
起動時に
1. Googleカレンダーから情報を取得
2. 予定間の空き時間を計算
3. 各アイテムに設定された時間帯で空き時間をフィルタリング
4. 連続する時間同士を結合
といった処理を行なっていました。
しかし、3,4の処理は 実はトップ画面では利用していないため、
計算処理をアイテム選択時の画面遷移のタイミングに切り離すことで処理を省略することができます。
また、処理対象のアイテム数も大幅に削減できるため計算処理がかなり早くなりました。
これにより3.8秒の短縮に成功します。
スレッドを正しく制御しよう( −0.7s )
UIKitのライフサイクルのなかでiOSではUIの制御はメインスレッド内でしか行うことができません。
そのため重い計算処理を描画中に書くとUI更新がブロックされてしまいます。
DispatchQueueやRxSwiftを利用して可能な限りUI更新以外の処理を別スレッドで捌くことで複数の
通信、計算処理でUIをブロックしないことが大事です。
DispatchQueue.global(qos: .background).async {
//データの通信処理など
}
//RxSwift
//observeOnで細かくスレッドの指定ができる
downloadItems
.observeOn(MainScheduler.asyncInstance)
.subscribe(onNext: { [weak self] items in
//UIの更新処理
})
.disposed(by: disposeBag)
さらに0.7秒の高速化に成功します!
最後に
今回は計 -10.6秒、起動時間を1/5に改善することに成功しました。
パフォーマンス改善は地道で孤独な試練だと思っていましたが、
エンジニアの実装のこだわりが確かな数字で示すことができ、アプリのUXとなり見てもらえる点など、
わかりやすくて楽しい花のある仕事でした。これからも腕を磨いていきたいです。
先日2ヶ月の開発期間を経て、自分のカレンダーの空き時間のみを用途別にシェアすることのできる
TIMEPACKというアプリのβ版をリリースしました。
まだまだバグも多いですが、ユーザからのフィードバックをもらいながら絶賛改善中です。
フィードバックをくださると幸いです!
Swiftでコマンドラインツール作成の誘い
Swift Advent Calendar 2018 の 8 日目です。
はじめに
Swiftの話題としてはやはりiOSアプリ開発に関わるものが多いかと思います。
ですがもっといろん場面で活用されればいいなと思い、ここでは手軽にCLIツールを作る方法を紹介します。
と思って、Qiitaで検索したら既にSwift Package Manager (SwiftPM) で作るコマンドラインツール - Qiita でかなり丁寧まとめられてました。とはいえ、時の流れとともに内容に若干変更されている部分もありますので挫けずに進めたいと思います。(つまりこの記事にも賞味期限があるということですね)
前提としている環境
- macOS Mojave
- Swift 4.2.0
コマンドラインでswiftを実行するには
例えば以下のように、あらかじめソースファイルを作っておけばswiftコマンドに渡すことで実行できます。
$ echo "print(\"Hello world\")" > hello.swift
$ swift hello.swift
Hello world
ほんのちょっとしたものであればこれで済むかもしれませんが、
ある程度規模が大きかったり、外部ライブラリを使用したいとかになるとSwift Package Managerを利用するのが便利です。
Swift Package Manager (SPM)
Swift用のパッケージ管理ツールです。
パッケージの構成やライブラリの依存関係などを管理してくれます。
ここではQiita APIを使って記事を検索するCLIツールを作成するイメージでSPMの使い方を紹介します。
パッケージを初期化
新しいディレクトリを作成し、パッケージとして初期化します。
この時ディレクトリの名前がそのままパッケージ名として設定されます。
$ mkdir Qiita
$ cd Qiita/
$ swift package init --type executable
Creating executable package: Qiita
Creating Package.swift
Creating README.md
Creating .gitignore
Creating Sources/
Creating Sources/Qiita/main.swift
Creating Tests/
Creating Tests/LinuxMain.swift
Creating Tests/QiitaTests/
Creating Tests/QiitaTests/QiitaTests.swift
Creating Tests/QiitaTests/XCTestManifests.swift
ここで -- type executable
としているのは実行可能形式とすることを示します。
ライブラリ形式など他にも指定できますがここでは触れません。
いくつかのディレクトリ・ファイルが作成されます。
-
Package.swift
- マニフェストファイルと呼ばれます。
- パッケージの構成やライブラリの依存関係などをここで定義します。
-
Sources/
- ソースコードを入れる場所です。Target毎にサブディレクトリを切ります。
- ここでは
Sources/Qiita/main.swift
がエントリポイントになっています。
-
Tests/
- テストコードを入れる場所です。
この時点でビルド&実行できる状態になっています。
$ swift build # ビルド(デバッグビルド)
$ swift run # 実行
Hello, world!
ビルドした成果物は .build/
ディレクトリ下に配置されています。
外部ライブラリを参照
CLIツールにはオプション指定がつきものですので、その辺を楽に解決するためにライブラリを導入します。探すといろいろ見つかりますが、ここではSPMが提供する Utility
を使ってみることにします。
外部ライブラリの参照を追加するために Package.swift
を編集します。
let package = Package(
name: "Qiita",
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
.package(url: "https://github.com/apple/swift-package-manager.git", from: "0.3.0") // ここに追加
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages which this package depends on.
.target(
name: "Qiita",
dependencies: ["Utility"]), // ここに追加
.testTarget(
name: "QiitaTests",
dependencies: ["Qiita"]),
]
)
Packageのdependenciesに依存ライブラリへのパスとバージョンを指定し、Targetのdependenciesに依存するパッケージ名を追加します。
そして以下のコマンドでパッケージを更新し、ライブラリをインストールします。
$ swift package update
ではコードを編集しましょう。
Sources/Qiita/main.swift
を以下のように編集します。
// main.swift
import Utility // ArgumentParserのために必要
// CommandLine.argumentsでコマンドラインから引数を受け取れます
// arguments[0]にはコマンド名が入ってくるので除いておきます。
let arguments = Array(CommandLine.arguments.dropFirst())
// コマンドオプションの定義
let parser = ArgumentParser(usage: "-k [keyword]", overview: "Qiitaで記事を検索します")
let keyword = parser.add(option: "--keyword", shortName: "-k", kind: String.self)
do {
let result = try parser.parse(arguments)
if let keyword = result.get(keyword) {
print("'\(keyword)'でQiitaの記事を検索します")
} else {
print("エラー")
}
} catch {
print(error)
}
ここではコードの内容については深く触れません。編集したらビルド&実行します。
$ swift run
エラー
オプションを指定していないので「エラー」となりますね。
オプションを与えて実行する場合には次のようにします。
$ swift run Qiita --keyword swift #swift run コマンド名 オプション
'swift'でQiitaの記事を検索します
Targetを追加
これからQiita APIを叩く処理を追加するのですが、その部分をTargetを分けて実装したいと思います。
その方が後でテストを書きやすくなりますので。
Package.swift
を編集し、新しいTargetとして QiitaCore
を追加します。Qiita
からQiitaCore
への依存も追加しています。
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages which this package depends on.
.target(
name: "Qiita",
dependencies: ["Utility", "QiitaCore"]), // "QiitaCore"を追加
.target( // これを追加
name: "QiitaCore",
dependencies: []),
.testTarget(
name: "QiitaTests",
dependencies: ["Qiita"]),
]
また、Targetと同名のサブディレクトリ Sources/QiitaCore/
として作成し、そこにソースファイル( Qiita.swift
)を追加します。
// Qiita.swift
import Foundation
public struct Qiita {
private(set) var keyword: String
private let session: URLSession
public init(keyword: String, session: URLSession = .shared) {
self.keyword = keyword
self.session = session
}
// keywordをもとに記事を検索する
public func search(completion: @escaping (String?) -> Void) {
let url = URL(string: "https://qiita.com/api/v2/items?page=1&per_page=20&query=\(keyword)")!
var req = URLRequest(url: url)
req.httpMethod = "GET"
session.dataTask(with: req) { (data, _, _) in
if let data = data, let result = String(data: data, encoding: .utf8) {
// 実際にはここでごにょごにょ整形する
completion(result)
} else {
completion(nil)
}
}.resume()
}
}
そしてこれを Sources/Qiita/main.swift
から利用します。
// main.swift
import Utility // ArgumentParserのために必要
import QiitaCore // Qiitaのために必要
import Dispatch // dispatchMainのために必要
// CommandLine.argumentsでコマンドラインから引数を受け取れます
// arguments[0]にはコマンド名が入ってくるので除いておきます。
let arguments = Array(CommandLine.arguments.dropFirst())
// コマンドオプションの定義
let parser = ArgumentParser(usage: "-k [keyword]", overview: "Qiitaで記事を検索します")
let keyword = parser.add(option: "--keyword", shortName: "-k", kind: String.self)
do {
let result = try parser.parse(arguments)
if let keyword = result.get(keyword) {
print("'\(keyword)'でQiitaの記事を検索します")
let q = Qiita(keyword: "swift")
q.search { result in
print("result: \(result ?? "")")
exit(0)
}
dispatchMain() // 非同期処理の終了を待つ
} else {
print("エラー")
}
} catch {
print(error)
}
コードの内容は本題から逸れるので軽く流して頂きたいですが、
非同期処理がある場合は、プログラムが即時終了しないようにする必要があります。
あとはビルド&実行するだけです。
$ swift build
$ swift run Qiita -k swift
....いっぱい出てくる....
テスト
Package.swift
を編集します。
テスト用のTargetとして QiitaTests
があるので、dependenciesを QiitaCore
に変更しておきます。
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages which this package depends on.
.target(
name: "Qiita",
dependencies: ["Utility", "QiitaCore"]),
.target(
name: "QiitaCore",
dependencies: []),
.testTarget(
name: "QiitaTests",
dependencies: ["QiitaCore"]), // ここを変更
]
テストコードは Tests/
ディレクトリの下にあります。
ここでは Tests/QiitaTests/QiitaTests.swift
にテストコードを書いていきます。
// QiitaTests.swift
import XCTest
import QiitaCore
final class QiitaTests: XCTestCase {
func testSearch() throws {
let session = URLSessionMock()
let qiita = Qiita(keyword: "swift", session: session)
qiita.search { _ in }
XCTAssertEqual(session.url, "https://qiita.com/api/v2/items?page=1&per_page=20&query=swift")
}
static var allTests = [
("testSearch", testSearch),
]
}
final class URLSessionDataTaskMock: URLSessionDataTask {
override func resume() {
// Do nothing
}
}
final class URLSessionMock: URLSession {
typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void
var url: String = ""
override func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
self.url = request.url?.absoluteString ?? ""
return URLSessionDataTaskMock()
}
}
本題とは逸れるのでコードの内容は(略)
QiitaTests.allTests
は何?と思うかもしれませんが、Linuxでのテスト対象となるテストケースを指定しています。(Tests/LinuxMain.swift
はLinux上でのテスト実行のためのもの、これを読むとわかると思います。)
次のコマンドでテストを実行します。
$ swift test
...
Test Suite 'All tests' passed at 2018-12-07 17:46:22.131.
Executed 1 test, with 0 failures (0 unexpected) in 0.083 (0.083) seconds
インストール
最終的に作成したCLIツールをリリースビルドしてPATHを通せばおしまいです。
$ swift build -c release -Xswiftc -static-stdlib
$ cd .build/release
$ cp -f Qiita /usr/local/bin/
ここで swift build
のオプションに -c release
を渡すことでリリースビルドとなります。また -Xswiftc -static-stdlib
としているのはSwift標準ライブラリを静的リンクし、実行環境のSwiftのバージョンに依存しなくて済むためです。
[補足]Xcodeプロジェクトの作成
次のコマンドでXcodeプロジェクトを作成できます。
$ swift package generate-xcodeproj
※ Xcodeで開くと最初は実行環境がiPhoneになっているかもしれません。”My Mac”にしてあげないとコンパイルエラーとなってしまいます。
まとめ
今回のコードはGitHubに上げておきました。
https://github.com/gibachan/swift-cli-sample
正直、ちょっとした仕事であればRubyとかの方がパッとかけて便利な気はします
でもSwiftもとても楽しい言語なので、もし興味がありましたらお試しください!
TableViewCell管理のグッドプラクティスについて
Swift 5 のResultに備える
Swift の機能提案は Swift Evolution で行われるのですが、 Swift の標準ライブラリに Result
型を追加するプロポーザル SE-0235 が承認されました。その結果、 Swift 5 で Result
が標準ライブラリに追加されます。
本投稿では
-
Result
とは何か - いつ
Result
を使うべきか -
Result
をどのように扱うべきか - 今のうちに
Result
に備える方法
を説明します。
Result
とは何か
Swift にはサードパーティ製の antitypical/Result という人気ライブラリがあり、 Result
に馴染みのある人も多いと思います。本節では、 Result
に馴染みのない人のために Result
について簡単に説明します。
Result
はエラーハンドリングに用いられる型で、次のように宣言されます。
public enum Result<Success, Failure: Error> {
case success(Success)
case failure(Failure)
}
Result
を用いると、たとえば、ファイルの内容を Data
インスタンスとして読み込む関数 readFile
のシグネチャは次のように書けます。
func readFile(at path: String) -> Result<Data, IOError>
ただし、入出力に伴うエラーは次のような IOError
というエラー型で表されるものとしています。
enum IOError: Error {
case fileNotFound(path: String) // ファイルが存在しないとき
case permissionDenied(Permission) // パーミッションを持たないとき
...
}
このとき、 readFile
利用時のエラーハンドリングは次のようになります。
switch readFile(at: path) {
case .success(let data):
... // `data` を使うオペレーション
case .failure(.fileNotFound(path: let path)):
... // ファイルが存在しないときのエラーハンドリング
case .failure(.permissionDenied(let permission)):
... // パーミッションを持たないときのエラーハンドリング
case .failure(let error):
... // その他の場合のエラーハンドリング
}
throws
との関係
Result
と throws
でできることはよく似ています。 throws
を使うと readFile
の例は次のように書けます。
func readFile(at path: String) throws -> Data
エラーハンドリングの例は次の通りです。
do {
let data = try readFile(at: path)
... // `data` を使うオペレーション
} catch IOError.fileNotFound(path: let path) {
... // ファイルが存在しないときのエラーハンドリング
} catch IOError.permissionDenied(let permission) {
... // パーミッションを持たないときのエラーハンドリング
} catch let error {
... // その他の場合のエラーハンドリング
}
構文の違いや throws
ではエラー型を指定できないという違いはありますが、できることはほぼ同じです。
いつ Result
を使うべきか
Result
と throws
は似た機能なのでどのように使い分ければ良いのかという疑問が生まれると思います。本節では Result
と throws
の使い分けについて説明します。
Manual Propagation と Automatic Propagation
Swift のエラーハンドリングを語るときに欠かすことのできない Error Handling Rationale and Proposal というドキュメントがあります。これは Swift 2.0 で throws/try
が追加されるに当たって Swift Core Team のメンバーによって書かれたドキュメントで、 Swift におけるエラーハンドリングがどのように設計されたかを知ることができる貴重な資料です。
その中で Manual Propagation と Automatic Propagation という概念が示されています。エラー値を通常の値と同じように扱い(戻り値として受け取り、変数や定数に格納するなど)、通常の制御構文( if
, guard
, switch
文など)を用いて取り扱う場合、それは Manual Propagation です。一方、 do/try/catch
のように専用の構文を用いてエラー発生箇所からハンドリング箇所へ暗黙的にジャンプするような場合は Automatic Propagation です。
現状( Swift 4.2 )の Swift の標準ライブラリや Foundation でもこれらは使い分けられています。
たとえば、文字列を整数に変換するコードは次のように書きます。
let numberOrNil: Int? = Int(string)
guard let number = numberOrNil else {
... // エラーハンドリング
}
... // `number` を使うオペレーション
Int(string)
の戻り値の型は Int?
で、 nil
によってエラーを表します。この場合は Optional
や guard let
( Optional Binding )という通常の言語機能を使ってエラーハンドリングを行っており、 Manual Propagation です。
一方、 Data
をファイルに書き込む処理は次のようになります。
do {
try data.write(to: URL(fileURLWithPath: path))
... // 何らかのオペレーション
} catch let error {
... // エラーハンドリング
}
この場合、 data.write
の呼び出し箇所から catch
までジャンプすることになり Automatic Propagation です。
Result
か throws
かという視点で考えると、 Result
は Manual Propagation 、 throws
は Automatic Propagation ということになります。
Automatic Propagation は多くの場合 Manual Propagation よりもコードを簡潔に書くことができます。たとえば、次のコードは Automatic Propagation で書かれたものですが、これと同じオペレーションを Manual Propagation で書こうとすると大変です。
let db: Database = ...
do {
let group: Group = try db.get(Group.self, withKey: groupID)!
let owner: User = try db.get(User.self, withKey: group.ownerID)!
let ownerFriends: User = try db.get([User.self], withKeys: owner.friendIDs)
... // `ownerFriends` を使うオペレーション
} catch DatabaseError.connectionFailure {
... // 接続に失敗した場合のエラーハンドリング
} catch DatabaseError.transactionError {
... // トランザクション関連のエラーハンドリング
} catch let error {
... // その他の場合のエラーハンドリング
}
これは当たり前の話で、 Manual Propagation は通常の言語機能を使うので、もし Manual Propagation で十分なのであれば言語が Automatic Propagation のための構文を用意する必要はありません。わざわざ Automatic Propagation ができるようになっているのは、 Manual Propagation だけでは何らかの不便があり、それを解決したいという意図があったからです。
しかし、それならすでに Automatic Propagation のための throws
があるのに、今さら何のために Manual Propagation のための Result
が追加されようとしているのでしょうか。それは、 Manual Propagation にはできて Automatic Propagation にはできないことがあるからです。
Automatic Propagation はエラーが発生したら即時ハンドリングすることが強制されます。一方、 Result
や Optional
はエラーハンドリングしないまま引数や戻り値として取り回したり、プロパティに保持しておいたりすることができます。実際に、そのような Manual Propagation でしか扱えないケースに対応するために、サードパーティ製の Result
ライブラリが使われています。
しかし、ライブラリ A がある Result
ライブラリを、ライブラリ B が別の Result
ライブラリを、ライブラリ C が依存性を避けるために独自の Result
型を使っているというようなケースが頻発し、 Result
同士の互換性が問題となっていました。 Result
を標準ライブラリに追加する主な目的は、標準の Result
型を導入することでそのような互換性の問題を解決することです。
Manual Propagation でしか解決できないケース
Manual Propagation でしかできない例をいくつか紹介します。
非同期処理
URL を指定してファイルをダウンロードする関数 download
を考えてみましょう。当然、この関数はネットワークエラーを起こす可能性があります。単純に考えると次のようなシグネチャになりそうです。
func download(from url: URL) throws -> Data
しかし、これは望ましくありません。上記の download
関数は同期処理になってしまっています。もし UI スレッドで上記のような関数を呼ぶとダウンロードが完了するまで画面が固まってしまうでしょう。非同期処理とするために、コールバックのクロージャを指定できるようにしたいところです。
次のようにコールバックで Data
を受け取るようにするとどうでしょうか?
func download(from url: URL, _ completion: (Data) -> Void) throws
なんとなくシグネチャの上ではうまくいきそうに見えるかもしれませんが、これではうまくいきません。上記の download
を使うコードは次のようになります。
do {
try download(from: url) { data in
... // `data` を使うオペレーション [A]
}
} catch let error {
... // エラーハンドリング [B]
}
上記のコードの [B] は download
を呼び出した直後に実行されることになります。しかし、その時点ではダウンロードは終わっていません。将来起こるかもしれないエラーのハンドリングを先に行うことはできません。非同期処理では、エラーハンドリングもオペレーション終了後に実行される [A] でやらなければなりません。
しかし、 [A] でエラーハンドリングさせるような download
関数を throws
でうまく書くことはできません。無理やり次のように書くことができないわけではないですが非常にわかりづらいです。
func download(from url: URL, _ completion: (() throws -> Data) -> Void)
そのため、次のような設計になっていることが多いです。
func download(from url: URL, _ completion: (Data?, Error?) -> Void)
ダウンロードに成功した場合は (Data?, Error?)
として (data, nil)
を、失敗した場合は (nil, error)
を受け取ります。
download(from: url) { data, error in
if let data = data {
... // `data` を使うオペレーション
} else {
let error = error! // ここで ! が必要
... // エラーハンドリング
}
}
残念ながらこれは型として厳密ではありません。 (Data?, Error?)
は (data, nil)
, (nil, error)
の他に (data, error)
, (nil, nil)
も許容します。それらが仕様として起こり得なくても型として表現できていないので、上記のコードのように !
が必要になってしまっています。
こんなときに Result
を使うと型を厳密に書くことができます。
func download(from url: URL, _ completion: (Result<Data, DownloadError>) -> Void)
download(from: url) { result in
switch result {
case .value(let data):
... // `data` を使うオペレーション
case .failure(let error):
... // エラーハンドリング
}
}
async/await
非同期処理は Result
のユースケースのかなりの割合を占めると予想されますが、現在提案されている async/await
が導入されるとほとんどの非同期処理で Result
は不要になります。
ここでは async/await
についての詳しい解説は行いませんので、興味があれば以前に書いた次の投稿を御覧下さい。
-
"Proposalには載っていないSwift 5のasync/awaitが素晴らしいと思う理論的背景"
- タイトルに「 Swift 5 の」と入っていますが、 Swift 5.0 で導入されないことは決定で、おそらく現状では Swift 6 以降になると思われます。
async
を使えば、前述の download
関数は throws
と組み合わせて次のように書けるようになります。
func download(from url: URL) async throws -> Data
download
を使う側のコードも await
と try
を使って簡潔に書けます。
do {
let data = try await download(from: url)
... // `data` を使うオペレーション
} catch let error {
... // エラーハンドリング
}
非常にシンプルですね。もし async/await
が導入されれば、このようなケースであえて Result
を使う必要はありません。
並行した非同期処理
しかし、 async/await
で書くことのできない非同期処理もあります。 async/await
では処理を順番に行うので、次のようなコードでは A をダウンロードしてから B をダウンロードすることになります。
let dataA = try await download(from: urlA)
let dataB = try await download(from: urlB)
... // `dataA` と `dataB` を使うオペレーション
A と B を並行にダウンロードしたい場合は async/await
だけでは書くことができません。そのため、プロポーザルで言及されている Future
のような型が必要になります。この Future
は Result
の提案以前に書かれたものなので内部に独自の Result
型を持っています。また、 Future
が Result
の機能も併せ持ったものになっています。もし Future
から Result
の機能を分離したとすると、 A と B を並行でダウンロードするオペレーションは次のように書けます1。
let futureA: Future<Result<Data, DownloadError>> = Future { try await download(from: urlA) }
let futureB: Future<Result<Data, DownloadError>> = Future { try await download(from: urlB) }
let dataA: Data = try await futureA.get()
let dataB: Data = try await futureB.get()
... // `dataA` と `dataB` を使うオペレーション
このように、 async/await
が導入されたからといってすべての非同期処理で Result
が不要になるわけではありません。
Observable
などの型と組み合わせる場合
RxSwift の Observable
など、ジェネリックな型の型パラメータと組み合わせて使いたい場合には Manual Propagation が必要となります。 Observable<Result<Foo, BarError>>
のような型を throws
で書こうとすると、先のコールバックのように Observable<() throws -> Foo>
のように複雑になってしまうので、このような場合は Result
を用いるのが適当です。
いつ Result
を使うことが想定されているか
Result
をどのような場合に使うことが想定されているのでしょうか。僕が注目したのは Swift Core Team のメンバーである John McCall の次のコメントです。 John McCall は "Error Handling Rationale and Proposal" の著者でもあります。
I want to clarify that I favor adding
Result
only to address situations where manual propagation is necessary.
参考訳: 私は Manual Propagation が必要となるような状況に対処するため だけ にResult
を追加することを支持するとはっきりさせたい。I will continue to recommend against using
Result
as an alternative mechanism for ordinary propagation of errors
参考訳: 通常のエラーの propagation の代わりとしてResult
を使用しないように引き続き推奨していく
( John McCall: https://forums.swift.org/t/se-0235-add-result-to-the-standard-library/17752/129 )
このように、 Result
は throws
で書くことが困難で、 Manual Propagation が必要になったときに だけ 使うのが良いでしょう。そして、 async/await
が導入されると多くの非同期処理では Result
が不要となるので、 Result
を利用すべきケースは限定的であると言えます。 Result
の使用は必要最小限に留め、本当に Result
がないと( throws
では)書けないようなコードを書くときにだけ利用することをオススメします。
ただ、他の Core Team メンバーのコメントなども見ているといつ Result
を使うべきか、必ずしも Core Team の中でもコンセンサスが得られているわけではない印象を受けました。そのため、レビュー時にいつ Result
を使うべきと意図されているのかを示してほしいというコメントを書き込んでみました。残念ながら直接の返信は得られなかったものの、全体へのアナウンスの中に次のように書かれていたので、 Result
が実際に追加されるときにはいつ使うべきかもより明確に示されるものと思われます。おそらく、僕は John McCall のコメントにあるような、 Manual Propagation が必要なケースでのみ Result
を利用するということで落ち着くのではないかと考えています。
The Core Team acknowledges the call from several reviewers to make a special effort to communicate how we think
Result
ought to be used in Swift. We absolutely agree that is an important part of addingResult
to the language, and we intend to provide this guidance as part of the release.
参考訳: Swift においてResult
がどのように使用されるべきと Core Team が考えているのかコミュニケートしようと特別な努力を払った何人かのレビュワーからの要望を Core Team は認知している。私達は、それはResult
の言語への追加の重要な一部であると完全に同意する。そして、私達はそのようなガイダンスをResult
のリリースの一部とするつもりだ。
( John McCall: https://forums.swift.org/t/accepted-with-modifications-se-0235-add-result-to-the-standard-library/18603 )
エラー型の指定
Result
と throws
は似た仕組みを提供しますが、 Manual Propagation か Automatic Propagation かということ以外に大きく異なるのがエラー型を指定できるかどうかです。 Swift 4.2 時点では、 Java のように throws
の後ろにエラー型を指定することはできません。
// これはできない
func readFile(at path: String) throws IOError -> Data
現状で(サードパーティ製の) Result
が利用されている目的の一つにエラー型を指定したいということがあります。 throws
でエラー型を指定できないのは意図的な設計によるものです。僕の解釈では、それは次のような理由によります。
エラーハンドリングのほとんどはオペレーションに成功したか失敗したかがわかればよく、エラーの型は重要ではありません(特にアプリケーションの実装においてはその傾向が強いと思います)。エラーの種類によって分岐したいようなケースでも、原因ごとの網羅的な分岐が必要になることは稀です。大抵は一つ二つの原因とその他というような分け方をすることになります。
たとえば、多様な原因を持つ I/O エラーを個別に分岐することはほぼないと思います。
do {
let data = try readFile(at: path)
... // `data` を使うオペレーション
} catch IOError.fileNotFound(path: let path) {
... // ファイルが存在しないときのエラーハンドリング
} catch IOError.permissionDenied(let permission) {
... // パーミッションを持たないときのエラーハンドリング
} catch let error { // ←ここを個別に分岐して対応することは少ない
... // その他の場合のエラーハンドリング
}
他にも、ネットワークエラーを HTTP のすべてのステータスコードに個別に対応するコードを書いたことのある人はまずいないでしょう。 404 などのいくつかのケースに対応して、後はその他としてまとめてしまうことがほとんどのはずです。
このように、エラー型に対して網羅的かつ個別に分岐が必要となるケースは稀です。それに対して、エラー型を上手く設計するのは困難です。後から case
の追加がしたくなったり、エラー型の種類を追加したくなったりすることはよくあることです。原因を整理してエラー型の実装を作り変えたくなることも多いでしょう。
もし、 throws
にエラー型を指定することができると、おそらく多くのプログラマはそれを丁寧に設計しようとするはずです。しかし、それによって得られるリターンとそのために費やしたコストを天秤にかけると、コストの方が大きいケースが多いのではないでしょうか。そのような現実を踏まえて、現状では throws
にエラー型を指定できないようになっているのではないかと思います。
しかし、エラー型を指定したいケースが存在するのも確かです。特に、ライブラリなどではエラー型が明確な方が良いことも比較的多いでしょう。 JSON のデコードのようなオペレーションでは、考えうるエラーの原因が数個程度に限定でき、個別に分岐したいケースも現実的に起こり得ます。
そのような場合にエラー型を指定するために Result
を使うべきかと言うと、僕はオススメできません。エラー型を指定したいというのは前述の Manual Propagation が必要な場合には該当しません。
また、 Typed Throws ( throws
の後ろにエラー型を指定できる構文)も Swift Evolution で議論され続けています。将来的にそのような構文が追加される可能性もあり、前述の JSON のデコードのようなケースは Typed Throws で対応すべきものだと思います。現状ではエラー型を指定できない状態の throws
で実装しておき、 Typed Throws が追加されたときにエラー型を指定するのが良いでしょう。
Result<T, Error>
逆に、 Result
に不必要にエラー型を指定するのは避けるのが良いでしょう。特に、非同期処理で Result
を使うようなケースでは、 async/await
さえあれば本来エラー型の指定されない throws
で十分なことが多いはずです。そういう場合には Result<Data, Error>
のように型パラメータ Failure
に汎用的な Error
を指定し、汎用的なエラー型として扱うのが良いと思われます。
func download(from url: URL, _ completion: (Result<Data, Error>) -> Void)
download(from: url) { result in
switch result {
case .value(let data):
... // `data` を使うオペレーション
case .failure(let error):
... // エラーハンドリング
}
}
Swift ではプロトコル型はそのプロトコル自身に適合しないという制約があったため、 Result<Data, Error>
のような型を作ることができませんでした。型パラメータ Failure
には Failure: Error
という制約がありますが、 Error
は Error
自身に適合しないので Failure
の制約を満たせませんでした。この制約は言語の実装上の都合によるものですが、 Result
の導入に伴って問題が生じるので、応急処置的に Error
に関してのみ自分自身のプロトコルに適合する仕様( self-conformance )が追加されます。積極的にこれを活用しましょう。
Result
をどのように扱うべきか
いつ Result
を使うべきかがわかったとして、実際に Result
を返すような API があった場合にどのように Result
を扱えば良いでしょうか。ここでは簡単に僕のオススメを書いておきます。
Result
を受け取ったらできるだけすぐにハンドリングすると良いでしょう。特に、非同期処理の結果として受け取った場合に、それ以上 Result
のまま値を取り回す必要はないはずです。成功か失敗かが知りたいだけなら、 get
と do/try/catch
で十分でしょう。
download(from: url) { result in
do {
let data: Data = try result.get()
... // `data` を使うオペレーション
} catch let error {
... // エラーハンドリング
}
}
もし、エラーの原因別に分岐してハンドリングが必要なケースでは、網羅的な分岐が書きやすい switch/case
を使うのが良いでしょう。
map
, flatMap
, mapError
, flatMapError
の利用は、個人的には Optional
同様に最小限に留めておくのが良いと思います。それらが必要なケースが存在するのは確かですが、 switch/case
や do/try/catch
と適切に使い分けないと可読性を損なうでしょう。
今のうちに Result
に備える方法
Result
が導入されると、既存のコードベースの中でサードパーティ製 Result
を使っている箇所や、コールバックで値とエラーを受け取っているケース(前述の func download(from url: URL, _ completion: (Data?, Error?) -> Void)
など)を標準ライブラリの Result
を使って書き換えることになると予想されます。できるだけ移行の負荷を小さくするために、今のうちから標準ライブラリの Result
に備えておきたいところです。
そのために、 SE-0235 で提案されている Result
互換の Result
型ライブラリ SwiftResult を作りました。
- SwiftResult: https://github.com/koher/SwiftResult
今のうちに SwiftResult の Result
を使ったコードに変更しておけば、 Swift 5 で Result
が導入されたときに import SwiftResult
を消すだけで移行が完了するはずです。是非ご活用下さい。
-
Future<Result<T, E: Error>>
に対してFuture
とResult
を同時にアンラップするextension
メソッドget
が実装されているものとします。 ↩
ARCの光と影
メモリ管理方法
SwiftはARC(Automatic Reference Counting / 自動参照カウント)というメモリの管理方式を採用しています。
一方で、JavaやKotlin・C#といった言語はメモリ管理の方式としてGC(Garbage Collection / ガベージコレクション) 1 を採用してます。
この記事では、ARCの特徴(光)とそれにまつわる問題点(影)を、GC方式と比較したりしながら取り上げます。
なお、長いので3行でまとめて欲しいという方は、まとめを読んでいただければと思います。
ARCとは
ARCは参照カウントという手法でメモリを管理します。この参照カウントの仕組みをざっくり言うと、参照型のインスタンス(オブジェクト)が各々どれだけ他から参照されているか(=変数などに保持されているか)を常にカウントしておき、どこからも参照されなくなったら(=カウントが0になったら)、そのインスタンスをメモリ上から解放するというシンプルな仕組みです。
ARCでは、カウントを増減させる部分の処理をコンパイラが自動的にコードに埋め込みます。つまり、プログラマが手動でカウント処理を指示しなくても、自動で参照がカウントされるのでARC(自動参照カウント)という名称になっています。
GCとは
GCには様々な種類がありますがこちらもざっくりと言うと、メモリ上にあるオブジェクト全部の中から実行中のプログラムから参照されていない不要なオブジェクトを探し出しそれを解放するという仕組みです。
ただ、この解放処理はまともに行うとかなり重たい処理で、実行中のアプリの処理も中断2されてしまいます。それで、解放処理を行うタイミングを工夫したり、管理するメモリ領域を分割したりするなど、なるべく効率良く処理ができるように様々な改良がなされています。
いずれにしても、プログラマ側ではメモリ管理に関しては特に実装することはなく、GCは自動で行われるようになっています。
ARCの特徴と問題点
参照されなくなったらメモリが解放される
ARCではそのインスタンスがどこからも参照されなくなったら(=不要になったら)、その直後にメモリからインスタンスが解放3されます。
GCの方は解放タイミングが自動で判断されるので、オブジェクトが不要になってもすぐに解放されるとは限らず、実際にメモリから解放されるタイミングは不明です。
メモリのピーク使用量が抑えられる
これはGCと比較した場合の話ですが、特にモバイルアプリにおいてメモリのピーク使用量が抑えられメモリ枯渇が起こりにくくなります。
一般的なモバイルアプリの特徴はインタラクティブなアプリである、つまり、ユーザの操作がトリガーとなってAPIを実行したりUIを更新したりといった種々の処理が実行されます。これをメモリの使用量という観点から見ると、ユーザの操作後にメモリの使用量が跳ね上がり、そこから処理が完了するにつれてメモリが解放されて減っていくといった形の波が生じます。
もう一つの特徴として、モバイルアプリは(最近は高スペックな端末が増えたとはいえ)利用できるメモリ量が限られているという点があります。それでメモリのピーク使用量が大きいとメモリが枯渇してしまい、動作が遅くなったりアプリが強制終了してしまうといったことが起こります。
GCの場合は、実際の解放処理が行われるまでの間、不要になったオブジェクトもメモリに残ったままです。その為、メモリのピーク使用量 = 解放待ちのオブジェクトの分 + 実際の使用量となり、メモリの使用量が跳ね上がった時には一時的にメモリが枯渇することがあります。また、そこまでいかなくても一気に重たいメモリ解放処理が走るので、GCスパイクと呼ばれるプチフリーズが起こることもあります。その点ARCは不要になったメモリがその都度こまめに解放されるので、メモリのピーク使用量と実際に使っているメモリ量はほぼ同じ状態となり、メモリ不足が起こりにくくなっています。
とはいえ、ARCにはオーバーヘッドが発生するという問題点があります。変数にインスタンスを代入したり、メソッドの引数として渡したり、その変数がスコープを抜けたりする度に参照カウンタのチェック処理が入るからです。それ自体はあまりコストのかからない処理とはいえ、ループ処理のように回数が多くなればそれなりのコストとなるので、アプリによっては問題となることがあります。
また、そもそもサーバサイドで使う場合のように潤沢なメモリが準備されている環境や、メモリ使用量がほぼ一定になる処理の場合には、メリットよりオーバーヘッドのデメリットの方が際立ってきます。なお、Swift5でOwnership(所有権)が導入される理由の一つには、このオーバーヘッド問題を解決するという目的があります。
メモリ解放のタイミングの予測や制御が可能
ARCではインスタンスが保持されているかどうかでメモリから解放されるかどうかが決まるので、大抵の場合、プログラマはいつメモリが解放されるのかを予測し制御することができます。
これは普段から役立つといったものではないですが、メモリ周りのパフォーマンスチューニングが必要になった時に有るのと無いのでは大きな違いとなります。GCの場合は事前にちゃんと不要なオブジェクトを破棄しておいても、実際のメモリ解放のタイミングが制御できない為、肝心な場面でGCスパイクが発生してしまう4ことがあります。
またARCの場合は、あるオブジェクトが解放されていないと思える時にdeinit
が呼ばれるかどうかで手軽に確認できるのも便利です。
循環参照によるメモリリークが起こる
これまでの2つの特徴はまさにARCの「光」、良い面でしたが、それを実現する為に犠牲となっているいわば「影」の部分がこの循環参照によるメモリリークの問題です。
参照型のインスタンス同士がお互いを保持し合っていると、いつまで経っても参照カウントが0にならないのでメモリが解放されずメモリリークを起こしてしまいます。GCの場合は、循環参照になっていても丸ごと解放される為、メモリリークは発生しません。
しかも、この循環参照を正しく解消するのはプログラマの責任となります。struct
のような値型を使ったりweak
を使ったりすれば解消できるとはいえ、特にクロージャが関係する場合はweak
とunowned
のどちらを使うべきなのか、そもそも循環参照にならないパターンなのかなどを切り分けて判断するのは難しい問題です。また、ケアレスミスで循環参照が入り込んでしまうリスクもあります。
ただ、これがものすごくデメリットなのかといえば一概にそうとは言えないと思います。最近よく同じ処理をSwiftとKotlin両方で書く機会が多くて気づいたのですが、Swiftの場合はキャプチャリストを見るだけで、クロージャ内の処理が呼び出し元のオブジェクトにどういう影響を与えるのかが判るというメリットがあります。確かにKotlinのあまり難しく考えずにコーディングを進められる生産性やメモリリークの心配をしなくて良いという安心感は大きいですが、やたらと非同期処理が多くなりがちなモバイルアプリを保守していくという場合にはSwiftの方が合っているかもしれません。
まとめ
ARCはモバイルアプリとの相性が良く、パフォーマンスへの影響も少なめです。シンプルな仕組みのゆえに、必要な場合にはプログラマ側で制御しやすいですが、その反面、循環参照によるメモリリークという注意すべき点もあります。
-
本来はARCを含む参照カウント方式もGCの一種ですが、本記事では便宜上、GCという語は参照カウント方式以外のGCを指して用います。 ↩
-
"Stop the World"と呼ばれる現象です。DIOのスタンドみたいな名前ですが、実際に遭遇するとDIOと同じくらいの強敵です。 ↩
-
iOSのようにautoreleasepoolが使われている場合は、そのブロックを抜けたタイミング(通常はイベントループを抜けた時)に解放されます。 ↩
-
最近だとUnity界隈でこの阿鼻叫喚が見られていましたが、その改善の為にUnity 2019からインクリメンタルGCが導入されました。ただ、GCが複雑な仕組みを持っていると、うまくいく場合は良いのですが、より予測が困難となりトラブルが起こった時の解決が難しくなってしまうこともあります。 ↩
Delegateについて。
Core Animation周りをタイプセーフに扱えるようにする
はじめに
UIKitでサポートされていないアニメーションをしたい場合、Core Animationを使うことになると思います。
例えば、以下のように四角から丸に変化するアニメーションです。
CABasicAnimationでアニメーションさせる
上図のような四角から丸に変化するアニメーションをCABasicAnimation
で実装する場合、意図したアニメーションを実現できる簡単な実装は以下のようになると思います。
let animation = CABasicAnimation(keyPath: "cornerRadius")
animation.duration = 3.0
animation.fromValue = 0.0
animation.toValue = 50.0
animation.autoreverses = false
animation.isRemovedOnCompletion = false
animation.fillMode = .forwards
greenView.layer.add(animation, forKey: nil)
アニメーションを実現するためには上記の実装で問題ありません。
しかし、実装する上では2つの懸念があります。
keyPathやfromValueなどがタイプセーフじゃない
CABasicAnimationのinitializerの引数であるkeyPathに注目します。
引数はString型
であるため、以下のようにcornerRadiusの文字列をタイポする可能性があります。
let animation = CABasicAnimation(keyPath: "conerRadius")
そして、アニメーションを実行しようとしたときに、アニメーションされないことでタイポに気づくことが多々あるかと思います。
また、fromValueに注目します。
fromValueは下図のようにOptional<Any>型
であるため、どんなものでも代入可能になっています。
本来であれば、keyPathがcornerRadiusの場合は数値を代入するという関連性がなければならないはずです。
keyPathやfromValueなどがタイプセーフな実装とは
上記であげたタイプセーフではない実装を、TheAnimation(iOS、tvOS、macOSをサポート)というOSSを利用することで、タイプセーフに実装することができます。
CABasicAnimationと同等の機能をもった、BasicAnimationを利用します。
initializerのkeyPathには、AnimationKeyPaths.cornerRadius
を渡しています。
AnimationKeyPaths.cornerRadius
はstaticな定数として定義されているため、入力にミスがある場合はコンパイルエラーとなるので、タイポする可能性がなくなります。
let animation = BasicAnimation(keyPath: .cornerRadius)
また、AnimationKeyPaths.cornerRadiusはAnimationKeyPath<CGFloat>型
となっています。
AnimationKeyPathのGeneric ArgumentがCGFloat型
となっており、下図のようにfromValueなどに型が紐付いています。
そのため、cornerRadiusのアニメーションに対してCGFloat型しか代入できない状態にできます。
このように実装できるため、2つあげた懸念点を改善することができます。
それでは、TheAnimation内部の実装はどのようになっているのでしょうか。
TheAnimationの実装
それでは、TheAnimationがどのようにしてタイプセーフな実装を実現しているのかを順を追って解説します。
本投稿では、CABasicAnimationに関連する部分に絞って解説します。
AnimationKeyPath
AnimationKeyPathは、keyPathを保持しつつ型を紐付けることができるようになっています。
まず、protocol AnimationValueType
を定義していて、アニメーション時に利用する値の型に対して採用しています。
public protocol AnimationValueType {}
たとえば、CGColorやCGFloatに対して採用し、アニメーション時に利用できる状態にします。
extension CGColor: AnimationValueType {}
extension CGFloat: AnimationValueType {}
...
AnimationKeyPathはGeneric ArgumentにAnimationValueTypeを採用した型を持ち、内部でkeyPathを保持しています。
open class AnimationKeyPaths {
fileprivate init() {}
}
public final class AnimationKeyPath<ValueType: AnimationValueType>: AnimationKeyPaths {
let rawValue: String
public init(keyPath: String) {
self.rawValue = keyPath
}
}
AnimationKeyPathがGeneric Argumentを持つため、static定数をAnimationKeyPathに定義しようとすると、extension AnimationKeyPath where ValueType == CGFloat
のようにGeneric Argumentの型を確定させた状態にする必要があります。
そのため、AnimationKeyPath.cornerRadius
のような呼び出しができず、AnimationKeyPath<CGFloat>.cornerRadius
のような呼び出しをする必要があります。
スーパークラスをAnimationKeyPaths
とし以下のようにstatic定数を定義することで、AnimationKeyPaths.cornerRadius
という呼び出しができます。
extension AnimationKeyPaths {
public static let backgroundColor = AnimationKeyPath<CGColor>(keyPath: "backgroundColor")
public static let cornerRadius = AnimationKeyPath<CGFloat>(keyPath: "cornerRadius")
...
}
つまり、init<ValueType: AnimationValueType>(keyPath: AnimationKeyPath<ValueType>)
という実装になっていても、.cornerRadius
のように型推論が利用できます。
SwiftyUserDefaultsのDefaultsKeys
も上記のような実装になっています。
protocol Animation
protocol Animation
はTheAnimationのアニメーションクラスが採用しているprotocolであり、CAAnimationをラップしています。
public protocol Animation: class {
var animation: CAAnimation { get }
var key: String { get }
}
内部でCAAnimationを保持しているため、CAAnimationのpropertyをTheAnimationで公開している型に変換して公開しています。
extension Animation {
public var timingFunction: TimingFunction? {
set { animation.timingFunction = newValue?.rawValue }
get { return animation.timingFunction.flatMap(TimingFunction.init) }
}
public var isRemovedOnCompletion: Bool {
set { animation.isRemovedOnCompletion = newValue }
get { return animation.isRemovedOnCompletion }
}
...
}
このようにして、CAAnimationと同等の機能を実現しています。
PrimitiveAnimation
PrimitiveAnimationはprotocol Animation
を採用しています。
Generic Argumentでは、CAPropertyAnimationまたはそのサブクラスの型をRawAnimation、AnimationValueTypeを採用した型をValueTypeとします。
initializerでは、AnimationKeyPath<ValueType>
を引数としています。
RawAnimationはCAPropertyAnimationまたはそのサブクラスなのでinitializerでkeyPathを引数としています。
そして、AnimationKeyPath内で保持しているrawValueを渡してRawAnimationを初期化し、保持します。
public final class PrimitiveAnimation<RawAnimation: CAPropertyAnimation, ValueType: AnimationValueType>: Animation {
public var animation: CAAnimation {
return _animation
}
public let key: String
let _animation: RawAnimation
public var keyPath: AnimationKeyPath<ValueType>? {
set { _animation.keyPath = newValue?.rawValue }
get { return _animation.keyPath.map(AnimationKeyPath.init) }
}
public init(keyPath: AnimationKeyPath<ValueType>) {
self._animation = RawAnimation(keyPath: keyPath.rawValue)
self.key = keyPath.rawValue
}
}
PrimitiveAnimationのextensionでGeneric Where Clauseを利用することで、RawAnimationのpropertyを利用できます。
例として、CAPropertyAnimationのサブクラスであるCABasicAnimationの場合は以下のようになります。
extension PrimitiveAnimation where RawAnimation == CABasicAnimation {
public var fromValue: ValueType? {
set { _animation.fromValue = newValue }
get { return _animation.fromValue as? ValueType }
}
...
}
そして、上記で定義したものは以下のように利用できます。
let animation = PrimitiveAnimation<CABasicAnimation, AnimationKeyPath<CGFloat>>(keyPath: .cornerRadius)
animation.fromValue = 50
BasicAnimation
BasicAnimationは以下のように定義されています。
typealias BasicAnimation<ValueType: AnimationValueType> = PrimitiveAnimation<CABasicAnimation, ValueType>
つまり、BasicAnimationはクラス名ではなく、RawAnimationがCABasicAnimationであるPrimitiveAnimationのtypealiasです。
RxSwiftのSingle
、Maybe
やCompletable
もPrimitiveSequence
をtypealiasとした上記のような実装になっています。
ちなみに
TheAnimationはCAAnimationのサブクラスに以下のように対応しています。
CAAnimation | TheAnimation |
---|---|
CAPropertyAnimation | PropertyAnimation |
CABasicAnimation | BasicAnimation |
CAKeyframeAnimation | KeyframeAnimation |
CASpringAnimation | SpringAnimation |
CATransition | TransitionAnimation |
CAAnimationGroup | AnimationGroup |
最後に
明らかに間違っていても、実行して動作を確認するまで気づきにくいバグを、このようにしてコンパイル時に気づくことがきるようになります。
型を紐付けることでエラーに気づける実装は、いろんな場面で応用することができると思うので、是非試してみてください。
また、TheAnimationの実装がベースになっていて、よりアニメーションの組み合わせを使いやすくしたSicaも公開されています。
Swiftの数値リテラルをチョットダケ詳しく調べた話
こんにちは、freddiと申します。Swiftを始めて8ヶ月少々、最近は周囲の影響からかSwiftコンパイラやSILについてちょっと勉強し始めています。
この記事は、「Swift Advent Calendar 2018」の14日目の記事として、Swiftの数値リテラルを、基本的なところから、普段のSwiftコードよりもちょっと低いレイヤーを覗いたり、実際に触れ合って見た話をします。
皆様、どうか最後までよろしくお願いいたします。
この記事では以下のSwiftコンパイラを利用して検証を行っています。
$ swiftc --version
Apple Swift version 4.2.1 (swiftlang-1000.11.42 clang-1000.11.45.1)
Target: x86_64-apple-darwin17.7.0
Swiftの数値リテラルの基本
この項目では、数値リテラルの基本をおさらいします。一部の方々には復習になるとは思いますので、ちょっとだけお付き合いください。
数値リテラルの種類(n進数)
Swiftでは、数値リテラルと言ってもいくつか種類があります。Swift.orgのLanguage Guideを見れば、17を表すリテラルにも以下の4種類が存在することが確認できます。
// Language GuideのThe BasicsのNumeric Literalsから引用しています
let decimalInteger = 17 // 17 を10進数(decimal number)で表したもの
let binaryInteger = 0b10001 // 17 を2進数(binary number)で表したもの
let octalInteger = 0o21 // 17 を8進数(octal number)で表したもの
let hexadecimalInteger = 0x11 // 17 を16進数(hexadecimal)で表したもの
10進数は接頭詞がなく、それ以外は接頭詞があるというのが、それぞれの特徴ですね。
小数を含んだ数値の表現
また、10進数と16進数は、小数点以下の数を含んだ表現が可能です。
10進数では.
を利用した表現と、e
($ 10^e $の指数部 $ e $)を利用した表現があります。これらは馴染みが深いと思います。
let decimal0Point01Value = 0.01 // 0.01 を10進数で表したもの
let decimal0Point01ValueWithE = 1e-2 // 0.01 をeを利用して10進数で表したもの -> 1 * 10^-2
let decimal100ValueWithE = 1e+2 // 100 をeを利用して10進数で表したもの -> 1 * 10^2
16進数では.
を利用した表現はありません。試しにlet someValue = 0x0b.1b
をコンパイルしたら、以下のようなエラーが出ます。1
hexadecimal floating point literal must end with an exponent
翻訳: 16進浮動小数点リテラルは指数で終わらなければならなりません
ですが、p
($ 2^p $の指数部 $ p $)を利用して小数点を表現することが可能です。
上記のエラーで出ている指数で終わらなければならなりません
の指数はp
のことのようです。また、指数部は10進数しか指定できません。
let hexadecimal0Point25ValueWithP = 0x01p-2 // 0.25 をpを利用して16進数で表したもの -> 1 * 2^-2
また、eとpを利用した数値リテラル単体は、Double型になるようです。
let someHexValue = 0x0p0
print(type(of: someHexValue)) // -> Double
let someDecValue = 0e0
print(type(of: someDecValue)) // -> Double
数値リテラルの可読性を上げる書き方
数値の桁の可読性が上がる表現として、_
を利用した桁区切りの表記もあります。これは小数点以下の桁にも、どの進数の表記でも利用できます(この場合、_
は、コンパイル時には無視されます)。
let someBigValue = 1_000_000.000_001
また、0を利用したパディング(余白詰め)も利用できます。これは10進数のみのようです。
let someBigValue1 = 000010
let someBigValue2 = 012000
数値リテラルの取り扱われ方
では、ここから数値リテラルについてもう少し掘り下げて探検してみます。ここからは私達が普段いるSwiftコードよりも、もう少し低い中間言語の部分から見て行くことが少々多くなりますが、必要なところだけ解説を入れているので、そこだけ読んでもらっても結構です。
整数リテラルと浮動小数点リテラル
数値リテラルには、宣言時にデフォルトで設定される型があります。
たとえば、以下のように小数点が現れない数値リテラルはすべて整数型(Int
)として設定されます。これらは 整数リテラル と呼ばれていることが多いです。
let someValue1 = 0
print(type(of: someValue1)) // -> Int
let someValue2 = 0x0
print(type(of: someValue2)) // -> Int
let someValue3 = 0o0
print(type(of: someValue3)) // -> Int
以下のように「小数点が出る、指数がついている」数値リテラルはすべて浮動小数点型(Int
)として設定されます。これらは 浮動小数点リテラル と呼ばれていることが多いです。
let someValue1 = 0.0
print(type(of: someValue1)) // -> Double
let someValue2 = 0e1
print(type(of: someValue2)) // -> Double
let someValue3 = 0x0p0
print(type(of: someValue3)) // -> Double
では、整数リテラルと浮動小数点リテラルは、コンパイルされているときにどのように扱われるのでしょうか?以下のソースコードのSIL2を読んで考察してみようと思います。
let someIntValue = 3
let someDoubleValue = 4.1
出力されたSILを見てみましょう(読みやすいようにSwiftのシンタックスハイライトを入れてます)。
まず、someIntValue
の宣言のSILを見てみます。
alloc_global @$S4test12someIntValueSivp // id: %2
%3 = global_addr @$S4test12someIntValueSivp : $*Int // user: %8
%4 = metatype $@thin Int.Type // user: %7
%5 = integer_literal $Builtin.Int2048, 3 // user: %7
// function_ref Int.init(_builtinIntegerLiteral:)
%6 = function_ref @$SSi22_builtinIntegerLiteralSiBi2048__tcfC : $@convention(method) (Builtin.Int2048, @thin Int.Type) -> Int // user: %7
%7 = apply %6(%5, %4) : $@convention(method) (Builtin.Int2048, @thin Int.Type) -> Int // user: %8
store %7 to [trivial] %3 : $*Int // id: %8
このSILコードをざっくり解説すると、
- 最初の方に出てくる
%5 = integer_literal $Builtin.Int2048, 3
がいわゆる整数リテラルです。 - そのリテラルを、
Int.init(_builtinIntegerLiteral:)
でIntに変換しています(%6 = ...
と%7 = ...
の部分)。 - そして、変換したものをsomeIntValueに格納しています。(
store %7 to [trivial] %3 : $*Int
)
次にsomeDoubleValue
の宣言のSILを見てみます。
alloc_global @$S4test15someDoubleValueSdvp // id: %9
%10 = global_addr @$S4test15someDoubleValueSdvp : $*Double // user: %15
%11 = metatype $@thin Double.Type // user: %14
%12 = float_literal $Builtin.FPIEEE80, 0x40018333333333333333 // 4.09999999999999999991 // user: %14
// function_ref Double.init(_builtinFloatLiteral:)
%13 = function_ref @$SSd20_builtinFloatLiteralSdBf80__tcfC : $@convention(method) (Builtin.FPIEEE80, @thin Double.Type) -> Double // user: %14
%14 = apply %13(%12, %11) : $@convention(method) (Builtin.FPIEEE80, @thin Double.Type) -> Double // user: %15
store %14 to [trivial] %10 : $*Double // id: %15
最初の方に出てくる%12 = float_literal $Builtin.FPIEEE80, 0x40018333333333333333
がいわゆる浮動小数点リテラルです。
※ 0x40018333333333333333
は4.1
を80bit浮動小数点数に変換したものです。どうやらSILになる時点では浮動小数点リテラルは80bit浮動小数点数に変換されるらしいです。3また、環境によっては64bitになるかもしれません。
そのリテラルを、Double.init(_builtinFloatLiteral:)
で... とIntのときと同じようにリテラルをDouble
のイニシャライザで変換しています。
このように、型アノテーションがない限りは、各リテラルはコンパイラ側でデフォルトで決まっている型になるようです。数値にかかわらず、どのリテラルがデフォルトでどの型になるかは、ここを見ればわかると思います。
これらのことから、数値リテラルは、型アノテーションがない限りは、指定されたデフォルトの型の(リテラルを引数に取る)イニシャライザを通して、デフォルトの型になる と考えられます。
ExpressibleByIntegerLiteral
プロトコルについて
最後にですが、数値リテラルと関係のあるprotocol
であるExpressibleByIntegerLiteral
を使ってちょっと遊んでみた話をします。
ExpressibleByIntegerLiteral
とは?
ここで、ちょっと皆さんに質問です。以下のコードは、コンパイル可能でしょうか?
let someValue: CGFloat = 10
ほとんどの人が知っていると思いますが、このコードはコンパイルが可能です。(当然と思った方はすいません。)
私自身、この一行がコンパイルが通ることには、最初はかなり疑問点を抱きました。CGFloatはかなり使っていましたが、リテラルを直接代入できることを知らない間はずっと
let someValue: CGFloat = CGFloat(10)
のように書いていました。どのようにすれば、自分が作った型で直接数値リテラルを代入して変数宣言ができるようになるのでしょうか。
例として、OriginalWrappingValueType
という型を作ってみます。この型は整数リテラルをイコールで代入する形で宣言できるようにするのが目標です。
import Foundation
struct OriginalWrappingValueType {
var value: Int
}
let someValue: OriginalWrappingValueType = 10 // 目標!だけどコンパイルはできません
知っている人もいるかも知れませんが、Numeric
プロトコルに準拠させればできないことはないです。
struct OriginalWrappingValueType: Numeric {
...
しかし、整数リテラルの直接代入はNumeric
の機能ではなく、Numeric
がさらに準拠しているExpressibleByIntegerLiteral
の準拠によって可能になります。
ちなみに、ExpressibleByIntegerLiteral
は、コメントで組み込みプロトコル(Intrinsic protocols)と書かれるほど、コンパイラのコアな部分に繋がりのあるプロトコルです。
では、早速OriginalWrappingValueType
を作り、ExpressibleByIntegerLiteral
に準拠させてみましょう。
import Foundation
struct OriginalWrappingValueType: ExpressibleByIntegerLiteral {
var value: Int
}
let someValue: OriginalWrappingValueType = 10
コンパイルするとエラーが出ます。どうやら、準拠するには必要なことがありそうですね。
test.swift:3:8: error: type 'OriginalWrappingValueType' does not conform to protocol 'ExpressibleByIntegerLiteral'
struct OriginalWrappingValueType: ExpressibleByIntegerLiteral {
^
Swift.ExpressibleByIntegerLiteral:2:20: note: protocol requires nested type 'IntegerLiteralType'; do you want to add it?
associatedtype IntegerLiteralType : _ExpressibleByBuiltinIntegerLiteral
実はinit(integerLiteral:)
だけを実装すればよいです。
struct OriginalWrappingValueType: ExpressibleByIntegerLiteral {
var value: Int
public init(integerLiteral value: Int) {
self.value = value
}
}
これで、コンパイルエラーも出なくなり、OriginalWrappingValueType
型の変数は整数リテラルをイコールで代入する形で宣言できるようになりました。
import Foundation
struct OriginalWrappingValueType: ExpressibleByIntegerLiteral {
var value: Int
public init(integerLiteral value: Int) {
self.value = value
}
}
let someValue: OriginalWrappingValueType = 10 // = でセットできるようになった!
コンパイラ側では、もしリテラルがOriginalWrappingValueType
型の変数に=代入される際に、先程実装したinit(integerLiteral value: Int)
を呼び出して変換しているそうです。
SILを出力してみたところ、init(integerLiteral value: Int)
が確かに呼ばれていることがわかりました。
alloc_global @$S4test9someValueAA016OriginalWrappingC4TypeVvp // id: %2
%3 = global_addr @$S4test9someValueAA016OriginalWrappingC4TypeVvp : $*OriginalWrappingValueType // user: %11
%4 = metatype $@thin OriginalWrappingValueType.Type // user: %10
%5 = metatype $@thin Int.Type // user: %8
%6 = integer_literal $Builtin.Int2048, 10 // user: %8
// function_ref Int.init(_builtinIntegerLiteral:)
%7 = function_ref @$SSi22_builtinIntegerLiteralSiBi2048__tcfC : $@convention(method) (Builtin.Int2048, @thin Int.Type) -> Int // user: %8
%8 = apply %7(%6, %5) : $@convention(method) (Builtin.Int2048, @thin Int.Type) -> Int // user: %10
// function_ref OriginalWrappingValueType.init(integerLiteral:)
// しっかりとここでExpressibleByIntegerLiteralのinit(integerLiteral:)が呼ばれている!!
%9 = function_ref @$S4test25OriginalWrappingValueTypeV14integerLiteralACSi_tcfC : $@convention(method) (Int, @thin OriginalWrappingValueType.Type) -> OriginalWrappingValueType // user: %10
%10 = apply %9(%8, %4) : $@convention(method) (Int, @thin OriginalWrappingValueType.Type) -> OriginalWrappingValueType // user: %11
store %10 to [trivial] %3 : $*OriginalWrappingValueType // id: %11
先程の項で話した整数リテラルがInt型の変数になるフローと似ている気がします。
浮動小数点リテラルに関しては、ExpressibleByFloatLiteral
に同じように準拠すればよいです。
ExpressibleBy...Literal
と型チェックの考察
ExpressibleBy...Literal
で遊んでいると、おもしろい面をもう一つ発見しました。
CGFloatとリテラルの演算で型チェックに時間がかかってしまったという記事も紹介されていますが、この記事では、以下のようなリテラルとCGFloatの混ざった計算で、型チェックにかなり時間がかかっていました。
let width = (view.bounds.width - 10 * 2 - collectionViewLayout.minimumInteritemSpacing * 4) / 4
この記事では、結局はコード内の数値リテラルをCGFloat
に明示的に変換することで、型チェックの時間を削減に成功していました。
実際に、個人の環境で同じようなシチュエーションを再現しようとしました。しかし、実装中のコンパイル時に不思議な問題に直面しました。
以下が、私が問題に直面したコードです。
import Foundation
struct OriginalWrappingValueType: ExpressibleByIntegerLiteral {
var value: Int
public init(value: Int) {
self.value = value
}
public init(integerLiteral value: Int) {
self.value = value
}
static func +(lhs: OriginalWrappingValueType, rhs: OriginalWrappingValueType) -> OriginalWrappingValueType {
return OriginalWrappingValueType(integerLiteral: lhs.value + rhs.value)
}
// 演算子のどれかの実装をあえて抜くのがポイント
// static func -(lhs: OriginalWrappingValueType, rhs: OriginalWrappingValueType) -> OriginalWrappingValueType {
// return OriginalWrappingValueType(integerLiteral: lhs.value - rhs.value)
// }
static func /(lhs: OriginalWrappingValueType, rhs: OriginalWrappingValueType) -> OriginalWrappingValueType {
return OriginalWrappingValueType(integerLiteral: lhs.value / rhs.value)
}
static func *(lhs: OriginalWrappingValueType, rhs: OriginalWrappingValueType) -> OriginalWrappingValueType {
return OriginalWrappingValueType(integerLiteral: lhs.value * rhs.value)
}
}
let someValue1 = (OriginalWrappingValueType(value: 10) * 5 - 5) / 4 - 5
let someValue2 = (OriginalWrappingValueType(value: 10) * 5 - OriginalWrappingValueType(value: 10)) / 4 - 5
コンパイルは失敗します。最後の計算時にOriginalWrappingValueType
の-
の演算の実装が見つからないからです。
このコードは私の環境では、コンパイル結果が出るまでに700~1200ミリ秒時間を要していました。
試しに、以下のような複雑な演算で試してみました。
let someValue = (OriginalWrappingValueType(value: 10 / 5) + (5 * 4) - 4 * 5 + OriginalWrappingValueType(value: 4) / 2) / 5 - 10
すると、コンパイルエラーはもちろん、型チェックに35秒要するという結果になりました。
最後のコードで、コンパイルオプションで-dump-ast
を指定して、抽象構文木を出力するようにしました。すると、各ノードで大量の型を列挙している構文木が出力されました。3
以下がその構文木です。(見やすくなるようにswiftのシンタックスハイライトを適用しています。)
(top_level_code_decl
(brace_stmt
(pattern_binding_decl
(pattern_named type='<<error type>>' 'someValue')
(binary_expr type='<<error type>>' location=test.swift:32:124 range=[test.swift:32:17 - line:32:126]
(overloaded_decl_ref_expr type='<null>' name=- number_of_decls=29 function_ref=unapplied decls=[
Swift.(file).Float.-,
Swift.(file).Double.-,
Swift.(file).Float80.-,
Swift.(file).UInt8.-,
Swift.(file).Int8.-,
Swift.(file).UInt16.-,
Swift.(file).Int16.-,
Swift.(file).UInt32.-,
Swift.(file).Int32.-,
Swift.(file).UInt64.-,
Swift.(file).Int64.-,
Swift.(file).UInt.-,
Swift.(file).Int.-,
Foundation.(file).Date.-,
Foundation.(file).Decimal.-,
Dispatch.(file).-,
Dispatch.(file).-,
Dispatch.(file).-,
Dispatch.(file).-,
CoreGraphics.(file).CGFloat.-,
Swift.(file).FloatingPoint.-,
Swift.(file).Numeric.-,
Swift.(file).BinaryInteger.-,
Swift.(file).Strideable.-,
Swift.(file).Strideable.-,
Swift.(file).Strideable.-,
Swift.(file).Strideable.-,
Foundation.(file).Measurement.-,
Foundation.(file).Measurement.-])
(tuple_expr implicit type='<null>'
(binary_expr type='<null>'
(overloaded_decl_ref_expr type='<null>' name=/ number_of_decls=20 function_ref=unapplied decls=[
test.(file).OriginalWrappingValueType./@test.swift:23:15,
Swift.(file).Float./,
Swift.(file).Double./,
Swift.(file).Float80./,
Swift.(file).UInt8./,
Swift.(file).Int8./,
Swift.(file).UInt16./,
// かなり長いので中略
// かなり長いので中略
// かなり長いので中略
Swift.(file).FloatingPoint.*,
Swift.(file).Numeric.*,
Swift.(file).BinaryInteger.*,
Foundation.(file).Measurement.*,
Foundation.(file).Measurement.*])
(tuple_expr implicit type='<null>'
(integer_literal_expr type='<null>' value=4)
(integer_literal_expr type='<null>' value=5)))))
(binary_expr type='<null>'
(overloaded_decl_ref_expr type='<null>' name=/ number_of_decls=20 function_ref=unapplied decls=[
test.(file).OriginalWrappingValueType./@test.swift:23:15,
Swift.(file).Float./,
Swift.(file).Double./,
Swift.(file).Float80./,
Swift.(file).UInt8./,
Swift.(file).Int8./,
Swift.(file).UInt16./,
Swift.(file).Int16./,
Swift.(file).UInt32./,
Swift.(file).Int32./,
Swift.(file).UInt64./,
Swift.(file).Int64./,
Swift.(file).UInt./,
Swift.(file).Int./,
Foundation.(file).Decimal./,
CoreGraphics.(file).CGFloat./,
Swift.(file).FloatingPoint./,
Swift.(file).BinaryInteger./,
Foundation.(file).Measurement./,
Foundation.(file).Measurement./])
(tuple_expr implicit type='<null>'
(call_expr type='<null>' arg_labels=value:
(type_expr type='<null>' typerepr='OriginalWrappingValueType')
(tuple_expr type='<null>' names=value
(integer_literal_expr type='<null>' value=4)))
(integer_literal_expr type='<null>' value=2))))))
(integer_literal_expr type='<null>' value=5)))
(integer_literal_expr type='<null>' value=10))))
Swift.(file).Float./
やSwift.(file).FloatingPoint.*
の一番最後の記号は、それぞれ+-*/
が確認できるので演算子だと考えられます。
OriginalWrappingValueType
も演算子の列挙に含まれていましたが、-
が列挙されている場所にはありませんでした。
また、-
演算子を実装すれば、エラーも出ずコンパイルもすぐに終了します。
これらから、型チェックに時間がかかる理由は、
- 「
ExpressibleByIntegerLiteral
に準拠していて」かつ「四則演算のうちどれかを実装している」型は、式中の数値リテラルの変換先の型のパターンマッチングに利用されている4 - 式が複雑になる、リテラルが増える、といったリテラルの型がすぐに決めづらい状況になればなるほど、パターンマッチングの走査の時間も増えてしまう
これらが原因なのかな、と考えています。まだ予測の段階なので、今後コンパイラのコードを読んでみて結論をつけたいと思っています。
最後に
数値リテラルを基本的な部分から、普段は意識しないようなちょっとレイヤーの低いところまで見ることで、数値リテラルがコンパイラでどのように取り扱われているかを知ることができました。役に立つ役に立たない関係なく、数値リテラルの世界やちょっと低いレイヤーに興味を持ってもらえれば、私として嬉しいです。もしミス等が発見されましたら、お気軽に修正リクエストやコメントをお願いします。
最後の項目では、(まだ考察ですが)ちょっとリテラルに対してマイナスなイメージを持ってしまった方々もいるかも知れません。
ですが、数日前のSwiftアドカレにも書いてあるとおり、Swiftコンパイラに優しいコードを書けば、私が今回挙げた不思議な問題点も簡単に解決できると思っています(例えば、紹介したCGFloatの記事では明示的に数値リテラルを変換して、コンパイラの型チェックを円滑に行えるようにしています)。
本日はここまでです。皆様本当にありがとうございました!
明日はkateinoigakukunさんがメタデータ周りに付いて書いてくださるそうです。メタデータの話はあまり聞いたことが無いので、とても楽しみです。皆さんもお楽しみください!
-
0x0b.bのように
.
以下がアルファベットで始まる際は、Int
のメンバを呼び出ししているようにコンパイラに認識されるので、別のエラーが出ます(例:error: value of type 'Int' has no member 'b'
) 。 10進数以外で.
が使えない理由は、メンバの呼び出しかそうでないかの構文解析時の判断が難しいからかな、と考えています(これは私の憶測なのでご注意ください)。 ↩ -
Swift Intermediate Languageの略。Swiftの中間言語のことです。詳しくは https://blog.waft.me/2018/01/09/swift-sil-1/ のSIL (Swift Intermediate Language)という項目を見てください。この記事では主に、
-emit-silgen
で出力したrawSILを載せています。 ↩ -
余談ですが、抽象構文木の時点では、指数表現があるリテラル以外はすべて10進数に変換されます。SILのときは指数部分も展開されますか、浮動小数点リテラルは先程も述べたとおり80bit浮動小数点数データで表記されます。 ↩
-
OriginalWrappingValueType
をリネームしただけのAnotherOriginalWrappingValueType
を別途用意したところ、最後の行の式でAnotherOriginalWrappingValueType
が関連している演算が無いのにもかかわらず、構文木中の型の列挙にはAnotherOriginalWrappingValueType
がありました ↩
Swift Type metadata
Swiftには実行時に型情報を保持するためのType metadata
という仕組みがあります。我々が頻繁に使うことはありませんが、Swiftのランタイムの動作を理解するための重要な要素です。
この記事では Type metadata
についてコンパイラのコードとドキュメントから調べたことを簡単に解説します。
Type metadata
とは
Swiftが実行時に保持している型情報です。ジェネリックでないclass
、struct
、enum
はコンパイル時に静的に作られますが、ジェネリックな型と関数型、タプルなどのnon-nominalな型については実行時に動的に作られます。
このType metadata
には
- その型の種類(
enum
なのかstruct
なのか、それともclass
なのか) -
ValueWitnessTable
(型を操作するための関数群) - 型パラメータ
- タプルのラベル
などの情報が含まれています。型の種類によってレイアウトが大幅に違うので逐一ドキュメントを読んでいくのがオススメです。
Type metadata
をSwiftから扱う
では、Type metadata
を使ってSwiftからクラスのインスタンスサイズを取得してみましょう。
Type metadata
から目的の情報を取得するには、その情報がメタデータのメモリレイアウト上のどの位置に存在するかを知る必要があります。swift/TypeMetadata.rst を参考にメモリレイアウトを再現していくと以下のようになります。
struct ClassMetadata {
let isaPointer: Int
let superClass: Any.Type
let objcRuntimeReserved1: Int
let objcRuntimeReserved2: Int
let rodataPointer: Int
let classFlags: Int32
let instanceAddressPoint: Int32
let instanceSize: Int32
}
さて、メモリ上の表現が分かったので実際にType metadata
を取得してみましょう。
SwiftにおけるMetatype
はその型のインスタンスのType metadata
へのポインタになっています。とりあえずunsafeBitCast
でポインタ型にキャストして試してみます。
class Cat {}
let catType: Cat.Type = Cat.self
MemoryLayout.size(ofValue: Cat.self) // 8
let metadataPointer = unsafeBitCast(catType, to: UnsafePointer<ClassMetadata>.self)
let metadata = metadataPointer.pointee
print(metadata.instanceSize) // 16
目的のインスタンスサイズが取得できました
しかし、この方法でstruct
のメタデータを取得しようとしてもうまくいきません。
struct Stone {}
let stoneType: Stone.Type = Stone.self
MemoryLayout.size(ofValue: Stone.self) // 0
let metadataPointer = unsafeBitCast(stoneType, to: UnsafePointer<StructMetadata>.self)
let metadata = metadataPointer.pointee // Crash! :bomb:
どうやらType metadata
を正しく使うにはもう少し知るべきことがあるようです。
Thin Metatype
struct
のメタタイプのサイズが0になってしまう原因を探るために、メタタイプを生成しているコンパイラのコードを読んでみましょう。
/// Emit a metatype value for a known type.
void irgen::emitMetatypeRef(IRGenFunction &IGF, CanMetatypeType type,
Explosion &explosion) {
switch (type->getRepresentation()) {
case MetatypeRepresentation::Thin:
// Thin types have a trivial representation.
break;
case MetatypeRepresentation::Thick:
explosion.add(IGF.emitTypeMetadataRef(type.getInstanceType()));
break;
case MetatypeRepresentation::ObjC:
explosion.add(emitClassHeapMetadataRef(IGF, type.getInstanceType(),
MetadataValueType::ObjCClass,
MetadataState::Complete));
break;
}
}
型の表現方法がMetatypeRepresentation::Thin
になっているとランタイム情報が出力されていません。では、MetatypeRepresentation
とは何でしょう。
enum class MetatypeRepresentation : char {
/// A thin metatype requires no runtime information, because the
/// type itself provides no dynamic behavior.
///
/// Struct and enum metatypes are thin, because dispatch to static
/// struct and enum members is completely static.
Thin,
/// A thick metatype refers to a complete metatype representation
/// that allows introspection and dynamic dispatch.
///
/// Thick metatypes are used for class and existential metatypes,
/// which permit dynamic behavior.
Thick,
/// An Objective-C metatype refers to an Objective-C class object.
ObjC
};
SwiftのメタタイプはThick、Thin、ObjCの3種類に分類されています。
- Thin
-
struct
やenum
などの静的に挙動が決まる型
-
- Thick
-
class
などの動的な挙動をする型
-
- ObjC
- Objective-C由来の型
Thinな型は静的に振る舞いが決まるためランタイム情報が必要にならず、メタタイプのサイズが0になります。これがstruct
のメタタイプの取得が失敗した理由です。
protocol Animal {
static func kind() -> String
}
struct Cat {
static func kind() -> String {
return "猫"
}
}
let catType: Cat.Type = Cat.self // Thin
let animalType: Animal.Type = Cat.self // Thick
animalType.kind() // 猫
一方で上記の例のようにメタタイプのサブタイピングによってThin型をThick型に代入することができます。
またanimalType.kind()
が正しく動作することから、Existentialのメタタイプには実際のメタタイプが保持されていることも分かります。つまり、Existential Metatypeを経由すればThin型のType metadata
も取得できそうです。
Existential Metatype container
Existential Metatype containerはExistential Metatypeの実行時表現で、Existential containerのメタタイプ版のようなものです。内部に実際の型のインスタンスのType metadata
とwitness tableへのポインタ保持しています。
swift/GenExistential.cpp
struct ExistentialMetatypeContainer<Metadata> {
let metadata: UnsafePointer<Metadata>
let witnessTable: UnsafePointer<WitnessTable>
}
これを使って先程失敗したstruct
のメタデータを取得してみましょう。
struct StructMetadata {
let kind: Int
}
protocol Animal {}
struct Cat: Animal {}
let animalType: Animal.Type = Cat.self
let metatypeContainer = unsafeBitCast(animalType, to: ExistentialMetatypeContainer<StructMetadata>.self)
metatypeContainer.metadataPointer.pointee.kind // 1
struct metadataのkindは1であるため、無事目的のType metadata
が取得できたことが分かります。
しかし、この方法には一つ問題点があります。メタデータを取得したいThin型を何かしらのプロトコルに準拠させなければExistential Metatype containerに詰めることができないのです。
Any.Type
しかし、我々は全ての型のsupertypeとして振る舞うAny
を持っています。加えてAnyはnon-nominalであるためThickな型として振る舞い、ランタイム情報を保持しています。
struct Stone {}
let stoneType: Any.Type = Stone.self
let metadataPointer = unsafeBitCast(stoneType, to: UnsafePointer<StructMetadata>.self)
let metadata = metadataPointer.pointee
metadata.kind // 1
こうして安全(?)なType metadata
の取得方法が確立しました
まとめ
Type metadata
は実際にServer Side SwiftのフレームワークZewoなどで使われており、Swiftの言語機能を拡張できる夢の情報源です。一方で、ドキュメント化されていない部分が多く、変更が加えられる可能性もあります。使う際は入念にテストを書くなどの対策が必要です。
楽しく適切にType metadata
と戯れましょう。
参考
Swiftにおけるコードの書き方や表現方法の考察
はじめに
Swift Advent Calendar 2018 の 16 日目です。
AdventCalendar初参加させていただきます。
@tattnさんのBetter Swiftと少し趣旨が被っているなと感じていますが
そこはご了承いただけますと幸いです。
(月初には9割方書いており、テーマを変更する余裕がありませんでした)
今回の内容の動機
11月に転職をしてiOS専任のエンジニアになりました。(今のところ)
これまでは人数が少ない受注開発の会社に勤めており、
1人1人がプロジェクト単位で
複数のプロジェクトを受け持つというスタイルで開発していました。
そのため
コードレビューという経験がほとんどなく
インターネットや書籍で調べ
サンプルを作成して検証して
これは確からしいことを確認して実装する
というスタイルで開発を進めることがほとんどでした。
今回
ご縁をいただいて転職し
ほぼ始めてのチーム開発を経験している中で
「どうやって他の人にとっても読みやすいコードを書くか」
「どうしたら自分の意図することを他の人に伝えることができるのか」
といったことに対する意識を持つようになりました。
今回は
そんな中でコードの書き方やデータの表現方法について
学んでいることや考えていることについて書きたいと思います。
名前の付け方
名前の付け方って本当に重要で難しいなと感じることが増えました。
わかりやすい名前をつければ、読む側の負担も減らせますし
後々改修作業を行う際に何をしたいのかが明確であれば
思い出すという作業時間が減り、すぐに改修作業が始められます。
(名前とやっていることが一致しているという前提ですが、、、)
具体的にこれはわかりやすいなと感じた例をご紹介します。
〇〇IfNeeded
初期化処理(initなど)を書き方について出てきた話です。
今までは
処理の最後にguard文を書き
処理がなければ処理を終わらせる
という形で書いていることが多くありました。
ところが
こう書いてしまうと
後からメンテナンスするときに
どこに処理を追加する必要があるか見通しが悪くなってしまう
という指摘を受け
確かに意図として十分に伝えられていないなと思いました。
さらに
if文で値があった時のみ処理を行うという書き方をすると
もし複数のif文があった場合
initの中がifだらけで複雑になってしまい
見づらくなる可能性もあるということも考えられます。
そこで出てきたのが〇〇IfNeededでした。
こうすることで
必要なときだけ処理を行なっている
ということが明示できます。
AppleでもlayoutIfNeededなどのメソッドで使っていたり
他の有名なオープソース化されている多くのアプリでも
使われているのが確認できました。
How視点とWhat視点
ちょっと広い話になりますが、
あるデータ構造に名前をつける場合は、
どう使われるか(How)
よりも
何をするものなのか(what)
を考えつけた方が良いなと感じることが多いです。
これはHowで考えると焦点が具体的過ぎて
役割が限定的になり過ぎてしまうからであると考えています。
例えば
ある投票結果を集計するアプリがあるとします。
アプリの要件としては
投票結果のトップ3を表示する
というものだとします。
ここで、この集計結果を表現する場合に考えられる型名としては、
enum VoteType {
case coffee
case sport
}
struct VotedItem {
let type: VoteType
let name: String
let number: Int
}
struct TopThreeItemList {
let items: [VotedItem]
func topThree(of type: VoteType) -> [VotedItem] {
let items = self.items.filter { $0.type == .sport }
.sorted(by: { $0.number < $1.number })
.reversed().prefix(3)
return Array(items)
}
}
let items = [
VotedItem(type: .sport, name: "Baseball", number: 100),
VotedItem(type: .sport, name: "Football", number: 200),
VotedItem(type: .sport, name: "Golf", number: 300),
VotedItem(type: .sport, name: "Basketball", number: 400),
]
let itemList = TopThreeItemList(items: items)
let topThree = itemList.topThree(of: .sport)
みたいなものが考えられます。
ここで仕様追加が入り、ワースト3も出したいとなった場合、どうなりますでしょうか?
まず考えられることとしては、新しい型を定義します。
struct WorstThreeItemList {
let items: [VotedItem]
func worstThree(of type: VoteType) -> [VotedItem] {
let items = self.items.filter { $0.type == .sport }
.sorted(by: { $0.number < $1.number })
.prefix(3)
return Array(items)
}
}
let worstItemList = WorstThreeItemList(items: items)
let worstThree = worstItemList.worstThree(of: .sport)
これはこれで問題なく動きますが、
なんだか似たようなコードが増えてしまっている気がします。
仕様追加があった場合はどんどん増えていくことになりそうです。
では、Whatで考えてみるとTopThreeItemListは何をしていますでしょうか?
「投票結果を集めるもの」と考えるとどうでしょうか?
struct VoteResultAggregator {
let items: [VotedItem]
func extract(type: VoteType, sortedBy: (VotedItem, VotedItem) -> Bool) -> [VotedItem] {
return self.items.filter { $0.type == type }.sorted(by: sortedBy)
}
}
let topSportItemList = VoteResultAggregator(items: items)
.extract(type: .sport, sortedBy: { $0.number > $1.number })
.prefix(3)
let worstSportItemList = VoteResultAggregator(items: items)
.extract(type: .sport, sortedBy: { $0.number < $1.number })
.prefix(3)
こうすると、型が増えることもなく色々なパターンに対応できます。
もっと抽象化して「何かを集計するもの」と捉えることもできます。
struct Aggregator<V> {
let items: [V]
func extract(filter: (V) -> Bool, sortedBy: (V, V) -> Bool) -> [V] {
return self.items.filter(filter).sorted(by: sortedBy)
}
}
このようにWhatで考えた方が、より抽象的に広い範囲で考えることができ、
多くのケースを網羅できるようになるのではないかと思っています。
もちろんこれは型の使用範囲によると思います。
例えばある型の中のインナークラスなどローカルなものとして使用する場合は
逆にHowに焦点を当てた方が意図がわかりやすい場合もあります。
ただ、今回のケースように
結構広い範囲で使われることが想定される場合や
変更がありそうな場合は
Whatで考えていった方が後々楽になることが多かったと思っています。
より良い名前を付けるには?
どうしたら良い名前がつけられるかということを学ぶために
個人的に参考にしている方法を紹介します。
APIデザインガイドラインを見る
https://swift.org/documentation/api-design-guidelines/
Swiftプログラマとしては
まず読むべきであると個人的には考えています。
理由として
Appleが提唱しているから正しい
というよりも
エンジニア間で共通の理解を持てる
からです。
ある意味デザインパターンと
同じような役割をしており
ある名前を聞けば
何をするのかがわかるので
認識合わせの手間や誤解を招くリスクが減らせます。
リーダブルコードを読む
これは非常に有名な本で
ページ数が少なく
内容も専門的なことは書かれていないため
とても読みやすいです。
どうしたら伝わるコードが書けるのか
といったことが網羅されています。
https://www.oreilly.co.jp/books/9784873115658/
※実はAmazonでは売っていない
電子書籍版があることを最近知って
買い直してしまいました
有名なオープンソースアプリを眺めてみる
github上で
- スターが多くついている
- 現在でも開発が行われている
ようなアプリのリポジトリを見てみると
使う前置詞が統一されていたり
直感的にわかりやすい名前をたくさん発見しました。
初めて見ても
これはこういうことをするんだな
とわかるような名前を参考にしてみると良いかもしれません。
具体的には下記のようなリポジトリを参照しています。
https://github.com/kickstarter/ios-oss/tree/master/Kickstarter-iOS
https://github.com/artsy/eidolon
https://github.com/wordpress-mobile/WordPress-iOS
https://github.com/wireapp
https://github.com/mozilla-mobile/firefox-ios
条件分岐の書き方
これも1人で開発していた時はあまり意識していなかったところで
- 各条件でどういう処理が起きるのかをどうやって伝えるか
- 将来的なミスを起きないようにできるか
などを考えるようになりました。
そんなきっかけになったいくつかの例を紹介します。
早期returnとif else
基本的に早めにreturnできる時は
returnしようと考えており
下記のような書き方をしていました。
// ある条件のtrue, falseで処理が変わる場合
if 条件がfalseの場合 {
return
}
// もう一つの処理
...
しかし
早期returnがあると
選択肢と思わず何かエラーがあったり
存在するはずの値が存在しないという印象を持つ
という意見がありました。
読む側にとっては
まずその可能性を考え
次の処理で初めて
これは条件分岐なんだとわかるので
2段階考える必要が出てくる
とのことでした。
場合によって
コードの書き方を変える必要があるんだな
という意識を持つようになりました。
追記
コメントでもご指摘いただいたのですが、
ここに書いてあることの前提条件として
副作用が生じない場合
という前提が抜けておりました。
副作用が生じる場合は
いただいたコメントの通り
条件分岐の中で生じる副作用について
条件分岐を抜けるまで考慮しておかなければいけない状態になってしまうので、
そういう場合は早期returnをするようにしています。
私の書き方の配慮不足でした申し訳ございません。
switch文のdefault
いくつかのcaseの場合だけ処理をする場合、
下記のように書いていました。
enum Animal {
case dog, cat, fish, bird
}
func run(animal: Animal) {
switch animal {
case .dog:
print("dog run")
case .cat:
print("cat run")
default:
print("I can not run!!!!!")
}
}
func fly(animal: Animal) {
switch animal {
case .bird:
print("bird fly")
default:
print("I can not fly!!!!!")
}
}
この書き方ですと
caseが追加された場合でもdefaultの動作をします。
Xcode上で検索をすれば
使われている場所はわかりますし
何もしないcaseを列挙するのは
正直面倒だなと思っていました。
ただ
これも自分1人ならわかる話ですが
他の人がcaseを追加した場合などは
気がつかない可能性があります。
そのため
下記のように書き換えます。
func run(animal: Animal) {
switch animal {
case .dog:
print("dog run")
case .cat:
print("cat run")
case .fish, .bird:
print("I can not run!!!!!")
}
}
func fly(animal: Animal) {
switch animal {
case .bird:
print("bird fly")
case .dog, .cat, .fish:
print("I can not fly!!!!!")
}
}
こうすることで
新しいケースを追加すると
コンパイルエラーが起き
漏れを防ぐことができます。
当初はcaseの列挙が増えるのは
大変だなと思いましたが
そもそもそういう場合が多いということは
このenum自体が妥当かどうか
を検討した方が良いと考えるようになりました。
Swift5の@unknown属性
Swift5からは
@unknownをdefaultに付けることで
警告を出してくれるようになるようです。
https://github.com/apple/swift-evolution/blob/master/proposals/0192-non-exhaustive-enums.md
func fly(animal: Animal) {
switch animal {
case .bird:
print("bird fly")
@unknown default:
print("I can not fly!!!!!")
}
}
こうすることでcaseが追加される可能性を示すことができるようになります。
ただ
エラーにはならないですし
@unknownをつけておらず
後で新しいcaseが追加されたような場合は
やはり気づかないかもしれません。
Optionalなenumをswitch文で分岐する
Optionalなenumを扱う場合
まずはnilチェックをするようにしていました。
enum Weapon {
case sword, arrow, bow
}
func attack(weapon: Weapon?) {
guard let weapon = weapon else {
print("hand attack")
return
}
switch weapon {
case .sword:
print("sword attack")
case .arrow:
print("arrow attack")
case .bow:
print("bow attack")
}
}
しかし
こうすると
選択肢として武器がない場合が
特別なcaseのように見えてしまいます。
ここでコンパイラの力を借りようと思います。
func attack(weapon: Weapon?) {
switch weapon {
case .sword?:
print("sword attack")
case .arrow?:
print("arrow attack")
case .bow?:
print("bow attack")
case nil:
print("hand attack")
}
}
Optionalなenumの場合
caseの後ろに?をつけます。
またcase nil(もしくは.none)も必要です。
見づらいようにも思えますが
ない場合はコンパイルエラーになるため
明示的に必要なことに気がつけます。
追記
コメントでご指摘いただいたのですが
そもそ組み合わせを網羅できるはずのenumが
Optionalになっていること自体が変なのかもしれません。
上記の例でも
武器が存在しない場合(素手という武器)という
選択肢を追加することで
Optionalである必要がなくなります。
私の中で存在しないという状態を示すのがnilである
という前提ができてしまっていることに
気がつくことができました
Optionalを含んだtupleをswitch文で分岐する
例えば
Memberという型が
Optionalなfirstとsecondというプロパティ
を持っているとします。
struct Member {
let id: Int
var first: String?
var second: String?
}
ここにdisplayNameという
Computedプロパティを追加します。
firstとsecondが存在する場合に
⭐️を間に差し込む仕様だとします。
struct Member {
...
var displayName: String {
var name: String = ""
if let first = first {
name += first
}
if let second = second {
if first != nil {
name += "⭐️"
}
name += "\(second)"
}
return name
}
}
let member = MemberName(id: 1, first: "スター", second: "です")
print(member.displayName) // スター⭐️です
let member2 = MemberName(id: 1, first: "スター", second: nil)
print(member2.displayName) // スター
let member3 = MemberName(id: 1, first: nil, second: "です")
print(member3.displayName) // です
let member4 = MemberName(id: 1, first: nil, second: nil)
print(member4.displayName) //
どういう条件で何が起きるのか
ちょっと見づらいですね。
こうしたらどうでしょうか?
struct Member {
...
var displayName: String {
var stringArray: [String] = []
if let first = first {
stringArray.append(first)
}
if let second = second {
stringArray.append(second)
}
return stringArray.joined(separator: "⭐️")
}
}
let member = MemberName(id: 1, first: "スター", second: "です")
print(member.displayName) // スター⭐️です
let member2 = MemberName(id: 1, first: "スター", second: nil)
print(member2.displayName) // スター
let member3 = MemberName(id: 1, first: nil, second: "です")
print(member3.displayName) // です
let member4 = MemberName(id: 1, first: nil, second: nil)
print(member4.displayName) //
少し見やすくなりましたが
これでもちょっとわかりづらいような気がします。
そこで
switch文を活用します。
struct Member {
...
var displayName: String {
switch (first, second) {
case (let first?, let second?):
return "\(first)⭐️\(second)"
case (let first?, nil):
return "\(first)"
case (nil, let second?):
return "\(second)"
case (nil, nil):
return ""
}
}
}
let member = MemberName(id: 1, first: "スター", second: "です")
print(member.displayName) // スター⭐️です
let member2 = MemberName(id: 1, first: "スター", second: nil)
print(member2.displayName) // スター
let member3 = MemberName(id: 1, first: nil, second: "です")
print(member3.displayName) // です
let member4 = MemberName(id: 1, first: nil, second: nil)
print(member4.displayName) //
こうすると
caseで条件と出力内容が列挙できるので
わかりやすくなった気がします。
空文字を返すか、Optionalを返すか
上記の例ですと
firstとsecondいずれもnilの場合
空文字を返しています。
Optionalを返すと
毎回のnilチェックや??で
値の設定をしなければいけない点が面倒なため
空文字を返していることって
意外とあるのではないでしょうか?
しかし
状況によっては
思わぬ不具合に繋がる可能性もあります。
例えば
ユーザに何かの招待状を送るとします。
func sendInvitation(to name: String) {
print("\(name)様、せひお越しください!!!")
}
これにfirst、secondがnilのユーザに招待状を送った場合
func sendInvitation(to name: String) {
print("\(name)様、せひお越しください!!!")
}
let member4 = MemberName(id: 1, first: nil, second: nil)
sendInvitation(to: member4.displayName)
// 様、せひお越しください!!!
となります。
これが意図した動作だとしたら問題ないのですが
気がつかないで誰かが埋め込んでしまったとしたら
実行されるまで気がつかない可能性があります。
これをdisplayNameをOptionalにしていた場合
コンパイルがOptionalチェックを強制してくるため
少なくともnilになる可能性がある
ということに気がつけます。
Optionalにする必要があるのが限定的であるならば
別の役割を持つ別のプロパティとするのもありなのかもしれません。
データの表現方法
あるデータを型で表現する場合
特に意識しなければ
まずはstructで考えるようにしていました。
しかし
他の表現方法の可能性を検討することが
増えていると感じています。
特に感じるのは
enumを活用する場合が増えています。
いくつか例をご紹介します。
structをenumに変換する
会員制サイトのUserを示す型があるとします。
そしてUserには4種類の会員が存在します。
enum UserType {
case normal
case gold
case silver
case bronze
}
Userをstructで表す場合、下記のようになります。
struct User {
let registeredDate: Date
let needDiscount: Bool
let userType: UserType
}
ここで作成できるUserの組み合わせは非常にたくさんあります。
四則演算で表すと
Bool(2) x Date(たくさん) x UserType(4)
になります。
次にenumで表現してみます。
enum User {
case normal(registeredDate: Date, needDiscount: Bool)
case gold(registeredDate: Date, needDiscount: Bool)
case silver(registeredDate: Date, needDiscount: Bool)
case bronze(registeredDate: Date, needDiscount: Bool)
}
四則演算で表すと
Bool(2) x Date(たくさん)
+ Bool(2) x Date(たくさん)
+ Bool(2) x Date(たくさん)
+ Bool(2) x Date(たくさん)
になります。
つまり
(Bool(2) x Date(たくさん)) x UserType(4)
となり
実はここで作成できるUserの組み合わせは
structの場合と同じです。
しかし
enumだと全体として考える必要のあるパターンは
4つに限定されました。
enumの方が同時に扱えるパターンが一つに決められ
考える必要のある数も絞れるため
個人的にはenumを使った方がより良いのではないかと思っています。
会員の種類別で処理が分岐するような場合
structの場合だと
user.registeredDate
などuser.を付ける必要がありますが、
enumの場合
Userの型自体で分岐ができ
より扱いやすくなります。
間違いが起きそうな文字列の扱いをenumで吸収する
enumを活用することで
文字列をそのまま扱うことによる間違い
のリスクを軽減させることができます。
例えば
ファイルをアップロードする画面があり
ユーザは様々な拡張子のファイルを
アップロードすることができるとします。
その際にアップロードされた拡張子によって
表示するメッセージを変えるような処理があるとします。
※本来はメタデータのチェックなど必要ですが
今回の趣旨から外れるため割愛させていただきます。
func showMessage(for fileExtension: String) {
switch fileExtension {
case "jpg":
print("This is jpg")
case "png":
print("This is png")
case "gif":
print("This is gif")
case "bmp":
print("This is bmp")
default:
print("Invalid!!!")
}
}
showMessage(for: "jpg") // This is jpg
これは正常に動きます。
しかし
いくつかのリスクを含んでいます。
jpgはjpegやJPEGという場合もありえます。
このような場合
This is jpg
と出力されて欲しいのに
Invalid!!!
と出力されます。
また
同じ文字列をメソッドの引数として
繰り返し使用するような場合
全てのメソッドで文字列の妥当性をチェックをする
または、
ずっと間違えた状態で処理が継続する
といったことが起きます。
また
新しい拡張子が追加されたけれども
あるメソッド処理に処理を追加し忘れた場合
コンパイルは問題なく通ってしまい
実行時の動作は意図したものになりません。
これを解消するために
enumを活用します。
enum ImageExtension: String {
case jpg
case png
case gif
case bmp
init?(rawValue: String) {
switch rawValue.lowercased() {
case "jpg", "jpeg":
self = .jpg
case "png":
self = .png
case "gif":
self = .gif
case "bmp":
self = .bmp
default:
return nil
}
}
}
func showMessage(for imageExtension: ImageExtension) {
switch imageExtension {
case .jpg:
print("This is jpg")
case .png:
print("This is png")
case .gif:
print("This is gif")
case .bmp:
print("This is bmp")
}
}
ポイントとしては
failable initializer
を活用している点です。
まずlowercasedを使うことで
大文字小文字の区別をなくします。
その後
複数の文字列がマッチする可能性のある拡張子は
複数の文字列のcaseを受け取れるようにしています。
さらに
どのケースにも当てはまらない場合は
nilを返します。
こうすることで
まず拡張子の文字列が妥当かどうかのチェックをしたあとに
処理を続けることができます。
guard let imageExtension = ImageExtension else {
// エラー処理
}
showMessage(for: imageExtension) // This is jpg
こうすると
文字列で新しいを追加したい場合も
まずenumにcaseを追加することで
自動でコンパイルエラーになってくれます。
もちろんケース自体を追加し忘れた場合はどうにもなりませんが
OptionalなBoolをenumとして扱う
Boolといえば
true
false
の2択を表す型ですが、
Swiftの場合、
Bool?
とすると、
true
false
nil
の3パターンの可能性があります。
例えば
APIの戻り値で下記のような値が返ってくるとします。
let returned: [String: Any] = [
"autoLogin": false, "UserId": 1, "canUseSpecial": true]
この中のをcanUseSpecial取り出すとBool?になります。
let canUseSpecial = returned["canUseSpecial"] as? Bool
print(canUseSpecial) // Optional(true)
Optionalなまま扱うのはちょっと気持ち悪いですね。
ではどう対処するか?
例えば
nilはfalse
として扱うとみなして
default値を設定してみるとどうでしょうか?
let canUseSpecial = returned["canUseSpecial"] ?? false
print(canUseSpecial) // false
これでBoolとして扱えるようになりました。
しかし
これは必ず意図した動作になりますでしょうか?
例えば、上記の例で
trueもしくは未設定の場合、設定ページを開く
という動作をさせたいとしたら
どうなりますでしょうか?
let canUseSpecial = returned["canUseSpecial"] ?? false
...
if canUserSpecial {
goToSettingPage()
}
この場合、
意図した動作とは逆になってしまいます。
一概にnilの場合はfalseとできない可能性がある
ということです。
ではどうするか?
3つの状態を持つenumにしてみるのはどうでしょうか?
enum UseSpecial: RawRepresentable {
case enabled
case disabled
case notSet
init(rawValue: Bool?) {
switch rawValue {
case true?:
self = .enabled
case false?:
self = .disabled
default:
self = .notSet
}
}
var rawValue: Bool? {
switch self {
case .enabled:
return true
case .disabled:
return false
case .notSet:
return nil
}
}
}
let returned: [String: Any] = ["autoLogin": false, "UserId": 1]
let canUseSpecial = returned["canUseSpecial"] as? Bool
let state = UseSpecial(rawValue: canUseSpecial)
print(state) // notSet
RawRepresentableに準拠することで
Bool?からの変換が可能になっています。
こうしておくと
ユーザがどういう設定をしているのか(またはしていないのか)
がわかり
より明確にユーザの状態を
把握することができるようになります。
Protocolである必要性を考える
WWDC2015で
AppleがProtocol Oriented Programmingを提唱して以来
Protocolを中心にコードを組み立てる人が
多くなったのではないでしょうか?
Protocolのメリットとして
- 様々な型を同じように扱うことができる
- デフォルト実装で同じ処理書く必要がなくなる
など多くの恩恵を受けることができます。
しかし
Protocolが適さない場合もあるような気がしています。
例えば
以下の2つはいかがでしょうか?
associatedtypeやSelfを使ったProtocolを型として扱う
公園で遊べるものを表すProtocolと
それに準拠した具体的な遊び方を表すstructがあるとします。
protocol ParkPlayable: Hashable { func play() }
struct Baseball: ParkPlayable {
func play() { print("Enjoy Baseball!") }
}
struct Football: ParkPlayable {
func play() { print("Enjoy Football!") }
}
各遊び方で何人まで遊べるのかを知りたいので
ParkPlayableをキーにDictionaryで保持 しようとします。
// error: using 'Playable' as a concrete type conforming to protocol 'Hashable' is not supported
var numbers: [Playable: Int] = [:]
これはエラーです。
理由はHashableがEquatableを継承しており、
EqatableでSelfが使用されているため
具体的な型として使えないからです。
ではどうすれば良いか?
一つの手段としてTypeEraserとしてAnyParkPlayable型を作成します。
※TypeEraserことはこちらに大変詳しくまとめられておりますので
リンク先の紹介のみとして割愛させて頂きます。
https://qiita.com/omochimetaru/items/5d26b95eb21e022106f0
struct AnyParkPlayable: ParkPlayable {
private let _play: () -> Void
private let _hashable: AnyHashable
init<S: ParkPlayable>(_ sport: S) {
self._play = sport.play
self._hashable = AnyHashable(sport)
}
func play() {
self._play()
}
}
extension AnyParkPlayable: Hashable {
func hash(into hasher: inout Hasher) {
_hashable.hash(into: &hasher)
}
static func ==(lhs: AnyParkPlayable, rhs: AnyParkPlayable) -> Bool {
return lhs._hashable == rhs._hashable
}
}
// OK
var numbers: [AnyParkPlayable: Int] = [
AnyParkPlayable(Baseball()): 100,
AnyParkPlayable(Football()): 200
]
エラーはなくなりました。
しかし
これを表現するために
- AnyParkPlayableクラスの作成
- AnyParkPlayableをHashableに準拠させるための実装
が必要になりました。
さらに
処理が複雑で何をしているのかが
パッと見てわかりづらくなっている
ようにも思えます。
本当にProtocolを用いる必要はあるのでしょうか?
例えば
enumを使ってみます。
struct Baseball: Hashable {
func play() { print("Enjoy Baseball!") }
}
struct Football: Hashable {
func play() { print("Enjoy Football!") }
}
enum ParkPlay: Hashable {
case baseball(Baseball)
case football(Football)
func play() {
switch self {
case .baseball(let baseball):
baseball.play()
case .football(let football):
football.play()
}
}
}
var numbers: [ParkPlay: Int] = [
.baseball(Baseball()): 100,
.football(Football()): 200,
]
print(numbers.values)
たったこれだけで済みます。
caseがたくさんある場合や
デフォルト実装をもっと活用したいといった場合は
Protocolを活用した方が良いことが多くなってくると思います。
しかし
caseが限られていて
型としてまとめて使用したい場合などでは
enumの方が簡単に使える場合もあるのではないかと
今回のような場合を考えると感じられます。
ある一つの処理を複数のタイプで使用する
バリデーションチェックをすることを表すProtocolがあるとします。
protocol Validatable {
associatedtype Value
func validate(_ value: Value) -> Bool
}
struct MinLength: Validatable {
let minLength: Int
func validate(_ value: String) -> Bool {
return value.count >= minLength
}
}
let min3Length = MinLength(minLength: 3)
min3Length.validate("aaa") // true
min3Length.validate("aa") // false
これでも十分に動きますが、
一つ一つのチェックに対して毎回型を宣言する必要が出てきます。
ちょっと面倒な気がしますね。
例えば
genericな型を持ったstructにしてみるとどうでしょうか?
struct Validator<Value> {
let validate: (Value) -> Bool
}
let min3Length = Validator<String> { string in
return string.count >= 3
}
min3Length.validate("aaa") // true
min3Length.validate("aa") // false
genericなValueを使用しているため
どんな型に対しても使用できます。
また
型を宣言する必要がないため
こちらの方が簡単に生成できるように感じられます。
さらに以下のメソッドを追加してみます。
extension Validator {
func combine(_ other: Validator) -> Validator<Value> {
return Validator { value in
return self.validate(value) && other.validate(value)
}
}
}
こうすることでチェックを組み合わせることができ、
より高度なチェックも簡単することができます。
let min5Length = Validator<String> { string in
return string.count >= 5
}
let notEmpty = Validator<String> { string in
return !string.isEmpty
}
let nonEmptyAndMin5 = notEmpty.combine(min5Length)
nonEmptyAndMin5.validate("") // true
nonEmptyAndMin5.validate("aaaaaa") // false
これをProtocolで実現しようとすると
新しい型が必要になり
いわゆるボイラープレートが増えていきます。
Protocolは大変便利で使いどころは非常に多くあるとは思いますが
一概に
Protocolを使うのがベスト
と考えるのではなく
他の選択肢の可能性にも目を向ける必要があるな
と思うことが増えました。
エラー処理
エラーの種類
エラーは大きく3つに分かれています。
- プログラミング上のエラー(arrayのout of boundsや0で割り算をするなど)
- ユーザが起こすエラー (間違った入力や設定ミスなど)
- システムが起こす実行時のエラー (容量上限でファイルが作成できない、ネットワークに繋がらないなど)
この中で最初の2つは
下記のような方法で防げる可能性が高まります。
プログラミング上のエラー
-> ユニットテストやassertを書いて開発中にミスに気がつけるようにするユーザが起こすエラー (間違った入力や設定ミスなど)
-> より意図が伝わりやすくするようにUIを変える。説明を加える
しかし
システムが起こす実行時のエラーは
その時の状況によって発生するかしないかもわからないため
適切にエラーに対処する必要があります。
Swiftのエラー処理方法
Swiftではエラーの処理方法が4つに分かれていると書かれています。
https://docs.swift.org/swift-book/LanguageGuide/ErrorHandling.html
- エラーを呼び出し側に伝播させる
- do-catch文
- Optionalとして扱う
- エラーが起きないことをassertで宣言する
また
こちらの記事などに詳しくまとめられています。
https://qiita.com/koher/items/a7a12e7e18d2bb7d8c77
プロジェクトによって
エラーの処理方法は異なると思いますが
対処方法として検討できるものをいくつかご紹介します。
Quick Help用のドキュメントを作成する
Swiftのthrowsは
その関数やメソッドが
どんなErrorを投げるのかを明示できません(Swift4.2時点)
そのため
関数やメソッドを追っていく必要があります。
そういった時に
alt + クリック
で表示されるQuick Helpに
throwする可能性のあるエラーの内容が出てくると
便利です。
関数やメソッドにカーソルを合わせて
cmd + alt + /
でテンプレートが生成されるので
そこにエラーの種類を記述するだけです。
記載すると下記のようにQuick Helpにエラーが出てきます。
throwsを活用する
Optionalを返す方法は楽ですが、
個人的にはthrowsを使った方が良いと考えています。
なぜならば
Optionalはエラーに関する情報を提供しないため
デバッグ時など原因を探すのに苦労をするケースがあるからです。
さらに
Optionalで良い、となった場合でも
try?を使うことで
呼び出し側でOptionalとthrowsの両方の処理の仕方に対応できます。
色々な例を考えてみたのですが
上記で記載したValidatorと同じような例で
throwsを活用した非常にわかりやすい記事があり
これは参考にしたいと思ったので紹介させて頂きます。
Using errors as control flow in Swift
上記で記載したValidatorの場合、結果がtrueかfalseしかわからず
どの項目がエラーになっているのかなどの詳細情報がわかりません。
そこで
throwsを使った形に変換してみます。
struct Validator<Value> {
let closure: (Value) throws -> Void
}
※関数名と衝突している関係上、変数名がclosureになっています。
さらに
このままですと無数のErrorに準拠した型を作成することになるため
共通のエラー用の型を定義します。
struct ValidationError: LocalizedError {
let message: String
var errorDescription: String? { return message }
}
※LocalizedErrorに関しては後ほど紹介しておりますが
ユーザに表示するためのエラーメッセージを定義します。
次に
これを利用した関数を定義します。
func validate(
_ condition: @autoclosure () -> Bool,
errorMessage messageExpression: @autoclosure () -> String
) throws {
guard condition() else {
let message = messageExpression()
throw ValidationError(message: message)
}
}
以下のように利用します。
let userNameValidator = Validator<String> { value in
if value.count < 5 {
throw ValidationError(message: "5文字以上で入力してください")
}
}
do {
try userNameValidator.closure("me")
} catch {
print(error.localizedDescription) // 5文字以上で入力してください
また
もっと簡単に利用するために下記のような方法も紹介されていました。
func validate<T>(_ value: T,
using validator: Validator<T>) throws {
try validator.closure(value)
}
こちらは以下のように利用します。
extension Validator where Value == String {
static var userNumber: Validator {
return Validator { string in
try validate(
!string.isEmpty,
errorMessage: "文字を入力してください"
)
try validate(
Int(string) != nil,
errorMessage: "数字のみ入力してください"
)
}
}
}
func showMessageIfValid(with input: String) throws {
try validate(input, using: .userNumber)
print("validation ok")
}
do {
try showMessageIfValid(with: "") // 文字を入力してください
} catch {
print(error.localizedDescription)
}
do {
try showMessageIfValid(with: "aaaaaaa") // 数字のみ入力してください
} catch {
print(error.localizedDescription)
}
ちょっと横道に逸れてしまいましたが
このようにすることで
catchしてエラー情報を取得することができます。
また
Validationのチェックも汎用的にできるので良いなと感じました。
エラーになった際に変更を元に戻す方法を考える
トランザクションのコールバックのように
関数やメソッドで何かの状態を変更していた場合
エラー発生時にはその状態を元に戻す必要が出てきます。
これに対処する方法としてはいくつか考えられます。
throwsする関数、メソッドでそもそも状態を変更しない
矛盾しているようですが
そもそも状態を変更しなければ
戻す必要もなくなります。
可能かどうか検討してみる価値はあると思います。
一時変数に変更を加えていく
こちらもそもそも状態を変更しないに近いですが
変更したい値をコピーした値に対して処理を加え
正常時は一時変数を返し
エラー時は元の値を返す。
そうすれば
エラー時に何か特別な処理をする必要もなくなります。
deferの中に後始末の処理を書く
deferを使うことで
関数やメソッドのどの時点でエラー投げられたとしても
最終的な後片付けをすることができます。
func throwError() throws {
defer { print("後始末します")}
throw UnbelievableError.unbelievable
}
throwError() // 後始末します
ただし
下記の場合はdeferが出力されないので
defer文は関数やメソッドの上の方に書くのがよいかと思います。
func throwError() throws {
throw UnbelievableError.unbelievable
defer { print("後始末します")}
}
throwError() 何も出ない
さらに
気をつけたい点として
defer文で後始末をする際に
元の状態とは違った状態にならないようにする点です。
どこかに元の状態を保持しておき
きちんと元に戻せる状態にしておけるように方法も
検討してみる必要がありそうです。
このように考えていくと
そもそも元の値は変更しないようにする
という方を探す方がより安全な気がします。
LocalizedErrorに準拠させる
上記でも一部出てきましたが
プログラマが確認するエラーのメッセージと
ユーザに表示するエラーメッセージは異なることがよくあります。
そんな時に LocalizedError プロトコルに準拠させることで
ユーザに表示するエラーメッセージを指定することができます。
https://developer.apple.com/documentation/foundation/localizederror
4つのプロパティを持っています。
- failureReason
- recoverySuggestion
- errorDescription
- localized String
これらは全てOptionalでデフォルト値を持っているため
必要なプロパティだけ定義するだけで済みます。
この中でもerrorDescriptionを設定することで
ErrorのlocalizedDescriptionプロパティから使用可能になります。
https://developer.apple.com/documentation/swift/error/2292912-localizeddescription
enum SurprisingError: Error, LocalizedError {
case fired
case inTheWater
var errorDescription: String? {
switch self {
case .fired:
return "バッテリーから火が出た"
case .inTheWater:
return "水没!!!"
}
}
}
func throwSurprisingError() throws {
throw SurprisingError.fired
}
do {
try throwSurprisingError()
} catch {
print(error.localizedDescription) // バッテリーから火が出た
}
今回は割愛しましたが
NSLocalizedStringを使用することで
ユーザのlocaleに合わせたメッセージを出力することもできます。
https://developer.apple.com/documentation/foundation/nslocalizedstring
Errorを処理する場所(do-catchする位置)を統一する
当たり前のことなのかもしれませんが
エラーをキャッチする位置を決めておかないと
エラー処理のコードが色々な箇所に散ってしまいます。
色々なソースを見てみると
エラー処理は呼び出し側でコントロールしたいことが多いため
呼び出し側に戻るまではthrowし
do-catch文で共通のエラーハンドラーに処理をさせる
といったパターンが多く見られます。
余談: 共通の理解があればFunctional Programmingを取り入れてみる
個人的には非常に興味があるのですが
なかなか導入するのは難しいとも感じているため
最後に余談として書かせていただきました。
structやenumを使用してドメインを表現しようとすると
ネストが深くなることが多くなります。
そうすると
中の値を取得したり
ある値だけ更新するといったことが面倒です。
Functional Programmingを活用すると
そういった問題の複雑さを軽減できる場合があります。
具体的には
あるデータ構造から特定の値を取り出したり
値を更新したりするデータ構造を作成することで
「どういう役割を果たすのか」
「何に対して何を行なっているのか」
を型として表現できます。
下記の発表の内容がとてもわかりやすく
Functional Programmingのメリットが感じられました。
https://www.youtube.com/watch?v=ki2WSw2WXV4
この中で3つのデータ構造が紹介されていますが
一部簡単にご紹介させて頂きます。
Lens
下記のような構造になっています。
struct Lens<Root, Value> {
let view: (Root) -> Value
let update: (Value, Root) -> Root
}
2つの関数を持っています。
Rootがデータ全体を表し
Valueはその中のある値です。
viewはデータ全体からある特定の値を取り出す関数で
updateはある特定の新しい値と
前の状態のデータ全体を引数として
新しいデータ全体を戻り値として返却します。
SwiftでLensを作成する場合
Swift4から使用できるKeyPathを使うことによって
簡単に表現することができます。
https://developer.apple.com/documentation/swift/keypath
func makeLens<Root, Value>(_ wkp: WritableKeyPath<Root, Value>) -> Lens<Root, Value> { |
return Lens<Root, Value>(
view: { root in root[keyPath: wkp] },
update: { newValue, root in
var m_root = root
m_root[keyPath: wkp] = newValue
return m_root
})
}
また2つのLensを組み合わせるメソッドも宣言します。
func zip<Root, Value1, Value2>(_ lens1: Lens<Root, Value1>, _ lens2: Lens<Root, Value2>) -> Lens<Root, (Value1, Value2)> {
return Lens<Root, (Value1, Value2)>(
view: { root in
(lens1.view(root), lens2.view(root))
},
update: { tuple, root in
lens2.update(tuple.1, lens1.update(tuple.0, root))
})
}
次に
ある特定の値を修正したLensを作成するメソッドを宣言します。
extension Lens {
func modify (_ transformValue: @escaping (Value) -> Value) -> (Root) -> Root { |
return { root in
self.update(
transformValue(self.view(root)),
root)
}
}
}
簡単な具体例を示すと
例えばユーザが入力した名前を表現する
FullNameを持ったUserInputがあります。
スペースの入力は可能ですが
最終的には前後のスペースはなくして扱いたい場合
下記のようの処理できます。
※WritableKeyPathを使うので
structのプロパティはvarで宣言します。
しかし
structはValue Semanticsなので
参照による思わぬ値の変更といった影響を気にする必要はありません。
struct UserInput {
var name: FullName
static func lens<Value>(_ wkp: WritableKeyPath<UserInput, Value>) -> Lens<UserInput, Value> {
return makeLens(wkp)
}
}
struct FullName {
var first: String
var family: String
static func lens<Value>(_ wkp: WritableKeyPath<FullName, Value>) -> Lens<FullName, Value> {
return makeLens(wkp)
}
}
let nameLens = zip(
UserInput.lens(\.name.first),
UserInput.lens(\.name.family)
)
let trimmedName =
nameLens.modify {
(
$0.0.trimmingCharacters(in: CharacterSet(charactersIn: " ")),
$0.1.trimmingCharacters(in: CharacterSet(charactersIn: " "))
)
}
let input = UserInput(name: FullName(first: " first ", family: " family"))
let trimmedInput = trimmedName(input)
print(trimmedInput.name) // FullName(first: "first", family: "family")
今回の例は
簡単なものなので恩恵をあまり感じられないかもしれませんが
もっとデータ構造が複雑になった場合でも
同じような形で処理することができるため
可読性は向上するのではないかと考えられます。
上記で紹介した発表では
PrismやAffineといった他の構造も紹介されていますので
ご興味のある方はぜひ見てみてください。
ただし
これには前提として
Functional Programmingに対する
チーム内での理解が必要です。
自分がわかっていても
周りがわからなければ可読性は下がりますし
コード量が増えて
余計に面倒になるということは大いに考えられます。
最後に
コードの書き方や表現方法について書かせて頂きました。
もちろん今回のことのみならず
もっと考える必要がある項目は限りなくあると思います。
また「これが正しい」というものはなく
正しいと思う判断をしても
「あっ、しまった。こうすればよかった。」
と後で思い直すことも多いと感じています。
今回自分なりに考えていることを色々と書いてきましたが
最終的には
チーム内での共通認識とコードの統一性
が大事なんだなと思います。
目的は
いかに開発メンバーや将来の自分に意図をわかりやすく伝えられるか
であり
これを考えないと
かえってプロジェクトを複雑にしてしまう可能性もあります。
ここは気をつけなければいけないところだと強く感じています。
(特に私の場合は考えすぎて失敗することがよくあるので、、、)
チーム開発という新しい経験を通じで
今まで意識してこなかったことに目を向けるようになり
日々学ぶ機会を得られたことが嬉しくも楽しくもあり、
今の環境にいられることに大変感謝しています
正解のないところではありますが
今後も日々学び、考え続けていきたいと思います!
「こっちの方が良い」
「こんな書き方もある」
などのご意見ございましたらぜひ教えてください
Swift Language Guideの全章の見出しの和訳
はじめに
Swift Advent Calendar 2018、17日目担当の@shtnkgmです。
Swiftスキルを上げるために、公式のSwift Language Guideの全章の見出しを翻訳してみました。
Swiftに関する体系的な内容がキーワードとしてまとまっていますので、知らないものがないかチェックするのに活用いただけるかと思います。
※Swiftのバージョンは執筆時最新のSwift4.2となります。
Swift Language Guide
章立て
- 基本 / The Basics
- 基本的な演算子 / Basic Operators
- 文字列と文字 / Strings and Characters
- コレクション型 / Collection Types
- 制御構文 / Control Flow
- 関数 / Functions
- クロージャ / Closures
- 列挙型 / Enumerations
- 構造体とクラス / Structures and Classes
- プロパティ / Properties
- メソッド / Methods
- 添字 / Subscripts
- 継承 / Inheritance
- 初期化 / Initialization
- 終了処理 / Deinitialization
- オプショナルチェイニング / Optional Chaining
- エラー処理 / Error Handling
- 型キャスト / Type Casting
- ネストした型 / Nested Types
- エクステンション / Extensions
- プロトコル / Protocols
- ジェネリクス / Generics
- 自動参照カウント / Automatic Reference Counting
- メモリ安全 / Memory Safety
- アクセス制御 / Access Control
- 応用的な演算子 / Advanced Operators
基本 / The Basics
- 定数と変数 / Constants and Variables
- 定数と変数の定義 / Declaring Constants and Variables
- 型アノテーション / Type Annotations
- 定数と変数の命名 / Naming Constants and Variables
- 定数と変数の出力 / Printing Constants and Variables
- コメント / Comments
- セミコロン / Semicolons
- 整数型 / Integers
- 整数型の上限と下限 / Integer Bounds
- Int型 / Int
- UInt型 / UInt
- 浮動小数点型 / Floating-Point Numbers
- 型安全と型推論 / Type Safety and Type Inference
- 数値リテラル / Numeric Literals
- 数値の型変換 / Numeric Type Conversion
- 整数型の変換 / Integer Conversion
- 整数型と浮動小数点型の変換 / Integer and Floating-Point Conversion
- 型エイリアス / Type Aliases
- Bool型 / Booleans
- タプル型 / Tuples
- オプショナル型 / Optionals
- nil / nil
- if文と強制アンラップ / If Statements and Forced Unwrapping
- オプショナルバインディング / Optional Binding
- 暗黙的アンラップ型 / Implicitly Unwrapped Optionals
- エラーハンドリング / Error Handling
- アサーションと前提条件 / Assertions and Preconditions
- アサーションによるデバッグ / Debugging with Assertions
- 前提条件の強調 / Enforcing Preconditions
基本的な演算子 / Basic Operators
- 用語 / Terminology
- 代入演算子 / Assignment Operator
- 算術演算子 / Arithmetic Operators
- 剰余演算子 / Remainder Operator
- 単項マイナス演算子 / Unary Minus Operator
- 単項プラス演算子 / Unary Plus Operator
- 複合代入演算子 / Compound Assignment Operators
- 比較演算子 / Comparison Operators
- 三項演算子 / Ternary Conditional Operator
- nil合体演算子 / Nil-Coalescing Operator
- 範囲演算子 / Range Operators
- 閉区間演算子 / Closed Range Operator
- 半開区間演算子 / Half-Open Range Operator
- 片側区間演算子 / One-Sided Ranges
- 論理演算子 / Logical Operators
- 論理NOT演算子 / Logical NOT Operator
- 論理AND演算子 / Logical AND Operator
- 論理OR演算子 / Logical OR Operator
- 論理演算子の結合 / Combining Logical Operators
- 明示的な括弧 / Explicit Parentheses
Strings and Characters
- 文字列リテラル / String Literals
- 複数行の文字列リテラル / Multiline String Literals
- 文字列リテラル内の特殊文字 / Special Characters in String Literals
- 空文字での初期化 / Initializing an Empty String
- 文字列の変更可否 / String Mutability
- 文字列は値型 / Strings Are Value Types
- 文字を扱う / Working with Characters
- 文字列と文字の連結 / Concatenating Strings and Characters
- 文字列の補間 / String Interpolation
- ユニコード / Unicode
- Unicodeスカラ値 / Unicode Scalar Values
- 拡張書記素クラスタ / Extended Grapheme Clusters
- 文字数を数える / Counting Characters
- 文字列のアクセスと変更 / Accessing and Modifying a String
- 文字列のインデックス / String Indices
- 挿入と削除 / Inserting and Removing
- 部分文字列 / Substrings
- 文字列の比較 / Comparing Strings
- 文字列と文字の同一性 / String and Character Equality
- 接頭辞と接尾辞の同一性 / Prefix and Suffix Equality
- 文字列のユニコード表現 / Unicode Representations of Strings
- UTF-8表現 / UTF-8 Representation
- UTF-16表現 / UTF-16 Representation
- ユニコードのスカラ表現 / Unicode Scalar Representation
コレクション型 / Collection Types
- コレクション型の変更可否 / Mutability of Collections
- 配列型 / Arrays
- 配列型の糖衣構文 / Array Type Shorthand Syntax
- 空配列の生成 / Creating an Empty Array
- デフォルト値を持つ配列の生成 / Creating an Array with a Default Value
- 二つの配列を連結することによる配列の生成 / Creating an Array by Adding Two Arrays Together
- 配列リテラルによる配列の生成 / Creating an Array with an Array Literal
- 配列へのアクセスと変更 / Accessing and Modifying an Array
- 配列のイテレート / Iterating Over an Array
- 集合型 / Sets
- 集合型のハッシュ値 / Hash Values for Set Types
- 集合型の構文 / Set Type Syntax
- 空集合の生成と初期化 / Creating and Initializing an Empty Set
- 配列リテラルによる集合の初期化 / Creating a Set with an Array Literal
- 集合へのアクセスと変更 / Accessing and Modifying a Set
- 集合のイテレート / Iterating Over a Set
- 集合演算の実行 / Performing Set Operations
- 基本的な集合の演算 / Fundamental Set Operations
- 集合の要素と同一性 / Set Membership and Equality
- 辞書型 / Dictionaries
- 辞書型の糖衣構文 / Dictionary Type Shorthand Syntax
- 空辞書の生成 / Creating an Empty Dictionary
- 辞書リテラルによる辞書の生成 / Creating a Dictionary with a Dictionary Literal
- 辞書へのアクセスと変更 / Accessing and Modifying a Dictionary
- 辞書のイテレート / Iterating Over a Dictionary
制御構文 / Control Flow
- for-inループ / For-In Loops
- whileループ / While Loops
- while文 / While
- repeat-while文 / Repeat-While
- 条件文 / Conditional Statements
- if文 / If
- switch文 / Switch
- 暗黙的なfallthroughはない / No Implicit Fallthrough
- 区間によるパターンマッチ / Interval Matching
- タプル / Tuples
- バリューバインディングパターン / Value Bindings
- where / Where
- ケースの合成 / Compound Cases
- フロー制御文 / Control Transfer Statements
- continue / Continue
- break / Break
- ループ文でのbreak / Break in a Loop Statement
- switch文でのbreak / Break in a Switch Statement
- fallthrough / Fallthrough
- label文 / Labeled Statements
- 早期リターン / Early Exit
- APIの利用可否をチェックする / Checking API Availability
関数 / Functions
- 関数の定義と呼び出し / Defining and Calling Functions
- 関数の引数と戻り値 / Function Parameters and Return Values
- 引数なしの関数 / Functions Without Parameters
- 複数の引数ありの関数 / Functions With Multiple Parameters
- 戻り値なしの関数 / Functions Without Return Values
- 複数の戻り値ありの関数 / Functions with Multiple Return Values
- オプショナルのタプル型の戻り値 / Optional Tuple Return Types
- 関数の引数ラベルとパラメータ名 / Function Argument Labels and Parameter Names
- 引数ラベルの指定 / Specifying Argument Labels
- 引数ラベルの省略 / Omitting Argument Labels
- 引数のデフォルト値 / Default Parameter Values
- 可変長引数 / Variadic Parameters
- inout引数 / In-Out Parameters
- 関数型 / Function Types
- 関数型を利用する / Using Function Types
- 引数の型としての関数型 / Function Types as Parameter Types
- 戻り値の型としての関数型 / Function Types as Return Types
- 関数のネスト / Nested Functions
クロージャ / Closures
- クロージャ式 / Closure Expressions
- ソート関数 / The Sorted Method
- クロージャ式の構文 / Closure Expression Syntax
- コンテキストからの型推論 / Inferring Type From Context
- 1つの式を持つクロージャの暗黙的な戻り値 / Implicit Returns from Single-Expression Closures
- 簡略引数名 / Shorthand Argument Names
- 演算子関数 / Operator Methods
- トレイリングクロージャ / Trailing Closures
- 値のキャプチャ / Capturing Values
- クロージャは参照型 / Closures Are Reference Types
- クロージャのエスケープ / Escaping Closures
- autoclosure属性 / Autoclosures
列挙型 / Enumerations
- 列挙型の構文 / Enumeration Syntax
- switch文による列挙型の値のパターンマッチ / Matching Enumeration Values with a Switch Statement
- 列挙型のイテレート / Iterating over Enumeration Cases
- 連想値 / Associated Values
- ローバリュー / Raw Values
- 暗黙的に代入されるローバリュー / Implicitly Assigned Raw Values
- ローバリューからの初期化 / Initializing from a Raw Value
- 再帰的な列挙型 / Recursive Enumerations
構造体とクラス / Structures and Classes
- 構造体とクラスの比較 / Comparing Structures and Classes
- 定義構文 / Definition Syntax
- 構造体とクラスのインスタンス / Structure and Class Instances
- プロパティへの操作 / Accessing Properties
- 構造体のメンバーワイズイニシャライザ / Memberwise Initializers for Structure Types
- 構造体と列挙型は値型 / Structures and Enumerations Are Value Types
- クラスは参照型 / Classes Are Reference Types
- アイデンティティ演算子 / Identity Operators
- ポインタ / Pointers
プロパティ / Properties
- ストアドプロパティ / Stored Properties
- 構造体インスタンスのストアドプロパティ / Stored Properties of Constant Structure Instances
- lazyストアドプロパティ / Lazy Stored Properties
- ストアドプロパティとインスタンス変数 / Stored Properties and Instance Variables
- コンピューテッドプロパティ / Computed Properties
- セッター定義での簡略表記 / Shorthand Setter Declaration
- 読み取り専用コンピューテッドプロパティ / Read-Only Computed Properties
- プロパティオブザーバー / Property Observers
- グローバル変数とローカル変数 / Global and Local Variables
- 型プロパティ / Type Properties
- 型プロパティの構文 / Type Property Syntax
- 型プロパティの参照と代入 / Querying and Setting Type Properties
メソッド / Methods
- インスタンスメソッド / Instance Methods
- selfプロパティ / The self Property
- インスタンスメソッドからの値型の変更 / Modifying Value Types from Within Instance Methods
- mutatingメソッドからのselfの代入 / Assigning to self Within a Mutating Method
- 型メソッド / Type Methods
添字 / Subscripts
- 添字の構文 / Subscript Syntax
- 添字の使い方 / Subscript Usage
- 添字のオプション / Subscript Options
継承 / Inheritance
- 基底クラスの定義 / Defining a Base Class
- サブクラス化 / Subclassing
- オーバーライド / Overriding
- スーパークラスのメソッド、プロパティ、および添字へのアクセス / Accessing Superclass Methods, Properties, and Subscripts
- メソッドのオーバーライド / Overriding Methods
- プロパティのオーバーライド / Overriding Properties
- プロパティのゲッターとセッターのオーバーライド / Overriding Property Getters and Setters
- プロパティオブザーバーのオーバーライド / Overriding Property Observers
- オーバーライドの防止 / Preventing Overrides
初期化 / Initialization
- ストアドプロパティの初期値の設定 / Setting Initial Values for Stored Properties
- イニシャライザ / Initializers
- プロパティのデフォルト値 / Default Property Values
- イニシャライザのカスタマイズ / Customizing Initialization
- 初期化パラメータ / Initialization Parameters
- パラメータ名と引数ラベル / Parameter Names and Argument Labels
- 引数ラベルなしの初期化パラメータ / Initializer Parameters Without Argument Labels
- オプショナルのプロパティ型 / Optional Property Types
- 初期化中の定数プロパティの代入 / Assigning Constant Properties During Initialization
- デフォルトイニシャライザ / Default Initializers
- 構造体のメンバーワイズイニシャライザ / Memberwise Initializers for Structure Types
- 値型の初期化の委譲 / Initializer Delegation for Value Types
- クラスの継承と初期化 / Class Inheritance and Initialization
- 指定イニシャライザとコンビニエンスイニシャライザ / Designated Initializers and Convenience Initializers
- 指定イニシャライザとコンビニエンスイニシャライザの構文 / Syntax for Designated and Convenience Initializers
- クラス型のイニシャライザの委譲 / Initializer Delegation for Class Types
- 2段階初期化 / Two-Phase Initialization
- イニシャライザの継承とオーバーライド / Initializer Inheritance and Overriding
- 自動的なイニシャライザの継承 / Automatic Initializer Inheritance
- 指定イニシャライザとコンビニエンスイニシャライザの実装例 / Designated and Convenience Initializers in Action
- 失敗可能イニシャライザ / Failable Initializers
- 列挙型の失敗可能イニシャライザ / Failable Initializers for Enumerations
- ローバリューを持つ列挙型の失敗可能イニシャライザ / Failable Initializers for Enumerations with Raw Values
- 失敗可能イニシャライザの伝播 / Propagation of Initialization Failure
- 失敗可能イニシャライザのオーバーライド / Overriding a Failable Initializer
- init!の失敗可能イニシャライザ / The init! Failable Initializer
- 必須イニシャライザ / Required Initializers
- クロージャや関数を用いたプロパティのデフォルト値の設定 / Setting a Default Property Value with a Closure or Function
終了処理 / Deinitialization
- 終了処理はどのように働くか / How Deinitialization Works
- 終了処理の実装例 / Deinitializers in Action
オプショナルチェイニング / Optional Chaining
- 強制アンラップの代替としてのオプショナルチェイニング / Optional Chaining as an Alternative to Forced Unwrapping
- オプショナルチェイニングのためのモデルクラスの定義 / Defining Model Classes for Optional Chaining
- オプショナルチェイニングを通してプロパティへアクセスする / Accessing Properties Through Optional Chaining
- オプショナルチェイニングを通してメソッドを実行する / Calling Methods Through Optional Chaining
- オプショナルチェイニングを通して添字へアクセスする / Accessing Subscripts Through Optional Chaining
- オプショナル型の添字へのアクセス / Accessing Subscripts of Optional Type
- オプショナルチェイニングの連鎖 / Linking Multiple Levels of Chaining
- オプショナル型の戻り値を持つメソッドのオプショナルチェイニング / Chaining on Methods with Optional Return Values
エラー処理 / Error Handling
- エラーの表現とスロー / Representing and Throwing Errors
- エラーの処理 / Handling Errors
- throw関数を用いたエラーの伝播 / Propagating Errors Using Throwing Functions
- do-catchを用いたエラー処理 / Handling Errors Using Do-Catch
- エラーをオプショナル値に変換する / Converting Errors to Optional Values
- エラーの伝播を無効にする / Disabling Error Propagation
- スコープを抜けるときのクリーンアップ処理の指定 / Specifying Cleanup Actions
型キャスト / Type Casting
- 型キャストのためのクラス階層の定義 / Defining a Class Hierarchy for Type Casting
- 型チェック / Checking Type
- ダウンキャスト / Downcasting
- Any型とAnyObject型の型キャスト / Type Casting for Any and AnyObject
ネストされた型 / Nested Types
- ネストされた型の実装例 / Nested Types in Action
- ネストされた型の参照 / Referring to Nested Types
エクステンション / Extensions
- エクステンションの構文 / Extension Syntax
- コンピューテッドプロパティ / Computed Properties
- イニシャライザ / Initializers
- メソッド / Methods
- mutatingインスタンスメソッド / Mutating Instance Methods
- 添字 / Subscripts
- ネストした型 / Nested Types
プロトコル / Protocols
- プロトコルの構文 / Protocol Syntax
- プロトコルの制約 / Property Requirements
- メソッドの制約 / Method Requirements
- mutatingメソッドの制約 / Mutating Method Requirements
- イニシャライザの制約 / Initializer Requirements
- プロトコルのイニシャライザ制約のクラス実装 / Class Implementations of Protocol Initializer Requirements
- 失敗可能イニシャライザの制約 / Failable Initializer Requirements
- プロトコルを型として扱う / Protocols as Types
- デリゲートデザインパターン / Delegation
- エクステンションによるプロトコルへの準拠 / Adding Protocol Conformance with an Extension
- 条件付きプロトコル準拠 / Conditionally Conforming to a Protocol
- エクステンションによるプロトコル準拠の宣言 / Declaring Protocol Adoption with an Extension
- プロトコル型のコレクション / Collections of Protocol Types
- プロトコル継承 / Protocol Inheritance
- クラス専用プロトコル / Class-Only Protocols
- プロトコルコンポジション / Protocol Composition
- プロトコル準拠のチェック / Checking for Protocol Conformance
- オプショナルなプロトコル制約 / Optional Protocol Requirements
- プロトコルエクステンション / Protocol Extensions
- デフォルト実装を与える / Providing Default Implementations
- プロトコルエクステンションに条件を付与する / Adding Constraints to Protocol Extensions
ジェネリクス / Generics
- ジェネリクスが解決する問題 / The Problem That Generics Solve
- ジェネリック関数 / Generic Functions
- 型パラメータ / Type Parameters
- 型パラメータの命名 / Naming Type Parameters
- ジェネリック型 / Generic Types
- ジェネリック型の拡張 / Extending a Generic Type
- 型制約 / Type Constraints
- 型制約の構文 / Type Constraint Syntax
- 型制約の実装例 / Type Constraints in Action
- 連想型 / Associated Types
- 連想型の実装例 / Associated Types in Action
- 既存の型を連想型を指定することにより拡張する / Extending an Existing Type to Specify an Associated Type
- 連想型に制約を追加する / Adding Constraints to an Associated Type
- 連想型の制約に自身のプロトコルを利用する / Using a Protocol in Its Associated Type’s Constraints
- ジェネリックなwhere句 / Generic Where Clauses
- ジェネリックなwhere句を利用したエクステンション / Extensions with a Generic Where Clause
- ジェネリックなwhere句を利用した連想型 / Associated Types with a Generic Where Clause
- ジェネリックな添字 / Generic Subscripts
自動参照カウント / Automatic Reference Counting
- ARCはどのように働くか / How ARC Works
- ARCの実装例 / ARC in Action
- クラスインスタンス間の循環強参照 / Strong Reference Cycles Between Class Instances
- クラスインスタンス間の循環強参照を解決する / Resolving Strong Reference Cycles Between Class Instances
- 弱参照 / Weak References
- unowend参照 / Unowned References
- unowend参照とIUOプロパティ / Unowned References and Implicitly Unwrapped Optional Properties
- クロージャの循環強参照 / Strong Reference Cycles for Closures
- クロージャの循環強参照を解決する / Resolving Strong Reference Cycles for Closures
- キャプチャリストの定義 / Defining a Capture List
- 弱参照とunowend参照 / Weak and Unowned References
メモリ安全 / Memory Safety
- メモリアクセスの参照コンフリクトを理解する / Understanding Conflicting Access to Memory
- メモリアクセスの特徴 / Characteristics of Memory Access
- inoutパラメータの参照コンフリクト / Conflicting Access to In-Out Parameters
- メソッドでのselfへの参照コンフリクト / Conflicting Access to self in Methods
- プロパティへの参照コンフリクト / Conflicting Access to Properties
アクセス制御 / Access Control
- モジュールとソースファイル / Modules and Source Files
- アクセスレベル / Access Levels
- アクセスレベルの原則 / Guiding Principle of Access Levels
- デフォルトのアクセスレベル / Default Access Levels
- 1つのターゲットを持つアプリのためのアクセスレベル / Access Levels for Single-Target Apps
- フレームワークのためのアクセスレベル / Access Levels for Frameworks
- ユニットテストターゲットのためのアクセスレベル / Access Levels for Unit Test Targets
- アクセス制御構文 / Access Control Syntax
- 独自型を定義したときのアクセスレベル / Custom Types
- タプル型 / Tuple Types
- 関数型 / Function Types
- 列挙型 / Enumeration Types
- ネストした型 / Nested Types
- サブクラス化 / Subclassing
- 定数、変数、プロパティ、soeji / Constants, Variables, Properties, and Subscripts
- ゲッターとセッター / Getters and Setters
- イニシャライザ / Initializers
- デフォルトイニシャライザ / Default Initializers
- 構造体のデフォルトのメンバーワイズイニシャライザ / Default Memberwise Initializers for Structure Types
- プロトコル / Protocols
- プロトコル継承 / Protocol Inheritance
- プロトコル準拠 / Protocol Conformance
- エクステンション / Extensions
- エクステンション内のプライベート変数 / Private Members in Extensions
- ジェネリクス / Generics
- 型エイリアス / Type Aliases
応用的な演算子 / Advanced Operators
- ビット演算子 / Bitwise Operators
- ビットNOT演算子 / Bitwise NOT Operator
- ビットAND演算子 /Bitwise AND Operator
- ビットOR演算子 /Bitwise OR Operator
- ビットXOR演算子 /Bitwise XOR Operator
- ビットシフト演算子 / Bitwise Left and Right Shift Operators
- 符号なし整数型のシフト演算の動作 / Shifting Behavior for Unsigned Integers
- 符号あり整数型のシフト演算の動作 / Shifting Behavior for Signed Integers
- オーバーフロー演算子 / Overflow Operators
- 値のオーバーフロー / Value Overflow
- 優先順位と結合性 / Precedence and Associativity
- 演算子メソッド / Operator Methods
- 前置演算子と後置演算子 / Prefix and Postfix Operators
- 複合代入演算子 / Compound Assignment Operators
- 等価演算子 / Equivalence Operators
- カスタム演算子 / Custom Operators
- カスタム中置演算子の優先順位 / Precedence for Custom Infix Operators
終わりに
なかなか翻訳が大変でしたが、labelでのループ脱出や再帰的Enumのindirectなどは初めて聞いたので収穫になりました。
長くなりましたが、Swift Advent Calendar 2018 17日目の記事でした!
What's new in Swift 5
Swift 用の汎用正規表現ライブラリーを作ってみた話
ワンランク上のSwiftを書くための厳選記法10選
はじめに
Swift Advent Calendar 2018の20日目を担当させていただきます @ruwatana です。
主流となっているモダンな言語は多様な概念を採用しており、さまざまな記法を使って十人十色のコードを書くことが可能となっています。
今回は、みんな大好きSwiftの記法に注目し、自分も普段から取り入れているオシャレでスマートないわゆるSwiftyな記法を実際の使用例やなぜそう書くと良いのかといった理由とともに厳選してみました。
設計方針などはある程度学習が必要となりますが、今回紹介するのは記法なので今から実践することが可能です
Type Omitting (型省略)編
Swiftには強力なType Inference(型推論)がありますが、これを用いて非常に簡潔に書くことが可能となっています。
こうした型省略はよく用いると思いますが、応用するとさらに強力です。
① Enum
まずは、enumを使った型省略の例です。
これは基本中の基本ですね。
enum Pattern {
case hoge, fuga
}
let pattern = Pattern.hoge // 型推論によって型を定義しなくてもPattern型となる
let pattern: Pattern = .hoge // Pattern型として定義されてるのでPattern.hogeと書かなくて良い
// switch構文でも省略可能
switch pattern {
case .hoge: print("hoge")
case .fuga: print("fuga")
}
② Property, Function
enum以外にも型省略は使えます。
次は、自分自身の型を返すstaticなpropertyやfunctionにて型省略をする例です。
functionに型省略を使ったりしてるコードを見ると個人的には「おっ!?」となります
let label = UILabel()
label.backgroundColor = .red // backgroundColorはUIColor?型なので型省略可能
label.font = .systemFont(ofSize: 10.0) // UIFont型を返すfunctionも型省略可能
③ Initializer
上記の応用です。initも自分自身を返すstatic functionといえるので適合可能です。
とっても長いTypeを用いたinitializerは横に長くなってしまい見にくいです。
そんな時は、こんな風にも書けるので覚えておくと良いかもしれません。
class ViewController: UIViewController {
private var edgePanGesture: UIScreenEdgePanGestureRecognizer? //長いClass名
override func viewDidLoad() {
super.viewDidLoad()
// △: とっても長い
edgePanGesture = UIScreenEdgePanGestureRecognizer(target: self, action:: #selector(handle))
// ○: .initだと非常に簡潔にかける
edgePanGesture = .init(target: self, action: #selector(handle))
view.addGestureRecognizer(edgePanGesture!)
}
@objc func handle() {
print("recognized")
}
}
型省略すると該当の行を見ただけでは型が想像しにくい?
自分は、冗長な書き方はせずに極力短くかつ明瞭に書いてある方が、よりSwiftyなのではないかなと思っています。
型省略してしまうことで、該当の行を読んだだけではかえって型の想像がつきにくい場合があるかもしれませんが、自分は下記の機能を有効にしているため、あまり気になりません。
カスタムカラースキームでシンタックスハイライトの色分けを細分化
こんな感じで、property/method/enum caseを標準ライブラリとカスタム定義に分けて全てを異なるカラーで定義するようにしたカラースキームを独自に作成して使っています。
これによって省略記法を使っても、一目でどんな型か想像がつきやすくしています。
カラースキームの設定についての詳細は、下記の過去記事をご参考ください
Xcode テーマのおすすめ設定〜自作してシンタックスハイライトをいい感じにする〜
トラックパッドの調べる機能で型を参照する
トラックパッドの調べる&データ検出機能を有効にし、該当の行のpropertyやmethod上で操作を行うと、簡単に型をしらべることができます。
自分は、3本指タップで調べる機能を使用するようにしています。
システム環境設定 > トラックパッド > ポイントとクリック > 調べる&データ検出をチェック
先ほどの例で使用するとこんな感じです。
modalTransitionStyleの上にマウスカーソルをfocusし3本指タップすると型の情報がポップアップで表示できるので覚えておくと便利です。
また別の方法としては、テキストカーソルを型を調べたいターゲットに合わせた状態で、Xcode右ペインのQuick Helpにも表示できます。
Extension編
Extensionは、既存のclassを拡張するためにも有効な手段ですが、可読性の面でも一役買ってくれます。
④ Protocol実装をExtensionで分ける
Protocol実装をExtensionにて分けることは、コードの可読性を上げる上で非常に有効な手段となります。
Protocolの定義はclass/structなどの最初で定義することができますが、これではいくつかの問題が生じます。最初に定義した場合の例を見ていきましょう。
class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {}
}
まず、最初の定義の行が非常に長くなってしまいました。
さらに、すべてのメソッドが羅列されているので、このメソッドはどのProtocolメソッドなのかがわからないといった問題が発生します。
そこで、ExtensionにてそれぞれのProtocolへの準拠と実装を分けてみます。
class ViewController: UIViewController { }
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {}
}
extension ViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {}
}
どうでしょうか。
Class定義の箇所もクラス継承のみとなり、短く明瞭になりました。
Protocolごとにextensionで区切られたため、一目でProtocolメソッドを実装しているということがわかるようにもなりました。
この記法は、もちろんProtocolだけではなく、単純な処理のカテゴライズにも使うことができます。
可読性を上げるためにextensionに(場合によっては別ファイルに)切り出すことを意識してみると、良いと思います
ただし、下記の注意点もあるので一緒に覚えておくと良いでしょう。
- Extensionに切り出すことでアクセス修飾子の公開範囲が広がってしまうケースがある
- Extensionではstored propertyを定義することができない
Optional編
SwiftではOptionalは?というリテラルで表現されています。
if letなどのOptional Bindingを駆使してunwrapして取り扱うことが多いと思いますが、ここではOptionalのままでも簡潔に扱う方法を紹介します。
⑤ Optional Chaining
optional型のプロパティにアクセスしつつ、unwrapできればそれ以降のロジックが実行され、nilの場合は実行されないという書き方が?一つで表現できます。
これはXcode上で勝手に補完されたりするのでよく目にする機会があると思います。
class ViewController: UIViewController {
@IBAction func didTouchUpInsideButton(sender: Any) {
let viewController = UIViewController()
// navigationControllerが存在すればpushViewController()を実行
navigationController?.pushViewController(viewController, animated: true)
}
}
オプショナルチェーンを用いればOptionalなClosureをunwrapせずに実行させることも可能です。
この場合の()はClosureの実行を意味しているので、これもオプショナルチェーンの応用例といえます。
func perform(completionHandler: (() -> Void)?) {
completionHandler?() // completionHanlderがnilでなければ実行
}
⑥ Switch meets Optional
unwrapせずにOptional型をそのままSwitchで簡潔に書くことができます。
非常にシンプルで、パターンの後ろに?をつけるだけです。
ただし、Optional型はenumであり、.none(つまりnil)と.some(Wrapped)の2つのcaseを持つためswitch文のcaseとしてnilを考慮する必要があります。
public enum Optional<Wrapped> : ExpressibleByNilLiteral {
case none
case some(Wrapped)
}
下記のようにOptionalであっても、defaultを使わずに全caseを?を使って列挙させるような書き方ができるため、enumのcaseがあとから変更されてしまっても、コンパイルエラーですぐに気づくことができるというメリットがあります。
無駄にswitchの前でguard letなどを書かなくても良いので1箇所にロジックが集約できるという利点もありますね。
class TableViewController: UITableViewController {
enum Section: Int {
case header, main, footer
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let section = Section(rawValue: section) // Section?型
switch section {
case .header?: // .some(let section) where section == .headerと同じ
return 1
case .main?:
return 5
case .footer?:
return 1
case nil: // .none: と同じ
fatalError()
}
}
}
⑦ flatMapで別の型を生成する
よくこういうシチュエーションがあると思います。
正攻法で行くと、わざわざString?型の変数をif letを用いてunwrapしてURL型を生成しないといけないのですが、たったこれだけの処理をするにしては、複数行書かないといけなくてめんどくさいです。
let urlString: String? = "http://hoge.com" // StringのOptionalの変数
let url: URL?
if let urlString = urlString {
url = URL(string: urlString)
} else {
url = nil
}
こういう時は、flatMapを用いるととってもシンプルに書けます
let urlString: String? = "http://hoge.com" // StringのOptionalの変数
let url = urlString.flatMap(URL.init)
あまり見かけない記法だと思う方もいると思いますが、一体どういう仕組みなのかを説明していきます。
flatMapの定義を見てみます。
flatMapはOptional以外にSequenceなどにも定義されてますが、今回はOptionalのflatMapを見ていきます。
public enum Optional<Wrapped> : ExpressibleByNilLiteral {
public func flatMap<U>(_ transform: (Wrapped) throws -> U?) rethrows -> U?
}
WrappedはOptionalのGeneric Typeです。
flatMap()は、そのWrappedの引数からGeneric TypeのU?型を返却するClosureであるtransformを引数に持ちます。
Optionalが.someのとき(nilでない)はtransformが実行されてその返り値Uを返します。
もし.none(nil)だった場合は、そのままnilを返すため、最終的にはU?型が返却される関数となっています。
先ほどの例において、変数urlStringはStringのOptional型であるため、flatMapを使うことができます。
さらに、URLのStringを引数にとるinitializerであるinit(string:)はURL型を返すため、flatMapの引数transformの型に等しく、引数として渡すことが可能ということがわかります。
また、URL型はデフォルトでは、Stringの引数1つのみを必要とするInitializerはinit(string:)がユニークであるため、このケースでは引数部分の記述を省略でき、URL.init
のみで定義が可能です。
URL(string:)とかけると非常にわかりやすいですが、Swift4.2現在ではそのように記述するとコンパイルエラーとなってしまうため、できないようです。
おそらくこの書き方だとメソッドとして解釈がされないのだと思われます。
もしそのように書きたい場合は、Closureを使ってこのように書くことでも実現可能です。
let urlString: String? = "http://hoge.com" // StringのOptionalの変数
let url = urlString.flatMap { URL(string: $0) }
Closure編
⑧ Trailing Closure
Swiftはメソッドの末尾の引数がClosureの場合は、引数名を省略して処理を書くことができます。
これをTrailing Closureといいます。
例を見てみるとわかりやすいかと思います。
// 引数名を省略しない書き方
UIView.animate(withDuration: 0.5, animations: { /* hoge */ })
// Trailing Closure
UIView.animate(withDuration: 0.5) { /* hoge */ }
どうでしょうか。
引数名を省略できた分だけ、短く書けているのがわかるかと思います。
ただし、ここからが重要でTrailing Closureは便利な反面、使い方に注意が必要です。
例えば、こんな状況ではどうでしょうか。
UIView.animate(withDuration: 0.5, animations: { /* hoge */ }) { (_) in
// fuga
}
Closureの引数を複数渡すメソッドにTrailing Closureを用いると、1つ目のClosure引数名はわかっても、2つ目の引数名がわからないため、いつ実行されるかが呼び出し側では一目でわからなくなってしまい、意図しない不具合を生んでしまう危険性が高くなってしまいます。
複数のClosure引数を持つメソッドの場合はTrailing Closureを使わない方がベターでしょう。
また、SwiftのLinterとしてメジャーなRealm製のSwiftLintもデフォルト機能として警告を出すように設定されています。
UIView.animate(withDuration: 0.5, animations: { /* hoge */ }, completion: { (_) in /* fuga */ })
複数のClosure引数を持つメソッドは、上記のようにどちらの引数名も明示して書くようにしましょう。
これによって、animation時の処理とcompletion(完了)時の処理の2つがClosureで書かれているということがわかるため混乱の原因にならなくて済みます。
⑨ 変数の初期化と下処理をClosureでまとめる
さきほどのOptionalなClosureの実行でも述べましたが、Closureとその実行を表す演算子である()を用いることで下処理を含んだ変数定義をネストを掘ってまとめることができます。
lazy varを書くときにも、よくこの書き方がされると思います。
func setup() {
// △: 全部同一スコープでパッと見の処理が追いにくい
let label = UILabel()
label.backgroundColor = .red
label.font = .systemFont(ofSize: 10.0)
view.addSubview(label)
let button = UIButton()
button.setTitle("Button", for: .normal)
button.addTarget(self, action: #selector(hoge), for: .touchUpInside)
view.addSubview(button)
// ○: 変数の定義をClosureとその実行()を使って責務を分けると処理が追いやすい
let label: UILabel = {
let label = UILabel()
label.backgroundColor = .red
label.font = .systemFont(ofSize: 10.0)
return label
}()
view.addSubview(label)
let button: UIButton = {
let button = UIButton()
button.setTitle("Button", for: .normal)
button.addTarget(self, action: #selector(hoge), for: .touchUpInside)
return button
}()
view.addSubview(button)
}
また、下記の場合のようにClosure内のスコープでは早期returnも可能になるため、簡潔に書けるのも強みです。
特に恩恵を受けるのはinitializerの中での処理でしょうか。
初期化されるまでは定義したインスタンスメソッドは呼び出すことはできないため、initの内部にclosureを使ってロジックを埋め込む必要が出てきますが、そういったときにも力を発揮してくれると思います。
struct Model {
enum Status {
case registered(id: Int, password: String)
case unregistered
}
let status: Status
let name: String?
init(dictionary: [String: Any]) {
// 変数ごとにパースロジックを定義すると読みやすい
status = {
guard let id = dictionary["id"] as? Int,
let password = dictionary["password"] as? String else {
return .unregistered // early returnが可能
}
return .registered(id: id, password: password)
}()
name = dictionary["name"] as? String
}
}
番外編: 命名規則
記法ではないですが、普段気をつけている命名規則についてまとめます。
基本的には、Swift.org - API Design Guidelinesに従って書くようにするのが良いと思います。
⑩ Delegateを作るときは標準ライブラリの命名規則に従う
みなさんは、カスタムのDelegateをつくるときの命名に気を配ってますでしょうか。
ちょっとアンチパターンの例を示したいと思います。
protocol FirstTableViewCellDelegate {
func didSelectAction()
}
FirstTableViewCellというクラスのDelegateということはprotocol名を見るとわかります。
また、メソッド名からCellの中にActionを実行できる何かがいて、それが選択された際の処理を委譲してることがわかります。
おそらくTableViewを管理するVCクラスなどがこのDelegateを実装すると思いますが、もし同じようなCellが存在して同じようにDelegateを実装しないといけなくなったとしたらどうなるでしょうか。
FirstTableViewCellと同じようにAction機能を実装したSecondTableViewCellがいた時に同様のメソッド名を持つDelegateをVCが実装しようとすると、同名のメソッドを定義できずコンパイルエラーになってしまいます。
class ViewController: UIViewController {}
extension ViewController: FirstTableViewCellDelegate {
func didSelectAction() {
print("FirstTableViewCellのActionが選択されたよ")
}
}
extension ViewController: SecondTableViewCellDelegate {
// 同名のメソッドは定義できないのでCompile Errorとなる
func didSelectAction() {
print("SecondTableViewCellのActionが選択されたよ")
}
}
では、CocoaやUIKitといった標準ライブラリはどのように命名をしているかを見ていきます。
非常によく使うUITableViewDataSourceならイメージがしやすいと思います。
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int
func numberOfSections(in tableView: UITableView) -> Int
これを見たらすぐにわかると思いますが、ポイントは下記の通りです。
- Delegate methodであると一目でわかるように、委譲したインスタンス自身を必ず第一引数に指定している
- 自身しか引数がない場合は、メソッド名に委譲する処理の詳細(numberOfSections)を指定する
- 2つ以上引数を持つ場合は、メソッド名にインスタンスのクラス名を指定(tableView)し、第一引数の外部引数名は冗長になるので省略(_)、第二引数の外部引数名に委譲する処理の詳細(numberOfRowsInSection)を指定する
一番最後のポイントはかなり英語のセンスを問われるかもしれませんが(笑)、メソッド・外部引数・内部引数名が一つの英文みたいにつながるように書かれています。
これを実践できるかが、より標準ライブラリぽく、そしておしゃれなDelegateを作る鍵といえます
先ほどの例を標準ライブラリの命名に準拠して書き直してみます。
どうでしょうか。
名前空間の問題も起こらず、どちらのDelegateも問題なく実装することができました
protocol FirstTableViewCellDelegate {
func didSelectAction(on firstTableViewCell: FirstTableViewCell)
}
protocol SecondTableViewCellDelegate {
func didSelectAction(on secondTableViewCell: SecondTableViewCell)
}
class ViewController: UIViewController {}
extension ViewController: FirstTableViewCellDelegate {
// 引数に委譲されてきたインスタンスがあるのでDelegateメソッドだとわかる
func didSelectAction(on firstTableViewCell: FirstTableViewCell) {
print("FirstTableViewCellのActionが選択されたよ")
}
}
extension ViewController: SecondTableViewCellDelegate {
// Firstとはメソッド定義が異なるためCompile Errorにならない
func didSelectAction(on secondTableViewCell: SecondTableViewCell) {
print("SecondTableViewCellのActionが選択されたよ")
}
}
おわりに
いかがでしたでしょうか。
一つでも参考になったり発見があったら嬉しいです!
余談ですが、型省略に頼って.(ピリオド)を型なしで入力して補完をきかせようとするとXcodeが全然サジェストしてくれないことが多々あります
いつか型省略による補完もばっちり効くようになってほしいです。
最後までお読みいただきありがとうございました
ここに書かれた以外の記法を推しの方がいましたら、コメントなどで教えていただけると幸いです!
iOS以外でもSwiftを活用したいじゃん?
【考察】なぜ Swift に `popLast()` があって `popFirst()` がないのか
さらにいうと、なぜ Swift に remove は removeFirst()
も removeLast()
もあるのに、pop は popLast()
しかなく、popFirst()
がないのか。
※本記事はあくまで個人的な考察であり、公式見解ではありません。
実は今日 Slack で @bannzai さんからこんな質問をいただきました:
Array に
popFirst
ってメソッド無いんだけど、生やすのはご法度なのかな
直感的に「おそらくそれは意図的に入れなかったので、自分で使う分には拡張で作っても問題ないけど多分 Proposal に出しても通らない」と思ったけど、なぜなのかはすぐには答えられなかったので、考えてみました。
popLast()
と removeLast()
は、何が違うのでしょう?
まず、両方とも配列から最後の要素を取り外しす操作です。つまり、コードで書くと、こんな風になります:
var a = [1, 2, 3]
a.removeLast() // a == [1, 2]
a.popLast() // a == [1]
では、何が違うかというと、removeLast()
は、配列に要素がなかったら、ない要素を削除しようとしてランタイムで落ちます1が、popLast()
はそのまま配列に何も操作を加えず落ちません。
それだけか?
実はもうちょっと両方深掘りしてみると、面白いことに、両方とも戻り値があって、削除された値を返しています。removeLast()
の方は @discardableResult
の修飾子があるので戻り値を使わなくてもワーニング出ませんが。
Swift ではどういう時に @discardableResult
使うかというと、普通の戻り値があるメソッドはインスタンスに何かの操作を加えることなく、計算した結果を戻り値として返すのが本来の目的ですが、時にはインスタンスに何か操作を加えるのが本来の目的だが、ついでに戻り値がおまけとしてつくメソッドがあります。そういうメソッドに @discardableResult
を宣言に入れることによって、コンパイラに「このメソッドを読んだけど戻り値ははあくまでおまけだから使わなくてもいいよ」ということを教えます。そして removeLast()
だけでなく、removeFirst()
も @discardableResult
メソッドです。
そして popLast()
の戻り値は Element?
になっているのに対し、removeLast()
の戻り値は Element
です。空配列に removeLast()
呼ぶと返す Element
がないから落ちるのも当然ですね。
ではなぜ、Element?
を返す popFirst()
がないのか?
いいえ、正確には popFirst()
は Swift に存在しているのです。多くの人がそれが気づいていないかもしれませんが。
しかし、上記のコード、a.popFirst()
を書こうとしてもコンパイルエラーになります。なぜなのか。
上のリンクをもうちょっと詳しくみてみると、その外に extension Collection where SubSequence == Self
が書いてあるのを気づけます。ここのミソはまさに最後の where SubSequence == Self
の制約です。通常の Array
は SubSequence
は Array
ではなく ArraySlice
ですので、この制約に満足せず、だから popFirst()
が使えません。
なぜ popFirst()
だけこの制約があるのか。
この謎をとくためには、removeFirst()
と removeLast()
の違いを見てみる必要があります。
この二つのメソッド、「最初を削除か最後を削除か」だけの違いに見えるかもしれませんが、実はもう一つの違いがあります。複雑度です。removeLast()
は $O(1)$ であるに対し、removeFirst()
は $O(n)$ です。
なぜこのようになっているのかは具体的に SIL 読んでないのであくまで推測ですが、配列の最後の要素を削除するには最後の要素をメモリからクリアするだけで終了ですが、最初の要素を削除するには最初の要素をメモリから削除したあと、その後の全ての要素を 1 個ずつ前のメモリに移す必要があります(もしくは別のメモリ容量を確保して丸ごとそっちのメモリに移す必要があります)。そのため、大して違わない操作に見えても、最終的にマシンコードに落とし込んだらパフォーマンスが全く違います。removeFirst()
も popFirst()
も、配列の操作においては同じことをするので、パフォーマンスには同じものになると推測できます。
じゃあなぜ Array
において remove には removeFirst()
があるのに pop に popFirst()
がないのか、それはまた remove
系の戻り値と、pop
系の戻り値の違いに理由があるのではないかと、個人的に思います。
-> Element
の戻り値と -> Element?
の戻り値、ほぼ同じようですが、ユースケースとして一つ大きな違いがあり、Optional には Optional Binding という機構がありますので、それを利用した Flow Control は Swift では多く見られます。そしてさらにそれをループ条件に組み込むことができ、while let value = optionalValue
でループが組めます。これは非 Optional ではできないことです。
そしてループは当然繰り返し演算ですので、複雑度は $O(n)$ です。この $O(n)$ の複雑度を維持してこれ以上複雑にならないためには、ループ条件が $O(1)$ である必要があります。しかし popFirst()
は前述通り、Array
の場合 $O(n)$ になりますので、ループ条件に組み込むのはあまり適しているとは言い難いです。
ちなみに、無理やりこの $O(n)$ の popFirst()
をループ条件に組み込んだらどうなるのかというと、$O(n)$ のループが回る度にさらに $O(n)$ の処理を行いますので、$O(n^2)$ の複雑度になります2。
そして上の popFirst()
の実装のリンクをクリックしてみると分かりますが、その popFirst()
は $O(1)$ だと説明されています。なぜそうなっているかというと、中身の実装は self = self[index(after: startIndex)..<endIndex]
となっています。Collection
は subscript
で SubSequence
を取得できますが、SubSequence
が自分自身の型ととな時である場合、メモリ上の複雑な操作はないと推測されるため $O(1)$ 複雑度に維持できると推測できます。この popFirst()
は $O(1)$ の複雑度だからこそ、Optional の戻り値を持つメソッドとして組み込むことが許されたと思われます。
では、それでも popFirst()
を使いたい、しかもまさにその while let first
で使いたい場合はどうすればいいのか?答えは簡単です。Array
の SubSequence
は ArraySlice
ですが、ArraySlice
の SubSequence
は自分と同じく ArraySlice
です。ですので、ArraySlice
を一回作っちゃえばいいです:
let a = [1, 2, 3]
var sliceA = ArraySlice(a)
while let first = sliceA.popFirst() {
print(first) // 1; 2; 3;
}
Conditional Conformanceで遊ぼう
Conditional ConformanceはSwift4.1で追加された言語機能です。
型パラメータに条件をつけて(Conditional)他のProtocolに適合する(Conformance)ことができる便利な機能です。
class Box<T> {
var value: T
init(_ value: T) { self.value = value }
}
解説用の箱です。これをConditional Conformanceで拡張して遊んでみましょう。最近私や身の回りの人が踏んだものを一通り紹介します。
前提となるProtocolは明示的に宣言する必要がある
// Conditional conformance of type 'Box<T>' to protocol 'Hashable' does not imply conformance to inherited protocol 'Equatable'
extension Box: Hashable where T: Hashable {
func hash(into hasher: inout Hasher) {
value.hash(into: &hasher)
}
}
HashableはEquatableを前提に持つProtocolです。ConditionalConformanceでHashableを使う場合は、「明示的にEquatableのConditionalConformanceを宣言する」必要があります。
複数のConditionから同一のConformanceは作れない
// Conflicting conformance of 'Box<T>' to protocol 'Hashable'; there cannot be more than one conformance, even with different conditional bounds
extension Box: Hashable where T: AnyObject {
func hash(into hasher: inout Hasher) {
return ObjectIdentifier(self).hash(into: &hasher)
}
}
// Conflicting conformance of 'Box<T>' to protocol 'Hashable'; there cannot be more than one conformance, even with different conditional bounds
extension Box: Hashable where T == Any.Type {
func hash(into hasher: inout Hasher) {
return ObjectIdentifier(self).hash(into: &hasher)
}
}
一度HashableにConformしたBoxは他の方法でHashableにConformできなくなります。
滅多にこの要求は発生しないものですが、ObjectIdentifierを使って良しなに動かす夢は潰えました。
Existentialが作れる
extension Box: CustomStringConvertible where T: CustomStringConvertible {
var description: String { return value.description }
}
func check(_ value: Any) {
guard let value = value as? CustomStringConvertible else { return }
print(value)
}
check(Box(1))
check(Box(Box(1)))
Swift4.2からExistentialが使えるようになりました。Swift4.1ではキャストの実行時に警告(warning: Swift runtime does not yet support dynamically querying conditional conformance
)が出ていたものです。
Existentialを作るとクラッシュする
作れると言ったな?あれは嘘だ
extension Box: CustomStringConvertible where T: Sequence, T.Element :CustomStringConvertible {
var description: String {
return "[" + value.map { $0.description }.joined(separator: ", ") + "]"
}
}
check(Box([1, 2, 3])) // EXC_BAD_ACCESS (code=EXC_I386_GPFLT
Conditionの複雑さが以下の条件を超えると実行時クラッシュです。
- associatedtypeを持つprotocolに条件付をする
- associatedtypeにも条件付をする
Bugsに報告されています https://bugs.swift.org/browse/SR-8666
typealiasはconditionalではなく、全体を汚染する。
数日前にDiscordで盛り上がっていたネタです。
extension Box: Sequence where T: Sequence {
typealias Element = T.Element
typealias Iterator = T.Iterator
func makeIterator() -> T.Iterator {
return value.makeIterator()
}
}
例えばSequenceのConditional Conformanceを書いてみましょう。
一見すると問題なさそうですが、ElementはT: Sequenceでない場合にも存在してしまいます。いろいろ困ったことが発生します。
// error: Segmentation fault: 11
print(Box<Int>.Element.self)
Box.Elementは存在しているのですが、Int.Elementは存在しない、これはコンパイル時にセグフォが発生します。
extension Box: IteratorProtocol where T: IteratorProtocol {
// Invalid redeclaration of 'Element'
typealias Element = T.Element
func next() -> Element? {
return value.next()
}
}
加えてIteratorProtocolのConditional Conformanceを追加しました。Elementは汚染されているので宣言することが出来ません。ところでこの場合は汚染されたElementはT.Elementなので、typealiasを消してそのまま利用すれば、「たまたま正しく」動きます。
極めて稀に発生する、ElementをT.Element以外で指定したいという要求が発生すると詰みます。他の方法を探しましょう。
Bugsに報告されています https://bugs.swift.org/browse/SR-9533
おわり
Conditional Conformanceで遊んでみました。残念ながら少し壊れてしまいました。
とはいえConditional Conformanceの用途の80%は「ElementがPならWrapperもPにしたい」というもので今回紹介した悪いコードを使う必要は殆どありません。でも20%ぐらいは壊しそうになること、ありますよね。
20%の具体例ですが @taketo1024 先生が書いているSwiftyMathが、限界を超えそうなConditional Conformanceを要所要所で利用されていて面白いです。
https://github.com/taketo1024/SwiftyMath