Haskell의 다양한 문자열 유형, Unicode, 변환, 스트리밍 및 파일 경로를 다루는 종합 가이드입니다.
2024년 5월 7일, Julian Ospald 작성
이 가이드는 초보자부터 숙련된 개발자까지, String 유형에 대한 이해를 높이고자 하는 Haskell 사용자들을 대상으로 합니다. 또한 주어진 상황에서 어떤 문자열 유형을 사용할지 결정하기 위한 빠른 참고 자료/치트 시트 역할도 합니다.
2022년에 저는 Abstract FilePath 제안을 구현했고, 이로 인해 OsString 같은 여러 새로운 문자열 유형이 생겼습니다.
글을 쓰는 현재, 저는 base API를 감독하는 Core Libraries Committee에서도 활동하고 있습니다. base의 맥락에서는 다음처럼 문자열 유형에 관한 논의가 반복적으로 있었습니다.
이 주제를 다른 Haskell 사용자들과 논의하면서, 실제로 상당히 혼란스러울 수 있으며 포괄적이고 전체적인 문서가 없다는 점을 깨달았습니다. 결국 The Rust book에 해당하는 자료는 없습니다.
이 블로그 글이 문서의 몇몇 공백을 메우고, 새로 도입된 유형과 String 유형이 너무 많지 않다고 생각하는 이유도 설명할 수 있기를 바랍니다.
Haskell에서 가장 널리 쓰이는 String 유형은 9장 Standard Prelude의 Haskell 표준에 정의되어 있습니다.
-- Lists
data [a] = [] | a : [a]
-- Not legal Haskell; for illustration only
-- Character type
data Char = ... 'a' | 'b' ... -- Unicode values
type String = [Char]
리스트는 Haskell에서 가장 관용적인 자료형 중 하나이므로, 문자열은 문자 리스트일 뿐이어서 쉽게 패턴 매칭할 수 있습니다. 예를 들어 다음 함수는 문자열의 첫 문자와 나머지를 반환하며, 리스트가 비어 있다면 Nothing을 반환합니다.
uncons :: [a] -> Maybe (a, [a])
uncons [] = Nothing
uncons (x:xs) = Just (x, xs)
Haskell 표준의 Char 의사 코드 정의를 자세히 살펴보면 -- Unicode values라는 주석을 볼 수 있습니다. 하지만 이는 다소 모호합니다. base의 Data.Char 문서를 보면, 실제로는 Unicode 코드 포인트로 구현되어 있음을 알 수 있습니다.
이는 스마트 생성자 chr에서도 확인할 수 있습니다.
chr :: Int -> Char
chr i@(I# i#)
| isTrue# (int2Word# i# `leWord#` 0x10FFFF##) = C# (chr# i#)
| otherwise
= errorWithoutStackTrace ("Prelude.chr: bad argument: " ++ showSignedInt (I# 9#) i "")
따라서 Char는 기본적으로 상한이 0x10FFFF인 Int일 뿐입니다. 이를 이해하려면 Unicode를 잠시 살펴봐야 합니다.
Unicode 표준은 세계의 모든 주요 문자 체계를 지원하면서, 텍스트를 구성하는 눈에 보이는 ‘문자’를 식별하고 인코딩하기 위한 표준입니다.
정확한 용어는 매우 혼란스러울 수 있습니다. 여기서는 몇 가지 핵심 개념에만 집중하겠습니다. 표준을 직접 더 읽고 싶다면 다음 자료를 참고하세요.
Unicode의 목표는 보편성, 효율성, 모호하지 않음입니다. 이를 달성하려면 다음이 필요합니다.
a 또는 쟬을 모호하지 않은 무언가로 변환하는 것‘문자’라는 용어는 여러 의미로 쓰이므로, 진행하면서 여러 정의를 살펴보겠습니다.
Unicode 코드 포인트는 숫자 값을 통해 단일 문자를 인코딩하는 방법입니다. 앞서 chr :: Int -> Char 정의에서 보았듯이, 16진수 값 0에서 10FFFF까지의 범위입니다. 코드 포인트의 공식 표기는 U+0000에서 U+10FFFF입니다.
본질적으로 정적인 대응 관계입니다. 예를 들면 다음과 같습니다.
| 문자 | 코드 포인트 |
|---|---|
| a | U+0061 |
| b | U+0062 |
| 쟬 | U+C7EC |
| 🇯 | U+1F1EF |
| 🇵 | U+1F1F5 |
| 🇯🇵 | U+1F1EF, U+1F1F5 |
여기서 몇 가지를 관찰할 수 있습니다.
a의 16진수 값 61과 b의 62는 ASCII 문자 집합에 대응합니다.하지만 이것은 단일 문자에 대한 대응 관계일 뿐입니다. 전체 텍스트를 효율적으로 표현하기 위해 여러 Unicode 변환 형식이 개발되었으며, 특히 다음이 대표적입니다.
이런 변환 형식은 바이트 시퀀스에서 코드 포인트 경계를 이해하고 검색과 분할을 가능하게 하기 위해 필요합니다. UTF-16과 UTF-8은 크기에도 최적화되어 있습니다.
텍스트를 위한 가장 단순한 인코딩은 코드 포인트 값을 그대로 사용하는 것입니다. 문제는 최대 코드 포인트 값이 U+10FFFF이고, 이는 21비트에만 들어맞는다는 점입니다.
UTF-32는 32비트, 즉 4바이트를 사용하는 고정 길이 인코딩이므로 실제 변환 없이 가능한 모든 Unicode 값을 담을 수 있습니다.
장점은 단순하다는 것이고, 단점은 대부분의 값에 21비트 전체가 필요하지 않기 때문에 공간을 낭비한다는 것입니다. 예를 들어 ASCII는 7비트만 필요합니다.
UTF-32는 ASCII 호환이 아닙니다. 즉, ASCII만 이해하는 프로그램은 사용된 모든 문자가 ASCII 집합에 속하더라도, 예를 들어 [a-zA-Z]의 라틴 문자만 있더라도, UTF-32 텍스트를 우연히 처리하지 못합니다.
이는 가변 폭 문자 인코딩이며, 특히 Windows에서 사용됩니다.
U+0000부터 U+FFFF까지의 코드 포인트는 나중에 설명할 서로게이트를 제외하면 2바이트, 즉 16비트로 ‘직접’ 표현됩니다.
U+10000부터 U+10FFFF까지의 코드 포인트는 2바이트에 들어가지 않습니다. 이를 우연한 모호성 없이 인코딩하기 위해 서로게이트가 도입되었습니다. 다른 선택지는 UTF-8처럼 매직 비트를 사용하는 것이었겠지만, 이 형식은 확장을 염두에 두고 설계되지 않은 듯합니다. 이 서로게이트는 항상 쌍으로, 즉 4바이트로 나타나야 하며 다음 범위에 있습니다.
U+DC00부터 U+DFFFU+D800부터 U+DBFF비트를 재배열하면 이 2바이트 쌍은 U+10000부터 U+10FFFF 범위의 값으로 대응될 수 있습니다. 관심 있는 독자를 위해 알고리즘은 다음과 같습니다(Wikipedia에서 인용).
- 코드 포인트(U)에서 0x10000을 빼서, 16진수 범위 0x00000–0xFFFFF의 20비트 수(U’)를 남긴다.
- 상위 10비트(범위 0x000–0x3FF)를 0xD800에 더해 첫 번째 16비트 코드 단위 또는 상위 서로게이트(W1)를 얻는다. 이는 0xD800–0xDBFF 범위에 있게 된다.
- 하위 10비트(역시 범위 0x000–0x3FF)를 0xDC00에 더해 두 번째 16비트 코드 단위 또는 하위 서로게이트(W2)를 얻는다. 이는 0xDC00–0xDFFF 범위에 있게 된다.
UTF-16도 ASCII 호환이 아닙니다. 다만 UTF-32보다는 공간 효율적입니다. 일부 언어에서는 UTF-8보다 공간 효율적일 수도 있습니다.
본질적으로 코드 포인트인 Haskell Char 유형은 UTF-16에서 사용되는 서로게이트를 표현할 수 있다는 점을 이해하는 것이 중요합니다.
Unicode 표준은 Unicode 스칼라 값이라는 개념도 정의합니다.
상위 서로게이트와 하위 서로게이트 코드 포인트를 제외한 모든 Unicode 코드 포인트. 다시 말해, 0부터 D7FF16까지 및 E00016부터 10FFFF16까지의 정수 범위이며 양 끝값을 포함한다.
즉, 서로게이트를 제외한 코드 포인트입니다. 이는 UTF-8에서 중요해집니다.
UTF-16과 마찬가지로 가변 폭 문자 인코딩입니다. 웹 API, 특히 JSON에서 흔히 사용되며 Unix 시스템에서는 종종 기본값입니다.
여기서 Unicode 코드 포인트는 바이트 시퀀스로 표현됩니다. 필요한 바이트 수는 코드 포인트의 범위에 따라 달라지며 1바이트에서 4바이트 사이입니다. 코드 포인트와 UTF-8 사이의 전체 비트 변환은 다음 표에 나와 있습니다(Wikipedia에서 채택).
| 첫 코드 포인트 | 마지막 코드 포인트 | 바이트 1 | 바이트 2 | 바이트 3 | 바이트 4 |
|---|---|---|---|---|---|
| U+00 0 0 | U+00 7 F | 0 xxx xxxx | |||
| U+0 0 8 0 | U+0 7 F F | 110 xxx xx | 10 xx xxxx | ||
| U+0 8 0 0 | U+F F F F | 1110 xxxx | 10 xxxx xx | 10 xx xxxx | |
| U+0 1 0 0 0 0 | U+1 0 F F F F | 11110 x xx | 10 xx xxxx | 10 xxxx xx | 10 xx xxxx |
여기서는 서로게이트와 다른 기법을 볼 수 있습니다. UTF-8은 첫 바이트에 매직 비트를 사용하여 코드 포인트로 변환하기 위해 총 몇 바이트를 읽어야 하는지 알립니다.
UTF-8의 주목할 만한 속성은 다음과 같습니다.
U+D800부터 U+DFFF의 Unicode 코드 포인트는 잘못된 바이트 시퀀스로 간주됩니다.
위의 인코딩을 바탕으로 앞의 표를 다시 살펴보겠습니다.
| 문자 | 코드 포인트 | 16진 UTF-8 | 16진 UTF-16 | 16진 UTF-32 |
|---|---|---|---|---|
| a | U+0061 | 61 | 0061 | 00000061 |
| b | U+0062 | 62 | 0062 | 00000062 |
| 쟬 | U+C7EC | ec 9f ac | c7ec | 0000c7ec |
| 🇯 | U+1F1EF | f0 9f 87 af | d83c ddef | 0001f1ef |
| 🇵 | U+1F1F5 | f0 9f 87 b5 | d83c ddf5 | 0001f1f5 |
| 🇯🇵 | U+1F1EF, U+1F1F5 | f0 9f 87 af, f0 9f 87 b5 | d83c ddef, d83c ddf5 | 0001f1ef, 0001f1f5 |
관심 있는 독자는 이 값들을, 적어도 UTF-8과 UTF-16에 대해서는, 직접 검증해도 좋습니다.
이제 다음을 이해했습니다.
Unicode Scalar Values = Unicode Code Points - surrotages).‘문자’의 정의로 돌아가면 이제 혼란을 알 수 있습니다.
이로 인해 **‘자소 클러스터’**라는 또 다른 정의가 생겼습니다. 이는 문자, 단어, 문장 사이의 경계를 결정하는 Unicode Standard Annex #29에 명시되어 있습니다. 역시 매우 기술적이지만, ‘사용자에게 보이는 문자’에 훨씬 가깝습니다.
이제 Unicode 코드 포인트가 무엇인지 알았으므로, Haskell String 유형에는 본질적으로 _텍스트 인코딩_이 없다는 점도 이해합니다. 이는 그 코드 포인트들의 연결 리스트일 뿐입니다. 실제로는 Int의 부분집합입니다. 이는 UTF-8에서 UTF-16으로 변환하는 경우처럼 인코딩 간 변환의 중간 표현으로는 좋은 속성일 수 있습니다.
하지만 다음 이유로 String 유형의 기본값으로는 의문스럽습니다.
Char마다 thunk가 있는 연결 리스트의 오버헤드를 갖습니다. Data.String의 haddock 문서에서 더 자세히 다룹니다.String -> Text에서 문제가 발생합니다. Unicode 스칼라 값이나 어쩌면 자소 클러스터였어야 합니다.안타깝게도 Haskell 표준에 정의되어 있고 태초부터 존재했으므로, 영원히 없앨 수 없을 것입니다.
이 유형은 작은 프로젝트, 프로토타입, 헬로 월드, 그리고 일부 알고리즘의 중간 표현에만 사용해야 합니다.
Char/String의 Show 인스턴스는 ASCII 밖 범위의 Unicode 코드 포인트 값을 10진수로 출력합니다.
ghci> "a"
"a"
ghci> "쟬"
"\51180"
Show는 디버깅을 위한 것이므로 괜찮아 보입니다. 하지만 이 동작은 이전에 이의를 제기받았습니다. 제안: showLitChar(및 show @Char)는 읽을 수 있는 Unicode 문자를 이스케이프하지 않아야 한다.
이 절에서는 각각의 문자열 유사 유형과 그 속성 및 사용 사례를 살펴봅니다. String은 이미 논의했으며 권장하지 않으므로 여기서는 생략합니다.
올바른 Unicode 텍스트
바이트 시퀀스
플랫폼 API 차이를 다루는 바이트 시퀀스
FFI 유형
파일 경로 유형(단지 타입 동의어)
파일 경로를 더 깊이 파고들면 강한 타입의 파일 경로처럼 실제로 더 많은 유형이 있습니다. 하지만 이는 범위 밖입니다.
무엇이 필요한지 확실하지 않다면 GHC와 함께 제공되는 text 패키지의 Text가 대체로 적합합니다. 이 유형은 사람이 읽을 수 있는 Unicode 텍스트를 위한 것이며 필요한 모든 기본 기능을 갖추고 있습니다. 실제로 API는 stripPrefix, toLower 같은 함수를 포함하여 String의 API보다 더 완전합니다.
Text는 버전 2.0부터 내부적으로 UTF-8 인코딩 바이트 배열을 사용하며, 2.0 이전에는 UTF-16을 사용했습니다. 따라서 항상 유효한 Unicode가 보장됩니다.
엄격형 Text의 현재 정의는 다음과 같습니다(2.1.1 기준).
-- | A space efficient, packed, unboxed Unicode text type.
data Text = Text
{-# UNPACK #-} !A.Array -- ^ bytearray encoded as UTF-8
{-# UNPACK #-} !Int -- ^ offset in bytes (not in Char!), pointing to a start of UTF-8 sequence
{-# UNPACK #-} !Int -- ^ length in bytes (not in Char!), pointing to an end of UTF-8 sequence
여기서 볼 수 있듯, 이 유형은 많은 연산에서 불필요한 memcpy를 피하기 위해 효율적인 슬라이싱을 허용합니다. 예를 들어 init과 tail은 시간과 공간이 _O(1)_입니다. splitAt은 공간이 _O(1)_이지만 시간은 _O(n)_입니다. UTF-8이 오프셋 계산을 복잡하게 만들기 때문입니다. Unicode 코드 포인트 인코딩은 UTF-8에서 1바이트에서 4바이트 사이일 수 있다는 점을 기억하세요.
이에 대해서는 슬라이싱 가능과 불가능에서 더 설명합니다.
지연형 Text 변형은 다음과 같습니다.
data Text = Empty
| Chunk {-# UNPACK #-} !T.Text Text
이는 리스트와 같은 구조이므로, 잠재적으로 상수 공간에서 스트리밍할 수 있고 분할/슬라이싱 후 GC가 사용하지 않는 청크를 정리할 수 있습니다.
Text는 서로게이트를 표현할 수 없습니다. Unicode 스칼라 값의 시퀀스입니다. 예를 들어 pack을 사용할 때 잘못된 값은 교체 문자 U+FFFD로 조용히 변환됩니다. 이것이 문제가 아니라고 생각할 수 있습니다. 하지만 실망시켜 드리겠습니다. String이 서로게이트를 허용하는 데는 이유가 있습니다. PEP-383입니다. 이는 끔찍한 것이며 base가 사용합니다. Unix에서 base는 파일 경로에 대해 왕복 가능한 인코딩을 고르기 위해 getFileSystemEncoding과 mkTextEncoding을 사용합니다. 예를 들어 로캘이 en_US.UTF-8을 반환하면 PEP-383 기반의 UTF-8//ROUNDTRIP``TextEncoding을 얻습니다. 잘못된 바이트는 왕복시키기 위해 특별한 표현, 즉 단독 서로게이트로 변환됩니다. 이는 제 블로그 Haskell에서 ‘FilePath’ 고치기에 설명되어 있습니다.
불변 조건:
U+FFFD 사용).유용한 경우:
그다지 유용하지 않은 경우:
엄격형 변형은 전체 내용을 메모리에 두어야 하므로, 지연형 변형은 스트리밍 및 점진적 처리에 유용합니다.
이는 많은 작은 텍스트 시퀀스를 위한 대안 Unicode 텍스트 유형입니다. text-short 패키지의 일부입니다. 정의는 다음과 같습니다.
newtype ShortText = ShortText ShortByteString
따라서 길이 또는 오프셋 필드가 없습니다. 이는 유효한 UTF-8이 보장된다는 점을 제외하면 비고정 ShortByteString과 같은 모든 속성을 갖는다는 뜻입니다.
불변 조건:
U+FFFD 사용).유용한 경우:
그다지 유용하지 않은 경우:
Text를 기대하는 text-icu 패키지와 함께 사용이는 GHC와 함께 제공되는 bytestring 패키지의 저수준 유형입니다. 단지 바이트 시퀀스일 뿐이며 인코딩 정보를 지니지 않습니다. 고정 메모리를 사용합니다(고정과 비고정 절 참조). 따라서 FFI를 다룰 때 복사가 필요하지 않습니다. FFI와 상호작용할 때 더 바람직한 경우도 많습니다. GHC 사용자 가이드를 참조하세요.
ByteString은 상당히 효율적이고 큰 API를 갖지만, Unicode나 다른 텍스트 형식을 모르므로 텍스트 처리 기능은 당연히 부족합니다. 대부분의 연산은 Word8 경계에서 작동합니다.
엄격형 ByteString의 정의는 다음과 같습니다(0.12.1.0 기준).
data ByteString = BS {-# UNPACK #-} !(ForeignPtr Word8) -- payload
{-# UNPACK #-} !Int -- length
이는 Text와 유사하게 포인터 연산과 길이 필드를 통해 메모리 복사 없이 슬라이싱할 수 있게 합니다. Unicode가 아니라 Word8 경계를 다루므로 splitAt 같은 연산은 시간과 공간이 _O(1)_입니다. 포인터를 전진시키기만 하면 되므로 오프셋 필드도 필요하지 않습니다.
그리고 지연형 대응물은 지연형 Text와 비슷합니다.
data ByteString = Empty
| Chunk {-# UNPACK #-} !S.StrictByteString ByteString
Data.ByteString.Char8라는 API 변형은 연산이 Char 경계에서 작동하도록 합니다. 하지만 실제로 모든 Char를 8비트로 잘라내므로 초보자에게 오해를 줄 수 있습니다. 무엇을 하는지 알고 있지 않다면 피해야 합니다. 아마도 사용할 인코딩을 지정할 수 있는 디코딩 라이브러리가 필요할 가능성이 큽니다. 예를 들어 bytestring-encoding입니다.
또한 많은 작은 ByteString에서 고정 메모리는 메모리 단편화를 일으킬 수 있습니다. 이는 Haskell에서 ‘FilePath’ 고치기에서도 논의됩니다. 다음에 다룰 ShortByteString이 대안 유형입니다.
불변 조건:
유용한 경우:
그다지 유용하지 않은 경우:
지연형 변형은 다시 말해, 엄격형 변형이 전체 내용을 메모리에 두어야 하므로 스트리밍 및 점진적 처리에 유용합니다.
이 유형도 bytestring 패키지에 속하며 Data.ByteString.Short에 있습니다.
0.11.3.0부터 ByteString과 같은 API를 가지므로 대체품으로 사용할 수 있습니다. 주요 차이점은 보통 _비고정 메모리_를 기반으로 하므로 힙 단편화를 일으키지 않는다는 점입니다. 내부 API를 통해 고정으로 생성할 수 있지만, splitAt 같은 슬라이싱 연산은 비고정 바이트 문자열을 반환합니다.
0.12.1.0 기준 정의는 다음과 같습니다.
newtype ShortByteString =
ShortByteString
{ unShortByteString :: ByteArray
}
따라서 Unix 파일 경로 같은 것에 적합합니다. 하지만 나중에 더 나은 파일 경로 유형을 살펴보겠습니다.
이름은 약간 오해의 소지가 있을 수 있습니다. 엄격성을 개의치 않는다면, 즉 전체 내용이 항상 메모리에 있다면, 대용량 데이터에도 충분히 사용할 수 있습니다. 하지만 이 유형은 Text 및 ByteString과 달리 슬라이싱을 허용하지 않으므로, 많은 연산이 memcpy를 일으킵니다. 반면 오프셋이나 길이 필드가 필요 없으므로 예를 들어 Text와 비교해 적어도 2워드를 절약한다는 장점이 있습니다.
유사하지만 슬라이싱 기능이 있는 유형이 필요하다면 Bytes를 사용하세요.
C FFI와 연결하면 고정 메모리가 필요하므로 역시 메모리 복사가 발생합니다.
지연형 변형은 없습니다.
불변 조건:
유용한 경우:
memcpy가 발생하지만)그다지 유용하지 않은 경우:
이 유형은 byteslice 패키지의 것이며 Data.Bytes에 있습니다. GHC와 함께 제공되지 않습니다.
본질적으로 0복사 슬라이싱(init, splitAt 등)을 제공하는 ShortByteString입니다. 고정 또는 비고정 바이트 시퀀스로 생성할 수 있고, 일반적인 모든 연산은 그 불변 조건을 유지합니다.
0.2.13.2 기준 정의는 다음과 같습니다.
data Bytes = Bytes
{ array :: {-# UNPACK #-} !ByteArray
, offset :: {-# UNPACK #-} !Int
, length :: {-# UNPACK #-} !Int
}
이는 Text 유형과 정확히 같은 정의입니다. 그러나 UTF-8을 유지하지 않습니다. ShortByteString처럼 ByteArray를 사용합니다. 하지만 ShortByteString과 비교하면 메모리 오버헤드가 3워드 더 있습니다.
API는 ByteString 및 ShortByteString으로 변환할 수 있게 합니다. 고정 또는 비고정 여부와 슬라이싱 여부에 따라 이들 역시 0복사 연산일 수 있습니다.
Data.Bytes.Chunks에는 Chunks라는 또 다른 변형이 있습니다.
data Chunks
= ChunksCons {-# UNPACK #-} !Bytes !Chunks
| ChunksNil
이는 지연형 Text의 정의와 상당히 비슷하지만, 이 유형은 전혀 지연형이 아닙니다. 값과 재귀 모두에 bang 패턴이 있으므로 척추 엄격형입니다.
Chunk 유형의 실질적인 사용 사례는, 예를 들어 파일을 점진적으로 읽기 때문에 ByteArray를 계속 덧붙이는 오버헤드를 피하고 싶은 경우입니다.
불변 조건:
유용한 경우:
그다지 유용하지 않은 경우:
이들은 비교적 새로운 유형이며 Abstract FilePath 제안의 사용자 공간 구현 일부로서 filepath-1.4.100.0에 처음 추가되었습니다. 자세한 내용은 여기에 있습니다.
filepath-1.5.0.0부터 이 유형들은 os-string 패키지라는 새 위치로 옮겨졌습니다.
이 유형들은 운영 체제 API를 다룰 때 플랫폼 차이와 그 인코딩을 추상화하기 위한 것입니다. Rust 유형 OsString과 유사하지만 구현은 상당히 다릅니다.
단순화한 Haskell 정의는 다음과 같습니다.
-- | Commonly used Windows string as wide character bytes.
newtype WindowsString = WindowsString ShortByteString
-- | Commonly used Posix string as uninterpreted @char[]@ array.
newtype PosixString = PosixString ShortByteString
-- | Newtype representing short operating system specific strings.
--
-- Internally this is either 'WindowsString' or 'PosixString',
-- depending on the platform. Both use unpinned
-- 'ShortByteString' for efficiency.
newtype OsString = OsString
#if defined(mingw32_HOST_OS)
WindowsString
#else
PosixString
#endif
볼 수 있듯이 Unix에서는 기본적으로 Word8 시퀀스(char[])를 다루지만, Windows에서는 Word16 시퀀스(wchar_t*)를 다룹니다.
생성자는 내부용이며 CPP 덕분에 OsString에서 잘못된 플랫폼을 패턴 매칭하는 것은 불가능합니다.
OsString은 ByteString처럼 풍부한 API를 제공합니다.
이를 통해 unix, Win32 같은 패키지는 운영 체제 API에서 받은 바이트를 변환, 디코딩 또는 다른 방식으로 왕복 처리하지 않는 String의 대안을 제공할 수 있습니다. 바이트는 변경되지 않습니다. 예를 들면 다음과 같습니다.
동시에 OsString을 사용하여 안전하고 플랫폼 독립적인 코드도 작성할 수 있습니다. 예를 들면 다음과 같습니다.
이 전략은 파일 경로에 사용되었습니다. unix 패키지는 PosixString을, Win32 패키지는 WindowsString을, 플랫폼 독립적인 directory 및 file-io 패키지는 Unix와 Windows API를 결합한 OsString을 사용합니다. 예제와 API 설명을 포함한 자세한 정보는 여기에서 찾을 수 있습니다.
이는 파일 경로에 한정되지 않으며 환경 변수, 프로그램 인수 및 운영 체제 API의 다른 요소를 다루도록 확장될 수 있습니다. 항상 String보다 안전하고 ByteString보다 타입 안전합니다.
불변 조건:
유용한 경우:
그다지 유용하지 않은 경우:
이들은 OsString, PosixString, WindowsString과 동등하며 filepath 패키지의 1.4.100.0부터 포함되었습니다. 단지 타입 동의어입니다.
-- | FilePath for Windows.
type WindowsPath = WindowsString
-- | FilePath for posix systems.
type PosixPath = PosixString
-- | Abstract filepath, depending on current platform.
-- Matching on the wrong constructor is a compile-time error.
type OsPath = OsString
새 파일 경로 API에서는 가능할 때마다 이를 사용하세요. 자세한 내용은 Haskell 파일 경로 고치기 블로그 글을 참고하세요.
이들은 base의 일부이며 저수준 FFI 유형입니다.
정의는 매우 단순합니다.
-- | A C string is a reference to an array of C characters terminated by NUL.
type CString = Ptr CChar
-- | A string with explicit length information in bytes instead of a
-- terminating NUL (allowing NUL characters in the middle of the string).
type CStringLen = (Ptr CChar, Int)
haddock도 예상 속성을 설명합니다.
흥미로운 경계 사례로, ByteString에서 CString으로 변환하고 ByteString에 NUL 바이트가 있으면 useAsCString은 바이트를 과할당합니다.
useAsCString :: ByteString -> (CString -> IO a) -> IO a
useAsCString (BS fp l) action =
allocaBytes (l+1) $ \buf -> do
unsafeWithForeignPtr fp $ \p -> copyBytes buf p l
pokeByteOff buf l (0::Word8)
action (castPtr buf)
따라서 일부 경우에는 ByteString에 NUL 바이트가 있는지 확인하는 것이 타당할 수 있습니다.
여기서는 Haskell C FFI를 깊이 다루지 않겠지만, 이것이 말 그대로 유일한 올바른 사용 사례입니다. Haskell FFI에 관한 위키책 문서를 참고하세요.
이 유형은 레거시 파일 경로 유형이지만, 글을 쓰는 현재 생태계 전반에서 가장 널리 쓰입니다. GHC와 함께 제공되는 filepath 패키지의 일부입니다.
정의는 다음과 같습니다.
type FilePath = String
이는 파일 경로에 그다지 좋은 선택이 아닙니다. 대신 새 OsPath를 사용하세요.
Text와 ByteString의 지연형과 엄격형 변형 속성은 많은 Haskell 사용자에게 이미 분명할 수 있습니다.
지연형:
엄격형:
사람들은 지연 IO와 함께 지연형을 많이 사용합니다. 그러나 Builder를 사용하는 것도 또 다른 사용 사례입니다. Text와 ByteString 모두에 존재합니다.
일반적으로 스트리밍 라이브러리는 지연형 Text/ByteString보다 더 우아하고 성능이 좋은 대안일 수 있습니다. 이에 대해서는 스트리밍 장에서 나중에 다룹니다. 하지만 생태계의 많은 부분이 지연형을 사용하므로 실용적인 목적에서 여전히 중요합니다.
모든 문자열은 슬라이싱할 수 있지만, 일부 문자열은 데이터를 복사하지 않고 슬라이스할 수 있습니다. 예를 들어 Text와 ShortText를 비교해 보겠습니다.
data Text = Text
{-# UNPACK #-} !A.Array -- ^ bytearray encoded as UTF-8
{-# UNPACK #-} !Int -- ^ offset in bytes (not in Char!), pointing to a start of UTF-8 sequence
{-# UNPACK #-} !Int -- ^ length in bytes (not in Char!), pointing to an end of UTF-8 sequence
newtype ShortText = ShortText ShortByteString
예를 들어 Text 값에 splitAt을 호출하면 ‘오프셋’과 ‘길이’ 필드만 다른 두 새 Text 값을 돌려받지만, 같은 바이트 배열을 가리킬 수 있습니다. 많이 슬라이싱한다면, 특히 큰 데이터에서 많은 memcpy를 절약할 수 있습니다.
이는 슬라이싱에 두 가지 비용이 있다는 의미입니다. 첫째, 텍스트를 반으로 나누면 원래 바이트 배열의 메모리를 GC가 정리할 수 없습니다. 오프셋과 길이 필드만 변경했을 뿐 다른 것은 없기 때문입니다. 예를 들어 전체 데이터가 더는 필요하지 않을 때 Data.Text.copy를 통한 명시적 복사 연산을 사용하면 이를 완화할 수 있습니다.
둘째, ‘오프셋’과 ‘길이’ 필드에 대해 언박싱된 Int 두 개를 함께 지녀야 하며, 이는 2워드의 ‘오버헤드’입니다. 박싱 및 언박싱 유형에 관한 자세한 내용은 GHC 사용자 가이드를 참조하세요.
반대로 ShortText는 예를 들어 splitAt에서 두 새 바이트 배열을 만들고 데이터를 복사합니다. 여기서는 2워드 메모리 오버헤드를 절약할 뿐 아니라(오프셋 및 길이 필드 없음), 이 댓글에 설명된 것처럼 실행 시간의 간접 참조와 메모리 압박도 조금 줄어듭니다. 이는 CPU 캐시에 맞추는 데 유용할 수 있습니다.
따라서 유형 이름에서 알 수 있듯이, 단순화한 기준은 다음과 같을 수 있습니다.
결국 어느 것이 더 나은지는 프로파일링만이 실제로 알려줄 수 있습니다.
고정 메모리는 GC가 이동시킬 수 없다는 뜻입니다. 이는 FFI 경계에서 전체 비고정 데이터를 고정 메모리 영역으로 먼저 복사하지 않고 데이터를 외부 코드로 직접 옮기려 할 때 유용합니다. 하지만 GC가 데이터를 재배치할 수 없기 때문에 메모리 단편화도 생긴다는 뜻입니다. 고정 메모리를 가진 작은 데이터 조각이 많으면 힙이 심하게 단편화될 수 있습니다.
이 문제와 그로 인해 발생할 수 있는 문제는 Well-Typed 블로그의 메모리 단편화 이해하기에서 더 자세히 설명합니다.
메모리 단편화 문제는 원래 Abstract FilePath 제안, 그리고 이후 새 OsPath 유형의 동기 중 하나이기도 했습니다.
아래 표에 관한 몇 가지 참고 사항입니다.
ShortByteString). 하지만 내부 API를 통해 수동으로 고정 생성할 수 있습니다.메모리 오버헤드 측정은 최선의 노력에 따른 것이며 이 gist에 더 자세히 설명되어 있습니다.
| 유형 | 목적 | Unicode 인지 | 내부 표현 | 메모리 오버헤드 | 고정 | 슬라이싱 | FFI 적합 | 스트리밍 |
|---|---|---|---|---|---|---|---|---|
| String | 단순성 | 예 | Unicode 코드 포인트 리스트 | 문자당 4워드 + 1워드 | 아니오 | -- | -- | 예 |
| Text | 사람이 읽을 수 있는 텍스트 | 예 | UTF-8 바이트 배열 | 7워드 | 아니오 | + | - | 아니오 |
| Lazy Text | 사람이 읽을 수 있는 텍스트 | 예 | UTF-8 바이트 배열 청크 리스트 | 청크당 9워드 + 1워드 | 아니오 | + | - | 예 |
| ShortText | 짧은 사람이 읽을 수 있는 텍스트 | 예 | UTF-8 바이트 배열 | 4워드 | 아니오 | - | - | 아니오 |
| ByteString | 큰 바이트 시퀀스 | 아니오 | Word8 바이트 배열(포인터) | 10워드 | 예 | ++ | ++ | 아니오 |
| Lazy ByteString | 큰 바이트 시퀀스 | 아니오 | Word8 바이트 배열 청크 리스트 | 청크당 12워드 + 1워드 | 예 | ++ | ++ | 예 |
| ShortByteString | 짧은 바이트 시퀀스 | 아니오 | Word8 바이트 배열 | 4워드 | 아니오 | - | + | 아니오 |
| Bytes | 슬라이싱 가능한 ShortByteString / 고정 ByteString | 아니오 | Word8 바이트 배열 | 7워드 | 둘 다 | ++ | + | 아니오 |
| Chunks | ‘Bytes’와 유사하지만 점진적 생성용 | 아니오 | Word8 바이트 배열 청크 리스트 | 청크당 9워드 + 1워드 | 둘 다 | ++ | + | 아니오 |
| OsString | OS API와의 연결 | 아니오 | Word8 또는 Word16 바이트 배열 | 4워드 | 아니오 | - | + | 아니오 |
이제 서로 다른 유형을 알았으므로, 문자열을 생성하는 여러 방법을 간단히 살펴보겠습니다.
Haskell 보고서는 언어의 일부로 문자 및 문자열 리터럴을 정의합니다.
Haskell 파일에서 `