こんにちは。コミュニケーションアプリLINEのクライアントを開発している安藤です。
この記事では、DroidKaigi2023の企業ブースで行ったCode Review Challengeの4問目の解説をします。 Code Review Challengeについてはこちらを参照してください。
出題タイトル: Caught on Running
4問目では、以下のコードが出題されました。
class SomeActivity : AppCompatActivity() {
private val logger: Logger = Logger()
private val userDataRepository = UserDataRepository()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_huge_legacy)
val userId = intent.getStringExtra(USER_ID) ?: "failed"
UpdateUserData(userId)
}
fun UpdateUserData(userId: String) {
val textView = findViewById<TextView>(R.id.text)
lifecycleScope.launch(Dispatchers.Main) {
if (userId != "falled") {
userDataRepository.getUserData(UserId(userId))
.onSuccess { userData ->
textView.text = userData.userName.value
}.onFailure {
Toast.makeText(
this@SomeActivity,
"Something wrong with it.",
Toast.LENGTH_SHORT
).show()
logger.sendLog()
}
}
}
}
companion object {
const val USER_ID = "user_name"
}
}
interface UserDataApi {
suspend fun findUser(userId: UserId): UserData
suspend fun findUser(userName: UserName): UserData
}
class UserDataRepository(private val userDataApi: UserDataApi = UserDataApiImpl()) {
suspend fun getUserData(userId: UserId): Result<UserData> =
runCatching { userDataApi.findUser(userId) }
suspend fun getUserData(userName: UserName): Result<UserData> =
runCatching { userDataApi.findUser(userName) }
}
data class UserData(val id: String, val userName: UserName, val userType: UserType,val profileImage: Bitmap)
value class UserId(val value: String)
value class UserName(val value: String)
sealed class UserType {
object ADMIN : UserType()
object NORMAL : UserType()
}
class UserDataApiImpl : UserDataApi {
override suspend fun findUser(userId: UserId): UserData = TODO("Call remote api")
override suspend fun findUser(userName: UserName): UserData = TODO("Call remote api")
}
class Logger {
fun sendLog() {
TODO("Send log to remote server")
}
}
これはユーザの情報を表示する画面のためのActivityと関連するRepositoryクラスです。 UserDataRepository
はUserDataApi(とその実装UserDataApiImpl)
を用いてリモートサーバからユーザの情報を取得します。SomeActivity
はUserDataRepository
を用いてユーザの情報を表示します。 この際、成功時には取得したユーザの情報を、失敗時にはToastを表示します。 取得するユーザの情報はUserData
として定義されています。
問題点と解決策
このコードには複数の問題が含まれていますが、問題の影響が比較的大きい以下の3つを重点的に解説します。
- runCatchingを使用している問題
- 関数のオーバーロードをしている問題
- data classの
equals
の実装の問題
1. runCatchingを使用している問題
UserDataRepository
が持つ2つの関数ではUserDataApi
のsuspend関数を呼び出すためにrunCathing
を使用しています。
suspend fun getUserData(userId: UserId): Result<UserData> =
runCatching { userDataApi.findUser(userId) }
suspend fun getUserData(userName: UserName): Result<UserData> =
runCatching { userDataApi.findUser(userName) }
リモートサーバから情報を取得するUserDataApi
はネットワークの問題を始めとするIOExceptionを発生させる可能性があります。 これらのハンドリングを行うためにrunCatching
を使用して、呼び出し側にて成功時と失敗時の処理を記述していますが、runCatching
を使用してエラーハンドリングを行っているのが問題です。
runCathing
を使用した場合のコードは次のコードと等価です。
suspend fun getUserData(userId: UserId): Result<UserData> = try {
Result.success(userDataApi.findUser(userId))
} catch (e: Throwable) {
Result.failure(e)
}
suspend fun getUserData(userName: UserName): Result<UserData> = try {
Result.success(userDataApi.findUser(userName))
} catch (e: Throwable) {
Result.failure(e)
}
UserDataApi
が発生させる例外は限られたものになるはずです。 しかし、runCathing
を使用することでその他の例外も全てキャッチしてしまいます。 今回の場合ではネットワーク関連のトラブルを示すIOException
のみをハンドリングすれば十分です。
suspend fun getUserData(userId: UserId): UserData? = try {
// ...snip
} catch (e: IOException) {
null
}
suspend fun getUserData(userName: UserName): UserData? = try {
// ...snip
} catch (e: IOException) {
null
}
不必要に広い範囲の例外をキャッチすることは、バグの早期発見の妨げになります。また、リカバリー不可能なエラーをキャッチしてしまうことで更に深刻な問題へと発展してしまう可能性もあります。
例えば、 NullPointerException
は実装の問題から生じるものです。そのためコードを修正する必要がありますが、 runCatching
を使っていると発見が難しくなってしまいます。
また、CancellationException
はCoroutineが正しく動作するために必要な例外です。こちらをキャッチしてしまうと正常にキャンセルが動作しません。 そのため必要最小限の例外をキャッチするよう心がけるべきでしょう。
2. 関数のオーバーロードをしている問題
UserDataRepository
が持つ2つの関数はどちらも同じgetUserData
という名前を持っています。 引数として各関数はvalue class
のUserId
,UserName
を取ります。
suspend fun getUserData(userId: UserId): Result<UserData> =
runCatching { userDataApi.findUser(userId) }
suspend fun getUserData(userName: UserName): Result<UserData> =
runCatching { userDataApi.findUser(userName) }
一見すると大きな問題を持っていないように見えますが、このUserDataRepository
がJavaから参照されるケースで非常に大きな問題となります。 これはKotlinにおけるvalue class
のJava上での振る舞いによって発生する問題です。
value class
は値を1つだけ持ったクラスで、値に制限をかける、意味のある名前をつけ型安全に取り扱いたい際に使用します。 例えば今回のUserId
とUserName
はどちらもString
の値を持ちますが、それぞれ別の型として扱われます。 そのため、UserId
の変数にUserName
を代入するといったString
ではできてしまうことを制限することができます。
しかし、このvalue class
をJavaの上で扱おうとした場合、内部に持っている1つの値の型(今回の場合ではどちらもString
)として扱われます。 Java上からUserDataRepository
を使用する場合、次のように認識されてしまいます。
public @Nullable UserData getUserData(String userId);
public @Nullable UserData getUserData(String userName);
その結果同一のシグネチャをもつ関数が2つあると認識されてしまい、呼び出そうとするJavaのコードにてコンパイルエラーが発生します。
これを解決するための一番簡単な方法は関数のオーバーロードをやめ、それぞれに別の名前をつけることです。
suspend fun getUserDataById(userId: UserId): UserData? = try { /* snip */ }
suspend fun getUserDataByName(userName: UserName): UserData? = try { /* snip */ }
@JvmName
アノテーションを用いて名前を宣言することで回避することもできますが、特に理由がなければ関数のオーバーロードをしない前述の方法を用いたほうがよいでしょう。 関数を作成する場合は、その関数が何をする関数なのかを的確に表す関数名にすべきです。 もし、似たような関数が複数ある場合は引数に関する言及を関数名に含めることも検討しましょう。
3. data classにequalsの実装がない値を含めている
Api
やUserDataRepository
で使用しているUserData
はdata class
として次のように定義されています。
data class UserData(
val id: UserId,
val userName: UserName,
val userType: UserType,
val profileImage: Bitmap
)
ユーザの情報を表示するための情報をまとめているUserData
クラスですが、data class
であることに起因して問題があります。
Kotlinのdata class
では、toString()
,hashCode()
,equals()
,copy()
, componentN()
といった関数が自動で実装されます。
Bitmap のequals
は同一であるかの確認をし、同値であるかの確認にはsameAs
を使います。 一方、data class
のequals
ではすべてのプロパティのequals
を確認するため、プロパティの詳細を見ない限りは同値性を確認していることを期待しやすいです。 もし、data class
の一部として Bitmapが含まれていた場合は、誤解を招く可能性があります。 例えば、UserData
の変更をdistinctUntilChanged
を用いて検知する場合を考えます。 distinctUntilChanged
の内部ではequals
を用いて変更を検知しているため、UserData
に対して使用するとBitmapの同一性を確認してしまいます。 結果としてBitmapは同一であるか、それ以外の値は同値であるかを確認するような挙動となってしまい、非常に理解するのが難しい状況になります。
この問題の解決方法ですが、一番簡単なのはdata class
ではなく通常のclass
を使用することです。
class UserData(
val id: UserId,
val userName: UserName,
val userType: UserType,
val profileImage: Bitmap
)
通常のclass
として定義することで誤解を招きづらい実装とすることができます。
data class
を使う必要がある場合は、equals
の実装をオーバーライドすることも可能です。 bitmap
では前述したようにsameAs
関数を使用することで同値であるかの確認を行うことができます。 こちらを用いてequals
を正しくオーバーライドすることで想定通りに動くコードを作成することができます。 equals
をオーバーライドする場合にはhashCode
についても正しくオーバーライドする必要があるので注意してください。
data class
を使用する他の方法としてBitmapではなく画像を示すUrlやリソースIDなどを変わりに持たせることでもdata class
を正しく使うことができます。
data class UserData(
val id: UserId,
val userName: UserName,
val userType: UserType,
val profileImageUrl: String
)
data class
は非常に便利に使えるケースが多いため気軽に使用するケースが多いと思いますが、使用すべきでないケースで使用してしまうことで思わぬ実装ミスに繋がってしまう場合もあるため注意が必要です。
その他の問題
この他にも様々な問題が含まれていました。
- 命名について Kotlinのconventionに則っていない変数、関数の命名や分かりづらい、間違った名前がついた変数などが多数存在していました。 それぞれの変数・関数に対して適切に名前を付ける必要があります。
a. 関数名がUpdateUserData
になっている - updateUserData
とする b. sealed classの子クラスの名前がenumの命名規則に従っている - ADMIN
, NORMAL
ではなく、Admin
, Normal
とする c. Intentを取得するためのkeyの値がuser_name
なのに対し、変数名がUSER_ID
となっている - keyの値と変数名を揃える d. ユーザの情報を表示するActivityの名前が不適切 - SomeActivity
ではなく、UserDataActivity
など、実態に即した名前にする
-
不必要なsealed classの定義 ユーザの属性を示す
UserType
をsealed classとして定義しています。 しかし、UserType
の小クラスを見ると、全てobjectとして定義されています。 今回の場合enum classを用いた表現で十分です。 -
value classの定義にアノテーションが付与されていない 現在value classを定義する場合には
@JvmInline
のアノテーションを付与することが必須ですが、問題のコードには含まれていません。 こちらのアノテーションを追記する必要があります。 -
Intentから取得した値の取り扱いによってバグが発生する Intentから
USER_ID
を用いて値を取得することを試みますが、失敗した場合にfailed
という値を代入しています。 また、updateUserData
関数にて値がfalled
以外の場合にのみUserDataRepository
での処理を行うという実装をしています。 今回の実装ではuserId
がfalled
の場合にはUserDataRepository
の処理が行われず、userId
を正常に取得できない場合にはfailed
というuserId
があるとして実行されてしまいます。基本的にエラーの値は明確にエラーであることがわかるように実装すべきでしょう。 今回の場合はnull
をエラーの値として取り扱うことでバグを埋め込む可能性を極力排除することができます。
さらに今回はStringからvalue classへの変換も行う必要がありました。 変数名と実際の値の食い違いによってvalue classへの変換も間違った形で実装してしまっています。 value classは内部の値が同じ型である場合には簡単に変換ミスを起こすことができてしまうのでこちらも注意する必要があります。
companion object {
const val INTENT_KEY_USER_ID = "user_id"
private fun getUserIdOrNull(intent: Intent): UserId? {
val userId = intent.getStringExtra(INTENT_KEY_USER_ID) ?: return null
return UserId(userId)
}
}
-
定義した
UserId
がUserData
で使われていない せっかく定義したUserId
がUserData
の内部では使用されていませんでした。定義するのであれば正しく使用すべきでしょう。 -
関数の公開範囲は最小化すべき
UpdateUserData
は現在SomeActivity
のonCreate
でのみ呼び出されています。そのため、private
化すべきでしょう。 -
UserData
のフォーマットが正しくないUserData
クラスの定義のうち、UserType
と次のval
の間にスペースが存在しません。これはconventionのルールに反しています。 linterを使用するなど、フォーマットルールに従うべきでしょう。 -
Dispatchers.Mainが必要ない
updateUserData
関数においてUserDataRepository
の関数を呼び出すためにlifecycleScope
からlaunchを行っていますが、Dispatchers.Main
を使用しています。 しかし、こちらの宣言は必要ありません。 -
Publicなsuspend関数がメインスレッドセーフでない 問題行数の都合上、
UserDataApi
がメインスレッドセーフであるかどうかを実装から判断することができるように記述できていません。 こちらでも紹介されている通り、publicなsuspend関数は基本的にメインスレッドセーフであるべきでしょう。
その場合、UserDataApiImpl
の内部にてIoDispatcherを使用するなどの考慮を行うのが望ましいです。
class UserDataApiImpl(private val ioDispatcher: CoroutineDispatcher) : UserDataApi {
//...snip
override suspend fun findUser(userId: UserId): UserData? = withContext(ioDispatcher) {
TODO("Call remote api")
}
}
今回紹介した問題・改善点を解決した想定解答がこちらになります。
class UserDataActivity : AppCompatActivity() {
private val logger: Logger = Logger()
private val userDataRepository = UserDataRepository()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_user_data)
val userId = getUserIdOrNull(intent)
updateUserData(userId)
}
private fun updateUserData(userId: UserId?) {
val textView = findViewById<TextView>(R.id.text)
if (userId == null) {
return
}
lifecycleScope.launch {
val userData = userDataRepository.getUserDataById(userId)
if (userData == null) {
Toast.makeText(
this@GoodSomeActivity,
"Something wrong with it.",
Toast.LENGTH_SHORT
).show()
logger.sendNetworkErrorLog()
return@launch
}
textView.text = userData.userName.value
}
}
companion object {
const val INTENT_KEY_USER_ID = "user_id"
private fun getUserIdOrNull(intent: Intent): UserId? {
val userId = intent.getStringExtra(INTENT_KEY_USER_ID) ?: return null
return UserId(userId)
}
}
}
interface UserDataApi {
suspend fun findUserById(userId: UserId): UserData
suspend fun findUserByName(userName: UserName): UserData
}
class UserDataRepository(
private val userDataApi: UserDataApi = UserDataApiImpl(),
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun getUserDataById(userId: UserId): UserData? = try {
userDataApi.findUserById(userId)
} catch (e: IOException) {
null
}
suspend fun getUserDataByName(userName: UserName): UserData? = try {
userDataApi.findUserByName(userName)
} catch (e: IOException) {
null
}
}
data class UserData(
val id: UserId, // or String userId
val userName: UserName, // or String userName
val userType: UserType,
val profileImage: Bitmap
)
@JvmInline
value class UserId(val value: String)
@JvmInline
value class UserName(val value: String)
enum class UserType { ADMIN, NORMAL }
class UserDataApiImpl(private val ioDispatcher: CoroutineDispatcher) : UserDataApi {
override suspend fun findUserById(userId: UserId): UserData = withContext(ioDispatcher) {
TODO("Call remote api")
}
override suspend fun findUserByName(userName: UserName): UserData = withContext(ioDispatcher) {
TODO("Call remote api")
}
}
class Logger {
fun sendNetworkErrorLog() {
TODO("Send log to remote server")
}
}
まとめ
この記事では、Code Review Challengeの 4 問目の解説として、以下の改善が必要なことを説明しました。
- runCatchingを使用せず、適切なスコープで例外を処理する
- 可能な限り関数のオーバーロードをせず、適切な関数名を設定する
- data classを使用する際には
equals
などの関数がどのように動作するか考慮した上でパラメータを決定する
言語の標準機能・標準ライブラリとして用意されているものであったとしても使い方によって不安定なコードになってしまう場合があります。 機能の理解はもちろんのこと、作成すべき機能はどのようなものかをしっかり考えることが重要です。