본문 바로가기

Programmer Jinyo/Scala & FP

scala with cats 책 읽으면서 필기(하다보니 번역급) Chapter 1 (Introduction)


투명한 기부를 하고싶다면 이 링크로 와보세요! 🥰 (클릭!)

바이낸스(₿) 수수료 평생 20% 할인받는 링크로 가입하기! 🔥 (클릭!)

책 링크 : 
https://books.underscore.io/scala-with-cats/scala-with-cats.pdf

 

Preface

 

모나드, 그리고 관련 컨셉은 코드에 반복적으로 나타나는 객체지향 디자인 패턴 아키텍처 빌딩 블록과 비슷한 것들이다.

이것들은 객체지향패턴과 두가지 큰 차이점을 가진다.

1. 그것들은 형식을 갖추며, 따라서 자세하고, 정의되어 있다.

2. 그리고 굉장히 굉장히 일반적(일반화 되어있는)이다.

 

일반화는 이해하기 힘들다는 것을 의미할 수 있다. 모든 사람들이 어려움을 느낀다. 그러나 이 일반화 떄문에 다양한 상황에서 모나드와 같은 것을 적용할 수 있는 것이다.

 

---

 

Chapter 1

Introduction

Cats 는 함수형 프로그래밍을 위한 다양한 툴들을 제공한다. 대부분의 툴들은 type classes의 형태로 우리는 이를 기존 스칼라 타입들에 적용할 수 있다. 이를 통해 기존의 상속과 같은 짓을 하지 않고 소스를 다시 적는 등의 짓을 할 필요가 없다.

* type class를 잘 모른다면
http://jaynewho.com/post/3

1.1 Anatomy of a Type Class

타입클래스 패턴에는 세가지 중요한 컴포넌트(구성품)들이 있다. : 타입클래스 그 자신, 특정 타입을 위한 인스턴스, 그리고 유저에게 보여주기 위한 인터페이스 메소드들이 그것이다.

 

1.1.1 The Type Class

 

타입클래스는 우리가 구현하고자 하는 기능들에 대한 정보를 담고있는 인터페이스, 혹은 API이다. (조금 더 직관적으로, 타입클래스를 통해서 기존에 정의된 클래스들에 우리가 정의한 일반적인 기능들을 부여할 수 있다. 전통적인 OOP에서는 이를 상속으로 구현한 반면, 타입클래스에서는 상속받을 클래스 외부에서 해당 동작을 구현할 수 있다.) Cats에서 타입 클래스는 최소한 한개의 타입 파라미터와 함께 trait 로 표현된다. 예를 들어, 우리는 "serialize to JSON"동작을 다음과 같이 표현할 수 있다.

// Define a very simple JSON AST
sealed trait Json
final case class JsObject(get: Map[String, Json]) extends Json
final case class JsString(get: String) extends Json
final case class JsNumber(get: Double) extends Json
case object JsNull extends Json

// The "serialize to JSON" behaviour is encoded in this trait
trait JsonWriter[A] {
  def write(value: A): Json
}
* AST란? ( abstract syntax tree )
https://ko.wikipedia.org/wiki/%EC%B6%94%EC%83%81_%EA%B5%AC%EB%AC%B8_%ED%8A%B8%EB%A6%AC

JsonWriter 의 경우 이번 예시의 타입 클래스이며, Json과 그 하위 타입들은 supporting code를 제공한다. (JSON AST의 경우 이번 type class의 활용을 설명하기 위해 미리 정의한 것 뿐, 타입 클래스 자체와는 무관하다.)

 

1.1.2 Type Class Instances

타입 클래스의 인스턴스들은 스칼라 표준 라이브러리와 우리의 도메인 모델로부터의 타입들을 포함한 타입들을 위한 implementation(구현의 이행...쯤으로 해석하면 되나) 들을 제공한다. 

도메인 모델? Domain Driven Design이란 무엇인가?
https://frontalnh.github.io/2018/05/17/z_domain-driven-design/
즉, 서브 도메인의 구체적인 형상을 나타내는 것을 domain model이라고 부릅니다.

스칼라에서 우리는 타입클래스의 구체적인 구현을 통해서 instances를 정의하며, 그것들을 implicit 키워드로 태깅한다.

(아래의 코드는 폴리모피즘을 실질적으로 구현하는 구문과 매우 유사하다.)

  final case class Person(name: String, email: String)
  object JsonWriterInstances {
    implicit val stringWriter: JsonWriter[String] =
      new JsonWriter[String] {
        def write(value: String): Json =
          JsString(value)
      }
    implicit val personWriter: JsonWriter[Person] =
      new JsonWriter[Person] {
        def write(value: Person): Json =
          JsObject(Map(
            "name" -> JsString(value.name),
            "email" -> JsString(value.email)
          ))
      }
    // etc...
  }
implicit 키워드로 태깅한 친구들은 다른곳에서 이 코드를 사용해야 할 때 적절히 알아서 불러서 사용을 해 줍니다.
implicit conversion(암시적 변환)의 경우에는 implicit def function_1(f : Type1 => Type2) = { } 요런 식으로 적당히 선언 해 주면 Type1인 값이 Type2로 바뀌어야만 하는 상황이 발생할 경우 함수에서 잘 바꿔주고여,
implicit  parameter(암시적 파라미터)의 경우에는 내가 넣지 않아도 파라미터의 값이 자동으로 들어가는 파라미터입니다. 위의 경우엔 object에서 누군가가 stringWriter이나 personWriter을 사용하는데 기본값은 정의되지 않을 경우, 위에 정의한 코드를 사용하는 식일 겁니다.
참고링크 : https://hamait.tistory.com/605
참고링크 : https://docs.scala-lang.org/tour/implicit-parameters.html

 

