저작권 안내: 저작권자표시 Yes 상업적이용 No 컨텐츠변경 No

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의

마틴 오더스키 교수님이 코세라에서 진행중인 Functional Programming Principles in Scala 강의(https://www.coursera.org/learn/progfun1)의 4주차 요약.


* 함수형을 잘 모르는 상태에서 요약한 것이므로 내용에 오류가 존재할 수 있음


순수 객체 지향


순수 객체 지향 언어에서 모든 값은 객체이다. 언어가 클래스를 기반으로 한다면 모든 값의 타입이 클래스라는 것을 뜻한다. 스칼라 역시 기본 타입과 함수와 같은 모든 것을 객체로 표현한다.


자바에서 기본 타입인 int, boolean을 스칼라에서는 특별한 타입이 아닌 클래스로 제공한다. 이들 클래스는 scala 패키지에 Int, Boolean으로 정의되어 있다. 정수값 1은 Int 타입이다.


Int를 포함 이들 타입은 자바에서 +, *, ==와 같은 연산자와 동일한 이름을 갖는 메서드를 제공한다. 예를 들어, 1 + 10은 실제로는 Int 타입의 + 메서드를 호출하고 첫 번째 인자로 10을 전달하는 1.+(10) 코드와 동일하다.


스칼라 컴파일러는 성능을 높이기 위해 Int 타입 값은 32비트 정수로 Boolean 타입은 자바의 boolean으로 표현한다.


함수도 객체로


함수 값도 객체로 처리한다. A=>B 타입의 함수는 Function1[A, B] 클래스를 축약한 것이다.  Function2, Function3 등 파라미터 개수별로 그에 해당하는 함수 타입이 존재한다.


함수는 apply() 메서드를 가진 객체로서 f(x)는 f.apply(x)와 같다. 예를 들어, 


var f = (x: Int) => x + x

f(7)


이 코드는 다음의 OO 코드로 해석할 수 있다. 


val f = new Function1[Int, Int] {

    def apply(x: Int) = x + x

}

f.apply(7)


제네릭과 타입 경계


다형은 하위 타입과 제네릭(generic)의 두 가지 형식이 존재하는데, 여기서는 이 두 가지와 관련된 주제인 타입 경계(type bound)와  가변성(variance)에 대해 설명한다.  


IntSet이 다음과 같이 두 개의 하위 타입을 갖는다고 하자.


class NonEmpty extends IntSet { ... }

object Empty extends IntSet { ... }


정수 집합을 의미하는 IntSet의 모든 정수가 양수면 자신을 리턴하고 아니면 익셉션을 발생하는 assertAllPos() 메서드가 있다고 하자. 이 메서드는 NonEmpty와 Empty의 두 타입을 인자로 받을 수 있다. 이 두 타입은 IntSet의 하위 타입이므로 assertAllPos에 전달할 수 있는 타입은 IntSet 타입의 하위 타입이 된다. 이를 제네릭을 사용하면 다음과 같이 메서드를 정의할 수 있다.


def assertAllPos[S <: IntSet](s: S): IntSet


여기서 "<: IntSet"은 타입 파라미터 S의 상위 경계로서, 이는 IntSet에 맞는 타입만 S에 올 수 있다는 것을 의미한다. 상위 경계와 하위 경계는 각각 다음과 같이 지정한다.

  • S <: T 는 S가 T의 하위 타입임을 의미한다
  • S >: T 는 S가 T의 상위 타입임을 의미한다
[S >: NonEmpty]는 S에는 NonEmpty의 상위 타입만 올 수 있다는 것을 의미하므로 S에는 NonEmpty, IntSet, AnyRef, Any 중 하나가 올 수 있다.(AnyRef, Any는 스칼라의 타입 계층에 출현)


[S >: NonEmpty <: IntSet]과 같이 상위 경계와 하위 경계를 함께 제한할 수 있다.


가변성(variance)


제네릭과 관련해서 처음에 잘 이해하기 힘든 게 가변성인데 이 강의에서도 제네릭과 가변성에 대해 다룬다.(여기서 <: 표시는 하위 타입과 상위 타입을 의미한다.)


A <: B일 때 제네릭 타입(즉, 파라미터화 타입) C[T]에 대해 다음의 세 가지 관계가 가능하다.

  • C[A] <: C[B] : C는 공변(convariant)이다
  • C[A] >: C[B] : C는 반공변(contravariant)이다
  • 서로 하위 타입이 아님 : C는 무공변(nonvariant)이다

얼핏 생각하면 A <: B일 때 C[A] <: C[B]일 것 같지만 꼭 그렇지는 않다. 예를 들어, 자바에서 배열은 공변이다. 즉, NonEmpty <: IntSet이면 NonEmpty[] <: IntSet[] 이다. 따라서 다음과 같이 할당이 가능하다.


NonEmpty[] a = new NonEmpty[] { ... }

IntSet[] b = a // NonEmpty[]는 IntSet[]의 하위 타입


그런데 공변 배열은 문제를 일으킨다. 다음 코드를 보자. b는 애초에 NonEmpty 타입의 배열이다. 그런데 a를 할당한 b는 IntSet[] 배열이다. 따라서 b 배열에는 Empty 타입의 값을 할당할 수 있다.


NonEmpty[] a = new NonEmpty[] { ... }

IntSet[] b = a // NonEmpty[]는 IntSet[]의 하위 타입

b[0] = Empty // NonEmpty 배열에 Empty 할당 시도

NonEmpty s = a[0]


Empty와 NonEmpty는 서로 상위-하위 타입 관계가 아니므로 NonEmpty 타입을 요구하는 곳에 Empty 값을 할당할 수 없고, 위 코드는 익셉션을 발생한다.


[박스]

하위 타입인지 여부는 다음 LSP(Liskov Substitution Principle)로 확인해 볼 수 있다.

A <: B이면 B 타입의 값을 사용하는 모든 것은 A 타입 값을 사용할 수 있어야 한다.


스칼라에서 공변, 반공변, 불공변은 다음과 같이 정의한다.

  • 공변: class C[+A] { ... }
  • 반공변: class C[-A] { ... }
  • 무공변: class C[A] { ... }

구성요소를 변경할 수 있는 타입은 (거의) 공변일 수 없다. 반대로 불변 타입은 특종 조건을 충족하면 공변일 수 있다. 보통 메서드에서 공변 타입 파라미터는 메서드 결과에만 위치할 수 있고, 반공변 타입 파라미터는 메서드 파라미터에만 올 수 있다.


데이터 구조에서 값을 가져오면 공변, 데이터 구조에 값을 넣으면 반공변 타입을 사용한다. 이펙티브 자바에 보면 PECS(Producer Extends, Consumer Super)란 규칙도 이에 대해 설명한다. 


분해(decomposition)와 패턴 매칭


1 + 2와 같은 수식을 표현하기 위해 다음의 세 타입을 사용한다고 하자.


trait Expr {

  def isNumber: Boolean

  def isSum: Boolean

  def numValue: Int

  def leftOp: Expr

  def rightOp: Expr

}

class Number(n: Int) extends Expr { ... }

class Sum(e1: Expr, e2: Expr) extends Expr { ... }


Expr 타입을 받아서 값을 구하는 eval() 함수는 다음과 같이 Expr에 정의된 메서드를 이용해서 타입을 구분하고 값을 구하고 필요한 계산을 수행할 수 있다.


def eval(e: Expr): Int = {

  if (e.isNumber) e.numValue

  else if (e.isSum) eval(e.leftOp) + eval(e.rightOp)

  else ...(익셉션발생코드)

}


이 방식에서 새로운 연산인 Prod를 추가하면, Expr과 각 하위 타입에 isProd 메서드를 추가해야 하고 eval() 메서드에 isProd를 이용하는 if 문을 추가해야 한다. 변수 표현을 위한 Var가 추가되어도 동일하게 각 타입마다 Var인지 여부를 판단하기 위한 isVar 메서드를 추가하고 eval에 관련 if 문을 추가하게 된다.


이 방식은 새로운 타입을 추가할 때마다 기존 타입의 코드를 수정해야 하는 문제가 발생한다.  isNumber 메서드나 isSum 메서드를 사용하지 않고 타입을 검사하는 방법을 사용할 수는 있지만 이는 깨지기 쉬운 코드를 만들곤 한다.


이를 객체 지향 방식으로 풀어보면 다음과 같이 Expr에 eval() 메서드를 추가하는 것이다.


trait Expr {

  def eval: Int

}

class Number(n: Int) extends Expr {

  def eval: Int = n

}

class Sum(e1: Expr, e2: Expr) extends Expr {

  def eval: Int = e1.eval + e2.eval

}


더 나아지긴 했지만 여전히 다음의 단점이 있다.

  • 식을 문자열로 구해주는 toStr()과 같은 새로운 기능을 구현하려면 모든 타입에 메서드를 정의해야 한다
  • a * b + a * c를  a * (b + c)로 축약하는 기능은 한 객체의 메서드로 캡슐화할 수 없다.
이런 문제를 해소할 수 있는 방법이 있는데 그것은 바로 패턴 매칭을 사용하는 것이다. 패턴 매칭을 사용하면 eval() 메서드를 다음과 같이 구현할 수 있다.

def eval(e: Expr): Int = e match {
  case Number(n) => n
  case Sum(e1, e2) => eval(e1) + eval(e2)
}


케이스 클래스를 패턴 매칭에 사용할 수 있다. 케이스 클래스를 패턴 매칭에서 사용하는 자세한 내용은 스칼라 관련 서적에 자세히 나와 있다.


케이스 클래스와 패턴 매칭을 사용하면 새로운 기능을 추가하더라도 Expr과 그 하위 타입을 바꿀 필요가 없다. 또한 단일 객체의 메서드로는 구현할 수 없는 기능도 구현할 수 있다.

Posted by 최범균 madvirus

댓글을 달아 주세요

마틴 오더스키 교수님이 코세라에서 진행중인 Functional Programming Principles in Scala 강의(https://www.coursera.org/learn/progfun1)의 1주차 요약.


프로그래밍 패러다임


과학의 경우 패러다임은, 어떤 학문 분야에서 구분되는 개념이나 사고 패턴을 설명한다.


프로그래밍 패러다임: 명령형(Imperative) 프로그래밍, 함수형(Functional) 프로그래밍, 로직(Logic) 프로그래밍

(객체 지향 프로그래밍은 이들 패러다임에 orthogonal하다)


명령형 프로그래밍과 한계


명령형 프로그래밍은 다음에 관한 것이다.
  • 변경가능한 변수를 수정하고 할당을 사용하고 (메모리, 로드/저장 명령)
  • 조건문, 루프, 브레이크, 리턴과 같은 제어 구조를 사용 (점프)

순수 명령형 프로그래밍은 명령어를 순차적으로 실행하는 구조로, 단어별로(word-by-word???) 데이터 구조의 개념을 도출하는 경향이 있다.


다항, 콜렉션, 문자열, 문서와 같은 고수준 추상화를 정의할 수 있는 다른 기법이 필요하다.


이론(Theory)이란?


이론은 다음으로 구성된다.

  • 데이터 타입
  • 타입에 대한 연산(오퍼레이션)
  • 값과 연산 간의 관계를 기술하는 규칙

보통, 이론은 변경(mutation)에 대해 묘사하지 않는다. 


변경 없는 이론 예1) 다항식 이론

(a*x + b) + (c*x + d) = (a+c)*x + (b+d) 계수를 변경하는 연산을 정의하지 않음


변경 없는 이론 예2) 문자열 이론의 문자열 연결 연산자

