티스토리 뷰
오늘 아침, 코틀린 커뮤니티 포럼에 계산값에 대한 질문이 올라와 있었다. Double 연산이 제대로 되지 않는다는 것이었다.
Original discussions: discuss.kotlinlang.org/t/how-to-do-an-operation-on-a-variable-and-assign-it-that-new-value-for-easing-calculations/19621
[
How to do an operation on a variable and assign it that new value for easing calculations?
Do calculations with double seems a mess. But within my calculation i have numbers like 2.245, so my vars can’t be int Horrible How do youe guys manage that?
discuss.kotlinlang.org
아래 코드가 질문자가 최초로 올린 코드이다.
/*e is a number from 0 to 100
t comes as 0
n and r comes as 100*/
if ((e / r) < 1 / 2.75) {
println("IS A")
return (n * 7.5625 * e * e + t).toDouble()
} else if (e < 2 / 2.75) {
println("IS B")
return (n * (7.5625 * (e- 1.5 / 2.75) * e + 0.75) + t).toDouble()
} else if (e < 2.5 / 2.75) {
println("IS C")
return (n * (7.5625 * (e - 2.25 / 2.75) * e + 0.9375) + t).toDouble()
} else {
println("IS D")
return (n * (7.5625 * (e - 2.625 / 2.75) * e + 0.984375) + t).toDouble()
}
그리고 멤버들 간의 많은 토론 끝에 질문자는 동작하는 자신의 최종버전을 올렸다.
fun bounceEaseOut(dt:Float, b:Float=0f, c:Float=100f, d:Float=100f):Float {
var t=dt
t /= d
if (t < (1/2.75f)) {
return c*(7.5625f*t*t) + b
} else if (t < (2/2.75f)) {
var dt=t-(1.5f/2.75f)
return c*(7.5625f*(dt)*dt + .75f) + b
} else if (t < (2.5/2.75)) {
var dt=t-(2.25f/2.75f)
return c*(7.5625f*(dt)*dt + .9375f) + b
} else {
var dt=t-(2.625f/2.75f)
return c*(7.5625f*(dt)*dt + .984375f) + b
}
}
그리고 바로 다음에, 한 개발자가 좀 더 코틀린스러운 코드를 올려주었다.
fun bounceEaseOut(t: Float, b: Float = 0f, c: Float = 100f, d: Float = 100f): Float {
// It's idiomatic to shadow a parameter to turn it into a var if that is needed by your code
var t = t / d
// b gets added to each value in the end, and so it can be moved to the start of the when expression. The when now is only responsible for calculating the multiplier for c, which reduces duplication and the possibility that later on someone might make a mistake and change the return value of a branch
return b + c * when {
t < 1 / 2.75f -> 7.5625f * t * t
t < 2 / 2.75f -> {
t -= 1.5f / 2.75f
7.5625f * t * t + .75f
}
t < 2.5f / 2.75f -> {
t -= 2.25f / 2.75f
7.5625f * t * t + .9375f
}
else -> {
t -= 2.625f / 2.75f
7.5625f * t * t + .984375f
}
}
}
사실 위의 코드는 Intellj나 Android Studio를 사용하면 IDE가 제공해주는 sugesstion 기능을 사용하면 거의 같은 결과를 얻을 수 있다. 하지만 개인적인 생각으로 코드를 좀 더 손보면 좀 더 읽기 쉽고 정리된 코드로 바꿀 수 있을 것으로 보이며, 리팩토링을 해보고자 한다.
먼저 테스트 코드부터 만들자. 테스트코드는 매번 코드 변경시마다 실행될 것이며, 리팩토링이 기존 동작을 제대로 수행하는지 검증해 주기 때문에 아주 중요하다. 사실 질문자 올렸던 코드는 정확하게 어떤 동작을 하는지 이해하지 못했다. 따라서 테스트 값과 결과는 기존 코드를 실행해서 나온 결과를 가지고 만들었다.
@RunWith(Parameterized::class)
class BounceEraseOutTest(val testData: Pair<Int, Float>) {
companion object {
@JvmStatic
@Parameterized.Parameters
fun data(): List<Pair<Int, Float>> = listOf(
0 to 0.0f,
1 to 0.075624995f,
2 to 0.30249998f,
3 to 0.68062496f,
4 to 1.2099999f,
5 to 1.890625f,
6 to 2.7224998f,
7 to 3.7056253f,
8 to 4.8399997f,
9 to 6.1256256f,
10 to 7.5625f,
20 to 30.25f,
30 to 68.06251f,
40 to 91.0f,
50 to 76.5625f,
60 to 77.25f,
70 to 93.0625f,
80 to 94.0f,
90 to 98.8125f,
100 to 100.0f,
)
}
@Test
fun testLowest() {
val (dt, expected) = testData
assertEquals(expected, bounceEaseOut(dt.toFloat()), "erase value for $dt invalid")
}
}
먼저 공통적으로 사용되는 숫자들, 2.75
와 7.5625
를 상수로 축출하자. 참고로 상수의 이름은 임의대로 주었다.
const val DIVISOR = 2.75f
const val RATIO = 7.5625f
fun bounceEaseOut(t: Float, b: Float = 0f, c: Float = 100f, d: Float = 100f): Float {
var t = t / d
return b + c * when {
t < 1 / DIVISOR -> RATIO * t * t
t < 2 / DIVISOR -> {
t -= 1.5f / DIVISOR
RATIO * t * t + .75f
}
t < 2.5f / DIVISOR -> {
t -= 2.25f / DIVISOR
RATIO * t * t + .9375f
}
else -> {
t -= 2.625f / DIVISOR
RATIO * t * t + .984375f
}
}
}
테스트를 돌려보자. 이상없이 잘 동작한다. 다음으로는 매개변수 t
를 다른 이름으로 변경하자. 왜냐하면 IDE가 Name shadowed: t
라는 경고 메세지를 보여주기 때문이다. 이런 불필요한 경고 메세지는 가능하면 제거해 주는 것이 좋다.
매개 변수 이름은 원래 질문자가 사용했던 dt
를 사용하기로 하자. (이게 무슨 의미인지 아직 모르겠다)
fun bounceEaseOut(dt: Float, b: Float = 0f, c: Float = 100f, d: Float = 100f): Float {
var t = dt / d
//No changes other lines
}
테스트도 잘 동작한다.
그럼, 여기서 코드를 잠시 잘 살펴보자. 어떤 데이터 구조가 필요할까? when
으로 고정된 경우의 수를 가지고 있고, 각각의 경우에 따라 다른 Float
값을 기반으로 계산을 하는 로직이다. 이런 고정된 경우의 숫자를 가지는 primitive 타입의 경우 enum
이나 sealed class
를 사용하여 처리할 수는 있는 경우가 많다. 코드가 별다른 복잡한 부분이 없으므로, enum
을 사용해 보자. 이경우 enum
은 마치 소형 데이터베이스나 lookup table 같은 역할을 한다.
enum class BounceType {
LOW, MEDIUM, HIGH, DEFAULT
}
각 멤버의 이름은 편의상 위처럼 주었고 멤버의 숫자는when
의 브랜치의 갯수와 동일하다. 이제 각각의 when
브랜치에 필요한 항목들을 enum
의 생성자 멤버로 설정해 준다.
enum class BounceType(val remainder: Float) {
LOW(1f / DIVISOR)
}
fun bounceEaseOut(dt: Float, b: Float = 0f, c: Float = 100f, d: Float = 100f): Float {
var t = dt / d
return b + c * when {
t < BounceType.LOW.remainder -> RATIO * t * t
//No changes for other branches
}
우선 케이스 브랜치 하나만 변경하고 테스트 해보자. 잘 동작하므로 다른 브랜치들도 변경해 보자.
enum class BounceType(val remainder: Float) {
LOW(1f / DIVISOR),
MEDIUM(2f / DIVISOR),
HIGH(2.5f / DIVISOR),
DEFAULT(0f / DIVISOR)
}
fun bounceEaseOut(dt: Float, b: Float = 0f, c: Float = 100f, d: Float = 100f): Float {
var t = dt / d
return b + c * when {
t < BounceType.LOW.remainder -> RATIO * t * t
t < BounceType.MEDIUM.remainder -> {
t -= 1.5f / DIVISOR
RATIO * t * t + .75f
}
t < BounceType.HIGH.remainder -> {
t -= 2.25f / DIVISOR
RATIO * t * t + .9375f
}
else -> {
t -= 2.625f / DIVISOR
RATIO * t * t + .984375f
}
}
}
다시 테스트를 실행한다.
이제 브랜치 간에 공통적인 코드가 더 있는지 살펴보자.
RATIO * t * t + .75f
위 부분은 모든 브랜치에 공통적으로 들어가 있다. 따라서 이부분을 BounceType
으로 옮겨 보자.
enum class BounceType(val remainder: Float, val adder: Float) {
LOW(1f / DIVISOR, 0f),
MEDIUM(2f / DIVISOR, .75f),
HIGH(2.5f / DIVISOR, .9375f),
DEFAULT(0f / DIVISOR, .984375f)
}
fun bounceEaseOut(dt: Float, b: Float = 0f, c: Float = 100f, d: Float = 100f): Float {
var t = dt / d
return b + c * when {
t < BounceType.LOW.remainder -> RATIO * t * t + BounceType.LOW.adder
t < BounceType.MEDIUM.remainder -> {
t -= 1.5f / DIVISOR
RATIO * t * t + BounceType.MEDIUM.adder
}
t < BounceType.HIGH.remainder -> {
t -= 2.25f / DIVISOR
RATIO * t * t + BounceType.HIGH.adder
}
else -> {
t -= 2.625f / DIVISOR
RATIO * t * t + BounceType.DEFAULT.adder
}
}
}
테스트도 성공적이다.
t -= 1.5f / DIVISOR
이 부분은 옮길 수 없을까? 첫번째 when
브랜치에는 이 계산식이 없지만 사실은 아래와 마찬가지 식이된다.
t < BounceType.LOW.remainder -> {
t -= 0
RATIO * t * t + BounceType.LOW.adder
}
내 추측이 맞는지 테스트를 돌려보자.
이제 모든 브랜치가 아주 유사한 모습이 되었다.
fun bounceEaseOut(dt: Float, b: Float = 0f, c: Float = 100f, d: Float = 100f): Float {
var t = dt / d
return b + c * when {
t < BounceType.LOW.remainder -> {
t -= 0
RATIO * t * t + BounceType.LOW.adder
}
t < BounceType.MEDIUM.remainder -> {
t -= 1.5f / DIVISOR
RATIO * t * t + BounceType.MEDIUM.adder
}
t < BounceType.HIGH.remainder -> {
t -= 2.25f / DIVISOR
RATIO * t * t + BounceType.HIGH.adder
}
else -> {
t -= 2.625f / DIVISOR
RATIO * t * t + BounceType.DEFAULT.adder
}
}
}
각 브랜치의 첫번째 계산식 또한 BounceType
으로 옮겨보자.
enum class BounceType(val remainder: Float, val adder: Float, val subtract: Float) {
LOW(1f / DIVISOR, 0f, 0f),
MEDIUM(2f / DIVISOR, .75f, 1.5f / DIVISOR),
HIGH(2.5f / DIVISOR, .9375f, 2.25f / DIVISOR),
DEFAULT(0f / DIVISOR, .984375f, 2.625f / DIVISOR)
}
fun bounceEaseOut(dt: Float, b: Float = 0f, c: Float = 100f, d: Float = 100f): Float {
var t = dt / d
return b + c * when {
t < BounceType.LOW.remainder -> {
t -= BounceType.LOW.subtract
RATIO * t * t + BounceType.LOW.adder
}
t < BounceType.MEDIUM.remainder -> {
t -= BounceType.MEDIUM.subtract
RATIO * t * t + BounceType.MEDIUM.adder
}
t < BounceType.HIGH.remainder -> {
t -= BounceType.HIGH.subtract
RATIO * t * t + BounceType.HIGH.adder
}
else -> {
t -= BounceType.DEFAULT.subtract
RATIO * t * t + BounceType.DEFAULT.adder
}
}
}
테스트가 잘 동작한다. 이제 한숨 고르면서 어떤 부분이 더 남았는지 살펴보자.
모든 브랜치가 동일한 패턴을 가지고 있다. 이 말은 각 브랜치의 코드를 해당 BounceType
멤버로 옮길 수 있다는 말이다.
먼저 모든 코드를 BounceType
로 옮기기 전에 각 브랜치의 조건을 체크하는 함수를 추가해 보자.
enum class BounceType(val satisfied: (t: Float) -> Boolean, val adder: Float, val subtract: Float) {
LOW({ t -> t < 1f / DIVISOR }, 0f, 0f),
MEDIUM({ t -> t < 2f / DIVISOR }, .75f, 1.5f / DIVISOR),
HIGH({ t -> t < 2.5f / DIVISOR }, .9375f, 2.25f / DIVISOR),
DEFAULT({ true }, .984375f, 2.625f / DIVISOR)
}
fun bounceEaseOut(dt: Float, b: Float = 0f, c: Float = 100f, d: Float = 100f): Float {
var t = dt / d
return b + c * when {
BounceType.LOW.satisfied(t) -> {
t -= BounceType.LOW.subtract
RATIO * t * t + BounceType.LOW.adder
}
BounceType.MEDIUM.satisfied(t) -> {
t -= BounceType.MEDIUM.subtract
RATIO * t * t + BounceType.MEDIUM.adder
}
BounceType.HIGH.satisfied(t) -> {
t -= BounceType.HIGH.subtract
RATIO * t * t + BounceType.HIGH.adder
}
else -> {
t -= BounceType.DEFAULT.subtract
RATIO * t * t + BounceType.DEFAULT.adder
}
}
}
이제 계산을 하는 코드를 BounceType
에 Lamda함수로 추가한다.
enum class BounceType(val satisfied: (t: Float) -> Boolean, val calculate: (Float) -> Float) {
LOW({ t -> t < 1f / DIVISOR }, { t ->
RATIO * t * t
}),
MEDIUM({ t -> t < 2f / DIVISOR }, { t ->
val tt = t - 1.5f / DIVISOR
RATIO * tt * tt + .75f
}),
HIGH({ t -> t < 2.5f / DIVISOR }, { t ->
val tt = t - 2.25f / DIVISOR
RATIO * tt * tt + .9375f
}),
DEFAULT({ true }, { t ->
val tt = t - 2.625f / DIVISOR
RATIO * tt * tt + .984375f
})
}
fun bounceEaseOut(dt: Float, b: Float = 0f, c: Float = 100f, d: Float = 100f): Float {
var t = dt / d
return b + c * when {
BounceType.LOW.satisfied(t) -> BounceType.LOW.calculate(t)
BounceType.MEDIUM.satisfied(t) -> BounceType.MEDIUM.calculate(t)
BounceType.HIGH.satisfied(t) -> BounceType.HIGH.calculate(t)
else -> BounceType.DEFAULT.calculate(t)
}
}
테스트를 수행하는 것을 잊지 말자.
여기까지만 해도 처음 코드에 비해 상당히 깔끔해 졌으며, 읽기 편해 졌고, 계산의 책임을 각 BounceType
의 멤버에게 위임하였다. 마지막으로 한가지 더 when
표현식은 collection
함수를 통해 좀 더 간단하게 만들 수 있다.
val t = dt / d
val bounceType = BounceType.values().find { it.satisfied(t) } ?: BounceType.DEFAULT
return b + c * bounceType.calculate(t)
다음은 최종 변경된 코드이다.
const val DIVISOR = 2.75f
const val RATIO = 7.5625f
enum class BounceType(val satisfied: (t: Float) -> Boolean, val calculate: (Float) -> Float) {
LOW({ t -> t < 1f / DIVISOR }, { t ->
RATIO * t * t
}),
MEDIUM({ t -> t < 2f / DIVISOR }, { t ->
val tt = t - 1.5f / DIVISOR
RATIO * tt * tt + .75f
}),
HIGH({ t -> t < 2.5f / DIVISOR }, { t ->
val tt = t - 2.25f / DIVISOR
RATIO * tt * tt + .9375f
}),
DEFAULT({ true }, { t ->
val tt = t - 2.625f / DIVISOR
RATIO * tt * tt + .984375f
})
}
fun bounceEaseOut(dt: Float, b: Float = 0f, c: Float = 100f, d: Float = 100f): Float {
val t = dt / d
val bounceType = BounceType.values().find { it.satisfied(t) } ?: BounceType.DEFAULT
return b + c * bounceType.calculate(t)
}
@RunWith(Parameterized::class)
class BounceEraseOutTest(val testData: Pair<Int, Float>) {
companion object {
@JvmStatic
@Parameterized.Parameters fun data(): List<Pair<Int, Float>> = listOf(
0 to 0.0f,
1 to 0.075624995f,
2 to 0.30249998f,
3 to 0.68062496f,
4 to 1.2099999f,
5 to 1.890625f,
6 to 2.7224998f,
7 to 3.7056253f,
8 to 4.8399997f,
9 to 6.1256256f,
10 to 7.5625f,
20 to 30.25f,
30 to 68.06251f,
40 to 91.0f,
50 to 76.5625f,
60 to 77.25f,
70 to 93.0625f,
80 to 94.0f,
90 to 98.8125f,
100 to 100.0f,
)
}
@Test
fun testLowest() {
val (dt, expected) = testData
assertEquals(expected, bounceEaseOut(dt.toFloat()), "erase value for $dt invalid")
}
}
'Object Oriented Programming > Refactoring' 카테고리의 다른 글
IfElse 대신 상속 사용하기 (0) | 2020.09.17 |
---|