1.1.3 Type Class Interfaces

타입 클래스 인터페이스는 유저에게 공개된 어떠한 기능이라도 타입 클래스 인터페이스이다. 인터페이스는, 타입클래스의 인스턴스들이 implicit 파라미터가 되는 것을 허락하는(연결해준다고도 볼 수 있는) 제네릭 메소드이다.

일반적으로, 두가지의 인터페이스를 명시할 수 있는 방법이 있다.: Interface Objects와 Interface Syntax이다.

 

Interface Objects

인터페이스를 만들 수 있는 가장 간단한 방법은 메소드를 싱글톤 객체 안에 넣는 것이다.

object Json {
  def toJson[A](value: A)(implicit w: JsonWriter[A]): Json =
    w.write(value)
}

이 객체를 사용하기 위해서, 우리는 우리가 처리하는 모든 타입클래스 인스턴스들을 import하고 관련 메소드들을 부른다.

import JsonWriterInstances._
Json.toJson(Person("Dave", "dave@example.com"))
// res4: Json = JsObject(Map(name -> JsString(Dave), email -> JsString (dave@example.com)))

컴파일러는 implicit파라미터들을 제공하는 과정 없이 우리가 불러온 toJson 메소드를 사용한다. 컴파일러는 관련 타입들의 타입 클래스 인터페이스들을 찾아본 후 call 위치에다 넣어준다.

Json.toJson(Person("Dave", "dave@example.com"))(personWriter)

 

Interface Syntax

우리는 기존에 인터페이스 메소드로 존재하고 있는 타입을 확장하기 위해서 대신 extension methods를 사용할 수 있다. Cats는 이것을 타입 클래스의 "syntax"로 나타낸다.

(extension methods란 기존 클래스를 수정하거나 새로 컴파일하지 않고도 메소드를 추가할 수 있는 방법이라는 뜻이다)

object JsonSyntax {
  implicit class JsonWriterOps[A](value: A) {
    def toJson(implicit w: JsonWriter[A]): Json =
      w.write(value)
  }
}

*참고 : 

더보기

 

위 implicit class 코드는 사실 아래와 같이 볼 수 있지만 그것을 문법적으로 줄인 것이다.

  class JsonWriterOps[A](value: A) {
    def toJson(implicit w: JsonWriter[A]): Json =
      w.write(value)
  }
  
  implicit def jsWriterOps[A](value: A): JsonWriterOps[A] = new JsonWriterOps[A](value)

또한,  만약 JsonWriter 타입을 부여받은 함수를 사용하고 싶다면 아래와 같이 쓸 수도 있다.

  def someFunc[A](a: A)(implicit jsonWriter: JsonWriter[A]) = {
    jsonWriter.write(a)
  }

그리고 위의 경우

  def someFunc[A : JsonWriter](a: A)= {
    implicitly[JsonWriter[A]].write(a)
  }

 로 쓰여질 수도 있다. (implicit 없이 함수를 정의할 수 있는데, JsonWriter타입이 필요하여 implicitly를 사용한 것. 곧바로 아래에 나온다.)

 

 

 

우리는 우리가 필요한 타입들을 처리하기 위해 기존의 instances와 나란히 import하여 Interface syntax를 사용한다.

import JsonWriterInstances._
import JsonSyntax._
Person("Dave", "dave@example.com").toJson
// res6: Json = JsObject(Map(name -> JsString(Dave), email -> JsString(dave@example.com)))

(그니까 여기까지 정리하자면, Interface Object를 사용하여 기존 인터페이스를 확장하여 세부 구현하였고, Interface Syntax를 통하여 Json.toJson(내부데이터) 식의 것을 내부데이터.toJSON으로 타입클래스의 syntax로 만들어 버린 것이다....!)

또다시, 컴파일러는 implicit paraameters들의 후보자들 중에서 적당한 것을 찾아 우리를 위해 빈 자리를 채워준다.

Person("Dave", "dave@example.com").toJson(personWriter)

 

The implicitly Method

스칼라 라이브러리는 implicitly라는 메소드를 제공한다. 아래와 같이 정의되어있다.

def implicitly[A](implicit value: A): A =
    value

implicitly를 사용하여 implicit scope의 어떤 값이라도 불러올 수 있다. 우리는 우리가 원하는 타입을 제공해주면 implicitly는 나머지를 해 준다.

import JsonWriterInstances._
// import JsonWriterInstances._
implicitly[JsonWriter[String]]
// res8: JsonWriter[String] = JsonWriterInstances$$anon$1@552f485

대부분의 Cats 안의 타입 클래스들은 인스턴스를 불러오는 다른 방법들을 제공한다. 그러나 implicitly는 디버깅 목적으로 사용하기 좋은 대비책이다.

 

1.2 Working with Implicits

스칼라에서 타입 클래스들을 사용한다는 것은 implicit value들과 implicit parameters를 사용한다는 말이다. 이를 효과적으로 사용하기 위한 몇가지 규칙이 있다.

 

1.2.1 Packing Implicits