(a ++ b) ++ c = a ++ (b ++ c) 시퀀스 원소 변경 연산자를 정의하지 않음


프로그래밍을 위한 결론

  • 연산자를 위한 이론을 정의하는데 집중
  • 상태 변경을 최소화
  • 연산자를 함수로 여기고, 종종 단순한 함수의 조합으로 처리

함수형 프로그래밍과 언어


 -

 함수형 프로그래밍

함수형 프로그래밍 언어 

 좁은(restricted) 의미

변경가능한 변수, 할당, 루프, 다른 명령형 제어 구조 없는 프로그래밍

변경 가능 변수, 할당, 명령형 제어 구조가 없는 언어 

 넓은(wider) 의미

함수에 집중하는 것 

함수를 이용해서 elegant한 프로그램 을 구성하는 것 


함수형 프로그래밍 언어에서 함수는 일급(first-class)이다.

  • 모든 곳에 정의 가능함 (다른 함수 내부 포함)
  • 다른 값처럼 함수를 파라미터로 전달 가능하고 결과로 리턴될 수 있음
  • 함수를 조합하는 집합 연산자 존재

요즘 함수형이 뜨는 이유


멅티코어와 클라우드 컴퓨티 환경에서 병렬을 활용하는 좋은 방법을 제공하기 때문이다.



