Haskell에서 Raku로 - 핵심 요약

Haskell에서 Raku로 - 핵심 요약 (Haskell to Raku - nutshell)

Haskell과 Raku는 아주 다른 언어예요. 이건 분명한 사실이에요. 하지만 그렇다고 두 언어 사이에 비슷한 점이나 공유하는 아이디어가 없다는 뜻은 아니에요. 이 페이지는 Haskell 사용자가 Raku를 바로 시작할 수 있도록 돕는 걸 목표로 해요. Haskell 사용자는 Raku로 스크립트를 쓰면서 Haskelly한 사고방식을 전부 버릴 필요가 없다는 걸 알게 될 거예요.

이 페이지는 초보자 튜토리얼이나 Raku 개요로 오해하면 안 돼요. Haskell 배경이 탄탄한 Raku 학습자를 위한 기술 참조로 보는 게 맞아요.

출처: Raku Documentation — Haskell to Raku - nutshell

본문

타입 (Types)

타입 vs 값 (Types vs values)

Haskell에는 타입 레벨 프로그래밍과 값 레벨 프로그래밍이 있어요.

plusTwo :: Integer -> Integer   -- Types
plusTwo x = x + 2               -- Values

Haskell에서 타입과 값을 아래처럼 섞지는 않아요.

plusTwo 2          -- This is valid
plusTwo Integer    -- This is not valid

반면 Raku에서 타입(일명 타입 객체, type object)은 값과 같은 레벨에 있어요.

sub plus-two(Int $x --> Int) { $x + 2 }

plus-two(2);    # This is valid
plus-two(Int);  # This is valid

Raku의 이 독특한 면을 예시 하나 더 들어볼게요.

multi is-string(Str $ --> True) {}
multi is-string(Any $ --> False) {}

is-string('hello');    #True
is-string(4);          #False

Maybe

Haskell에는 null 타입의 걱정을 덜어주는 Maybe 타입이 있어요. 문자열을 정수로 파싱하는 가상의 함수가 있다고 해볼게요.

parseInt :: String -> Maybe Integer

case parseInt myString of
  Just x  -> x
  Nothing -> 0

Raku에서는 타입 객체가 일반 객체와 공존하기 때문에, 정의됨(Defined)과 정의되지 않음(Undefined) 객체라는 개념이 있어요. 평범한 타입 객체는 정의되지 않은 상태이고, 인스턴스화된 객체는 정의된 상태예요.

sub parse-int(Str $s --> Int) { ... }

my $string = {...};
given parse-int($string) {
  when Int:D { $_ }
  when Int:U { 0 }
}

그래서 Raku에는 타입의 정의됨(definedness) 상태를 나타내는 타입 제약이 있어요. 다음과 같아요.

Int:D; # This is a defined Int.
Int:U; # This is an undefined Int, AKA a type object
Int:_; # This is either defined or undefined.

위 예시에서 명시적으로 하고 싶다면(아마 좋은 생각이에요), 반환 타입에 :_ 제약을 추가할 수 있어요. 그러면 사용자에게 정의된 값과 정의되지 않은 값 둘 다를 고려해야 한다는 걸 알려줄 수 있어요. 정의됨을 특별히 검사하는 다른 메서드나 구성도 쓸 수 있어요.

sub parse-int(Str $s --> Int:_) { ... }

# 한 가지 방법
my $string = {...};
given parse-int($string) {
  when Int:D { $_ }
  when Int:U { 0 }
}

# 또 다른 방법
my Int $number = parse-int($string);
if $number.defined { $number } else { 0 }

# 더 나은 방법
with parse-int($string) { $_ } else { 0 }

# defined-or 연산자 사용
parse-int($string) // 0

위에서 보이는 with 연산자는 if와 비슷한데, 정의됨을 명시적으로 검사한 다음 그 결과를 이어지는 블록에 전달해요. 비슷하게 without은 객체가 정의되지 않았는지 검사하고 그 결과를 이어지는 블록에 전달해요.