언어 자체의 특이한 점으로서, implicit이 표기된 스칼라 안의 그 어떤 정의도 object나 trait안에 있어야 하지, 절대로 최상위 레벨에 있을 수 없다. 우리의 위 예시의 경우 타입 클래스 인스턴스들을 JsonWriterInstances 라 불리는 오브젝트 안에 묶어놓았다. 우리는 동일하게 그들을 JsonWriter의 동반객체(companion object)로 위치시킬 수 있었다. 인스턴스들을 타입클래스의 동반객체에 위치시키는 것은 스칼라에서 특별한 의미를 가지는데, 왜냐하면 그것은 implicit scope라는 규칙을 따르게 되기 때문이다.

 

1.2.2 Implicit Scope

우리가 위에서 살펴본 바와 같이, 컴파일러는 타입을 기준으로 후보 타입클래스 인스턴스들을 찾는다. 예를 들어, 아래의 예시에서 컴파일러는 JsonWriter[String]타입을 찾을 것이다.

Json.toJson("A string!")

컴파일러는 call site에서 implicit scope안의 후보 인스턴스들을 찾는데, 대충 다음과 같은 것들을 포함한다.

- local or inherited definitions (지역 혹은 상속된 정의들)

- imported defintitiopns (임포트된 정의들)

- definitions in the companion object of the type class or the parameter type (in this case JsonWriter or String) (타입클래스의 동반객체나 파라미터 타입의 정의들. 이 경우에는 JsonWriter 이나 String)

 

정의들은 implicit 키워드로 태깅되어 있을 경우에는 오직 implicit scope에만 포함되어 있다. 더해서, 컴파일러가 중복된 정의 후보를 발견하면, ambiguous implicit values error를 뱉어낸다.

implicit val writer1: JsonWriter[String] = JsonWriterInstances.stringWriter
implicit val writer2: JsonWriter[String] = JsonWriterInstances.stringWriter
Json.toJson("A string")
// <console>:23: error: ambiguous implicit values:
// both value stringWriter in object JsonWriterInstances of type => JsonWriter[String]
// and value writer1 of type => JsonWriter[String]
// match expected type JsonWriter[String]
// Json.toJson("A string")
//                 ^

실제로 규칙은 좀 더 복잡하지만, 우리의 목적을 위해서, 우리는 타입 클래스 인스턴스들을 대략 아래의 4가지 방법으로 package(묶음) 할 수 있다.

1. object안에 위치시킨다. (JsonWriterInstances와 같이)

2. trait 안에 위치시킨다.

3. type class의 동반 객체에 위치시킨다.

4. parameter type의 동반 객체에 위치키신다.

 

옵션 1을 사용하여 scope로 인스턴스들을 불러오기 위해서는 import 해야한다.

옵션 2를 사용하기 위해서는 상속을 사용해야 한다.

옵션 3,4에서 인스턴스들은 항상 implicit scope에 존재한다. 우리가 어디에서 사용하려고 하던지간에.

 

1.2.3 Recursive Implicit Resolution

 

타입 클래스와 implicit의 강점은 후보 인스턴스들을 찾는 과정에서 implicit 정의들을 합치는(combine) 능력에 있다.

이전에 우리는 모든 타입 클래스 인스턴스들은 implicit vals라는 것을 암시한 적이 있다. 이것은 단순화 한 것이었다. 우리는 정확하게는 인스턴스들을 두가지 방법으로 정의할 수 있다.

 

1. by defining concrete instances as implicit vals of the required type (실제 인스턴스들을 required type의 implicit vals로 정의하기)

2. by defining implicit methods to construct instances from other type class instances. (다른 타입클래스 인스턴스들로부터 인스턴스들을 만들기 위해 implicit methods를 정의하기) 

 

왜 우리는 다른 인스턴스로부터 새로운 인스턴스를 만드는 걸까? 모티브를 알기 위한 예시로, Option을 위한 JsonWriter 를 정의하는 것을 생각 해 보자. 우리는 모든 A에 대한 JsonWriter[Option[A]]를 정의해야 할 것이다. 우리는 브루트포스하게 모든 implicit vals를 정의할 수도 있을 것이다.

implicit val optionIntWriter: JsonWriter[Option[Int]] =
???
implicit val optionPersonWriter: JsonWriter[Option[Person]] =
???
// and so on...

그치만...

이 접근 방법은 스케일러블 하지 않다. 우리는 두개의 implicit vals를 통해 이것을 해결할 수 있다. 하나는 A를 위한 것이며, 하나는 Option[A]를 위한 것이다.

 

운좋게도, 우리는 Option[A]를, A를 위한 인스턴스에 기반한 일반적인 생성자를 통해서 코드를 추상화 할 수 있다.

- 만약 옵션이 Some(aValue) 라면, aValue를 A의 writer를 사용하여 write 한다.

- 만약 옵션이 None이라면 JsNull을 리턴한다.

 

아래에 implicit def로 적혀진 예시가 있다. 

implicit def optionWriter[A](implicit writer: JsonWriter[A]): JsonWriter[Option[A]] = new JsonWriter[Option[A]] {
  def write(option: Option[A]): Json =
    option match {
      case Some(aValue) => writer.write(aValue)
      case None => JsNull
    }
}

개꿀딱ㅋ 이런식으로 재귀적으로 타입을 전부 정의해줄 수 있다. 컴파일러가 아래와 같은 표현을 보면

Json.toJson(Option("A string"))