프로그래밍의 요소(Elements of Programming)


언어는 다음을 제공한다.

  • 단순한 요소를 표현하는 프리미티브(원시) 식(primitive expression)
  • 식을 묶는 방법
  • 식을 추상화하는 방법(식에 이름을 붙이고 이를으로 참조)

식의 계산(evaluation)


원시 식이 아닌 연산자를 포함한 식은 다음 과정을 따라 계산한다.

  1. 가장 왼쪽 연산자를 선택
  2. 선택한 연산자의 피연산자의 값을 구함(왼쪽 피연산자의 값을 먼저 구함)
  3. 두 피연산자에 연산자를 적용
식에 포함된 이름은 그 이름 정의의 오른쪽으로 대치한다.(예 def name = body 이면, name을 body로 대치)
최종 결과가 값이 될 때까지 과정을 반복한다.

과정 예) pi = 3.14159 이고 radius = 10
(2 * pi) * radious (가장 왼쪽 연산자 선택)
-> (2 * 3.14159) * radius (연산자의 피연산자 값 구함, pi를 3.14159로 대치 후 연산자 적용)
-> 6.28318 * radius (가장 왼쪽 연산자 선택)
-> 6.28318 * 10 (연산자의 피연산자 값 구함, radius를 10으로 대치 후 연산자 적용)
-> 62.8318 (최종 결과로 값 구함)

