타입으로 안전한 HTML 구성하기
이번 장에서는 HTML을 위한 구분된 타입을 만드는 방법을 배웁니다. 또한, HTML 문자열을 잘못 구성하는 것을 방지하는 데 도움이 되는 방법을 배울 것입니다.
하스켈에서 새로운 타입을 정의하는 방법은 여러 가지가 있습니다.
이번 장에서는 newtype과 type 두 가지 방법을 알아보겠습니다.
newtype
newttype 선언은 기존 값 집합에 대해 새로운 타입을 정의하는 방법입니다.
이는 기존 값을 재사용하지만 다른 의미를 부여하고 두 가지를 혼동되지 않도록 하기에 유용합니다.
예를 들어, 초, 분, 그램, 엔을 정수 값으로 표현할 수 있지만,
초와 그램을 잘못 섞어 쓰는걸 원하진 않을것입니다.
우리가 만드는 프로그램의 경우 구조화된 HTML을 텍스트 값으로 표현하고 싶지만, 유효한 HTML이 아닌 평범한 문자열과 구분하고 싶습니다.
newtype 선언은 다음과 같이 생겼습니다:
newtype <타입 이름> = <생성자> <기존 타입>
예를들면 Html 타입을 다음과 같이 정의할 수 있습니다:
newtype Html = Html String
등호 왼쪽의 Html은 타입 이름 공간(namespace)에 존재합니다.
즉, :: 뒤에만 나올 수 있는 이름입니다.
등호 오른쪽의 Html은 표현식 (혹은 terms/values) 이름 공간에 존재합니다.
즉, 표현식에서만 나올 수 있는 이름입니다. (곧 그 위치를 살펴보겠습니다).
두 이름, <타입 이름>과 <생성자>는 같을 필요는 없지만, 일반적으로 같습니다.
그리고 두 이름 모두 대문자로 시작해야 합니다.
newtype 선언의 오른쪽은 해당 타입의 모양을 의미합니다.
위의 경우, Html 타입의 값은 Html 생성자와 그 뒤에 오는 문자열 타입의 표현식을 가지고 있습니다.
예를 들면 Html "hello" 또는 Html ("hello " <> "world")가 될 수 있습니다.
생성자는 인자를 받아서 새로운 타입의 반환하는 함수로 생각할 수 있습니다:
Html :: String -> Html
Html 타입의 표현식을 String 타입의 표현식과 같은 방식으로 사용할 수 없습니다.
즉, "hello " <> Html "world"는 타입 에러가 발생합니다.
이것은 캡슐화(encapsulation)가 필요할 때 유용합니다. 기본 타입에 대한 표현과 함수를 정의하고 사용할 수 있지만, 관련 없는(우리 도메인과 관련이 없는) 타입들과 혼동되지 않도록 합니다. 미터와 피트가 모두 숫자가 될 수 있지만, 우리는 변환 없이 실수로 미터와 피트를 더하는 것을 원치 않습니다.
이제 유용한 몇 가지 타입을 더 만들어 보겠습니다. 우리는 다음 두 가지 타입을 사용할 것입니다:
- 완전한 HTML 문서
<body>태그 안에 들어갈 수 있는 제목과 문단과 같은 HTML 구조에 대한 타입
우리는 두 가지 타입이 혼용되지 않기를 원합니다.
정답
newtype Html = Html String
newtype Structure = Structure String
newtype 사용하기
newtype이 감싸고 있는 내부 타입을 사용하려면, 우선 그 타입을 추출해야 합니다. 이를 위해 패턴 매칭을 사용합니다.
패턴 매칭은 두 가지 방법으로 사용할 수 있습니다. 하나는 case 표현식이고, 다른 하나는 함수 정의입니다.
case 표현식은 switch 표현식과 비슷하며 다음과 같이 생겼습니다:
case <표현식> of
<패턴> -> <표현식>
...
<패턴> -> <표현식><표현식>은 추출하고자 하는 것이고,<패턴>은 그것의 구체적인 모양입니다. 예를 들어,Structure타입을 정의했다고 가정해 봅시다. 이 타입의String값을 추출하고 싶다면 다음과 같이 합니다:getStructureString :: Structure -> String
getStructureString struct =
case struct of
Structure str -> str이렇게 하면
Structure에서String을 추출하고 반환할 수 있습니다.이후 장에서
data선언을 소개할 것입니다. (이는 struct + enum의 혼성체와 비슷합니다.) 여기서는 여러 생성자를 가진 타입을 정의할 수 있습니다. 그러면 case 표현식의 여러 패턴이 더 의미가 있게 될 것입니다.다른 방법으로, 함수를 정의할 때, 인자에 대해 패턴 매칭을 사용할 수 있습니다:
func <패턴> = <표현식>예를 들면:
getStructureString :: Structure -> String
getStructureString (Structure str) = str이제 이전에 정의한
html_,body_,p_등의 함수를String대신에 새로운 타입을 사용하도록 변경할 수 있습니다.그 전에, 우리의 코드를 더 간결하게 만들 수 있는 또 다른 연산자를 만나보겠습니다.
newtype의 또 다른 유용한 점은, 표현식을 감싸고 추출하는 것이 실제로는 성능에 영향을 주지 않는다는 것입니다!
컴파일러는 newtype 생성자의 감싸고 추출하는 것을 제거하고 기본 타입을 사용합니다.
우리가 정의한 타입과 생성자는 오직 우리가 코드를 작성할 때 기존 타입과 새로운 타입을 구분하는 데 도움을 주는 것이고, 코드가 실행 중에는 필요하지 않습니다.
newtype은 타입 안전성을 제공하면서도 성능에 영향을 주지 않습니다!
함수 합성
또 다른 흥미롭고 굉장히 자주 쓰이는 연산자로 (하스켈 일반 라이브러리 함수인) . (compose)가 있습니다.
이 연산자는 수학에서 알고 있는 합성 연산자(∘)와 비슷하게 생겼습니다.
연산자의 타입과 구현을 살펴보겠습니다:
(.) :: (b -> c) -> (a -> b) -> a -> c
(.) f g x = f (g x)
이 연산자는 3 개의 인자를 받습니다: 두 개의 함수(여기서는 f와 g라고 부릅니다)와 세 번째 인자 x입니다.
그런 다음 x를 두 번째 함수 g에 전달하고, g x의 결과를 첫 번째 함수 f에 전달합니다.
g 함수가 a 타입의 값을 받고 b 타입의 값을 반환하고, f 함수가 b 타입의 값을 받고 c 타입의 값을 반환한다는 점을 주목하세요.
또 다른 주목해야 할 점으로 소문자로 시작하는 타입이 있는데 이는 타입 변수(type variables)라 합니다.
이는 일반적인 변수와 비슷합니다. content는 hello나 world처럼 어떤 문자열이 될 수 있듯이,
타입 변수는 Bool, String, String -> String 등 어떤 타입이 될 수 있습니다.
이러한 기능을 매개변수 다형성(parametric polymorphism)이라고 합니다.
(다른 언어에서는 이를 제네릭(generics)이라고 부릅니다.)
주의해야 할 점은 타입 변수는 반드시 서명(signature)과 일치해야 한다는 것입니다.
예를 들어, a -> a라는 서명을 가진 함수를 정의하려고 한다면, 입력 타입과 반환 타입은 반드시 일치해야 하지만,
어떤 타입이든 될 수 있습니다. 그래서 이 서명을 가진 함수를 구현하는 유일한 방법은 다음과 같습니다:
id :: a -> a
id x = x
항등 함수를 의미하는 id는 받은 값을 그대로 반환합니다.
만약 다른 방법을 사용하려고 하면, 예를 들어 "hello"와 같은 임의의 값을 반환하거나,
x를 알고 있는 타입의 값처럼 사용해 x + x와 같은 표현식을 작성하면, 타입 오류가 발생합니다.
-> 연산자는 오른쪽으로 결합되는 것을 기억하시나요? 이 서명은 다음과 같이 해석할 수 있습니다:
(.) :: (b -> c) -> (a -> b) -> (a -> c)
이는 마치 함수가 두 함수를 받아서 합성한 함수를 반환하는 것처럼 보이지 않나요?
이제 이 연산자를 사용해 HTML 함수를 다시 작성해보겠습니다.
p_ 함수부터 시작해보겠습니다.
기존 정의는 다음과 같았습니다:
p_ :: String -> String
p_ = el "p"
이제, 다음과 같이 작성할 수 있습니다:
p_ :: String -> Structure
p_ = Structure . el "p"
p_ 함수는 문단을 의미하는 임의의 String을 받아서 <p>와 </p> 태그로 감싼 후,
타입을 더 자세히 살펴보겠습니다:
Structure :: String -> Structureel "p" :: String -> String(.) :: (b -> c) -> (a -> b) -> (a -> c)Structure . el "p" :: String -> Structure
표현식 Structure . el "p"가 타입 검사를 통과하는 이유와, 타입이 String -> Structure인 이유를 살펴보겠습니다.
펜과 종이로 타입 검사하기
표현식이 어떻게 타입 검사를 통과하는지 이해하고 싶다면, 체계적으로 타입 검사를 해보는 것이 좋습니다. 다음 예제를 살펴보겠습니다. 이 예제에서 우리는 다음 표현식을 타입 검사하려고 합니다.
p_ = Structure . el "p"
우선, 가장 바깥쪽 함수의 타입을 적습니다. 이 경우에는 연산자 .의 타입입니다:
(.) :: (b -> c) -> (a -> b) -> (a -> c)
그런 다음, 우리는 이 함수에 적용할 인자의 타입과 함수서명의 타입과 비교합니다.
먼저, .의 두 개의 인자의 타입을 확인합니다:
Structure :: String -> Structureel "p" :: String -> String
다행히 . 연산자는 두 개의 인자를 받고, 다음과 같은 타입을 가집니다:
b -> ca -> b
함수가 받을 수 있는 인자의 수보다 더 많은 인자를 적용하면 타입 오류가 발생합니다.
. 연산자는 우리가 제공한 인자의 수만큼 인자를 받기 때문에, 다음 단계로 넘어갑니다:
타입 검사의 다음 단계는 입력 타입과 (연산자의 서명을 통해) 예상되는 타입을 비교하는 것입니다.
두 타입을 비교할 때, 둘 간의 동등성(equivalence)를 확인합니다. 여기에는 몇 가지 가능한 시나리오가 있습니다:
- 두 타입이
Int,Bool과 같은 구체적(concrete)(타입 변수가 아닌)이고 단순한 타입인 경우, 두 타입이 같은지 확인합니다. 만약 같다면, 타입 검사를 통과하고 계속 진행합니다. 만약 다르다면, 타입 검사를 통과하지 못하고 오류를 발생시킵니다. - 두 타입이 더 복잡한 경우(예를 들어 둘 다 함수인 경우), 입력과 출력을 비교합니다. 만약 입력과 출력이 일치한다면, 두 타입이 일치한다고 판단합니다.
- 두 타입중 하나가 타입 변수인 특별한 경우가 있습니다. 이 경우, 타입 검사를 방정식처럼 처리합니다. 그리고 어딘가에 적어둡니다. 다음에 이 타입 변수를 볼 때, 방정식에 있는 값으로 대체합니다. 이것을 타입 변수에 값을 할당(assign) 한다고 생각하면 됩니다.
위 예제에서, 우리는 다음 두 가지 타입을 비교해야 합니다:
String -> Structure과b -> cString -> String과a -> b
하나씩 살펴보겠습니다. 먼저, String -> Structure과 b -> c를 비교해보겠습니다:
- 두 타입이 복잡하므로, 두 타입이 모두 함수라는 것을 확인하고, 입력과 출력을 비교합니다.
String과b, 그리고Structure와c를 비교합니다. b는 타입 변수이므로, 어딘가에 적어둡니다.b는String과 동등하다고 적어둡니다.b ~ String(우리는~를 동등함을 나타내는 기호로 사용합니다).- 같은 방식으로
c도Structure와 동등하다고 적어둡니다.c ~ Structure.
지금까지 문제는 없습니다. 이제 String -> String과 a -> b를 비교해보겠습니다:
- 두 타입이 복잡하므로, 두 타입이 모두 함수라는 것을 확인하고, 입력과 출력을 비교합니다.
String과a를 비교합니다.a ~ String으로 적어둡니다.String과b를 비교합니다.b는 이미 적어둔 방정식을 가지고 있습니다.b ~ String이라고 적어둔 것을 기억합니다.b를String으로 대체하고, 이 타입과 비교합니다.String과String을 비교하므로, 타입 검사를 통과합니다.
우리는 무사히 표현식을 타입 검사했고, 다음과 같은 타입 변수의 동등성을 찾았습니다:
a ~ Stringb ~ Stringc ~ Structure
이제, 다음 표현식의 타입을 찾아보겠습니다:
p_ = Structure . el "p"
이 표현식의 타입을 찾기 위해, 우리가 찾은 방정식을 사용하여 타입 변수를 대체하고, 우리가 적용한 인자를 제거합니다. 그래서 다음과 같은 타입을 얻습니다:
(.) :: (b -> c) -> (a -> b) -> (a -> c)
그리고 다음과 같이 타입 변수를 대체합니다:
(.) :: (String -> Structure) -> (String -> String) -> (String -> Structure)
그리고 함수를 적용하면서 두 개의 인자를 제거합니다:
Structure . el "p" :: String -> Structure
이제, 우리는 이 표현식의 타입을 찾았습니다!
다행히, 하스켈은 이 과정을 대신 해줄 수 있습니다. 하지만 하스켈이 타입 에러를 발생시키는 경우, 그 이유를 이해하지 못하는 상황이 발생할 수 있습니다. 이럴때 위와 같은 과정을 거치면, 타입이 일치하지 않는 부분을 찾을 수 있고, 그 부분을 해결할 수 있습니다.
만약 우리가 매개변수화된 다형성을 가진 함수를 두 번 이상 사용하거나, 비슷한 타입 변수 이름을 가진 다른 함수를 사용한다면, 이름이 같다고 해서 모든 인스턴스에서 타입 변수가 일치할 필요는 없습니다. 각 인스턴스는 고유한 타입 변수 집합을 가집니다. 예를 들어:
id :: a -> a
ord :: Char -> Int
chr :: Int -> Char
incrementChar :: Char -> Char
incrementChar c = chr (ord (id c) + id 1)
위의 코드에서 id를 두 번 사용합니다. (예시로 사용하기 위함일 뿐 좋은 구조는 아닙니다)
첫 번째 id는 Char를 인자로 받고, a는 Char와 동등합니다.
두 번째 id는 Int를 인자로 받고, 구별된 a는 Int와 동등합니다.
이 상황은 오직 최상위(top-level)에 정의한 함수에만 적용됩니다.
만약 우리가 incrementChar에 인자로 전달할 지역 함수를 정의하고,
id와 같은 타입 시그니처를 가진다면, 모든 사용처에서 타입이 일치해야 합니다.
다음 코드의 경우:
incrementChar :: (a -> a) -> Char -> Char
incrementChar func c = chr (ord (func c) + func 1)
타입 에러가 발생합니다. 직접 확인해보세요!
구조 확장하기
이전에 우리는 풍부한 HTML 컨텐츠를 만들고 위해 노드를 이어붙이기 위해 <> 연산자를 사용했습니다.
하지만 이제 String을 사용하지 않으므로, 다른 방법을 사용해야 합니다.
물론 하스켈에서 타입클래스로 불리는 기능을 통해 <> 연산자를 오버로딩하여 사용할 수 있지만,
이번에는 append_라는 이름의 새로운 함수를 만들고, 타입클래스에 대해서는 나중에 다루겠습니다.
append_는 두 개의 Structure를 인자로 받고, 첫 번째 Structure의 내부 String을 두 번째 Structure에 붙여서
새로운 Structure를 반환합니다.
append_ 함수를 구현해보세요.
정답
append_ :: Structure -> Structure -> Structure
append_ (Structure a) (Structure b) =
Structure (a <> b)
Html을 String으로 되돌리기
유효한 Html 값을 만들었으니, 브라우저에서 출력할 수 있도록 Html을 String으로 변환할 수 있는 함수가 필요합니다.
이를 위해 Html을 String으로 변환한 후 putStrLn에 전달하는 함수가 필요합니다.
render 함수를 구현해보세요.
정답
render :: Html -> String
render html =
case html of
Html str -> str
type
타입에 새로운 이름을 붙이는 방법은 또 다른 방법이 있습니다.
type 정의는 newtype 정의와 매우 유사합니다. 유일한 차이점은 생성자 없이 타입 이름을 참조한다는 것입니다:
type <type-name> = <existing-type>
이전 예제에서 type을 사용하면 다음과 같이 작성할 수 있습니다:
type Title = String
type은 newtype과 다르게 타입 이름에 대한 별칭입니다.
Title을 String의 타입 별칭으로 선언했다는 것은,
두 타입은 서로 대체 가능하고, 언제든지 하나를 다른 하나로 사용할 수 있다는 것을 의미합니다:
"hello" :: Title
"hello" :: String
위 코드는 모두 유효합니다.
type은 때때로 코드 가독성을 높이기 위해 사용합니다.
같은 타입을 다르게 구별할 수 있는 newtype에 비해 type은 덜 유용합니다.
또 다른 문제
이전 장에서 작성한 코드를 새로운 타입을 사용하도록 변경해보세요.
이제 makeHtml과 html_를 합치고 body_, head_, title_을 제거할 수 있습니다.
html_에서 el을 직접 호출할 수 있으며, 타입은 Title -> Structure -> Html가 됩니다.
이러면 HTML EDSL이 덜 유연하지만 더 간결해집니다.
대안으로, HtmlHead와 HtmlBody라는 newtype을 만들고 html_에 전달할 수 있습니다.
하지만 이번에는 API를 간단하게 유지하기 위해 사용하지 않았고 이후 장에서 다룰 예정입니다.
정답
main :: IO ()
main = putStrLn (render myhtml)
myhtml :: Html
myhtml =
html_
"My title"
( append_
(h1_ "Heading")
( append_
(p_ "Paragraph #1")
(p_ "Paragraph #2")
)
)
newtype Html
= Html String
newtype Structure
= Structure String
type Title
= String
html_ :: Title -> Structure -> Html
html_ title content =
Html
( el "html"
( el "head" (el "title" title)
<> el "body" (getStructureString content)
)
)
p_ :: String -> Structure
p_ = Structure . el "p"
h1_ :: String -> Structure
h1_ = Structure . el "h1"
el :: String -> String -> String
el tag content =
"<" <> tag <> ">" <> content <> "</" <> tag <> ">"
append_ :: Structure -> Structure -> Structure
append_ c1 c2 =
Structure (getStructureString c1 <> getStructureString c2)
getStructureString :: Structure -> String
getStructureString content =
case content of
Structure str -> str
render :: Html -> String
render html =
case html of
Html str -> str
아직 안전하지 않나요?
이제 우리는 문단과 제목이 필요한 곳에 "Hello"같은 문자열을 사용할 수 없게 만들었습니다.
하지만 Structure "hello"처럼 제목도 문단도 아닌 값을 사용할 수 있는 문제가 남아있습니다.
사용자의 실수를 방지할 수 있게 노력했지만 아직 원하는만큼 불변성을 강제하지 못했습니다.
다음 장에서는 모듈(modules)과 스마트 생성자(smart constructors)를 사용해 Structure "hello"와 같은 표현식을 사용할 수 없게 만들어보겠습니다.