컴파일러는 implicit JsonWriter[Option[String]]을 찾는다. 그것은 JsonWriter[Option[A]] 의 implicit method를 찾게 된다.

Json.toJson(Option("A string"))(optionWriter[String])

그리고 재귀적으로 파라미터를 optionWriter로 사용하기 위해서 JsonWriter[String]를 찾는다.

Json.toJson(Option("A string"))(optionWriter(stringWriter))

이런 방식으로, implicit resolution은 가능한 implicit definitions 에 대한 공간 안에서의 조합을 찾는 것이 되며, 이를 통해 모든 타입들 중에 올바른 타입을 불러오게 된다.

 

(ㅎㅎ.. resolution이 해상도라는 뜻만 있는게 아니라 해결하다는 뜻도 있네 ㅎㅎ......)

 

Implicit Conversions

implicit def를 사용하여 타입클래스 인스턴스 생성자를 만들 때, 꼭 메소드 안의 파라미터들이 implicit 파라미터로 선언되도록 해라. 이 키워드 없이는 컴파일러가 implicit resolution중에 파라미터를 자동으로 채울 수 없을 것이다.

non-implicit파라미터와 함께 작성된 implicit 메소드는 implicit conversion이라고 불리는 또 다른 스칼라 패턴이다. 이것은 이전에 Interface Syntax와는 다른 것인데, 왜냐하면 그 경우는 JsonWriter이 extension methods와 함께 하는 implicit class 였기 때문이다. Implicit conversion은 더 이전의 프로그래밍 패턴이며 요즘은 잘 안쓴다. 운좋게도, 컴파일러는 이런식으로 코드를 작성하면 알아서 경고 메시지를 띄워 줄 것이다.

 

1.3 Exercise: Printable Library

 

스칼라는 우리가 어떤 값이라도 String toString 메소드를 제공해준다. 그치만... 이 메소드는 몇몇 단점이 있다. 이 함수는 모든 타입에 대해서 구현되어 있고, 제한적 사용만 할 수 있으며, 특정 타입에 대한 추가적인 구현 옵션을 추가할 수 없다.

이 문제를 해결하기 위해 Printable 타입클래스를 정의 해 보자.

1. format이라는 하나의 메소드를 가지고 있는 type class Printable[A] 를 정의하자 :

format 은 타입 A를 허용하여 String을 리턴해야 한다.

 

2. String과 Int를 위한 Printable인스턴스를 포함하는 PrintableInstances object를 만들어라.

 

3. 두가지 제네릭 인터페이스 메소드와 함께 Printable object를 정의하라 :

format 은 타입 A와 해당 타입의 Printable을 허용한다. 이는 해당 타입의 Printable이 A를 String으로 바꿀 수 있게 한다.

print 는 format과 같은 파라미터 타입을 받으며 Unit을 리턴한다. println을 사용하여 콘솔에 A값을 출력한다.

 

내 정답은 아래와 같고 , 책의 정답 볼려면 거기 가서 보3

  // define type class
  trait Printable[A] {
    def format(value: A) : String
  }
  
  //instances 구현
  object PrintableInstances {
    implicit val PrintableInt : Printable[Int] = new Printable[Int] {
      def format(value: Int): String = {
        value.toString()
      }
    }
//    이렇게도 구현 가능
//    implicit val PrintableInt : Printable[Int] = (value: Int) => {
//      value.toString()
//    }
    implicit val PrintableString : Printable[String] = new Printable[String] {
      def format(value:String) : String = {
        value
      }
    }
  }
  // Interface Object
  object PrintableInterfaceObject {
    def format[A](value : A)(implicit ev:Printable[A]) : String = {
      ev.format(value)
    }
    def print[A](value : A)(implicit ev:Printable[A]) : Unit = {
      println(ev.format(value))
    }
  }
  // Interface Syntax
  object PrintableInterfaceSyntax {
    implicit class PrintableOps[A](value : A) {
      def format(implicit ev : Printable[A]):String = {
        ev.format(value)
      }
      def print(implicit ev : Printable[A]):Unit = {
        println(ev.format(value))
      }
    }
  }

+ 더해서, 아래와 같이 Companion(동반)객체를 만들어 주면

  trait Printable[A] {
    def format(value: A) : String
  }
  object Printable {
    def apply[A](implicit ev: Printable[A]): Printable[A] = ev
  }

자동으로 인터페이스 오브젝트가 구현 가능하다.

Printable[Int].format(1)

이런 식으로 구현이 가능한데 위의 구현은 실제로

Printable.apply[Int].format(1)

이렇게 변환이 되고, 그러면 인터페이스 오브젝트에서 쓰는 방식으로 

PrintableInterfaceObject.format(1)

이렇게 하지 않더라도 더 짧게 apply가 가능하다. 다만 이때, print라는 함수는 구현한 적이 없으므로 그냥 단순 apply만 할 때는 유용하게 사용되겠지만 추가적인 구현을 하는것이 필요하다면 따로 오브젝트를 선언하여 구현하는 방법을 사용하면 되겠다.

 

 

Using the Library

위의 코드는 다양한 어플리케이션에 사용하는 프린팅 라이브러리를 구현하고 있다. 이제 라이브러리를 사용하는 "어플리케이션"을 정의 해 보자.

 

우선, 우리는 털이 있는 동물의 잘 알려진 타입을 표현하는 데이터 타입을 정의하여 보자.

final case class Cat(name: String, age: Int, color: String)

