6. 함수형 객체

dev.jhjhj·2021년 8월 22일
0

Scala

목록 보기
5/10

6장에서는 함수형 객체의 변경이 불가능한 상태 특징에 대해 설명한다. 이를 위해 책에서 분수(유리수)를 나타내는 클래스로 예를 든다.

변경 불가능한 객체의 장단점

장점
  • 객체 생명주기 동안 상태가 변하지 않기 때문에 객체의 내부 값을 추론하기 쉽다.
  • 내부 상태 변경이 불가능하기 때문에 메소드 인자로 전달을 자유롭게 할 수 있다.
  • 내부 상태 변경이 불가능하기 때문에 여러 스레드에서 접근할 수 있다. (동기화 문제를 고려X)
  • 변경 불가능한 객체는 안전한 해시 테이블 키로 사용될 수 있다. ( HashSet에 변경 가능한 객체를 키로 설정하고, 상태를 변경하게 되면 HashSet에서 해당 객체를 찾을 수 없는 경우가 발생한다. )
단점
  • 상태를 변경하기 위해서는 각 상태마다 새로운 객체를 만들어야 된다. 만약 객체 생성 비용이 높다면 성능상의 병목이 발생할 수 있다.

분수 클래스 명세

  • 분수(rational number)는 n과 d가 정수, d>0 일 때, n/d로 표시
  • n은 분자, d는 분모
  • 분수의 사칙연산을 보면, 수학의 분수에는 변경 가능한 상태가 없음을 알 수 있다. (각각의 피연산자들을 연산하면, 새로운 분수가 결과값으로 나온다.) 원래 있던 두 분수 자체를 변경한 것은 아니다.

따라서 피연산자가 되는 각 분수는 서로 다른 Rational 객체이다. 어떤 두 Rational 객체를 더하면, 그 합에 해당하는 새로운 Rational 객체가 생긴다.

이번 장을 마치면, Rational 클래스를 사용해 다음과 같은 일을 할 수 있다.

scala〉 val oneHalf = new Rational(l, 2)
oneHalf: Rational = 1/2

scala〉 val twoThirds = new Rational(2, 3)
twoThirds: Rational = 2/3

scala> (oneHalf / 7) + (1 - twoThirds)
resO: Rational = 17/42

Rational 생성

Rational 객체는 변경 불가능한 객체라고 결정하였다.
변경 불가능한 객체는 인스턴스에 필요한 정보(분수 객체라면 분자,분모 값)를 생성 시점에 모두 제공하여 설계한다.
→ 생성한 다음에 값을 수정할 수 있게 되면 변경 가능한 객체가 된다.

class Rational(n: Int, d:Int)	// n, d는 클래스 파라미터

◼️ 스칼라 컴파일러는 내부적으로 두 클래스 파라미터를 종합해서 클래스 파라미터와 같은 두 인자를 받는 주 생성자 (Primary Constructor)를 만든다.

◼️ Java에서는 클래스에 인자를 받는 생성자가 있지만, 스칼라에서는 클래스가 바로 인자를 받는다. 이러한 스칼라의 표기는 클래스 내부에서 파라미터를 바로 사용할 수 있어서 좀 더 간결하다.

필드를 정의하고 생성자의 인자를 필드로 복사하는 할당문 없어도 됨(자바와 다르게 따로 생성자를 만들지 않아도 된다. )

◼️ 스칼라 컴파일러는 클래스 내부에 있으면서 필드나 메소드 정의에 들어 있지 않은 코드를 주 생성자 내부로 밀어 넣는다.
예를 들면, 디버깅용 메시지를 출력하는 코드로 작성할 수 있다.

class Rational(n: Int, d:Int){
	println("Created " + n + "/" + d)
}

====================
scala > new Rational(1, 2)
Created 1/2
res0: Rational = Rational02591e0c9
  • 스칼라 컴파일러는 println을 호출하는 이 코드를 Rational 클래스의 주 생성자에 넣는다. 따라서 Rational 인스턴스를 새로 생성할 때 마다 println을 통해 디버그 메시지 출력

toString 메소드 다시 구현하기

기본적으로 Class는 java.lang.Object 클래스에 있는 toString 메소드를 호출하게 되는데, toString은 클래스 이름,

@ 표시, 16진수 숫자를 출력한다. ex) Rational@2591e0c9

이러한 메소드들은 override 수식자를 통해 재정의 할 수 있다.

class Rational(n: Int, d: Int) {
    override def toString = n + "/" + d
}

선결 조건 확인

분수의 분모는 0일 수 없다.
따라서 객체를 생성할 때 정보가 유효한지 확인이 반드시 필요
선결 조건을 정의해서 해결 가능

  • 선결조건이란 ?
    메소드나 생성자가 전달받은 값에 대한 제약
    호출하는 이가 지켜야할 요구 조건

require 문을 통해 선결 조건을 만들 수 있다.

class Rational(n: Int, d: Int) {
    require(d != 0)     // d == 0 이라면 IllegalArgumentException 예외 발생
    override def toString = n + "/" + d
}