정의되지 않은 타입과 정의된 타입을 더 자연스러운 제어 흐름으로 다루기 위해 Raku는 andthenorelse를 도입했어요.

sub parse-int(Str $s --> Int:_) { ... }

my $string = {...};
my $result = parse-int($string) orelse 0;

sub hello() { say 'hi' }
hello() andthen say 'bye';

실제로 Raku에는 null 타입이라는 개념이 없고, 대신 정의된/정의되지 않은 타입이라는 개념이 있어요.

데이터 정의 (Data definitions)

Raku는 근본적으로 객체 지향 언어예요. 하지만 사실상 원하는 거의 모든 패러다임으로 자유롭게 쓸 수도 있어요. 객체 하나를 받아 새 객체를 반환하는 순수 함수만 쓰고 싶다면 충분히 그렇게 할 수 있어요.

Haskell 코드 예시를 볼게요.

data Point = Point x y

moveUp :: Point -> Point
moveUp (Point x y) = Point x (y + 1)

그리고 동등한 Raku 예시예요.

class Point { has $.x; has $.y; }

sub move-up(Point $p --> Point) {
  Point.new(x => $p.x, y => $p.y + 1)
}

위에 보여준 코드는 **곱 타입(Product Type)**의 예시예요. 반대로 **합 타입(Sum Type)**을 쓰고 싶다면, Raku에는 정확히 동등한 게 없어요. 가장 가까운 것은 Enum이에요.

data Animal = Dog | Cat | Bird | Horse

testAnimal :: Animal -> String
testAnimal Dog   = "Woof"
testAnimal Horse = "Neigh"

정확히 같은 사용 사례에 맞지는 않지만, 타입에 제약을 거는 데 쓸 수 있어요.

enum Animal < Dog Cat Bird Horse >;

proto test-animal( Animal        ) {*}
multi test-animal( Dog           ) { 'Woof' }
multi test-animal( Animal::Horse ) { 'Neigh'  }   # more explicit

say test-animal Animal::Dog;                          # more explicit
say test-animal Horse;

타입 별칭과 부분집합 (Type aliases and subsets)

Haskell에서는 의도 명확성을 높이고 기존 타입을 재사용하기 위해 기존 타입에 별칭을 붙일 수 있어요.

type Name = String

fullName :: Name -> Name -> Name
fullName first last = first ++ last

Raku의 동등한 표현은 다음과 같아요.

my constant Name = Str;

sub full-name ( Name \first, Name \last --> Name ) { first ~ last }

Raku에서는 기존 타입의 부분집합(subset)을 만들 수도 있다는 점을 알아두세요.

subset Name of Str where *.chars < 20;

sub full-name(Name $first, Name $last) {
  $first ~ $last
}

full-name("12345678901234567890111", "Smith") # This does not compile, as the first parameter
                                              # doesn't fit the Name type

타입클래스 (Typeclasses)

TODO

함수 (Functions)

정의와 시그니처 (Definitions and signatures)

패턴 매칭 (Pattern Matching)

Haskell은 함수 정의에서 패턴 매칭을 많이 사용해요.

greeting :: String -> String
greeting  ""   = "Hello, World!"
greeting "bub" = "Hey bub."
greeting  name = "Hello, " ++ name ++ "!"

Raku도 이걸 해요! multi 키워드를 쓰면 그게 다중 디스패치 함수라는 뜻이에요.

proto greeting ( Str   --> Str ) {*}
multi greeting ( ""    --> "Hello, World!" ) {}
multi greeting ( "bub" --> "Hey bub." ) {}
multi greeting ( \name ) { "Hello, " ~ name ~ "!" }

proto 선언자는 꼭 필요하지 않아요. 하지만 모든 multi가 비즈니스 규칙을 따르게 하는 데 도움이 될 수 있어요. proto의 시그니처에 변수 이름을 쓰면 오류 메시지와 인트로스펙션에서 더 많은 정보를 줄 수 있어요.