다음으로, 우리는 아래의 포멧으로 content를 리턴하는 Cat을 위한 Printable의 implementation을 만들어 보자.

NAME is a AGE year-old COLOR cat.

최종적으로, 콘솔이나 짧은 데모 앱에서 타입 클래스를 사용 해 보자. Cat을 만들고 console에 프린트 해 보자.

// Define a cat:
val cat = Cat(/* ... */)
// Print the cat!

내 정답은 아래와 같다.

  final case class Cat(name: String, age: Int, color: String)
  object PrintableInstanceForCat {
    implicit val PrintableCat : Printable[Cat] = new Printable[Cat] {
      def format(value: Cat):String = {
        value.name + " is a "+value.age+" year-old "+value.color+" cat."
      }
    }
  }
  import PrintableInstanceForCat._
  import PrintableInterfaceSyntax._
  println(Printable[Cat].format(Cat("Tom",10,"black")))
  Cat("Tom",10,"black").print

(오브젝트 패턴의 경우에는 바로 위에 설명한 트릭으로 조금 다르게 넘어가부렸다)

 

 

Better Syntax

더 나은 syntax를 제공하기 위해 몇몇 extention을 정의해서 프린팅 라이브러리를 더 쉽게 만들어 보자.

1. PrintableSyntax를 만들자

2. PrintableSyntax안에 타입 A의 값을 포함하기 위해 implicit class PrintableOps[A]를 정의하자.

3. PrintableOps안에 다음의 메소드들을 정의하자.

- format 는 implicit Printable[A]를 받으며 받은 A를 감싸서 String으로 리턴한다.

- print는 implicit Printable[A]를 받으며 Unit을 리턴한다. 그것은 감싸진 A를 콘솔에 프린트 한다.

4. 이전에 만든 Cat예시를 프린트 하기 위해 extension methods를 사용하라.

 

음.. 위에서 혼자 신나서 신텍스까지 다 해부렸네?

(말 나온 김에 복습이나 하자면, extension methods란 기존 클래스를 수정하거나 새로 컴파일하지 않고도 메소드를 추가할 수 있는 방법이라는 뜻이다)

 

1.4 Meet Cats

앞선 예시에서는 Scala의 타입클래스를 implement하는 법에 대해서 봤다. 이 섹션에선 Cats에서 타입클래스가 어떻게 구현(implemented)되어있는지 살펴 볼 것이다.

Cats는 우리가 어떤 타입의 classes, instances, interface methods를 선택하기를 원하는지 허용하는 modular structure을 사용하여 구현되어 있다.

Modular Structure란?
http://oer2go.org/mods/en-boundless/www.boundless.com/business/textbooks/boundless-business-textbook/organizational-structure-9/common-organizational-structures-66/modular-structure-316-3981/index.html
(왠만하면)서로 상호 의존적이지 않은 작은 단위의 기능으로 구조화 시킨다.

cats.Show를 우리의 첫 예시로서 살펴보자.

 

Show는 Cats에서 우리가 이전에 작성한 Printable type class와 같은 기능을 수행한다. 그것은 toString을 사용하지 않으면서 개발자 친화적인 콘솔 아웃픗을 생산해내는 방법을 제공한다. 아래에 단축된 정의가 있다.

package cats
trait Show[A] {
  def show(value: A): String
}

1.4.1 Importing Type Classes

Cats안의 타입클래스들은 cats 패키지 안에 정의되어있다. 우리는 이 패키지에서 Show를 직접 import할 수 있다.

import cats.Show

Cats 타입클래스의 동반객체 (companion object)는 우리가 특정하는(정의하는) 모든 인스턴스 타입에 대한 apply메소드를 제공하고 있다.

val showInt = Show.apply[Int]
// <console>:13: error: could not find implicit value for parameter instance: cats.Show[Int]
// val showInt = Show.apply[Int]
//                      ^

그치만.. 동작하지 않는걸..?

apply method는 implicits를 각각의 인스턴스를 찾기 위해 사용하기 때문에 우리는 instance를 scope내로 들여와야 한다.

 

1.4.2 Importing Default Instances

cats.instances package는 다양한 타입에 대한 기본 인스턴스들을 제공한다. Cats의 타입클래스들의 모든 import들은 특정 파라미터 타입을 위한 인스턴스들을 제공한다.

• cats.instances.int provides instances for Int

• cats.instances.string provides instances for String

• cats.instances.list provides instances for List

• cats.instances.option provides instances for Option

• cats.instances.all provides all instances that are shipped out of the box with Cats

https://typelevel.org/cats/api/cats/instances/

위 링크에 가능한 import들의 리스트가 있다.

 

Int와 String을 위한 Show를 import해보자.

import cats.instances.int._ // for Show
import cats.instances.string._ // for Show
val showInt: Show[Int] = Show.apply[Int]
val showString: Show[String] = Show.apply[String]

이제 오류가 나지 않는다. 이제 Show의 두 인스턴스에 접근했고, 이 친구들을 사용해서 Ints와 Strings를 프린트 하는데에 사용할 수 있다.

  val intAsString: String = showInt.show(123)
  // intAsString: String = 123
  
  val stringAsString: String = showString.show("abc")
  // stringAsString: String = abc

* 다시말해서 Show.apply[Int]는 Int 관련 인스턴스 구현 + 인터페이스 오브젝트의 용도로 사용하는 문법이 된다.

 

1.4.3 Importing Interface Syntax