필드 추가

덧셈기능

Rational이 변경 불가능한 객체이기 때문에 add 메소드가 객체 자체의 값을ㄹ 변경해서는 안된다.
따라서 더한 결과값을 담는 새로운 Rational 객체를 반환해야 한다.

// 아래 코드는 컴파일 에러가 발생한다.
class Rational(n: Int, d: Int) {
    require(d != 0)
    override def toString = n + "/" + d
 
    def add(that: Rational): Rational =
        new Rational( n * that.d + that.n * d, d * that.d )
}
  • 클래스 파라미터 n,d는 자신의 클래스 내부에서만 접근 가능
  • add 메소드와 같이 다른 객체의 클래스 파라미터에서는 접근 불가

따라서 변경 불가능한 객체의 클래스 파라미터에 접근하기 위해서는 필드를 따로 선언해줘야한다.

class Rational(n: Int, d: Int) {
    require(d != 0)
    val numer: Int = n
    val denom: Int = d
    override def toString = numer + "/" + denom
 
    def add(that: Rational): Rational =
        new Rational( numer * that.denom + that.numer * denom, denom * that.denom )
}

자기 참조

현재 실행중인 메소드의 호출 대상 인스턴스에 대한 참조를 자기 참조(self reference) 라고 한다.
생성

class Rational(n: Int, d: Int) {
    require(d != 0)
    val numer: Int = n	// 분자
    val denom: Int = d	// 분모
    override def toString = numer + "/" + denom
 
    def add(that: Rational): Rational =
        new Rational( numer * that.denom + that.numer * denom, denom * that.denom )
     
     // 인자로 받은 Rational과 비교해 더 작은지 여부를 확인
     // 자기 스스로를 참조한다.
    def lessThan(that: Rational) =
        this.numer * that.denom < that.numer * this.denom
 
    def max(that: Rational) =
        if (this.lessThan(that)) that else this
}
  • this.numer는 lessThan 메소드가 속한 객체의 분자를 나타낸다.

  • this를 생략할 수 없는 경우...

    • max 함수에서 첫번째 this는 불필요한 중복
      → this.lessThan(that) 을 lessThan(that)으로 변경해도 무방
    • 하지만 두번째 this는 if 수행 결과가 false 일 경우 수행 결과로 반환할 값이다. 이건 생략하면 안됨(반환값이 사라짐)

보조 생성자

하나의 클래스에 여러 생성자가 필요한 경우 → 보조생성자 사용
ex ) 분모는 1로 미리 정해져 있고, 분자만을 인자로 받는 생성자

  • 스칼라의 보조 생성자는 def this(...)로 시작
class Rational(n: Int, d: Int){

    require(d!=0)
    val numer : Int = n
    val denom: Int = denom


    def this(n: Int) = this(n, 1)       // 보조 생성자

    override def toString = numer + "/" + denom
    def add(that: Rational) : Rational = 
        new Rational(
            numer * that.denom + that.numer * denom,
            denom * that.denom
        )
}
scala> val y = new Rational(3)
y: Rational = 3/1

비공개 필드와 메소드

비공개 필드와 메소드가 있는 Rational 클래스

  • 비공개 변수 g
  • 비공개 메소드 gcd

스칼라 컴파일러는 Rational 클래스의 필드 3개와 관련된 초기화 코드를 소스코드에 나온 순서대로 주 생성자에 위치 시킴 → g의 초기화 코드 gcd(n.abs, d,abs)는 다른 두 초기화 코드보다 먼저 수행됨

class Rational(n: Int, d: Int){

    require(d!=0)
    private val g = gcd(n.abs, d,abs)       // 비공개 변수
    val numer : Int = n / g
    val denom: Int = denom / g


    def this(n: Int) = this(n, 1)       // 보조 생성자

    override def toString = numer + "/" + denom
    def add(that: Rational) : Rational = 
        new Rational(
            numer * that.denom + that.numer * denom,
            denom * that.denom
        )

    override def toString = numer + "/" + denom

    private def gcd(a: Int, b:Int): Int =       // 비공개 메소드
        if(b == 0) a else gcd(b, a%b)
}

연산자 정의

수학 기호로 기존의 add 메소드를 대체할 수 있다.
즉, 연산자를 재정의할 수 있다.

class Rational(n: Int, d: Int){

    require(d!=0)
    private val g = gcd(n.abs, d,abs)       // 비공개 변수
    val numer : Int = n / g
    val denom: Int = denom / g


    def this(n: Int) = this(n, 1)       // 보조 생성자

    override def toString = numer + "/" + denom

    def + (that: Rational) : Rational = 
        new Rational(
            numer * that.denom + that.numer * denom,
            denom * that.denom
        )		// + 연산자 정의
    
    def * (that: Rational) : Rational = 
        new Rational(numer * that.numer, denom * that.denom)		// * 연산자 정의

    override def toString = numer + "/" + denom