proto greeting ( Str \name --> Str ) {*}

say &greeting.signature;                  # OUTPUT: «(Str \name --> Str)␤»

위 Raku 코드에서 흥미로운 점은, 함수 인자로 'bub' 같은 값을 넘기는 게 where 가드의 문법적 설탕(syntax sugar)이라는 거예요.

가드 (Guards)

이 페이지 "패턴 매칭" 절의 예시를 써서, 함수 인자를 제약하는 데 뒤에서 쓰이는 가드를 볼 수 있어요.

multi greeting ( ""    --> "Hello, World!" ) {}
multi greeting ( "bub" --> "Hey bub." ) {}

# 위는 아래와 같아요

multi greeting(Str \name where ''    ) {'Hello, World!'}
multi greeting(Str \name where 'bub' ) {'Hey bub.'}

# 위는 아래와 같아요, 다시 한번

multi greeting(Str \name where $_ ~~ ''   ) {'Hello, World!'}
multi greeting(Str \name where $_ ~~ 'bub') {'Hey bub.'}

$_는 **주제 변수(topic variable)**로 알려져 있어요. 상황에 맞는 형태를 그때그때 취해요. 스마트매치 연산자 ~~는 왼쪽이 오른쪽과 매치하는지(숫자 범위든, 문자열이든) 가장 좋은 방법을 스스로 찾아요. 위의 세 예시는 가장 설탕을 많이 친 것(위)부터 가장 적게 친 것(아래) 순서예요.

위 아래쪽 예시들은 중괄호로 감싸면 코드 블록이라는 게 더 분명해져요. where 절은 명시적인 Callable을 받을 수도 있다는 점을 유의하세요.

multi greeting(Str \name where { $_ ~~ '' } ) {'Hello, World!'}

multi greeting(Str \name where -> $thing { $thing ~~ '' } ) {'Hello, World!'}

multi greeting ( Str \name where { Bool.pick } --> 'True' ){}

multi greeting ( Str \name where &some-subroutine ){…}

이 페이지의 부분집합 절을 읽었다면, subset을 만들 때도 where를 쓰고 여기서도 where를 쓴다는 걸 알아차렸을 거예요. 두 곳에서 where의 사용법은 완전히 같아요.

where를 쓸 때 정의 순서가 중요하다는 점을 기억하세요. Haskell과 같아요.

multi greeting ( Str \name where '' --> 'Hello, World!' ){}
multi greeting ( Str \name where { Bool.pick } --> 'True' ){}
multi greeting ( Str \name where 'bub' --> 'Hey, bub.' ){}

say greeting ''   ; # will never say True
say greeting 'bub'; # about 50% of the time it will say True

인자 분해 (Argument Deconstruction)

TODO

커링 / 부분 적용 (Currying / Partial Application)

Haskell 함수는 기본적으로 커리(curried) 형태라서 부분 적용을 직접 허용해요.

plus : Int -> Int -> Int
plus a b = a + b

plusTwo = plus 2

Raku에서 부분 적용은 임의의 Callable 객체에 assuming 메서드를 써서 이룰 수 있어요.

sub plus(Int $i, Int $j --> Int) { return $i + $j; }

my &plus-two = &plus.assuming(2, *);

합성 (Composing)

TODO

함수 합성 연산자를 보여주세요. 아마 이걸 하는 더 Raku다운 방식을 설명해주세요.

case / 매칭 (Case / matching)

Haskell은 아래처럼 case 매칭을 많이 사용해요.

case number of
  2 -> "two"
  4 -> "four"
  8 -> "eight"
  _ -> "don't care"

Raku에서는 given/when 구조로 같은 일을 할 수 있어요.

my $number = {...};
given $number {
  when 2  { "two" }
  when 4  { "four" }
  when 8  { "eight" }
  default { "don't care" }
}

when의 순서도 중요하다는 점을 기억하세요. 이 페이지 가드 절의 where와 마찬가지예요.