우리는 cats.syntax.show의 interface syntax를 import함으로써 show를 더 쉽게 사용할 수 있다. 이것은 scope안에 Show가 있는 모든 타입에 대해서 show라는 extention method를 추가한다.

import cats.syntax.show._ // for show

val shownInt = 123.show
// shownInt: String = 123

val shownString = "abc".show
// shownString: String = abc

Cats 는 각 타입클래스에 대한 분리된 syntax를 제공한다(다시말해 각각 타입클래스마다 syntax가 따로 있다는 뜻).

 

1.4.4 Importing All The Things!

 

이 책에선 구체적으로 뭘 쓰는지 보여주려고 각각 세분화 하여 모두 임포트 하는데 그냥 귀찮아서 한번에 임포트 하고 싶을 경우가 있을 것이다. 그러면 아래처럼 임포트 하면 된다.

import cats._ imports all of Cats’ type classes in one go;

import cats.instances.all._ imports all of the type class instances for the standard library in one go;

import cats.syntax.all._ imports all of the syntax in one go;

import cats.implicits._ imports all of the standard type class instances and all of the syntax in one go.

 

대부분 아래와 같이 임포트 하고 시작하고, 세세하게 임포트 하는 경우는 충돌이 일어났을 때 뿐이다.

import cats._
import cats.implicits._

 

1.4.5 Defining Custom Instances

우리는 (새롭게)주어진 타입에 대해 trait을 구현(implementing)하는 것으로 간단하게 Show의 인스턴스를 정의할 수 있다.

import java.util.Date
implicit val dateShow: Show[Date] = new Show[Date] {
  def show(date: Date): String = s"${date.getTime}ms since the epoch."
}

그러나 Cats는 이 process를 쉽게 해주는 쉬운 메소드들을 여럿 제공한다.  Show의 동반객체에 두 생성자 메소드가 있는데, 이는 우리의 새로운 타입들을 위해 우리의 Show를 정의할 수 있게 한다.

object Show {
  
  // Convert a function to a `Show` instance:
  def show[A](f: A => String): Show[A] = ???
  
  // Create a `Show` instance from a `toString` method:
  def fromToString[A]: Show[A] = ???
}

이는 우리에게 적은 노력으로 우리에게 처음부터 Show를 정의할 수 있게 한다.

implicit val dateShow: Show[Date] = Show.show(date => s"${date.getTime}ms since the epoch.")

볼수 있듯, 생성 메소드를 사용하는게 그렇지 않을 때 보다 코드가 훨씬 간결하다.

미리 Show.show가 정의되어 있기 때문에 new Show[Date]를 생성하고 그 안에서 show를 재정의하는 그 과정을 

함수를 통해서 Show instance를 만들어내는 과정을 통해서 new Show[Date] 를 만들고 def show를 바로 정의 해버린다.

 

(뭔가 껄쩍지근 해서, 라이브러리 코드 자체를 봤는데 아래와 같더라.. 걍 함수를 입력받아서 show를 정의해놓은 새로운 Show instance를 리런해주는 생성자인 것 같다 ㅋㅋ 짧은데 본 책에서는 왜 물음표 쳐놨냐 ㅋㅋㅋㅋㅋ)

def show[A](f: A => String): Show[A] = new Show[A] {
    def show(a: A): String = f(a)
  }

 

1.4.6 Exercise: Cat Show

Cat applicatiopn을 Printable말고 Show 라이브러리를 사용해서 바까바라.

 

내 솔루션은 이렇다

  import cats.Show
  import cats.syntax.show._

  final case class Cat(name: String, age: Int, color: String)
  implicit val CatShowInstance: Show[Cat] = Show.show(value => value.name + " is a "+value.age+" year-old "+value.color+" cat.")
  println(Cat("Tom",10,"black").show)

 

1.5 Example: Eq

또 다른 유용한 타입클래스 cats.Eq.를 살펴보자. Eq는 type-safe equality와 스칼라의 내장 == 연산자를 사용할 때의 불편함을 도와주기 위하여 디자인 되었다. 

거의 모든 스칼라 개발자들은 이런 코드를 작성해 본 적이 있을 것이다.

List(1, 2, 3).map(Option(_)).filter(item => item == 1)
// res0: List[Option[Int]] = List()

이 필터의 결과는 항상 거짓인데, Option[Int]와 Int를 비교하는 중이기 때문이다. (제대로 하려면 Some(1) 과 비교 해야 한다) 이게 근데 타입 에러는 아닌게, ==는 모든 오브젝트에 대해 동작하기 때문에 틀린 문법은 아니다. Eq는 등호에 대한 타입 안전성(type safety)을 체크해주며 이런 부류의 문제를 다룬다.

 

1.5.1 Equality Liberty and Fraternity

(Fraternity: 협회, 평등, 우애, 동지애 등을 의미한다고 하네요;;)

우리는 Eq를 어떤 주어진 타입에 대해서든 type-safe equality를 정의하는데에 사용할 수 있다.

package cats
trait Eq[A] {
  def eqv(a: A, b: A): Boolean
  // other concrete methods based on eqv...
}

인터페이스 syntax (cats.syntax.eq에 정의되어있는) 는 Eq[A]가 scope내에 있는 경우 equality check 을 위한 두가지 메소드를 제공한다.

• === compares two objects for equality;

• =!= compares two objects for inequality.

 

1.5.2 Comparing Ints

몇몇 예시를 보자. Eq를 임포트 하자.

import cats.Eq