함수 적용(function application) 계산

파라미터를 가진 함수의 계산 과정은 다음과 같다.
  1. 모든 함수 인자를 왼쪽부터 오른쪽으로 차례대로 계산
  2. 함수 적용을 함수의 우측(함수의 몸체)으로 대치하고, 동시에
  3. 함수의 formal 파라미터를 실제 인자로 대치함
적용 예) 
def square(x: Double) = x * x
def sumOfSquares(x: Double, y: Double) = square(x) + square(y)

sumOfSquares(3, 2+2)
-> sumOfSquares(3, 4) (인자 값 구함)
-> square(3) + square(4) (sumOfSquares 함수의 우측으로 대치 및 파라미터를 인자로 대치)
-> 3 * 3 + square(4) (square 함수의 우측으로 대치)
-> 9 + square(4) (식의 계산에 의해 첫 번째 연산자 식을 계산)
----> 9 + 16 (비슷한 과정 생략)
-> 25

치환 모델(Substitution model)

앞의 식을 계산하는 방식을 치환 모델이라고 부른다. 이 모델의 아이디어는 모든 계산은 식을 한 개의 값으로 reduce한다. 식에 side effect가 없으면 이 모델을 모든 식에 적용할 수 있다.

계산의 끝(termination)

모든 식의 계산이 끝나는 것은 아니다.

예)
def loop: Int = loop
val i = loop()  // loop 식 계산은 끝나지 않음


값 계산의 두 가지 전략

  • call-by-value(이하 CBV): 식을 값으로 바로 계산해서 전달, 모든 함수 인자를 한 번만 계산하는 장점.
  • call-by-name(이하 CBN): 식의 값이 실제로 필요할 때까지 이름으로 전달, 함수에서 사용하지 않는 인자는 함수 몸체를 적용하는 과정에서 계산하지 않는 장점

두 방식은 다음 조건을 충족하는 한 두 방식은 동일한 값으로 reduce된다.

  • reduce한 식이 순수 함수로 구성
  • 둘 다 계산이 끝남

CBV 식이 끝나면 동일한 CBN 식도 끝나지만, 반대는 성립하지 않는다. 아래 코드에서 CBN의 경우 first(1, loop)는 1로 계산되지만 CBV의 경우 두 번째 인자인 loop의 값 계산이 끝나지 않아 식이 끝나지 않는다.


def first(x: Int, y: Int) = x

first(1, loop)


조건 식


스칼라의 if-else은 식이다. if의 predicate은 Boolean 값이다. &&와 ||는 왼쪽 피연산자의 값에 따라 오른쪽 피연산자를 계산하지 않는다. 예를 들어, true || e의 경우 왼쪽이 true이므로 오른쪽의 e 값을 구할 필요가 없으므로 결과는 true가 된다. 이런 식을 "short-circuit evaluation"을 사용한다고 말한다.


중첩 함수


스칼라는 함수 안에서만 접근할 수 있는 중첩 함수를 정의할 수 있다. 특정 함수 안에서만 사용하는 함수를 중첩함으로써 "name-space pollution"을 피할 수 있다.


블록


스칼라의 블록은 중괄호로 구분하며, 정의나 식을 포함한다. 블록 그 자체는 식이므로 식이 올 수 있는 모든 자리에 블록이 올 수 있다.


블록 안에 위치한 정의는 블록 안에서만 보이며, 블록 밖의 같은 이름을 가진 정의를 감춘다. 블록 밖의 정의는 블록 안에서 접근할 수 있다.


val x = 0

val y = 1

def f(y: Int) y + 1

val result = {

    val x = f(3) // 블록 밖의 x를 감춤

    x * x + y // 블록 안의 x임, 블록 밖의 정의에 접근 가능

} + x // 블록 밖의 x


꼬리 재귀(tail recursion)


함수의 마지막 액션이 자신을 호출하면, 함수의 스택 프레임을 재사용할 수 있다. 이를 꼬리 재귀라 한다. 스칼라는 꼬리 재귀를 최적화한다.


def gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b) // 마지막 액션이 꼬리 재귀, 최적화함


def factorial(n: Int): Int = if (n == 0) 1 else n * factorial(n-1) // 마지막 액션이 꼬리 재귀 아님



Posted by 최범균 madvirus

댓글을 달아 주세요