리스트 (Lists)

TODO

Raku의 Array, Sequence, List의 차이를 설명하세요. @ 시길과 관련된 데이터 형태(shape)를 설명하세요. |@로 Array를 평탄화된 객체 리스트로 변환하는 방법을 설명하세요.

데이터 형태는 금방 직관적으로 익히게 되지만, 약간의 연습이 필요해요.

리스트 컴프리헨션 (List comprehensions)

Raku에는 명시적인 리스트 컴프리헨션이 없어요. 하지만 리스트 컴프리헨션을 여러 가지 방법으로 만들 수 있어요.

Haskell의 간단한 예시를 볼게요.

evens = [ x | x <- [0..100], even x ]

이제 Raku로요.

# `if`와 `for` 사용
my @evens = ($_ if $_ %% 2 for 0..100);

# gather/take로 Seq 만들기
my $evens = gather for 0..100 { take $_ if $_ %% 2 };

# gather/take로 Array 만들기
my @evens = gather for 0..100 { take $_ if $_ %% 2 };

for는 항상 eager하므로, 보통은 map이나 grep을 쓰는 게 더 좋아요. 이 둘은 리스트 인자의 느긋함(laziness)이나 적극성(eagerness)을 물려받으니까요.

my @evens = map { $_ if $_ %% 2 }, 0..100;

my @evens = grep { $_ %% 2 }, 0..100;

# Whatever 람다 사용
my @evens = grep  * %% 2,  0..100;

다음은 Haskell에서 튜플을 만드는 예시예요.

tuples = [(i,j) | i <- [1,2],
                  j <- [1..4] ]
-- [(1,1),(1,2),(1,3),(1,4),(2,1),(2,2),(2,3),(2,4)]

그리고 Raku로요.

my @tuples = 1,2  X  1..4;
# [(1,1), (1,2), (1,3), (1,4), (2,1), (2,2), (2,3), (2,4)]

어떤 종류의 리스트 컴프리헨션이 가능한지 더 알고 싶으면 이 디자인 문서를 참고하세요: https://github.com/Raku/old-design-docs/blob/master/S04-control.pod#The_do-once_loop.

보다시피, 좀 더 고급 Haskell 리스트 컴프리헨션에 들어가면 Raku가 정확히 똑같이 번역되지는 않아요. 하지만 어쨌든 같은 일을 하는 것은 가능해요.

Fold

Haskell의 Fold는 Raku에서 Reduce라고 불러요.

mySum = foldl (+) 0 numList
my @numbers = {...};
reduce { $^a + $^b }, 0, |@numbers;
@numbers.reduce: {$^a + $^b}

그런데 Raku에서 중위 연산자(+ - / % 등)를 쓰고 싶다면, Reduction 메타연산자라는 아주 편리한 헬퍼가 있어요.

my @numbers = {...};
[+] @numbers     # This is the same
[+] 0, |@numbers # as this

이것은 연산자를 리스트의 모든 값 사이에 끼워 넣고 결과를 만들어요. Fold와 똑같이 말이죠.

Haskell에는 foldlfoldr이 있어요. Raku에서 이 차이는 연산자/서브루틴에 붙은 결합성(associativity)으로 결정돼요.

sub two-elem-list ( \a, \b ) { ( a, b ) }

# 서브루틴을 중위 연산자로 쓸 수 있어요
say 'a' [&two-elem-list] 'b'; # OUTPUT: «(a b)␤»

# reduction 접두사 메타연산자가 중위 연산자를 받으므로, 거기서도 동작해요;
[[&two-elem-list]] 1..5;           # OUTPUT: «((((1 2) 3) 4) 5)␤»
say (1..5).reduce: &two-elem-list; # OUTPUT: «((((1 2) 3) 4) 5)␤»

# 오른쪽 결합
sub right-two-elem-list( \a, \b ) is assoc<right> { ( a, b ) }
say (1..5).reduce: &right-two-elem-list; # OUTPUT: «(1 (2 (3 (4 5))))␤»