그리고 Int를 위한 인스턴스를 가져오자.

import cats.instances.int._ // for Eq
val eqInt = Eq[Int]

eqInt를 바로 동등성 테스트를 위해 사용할 수 있다.

eqInt.eqv(123, 123)
// res2: Boolean = true

eqInt.eqv(123, 234)
// res3: Boolean = false

스칼라의 == 매소드와는 다르게, 다른 타입을 비교하려고 하면 에러가 난다.

eqInt.eqv(123, "234")
// <console>:18: error: type mismatch;
// found : String("234")
// required: Int
// eqInt.eqv(123, "234")
//              ^

 

1.5.3 Comparing Options

이제 더 흥미로운 Option[Int]예시를 보자. Option[Int]를 비교하기 위해서, 우리는 Int와 같이 Option을 위한 Eq또한 Import해야 한다.

import cats.instances.int._ // for Eq
import cats.instances.option._ // for Eq

이제 비교를 시도해볼 수 있다.

Some(1) === None
// <console>:26: error: value === is not a member of Some[Int]
// Some(1) === None
//             ^

근데 에러가 난다. 타입이 같지 않기 때문인데, None의 경우도 Option[Int] 타입으로 확실하게 맞춰줘야 한다. (내부타입도 검사한다는 소리)

(Some(1) : Option[Int]) === (None : Option[Int])
// res9: Boolean = false

우리는 기본 라이브러리의 Option.apply와 Option.empty메소드를 사용해서 더 익숙한 표현으로 비교할 수 있다.

Option(1) === Option.empty[Int]
// res10: Boolean = false

혹은 cats.syntax.option의 더 특별한 syntax를 사용할 수도 있다.

import cats.syntax.option._ // for some and none

1.some === none[Int]
// res11: Boolean = false

1.some =!= none[Int]
// res12: Boolean = true

 

1.5.4 Comparing Custom Types

우리는 Eq.instance메소드를 이용해서 우리의 Eq 인스턴스를 정의할 수 있다. 이는 (A, A) => Boolean 타입을 입력으로 받아서 Eq[A]를 리턴해준다.

import java.util.Date
import cats.instances.long._ // for Eq
implicit val dateEq: Eq[Date] =
  Eq.instance[Date] { (date1, date2) =>
    date1.getTime === date2.getTime
  }
  
  val x = new Date() // now
  val y = new Date() // a bit later than now
  
  x === x
  // res13: Boolean = true
  
  x === y
  // res14: Boolean = false

 

1.5.5 Exercise: Equality, Liberty, and Felinity

(Felinity: 고양이 성질, 교활함, 잔인함 등을 의미한다고 하네요;;)

 

우리의 Cat 예제를 돌리기 위한 인스턴스를 구현 해 보자.

final case class Cat(name: String, age: Int, color: String)

아래 칭구들을 비교 해 보자.

val cat1 = Cat("Garfield", 38, "orange and black")
val cat2 = Cat("Heathcliff", 33, "orange and black")

val optionCat1 = Option(cat1)
val optionCat2 = Option.empty[Cat]

 

나의 구현은 아래처럼 했다

  import cats.Eq
  import cats.syntax.eq._
  import cats.instances.option._

  final case class Cat(name: String, age: Int, color: String)

  val cat1 = Cat("Garfield", 38, "orange and black")
  val cat2 = Cat("Heathcliff", 33, "orange and black")
  val optionCat1 = Option(cat1)
  val optionCat2 = Option.empty[Cat]

  implicit val EqForCat: Eq[Cat] = Eq.instance[Cat] {
    (x,y)=> (x.name==y.name && x.age==y.age && x.color==y.color)
  }

  println(cat1===cat2)
  println(optionCat1===optionCat2)

 

1.6 Controlling Instance Selection

타입클래스를 다룰때는 instance selection을 컨트롤하는 두가지 이슈를 항상 고려해야 한다.

- 타입에 대한 인스턴스 정의와 그 서브 타입들은 무엇인가?

예를 들어, 우리가 JsonWriter[Option[Int]]를 정의한다면, Json.toJson(Some(1)) 표현은 해당 인스턴스를 선택 할 것인가? (Some은 Option의 서브타입임을 기억하자.)

- 여러 가능성이 있을때 어떻게 타입클래스 인스턴스들을 선택 할 것인가?

만약 Person을 위한 두 JsonWriter를 정의하면 ? Json.toJson(aPerson)하면 누가 선택되는가?

 

1.6.1 Variance

타입클래스를 정의할 때 Variance annotation을 추가해서 implicit resolution중에 컴파일러가 인스턴스를 선택하는 능력에 개입할 수 있다.

근본적인 스칼라를 다시 생각해 볼때, variance는 서브 타입에 관련 되어 있다. 우리는 타입 B의 값을 타입 A가 기대되는 모든 곳에 사용할 수 있다면 B를 A의 서브 타입이라고 말한다. (다시 말하면 B가 A에 포함될 때)

Co-와 covariance annotation은 type constructor을 가지고 작업을 할 때 나타난다. 예들들어, 우리는 covariance는 + 심볼로 표기한다.

trait F[+A] // the "+" means "covariant"

Covariance

Covariance는 만약 B가 A의 서브타입이라면 타입 F[B]는 타입 F[A]의 서브타입이라는걸 의미한다. 이것은 많은 타입을 모델링하는 데에 유용하며 거기엔 List나 Option와 같은 collections도 포함이다. (+를 붙이면 저렇게 취급이 된다는 소리)