    private def gcd(a: Int, b:Int): Int =       // 비공개 메소드
        if(b == 0) a else gcd(b, a%b)
}
scala> val x = new Rat onal(l, 2)
x: Rational = 1/2

scala> val y = new Rat onal(2, 3)
y: Rational = 2/3

scala> x + y
res7: Rational = 7/6

스칼라의 식별자

  • 영숫자 식별자
    - 문자나 밑줄(_)로 시작, 두 번째 글자부터는 문자, 숫자, 밑줄 모두 사용 가능
    - 특수문자 $ 도 문자로 취급, 하지만 예약문자이기 떄문에 사용 X
    - 자바의 관례에 따라 Camel-Case로 표기한다. ( ex) toString, HashSet )
    - 필드, 메소드 인자, 지역 변수, 함수는 소문자로 시작
    - 클래스는 대문자로 시작
    - 상수는 첫 글자만 대문자로 표기 (자바의 관례와 다르다)
    - 스칼라에서는 상수를 첫 글자만 대문자로 표기한다.
    X_OFFSET 같은 자바 스타일의 상수 표기는 스칼라에서는 XOffset처럼 Camel-Case를 사용하는 것이 관례

  • 연산자 식별자
    - 하나 이상의 연산자 문자로 이루어짐
    - 스칼라 컴파일러는 내부적으로 $를 사용해 연산자 식별자를 해체하여 적합한 자바 식별자로 다시 만드는 작업을 수행
    - 식별자 :-> 는 내부적으로 $colon$minus$greater로 바뀐다.

  • 혼합 식별자
    - 영문자와 숫자로 이뤄진 식별자의 뒤에 밑줄이 오고, 그 뒤에 연산자 식별자가 온다.
    - unary_+는 단항 연산자인 +를 정의하는 메소드의 이름

  • 리터럴 식별자
    - 역따옴표`...`로 둘러싼 임의의 문자열
    - 런타임이 인식할 수 있는 어떤 문자열이라도 역따옴표 사이에 넣을 수 있다.
    - 스칼라 예약어조차도 리터럴 식별자로 정의할 수 있다.
    ex ) yield는 스칼라의 예약어이므로 자바의 Thread 클래스에 있는 정적 메소드 yield에 접근하고 싶을 때, Thread.yield()와 같이 사용할 수 없다.
    Thread.`yield()` 처럼 메소드 이름을 지정하면 사용할 수 있다.

메소드 오버로드

오버로드한 여러 메소드를 추가한 Rational 클래스
메소드 호출 시점에 컴파일러는 오버로드한 메소드 중 인자의 타입이 일치하는 메소드를 선택 (자바와 유사)

class Rational(n: Int, d: Int){

    require(d!=0)
    private val g = gcd(n.abs, d,abs)       // 비공개 변수
    val numer : Int = n / g
    val denom: Int = denom / g


    def this(n: Int) = this(n, 1)       // 보조 생성자

    override def toString = numer + "/" + denom

    def + (that: Rationa): Rational = 
    new Rational(numer * that.denom + that.numer * denom, denom * that.denom)

    def + ( : Int): Rational =
    new Rational(numer + i * denom, denom)
    
    def - (that: Rational): Rational =
     new Rational(numer * that.denom - that.numer * denom, denom * that.denom)

    def - ( : Int): Rational =
    new Rational(numer - i * denom, denom)

    def * (that: Rational): Rational =
    new Rational(numer * that.numer, denom * that.denom)

    def * ( : Int): Rational =
    new Rat onal(numer * i, denom)

    def / (that: Rational): Rational =
    new Rational(numer * that.denom, denom * that.numer)
    
    def / (i: Int): Rational =
    new Rational(numer, denom * i)
    override def toString = numer + "/" + denom

    private def gcd(a: Int, b:Int): Int =       // 비공개 메소드
        if(b == 0) a else gcd(b, a%b)
}

암시적 타입 변환

val x = new Rational(2, 3)
x * 2   // Int를 인자로 받는 * 메소드를 정의하였기 때문에 사용가능
2 * x   // Error 발생
  • 2 x는 `2.(x)`와 같기 때문에 결국 Int의 메소드를 호출해 문제가 발생
  • 스칼라는 필요할 경우 암시적 타입 변환을 지정해 문제를 해결 가능
// int를 Rational로 변환하는 메소드 정의
// implicit 수식자는 컴파일러에서 몇몇 상황에 해당 메소드를 활용해 변환을 수행하라고 알려줌
implicit def inToRational(x: Int) = new Rational(x)

val r = new Rational(2,3)
println(2 * r)
===========================================
4/3
  • 암시적 타입 변환이 동작하기 위해서는 해당 스코프 안네 변환 메소드가 존재해야함

  • 만약, 암시적 타입 변환 메소드를 Rational 클래스 내부에 정의하였다면, 메소드를 호출한 스코프에는 존재하지 않으므로 변환이 이뤄지지 않음

profile
어제보다 더 나은

0개의 댓글