# XXX 아마 여기 버그가 있을 수 있음. 현재 이것은 &right-two-elem-list의
# XXX 결합성을 보지 않고 항상 왼쪽 결합을 수행함
say [[&right-two-elem-list]] 1..5;

# 체이닝
say [<] 1..5;            # OUTPUT: «True␤»
say (1..5).reduce: &[<]; # OUTPUT: «True␤»

takeWhile

Haskell의 takeWhile 함수는 리스트를 훑으면서 조건이 충족될 때까지 모든 요소를 반환해요.

fibs = 0 : 1 : zipWith (+) fibs (tail fibs)
takeWhile (<20) fibs -- Returns [0,1,1,2,3,5,8,13]

Raku에는 단일의 동등한 함수가 없어요. 이 글의 원천이 된 이슈에서 여러 대안이 제안됐는데, 그중 하나가 이거예요.

[1, 2, 3, 40, 50, 60, 7, 8, 9] ...^ !(* < 10)

단일 함수는 아니지만, 본질적으로 caret으로 마지막 항을 제외하고 조건으로 시퀀스를 끝내는 시퀀스 연산자의 특정한 사용 방식이에요. 이 특정 예시는 아래와 동등해요.

takeWhile (<10) [1, 2, 3, 40, 50, 60, 7, 8, 9]

Map

TODO

범위 (Ranges)

Haskell과 Raku 둘 다 값의 범위를 지정할 수 있어요.

myRange1 = 10..100
myRange2 = 1..        -- Infinite
myRange3 = 'a'..'h'   -- Letters work too
my $range1 = 10..100;
my $range2 = 1..*;      # Infinite
my $range3 = 'a'..'h';  # Letters work too

느긋함 vs 적극성 (Laziness vs eagerness)

위 예시들에서 느긋함(laziness)의 개념이 아주 명확하게 드러나요. Raku는 가장 말이 되는 곳에서만 느긋함을 가져요. 예를 들어 10..100 범위는 명확한 끝이 있으므로 eager해요. 리스트에 명확한 끝이 없다면, 당연히 그 리스트는 lazy해야 해요.

(1 .. 100).is-lazy; # False
(1 .. Inf).is-lazy; # True

이것들이 Raku가 자랑스러워하는 "합리적인 기본값(sane defaults)"이에요. 하지만 여전히 기본값일 뿐이고, 어느 쪽으로든 바꿀 수 있어요.

(1 .. 100).lazy.is-lazy;       # True
(1 .. 100).lazy.eager.is-lazy; # False

문맥 (let-in / where) (Contexts (let-in / where))

TODO

given/whenwith/without 그리고 for 루프가 어떻게 인자를 문맥으로 하는 어휘 스코프를 여는지 설명하세요.

let/inwhere 구성과 비교해볼까요?

파서 (Parsers)

파서 컴비네이터 vs 그라마 (Parser combinators vs grammars)

TODO

꼬리 호출 최적화 또는 꼬리 호출 제거 (Tail Call Optimization / Elimination)

Haskell과 다른 많은 함수형 프로그래밍 언어는 스택 오버플로를 피하기 위해 꼬리 호출 최적화(tail call optimization, 또는 꼬리 호출 제거)를 사용해요. 일부 유형의 재귀 함수 호출에서 스택 오버헤드를 제거하는 기법이에요.

Raku 언어 명세에는 이 부류의 최적화 구현을 금지하는 내용이 없어요. 하지만 현재 어떤 구현에도 아직 그게 없어요.

많은 Haskell 반복 구성이 재귀 함수 호출을 사용한다는 점을 유의하세요. 꼬리 호출 최적화가 없다면 Haskell 프로그램은 더 자주 스택 오버플로 오류를 만나게 될 거예요. 반면 표준 Raku 반복 구성은 재귀 함수 호출 위에 만들어지지 않아서, 이 기능의 중요성이 상대적으로 낮아요.