trait List[+A]
trait Option[+A]

스칼라 컬렉션의 covariance는 코드 안에서 하나의 타입으로 또다른 타입의 컬렉션으로 대체할 수 있게 해준다. 예를 들어, 우리는 List[Circle]을 List[Shape]가 기대되는 곳에서 사용할 수 있는데, 이는 Circle이 Shape의 subtype이기 때문이다.

sealed trait Shape
case class Circle(radius: Double) extends Shape

val circles: List[Circle] = ???
val shapes: List[Shape] = circles

그렇다면 contravariance는 어떨까? 우리는 contravatiant type constructors를 - 심볼로 다음과 같이 쓸 수 있다.

trait F[-A]

Contravariance

헷갈리게, contravariance는 타입 A가 B의 서브 타입일 경우 F[B]가 F[A]의 서브 타입이다. 이것은 JsonWriter 타입클래스와 같은 프로세스를 표현하는 타입을 모델링 할 때 유용하다.

trait JsonWriter[-A] {
  def write(value: A): Json
}
// defined trait JsonWriter

이걸 좀 더 디깅해보자. variance는 한 값을 또 다른 값으로 대체하는 능력에 대한 것임을 기억하자. 우리가 하나는 Shape 하나는 Circle인 두 value들을 가지고 있고, Circle 과 Shape 각각에 대한 JsonWriters를 가지고 있다고 해 보자.

val shape: Shape = ???
val circle: Circle = ???
val shapeWriter: JsonWriter[Shape] = ???
val circleWriter: JsonWriter[Circle] = ???

def format[A](value: A, writer: JsonWriter[A]): Json = writer.write(value)

이제 한번 스스로에게 질문을 해 보자. "어떤 조합들을 내가 format에게 건네줄 수 있을까?" 우리는 circle을 두 writer에게 전할 수 있다 왜냐하면 Circles는 Shapes이기 때문이다. 반대로, 우리는 Shape을 circleWriter에게는 전달해줄 수 없다. 왜냐면 Shape은 Circle이 아니기 때문이다.

이 관계가 보통 contravariance를 사용하여 모델링할때이다. JsonWriter[Shape]은 JsonWriter[Circle]의 서브타입인게, 왜냐면 Circle은 Shape의 서브타입이기 때문이다. (헷갈리면 JsonWriter[Shape]으로 처리할 수 있는걸 JsonWriter[Circle] 로는 처리 못하는 경우가 있다는 것을 기억하면 왜 저런 서브타입 관계인지 알 수 있다) 이걸 보면, shapeWriter을 우리가 JsonWriter[Circle]을 기대한 곳에서 쓸 수 있다는 뜻이다.

 

Invariance

invariance는 젤 쉬운거다. + - 다 안붙이면 그게 invariance이다.

trait F[A]

이것은 type F[A]와 F[B]는 서로간에 서브타입 관계가 존재하지 않는다는 뜻이다. A 와 B의 서브타입 관계와는 전혀 무관하게 말이다.

컴파일러가 implicit을 찾을 때면, 걔는 매칭 타입이나 서브타입을 찾는다. 따라서 우리는 variance annotation을 잘 사용해서 타입클래스 인스턴스 셀렉션을 일부 상황에서 잘 활용할 수 있다.

두가지 이슈가 발생할 수 있다. 우리가 대수적으로 아래와 같은 데이터 타입이 있다고 생각 해보자.

sealed trait A
final case object B extends A
final case object C extends A

다음과 같은 이슈가 발생할 수 있다.

1. 최상위 타입(supertype)으로 정의된 인스턴스가 변경이 가능하면 동작할것인가? 예를 들어, A를 위한 인스턴스를 정의했는데, 거기에 type B나 C가 동작 할 것인가?

2. 서브타입을 위한 인스턴스가 그 supertype보다 더 선호되어 선택될 것인가? 예를 들어, 우리가 A와 B에 대한 인스턴스를 만들었을 경우에, 우리는 B type의 값을 가지고 있다면 B type이 A type보다 선호될 것인가?

 

우리가 두 경우를 동시에 적용할 수는 없기 때문에 아래에 초이스를 하는 법이 있다.

그냥 그렇게 정했다.

위의 표를 보면 Covariant의 경우 더 세세한 타입이 선호되고, contravariant의 경우 더 supertype이 선호된다.

(더 근본적으로 선택된 타입에 가까운 쪽으로 선택된다고 생각하면 편하다.)

 

1.7 Summary

이 챕터에서는 타입 클래스엣 대해서 알아봤다.

Cats 타입클래스에 있는 일반적인 패턴에 대해 살펴본 것.

• The type classes themselves are generic traits in the cats package.

• Each type class has a companion object with, an apply method for materializing instances, one or more construction methods for creating instances, and a collection of other relevant helper methods.

• Default instances are provided via objects in the cats.instances package, and are organized by parameter type rather than by type class.

• Many type classes have syntax provided via the cats.syntax package.

 

앞으로 더 다양한 친구들에 대해서 살펴볼거에요~

챕터 1 그럼 이만 안녕~

 

 

제 실력이 미천하여 잘못 번역 / 이해한 것 같은게 있다면 댓글 주세여 희희

다음 글

2020/02/11 - [Programmer Jinyo/Scala & AKKA] - scala with cats 책 읽으면서 필기(하다보니 번역급) Chapter 2