배경
백준 알고리즘 문제를 풀다가 빠른 입력 A+B 문제를 봤는데 readLine()으로는 Swift의 처리속도가 느리기때문에
시간초과가 뜨는 문제들이 있다는 걸 알았고 이를 해결하기 위해서 fread 방식으로 빠른입력 처리를 해둔 라이노님의 코드가 존재한다는 걸 알게 됐다.
https://gist.github.com/JCSooHwanCho/30be4b669321e7a135b84a1e9b075f88
그치만 이 코드 내가 쓴다고 이해한 게 맞는걸까??
그래서 오래 걸리지만 직접 공부해보기로 했다.
지금 2023년 10월 3일 15:39 인데 얼마나 걸릴지 모르겠다!
시작!
문제상황
코드를 보면 알겠지만 시작부터
- FileHandeler, FileHandeler.stardartInput
- UInt8
- buffer
- @inline 등등
비전공자인 나한테는 생소하고 직관적이지 못한 단어들..
이것부터 해결하기로 했다!
Uint? 버퍼? 스트림? 인라이닝?
Uint
먼저 버퍼의 타입은 UInt8 인데. UInt 타입은 뭘까?
개발하면서 오픈소스를 보다보면 종종 봤던 기억은 나는데.. 알아보려 했던 적이 거의 없는 것 같다.
위 표에서 보다시피 기본 Int8 타입은 음의 정수를 포함한 2^8 크기의 데이터를 표현할 수 있는 데이터 타입이다.
Uint 는 Unsigned Integer 8 bits의 약자로, Unsigned 라는 것은 음수를 가질 수 없다는 뜻이다.
즉, 0~255 사이의 정수를 가진다.
Stream (스트림)
- 스트림은 데이터의 연속적인 흐름을 뜻한다.
데이터를 조각조각 나눠서 순서대로 처리하거나 전송하는 방법이라고 할 수 있는데,
이름처럼 강물이 흐르는 모습을 생각해보면- 연속적으로 움직이고
- 단방향으로 움직인다
이처럼 데이터는 일련의 연속적인 바이트로 표현되고, 스트림을 통해 순차적으로 읽거나 쓸 수 있다.
- Input/Output (I/O)
스트림은 입력스트림과 출력스트림으로 나누어지고, 각각 데이터를 읽거나 쓰는 데에 사용된다.
우리가 분석하는 코드의 FileHandle이나 InputStream, OutputStream 등은 스트림을 사용하여 데이터를 순차적으로 읽거나 쓸 수 있게 해준다.
또, 네트워크 통신에서도 송수신 시 스트림을 사용한다.
Buffer (버퍼)
- 버퍼는 임시 저장공간으로 데이터를 일시적으로 보관하는 역할을 한다.
예시를 들자면 카페에서 에스프레소를 추출하면 이것을 바로 손님에게 전달하지 않고 테이크아웃잔에 담아둔다.
버퍼는 테이크아웃잔 이 바로 버퍼와 같은 역할이고 일시적으로 저장된다.
조금 더 기술적으로 설명하자면,
버퍼는 메모리의 연속적인 영역에서의 데이터를 저장하기 위해 할당된 임시 공간이다.
I/O 연산의 효율성을 향상시키기 위해 사용되고, 버퍼가 가득차거나 다른 특정조건이 충족되면 데이터가 한꺼번에 전송된다.
동영상 스트리밍 서비스에서 네트워크상황에 따라 데이터가 끊어지거나 지연되더라도, 버퍼링 덕분에 유저는 영상을 끊김 없이 볼 수 있다.
버퍼에 미리 데이터가 저장되어 있기 때문이다.
버퍼와 스트림의 관계?
버퍼는 스트림과 함께 사용되기도 하는데 예를 들어,
네트워크에서 데이터를 수신할 때, 데이터는 스트림을 통해서 도착하게되고, 일정량의 데이터가 도착하면 이 데이터는 버퍼에 저장된다. 이 버퍼가 가득차면 데이터가 처리된다.
그렇다면 버퍼는 무조건 클 수록 좋은 것인가?
결론부터 말하자면 그렇지는 않다.
필요한 데이터의 크기가 4바이트 인데, 그것보다 훨씬 크게 버퍼의 크기를 잡는 다면 메모리가 부족해지고,
반대로 버퍼의 크기가 데이터보다 작다면 버퍼오버플로우가 일어나 오류가 생길 수 있다. 따라서 이 버퍼의 크기를 정하는 것이 중요하다.
@inline(__always)
- Swift 에서 특정 메소드가 호출 될 때 그 내용을 다른곳에 복사해서 사용하는 것을 '인라이닝'이라고 한다. 함수의 오버헤드를 줄이기 위한 방법이라고한다.
- 오버헤드란 어떠한 작업을 수행하는 데 필요한 추가적 리소스나 시간을 뜻하는데, 인라이닝을 사용하면 이를 줄일 수 있다.
중간 정리
- Swift의 readLine은 한 줄씩 읽어오는데, 많은 데이터가 들어올 때 한 줄씩 보다는 한꺼번에 읽어들인 후 한 번에 처리하는 것이 당연히 효율적이다 (=빠르다)
코드 분석
# 라이노님의 오픈소스 - FileIO
final class FileIO {
private var buffer:[UInt8]
private var index: Int
init(fileHandle: FileHandle = FileHandle.standardInput) {
buffer = Array(fileHandle.readDataToEndOfFile())+[UInt8(0)] // 인덱스 범위 넘어가는 것 방지
index = 0
}
Initializer init(fileHandle: FileHandle = FileHandle.standardInput)
FileHandle은 파일이나 소켓,파이프, 디바이스와 관련된 데이터와 같은 저수준의(저수준이란?) I/O 리소스에 접근하기 위한 객체이다. 디폴트 값으로 FileHandle.standardInput이 주어지는데, 이는 사용자의 입력을 받는 표준 입력 스트림이다.
buffer
fileHandle.readDataToEndOfFile()를 통해 fileHandle에서 데이터를 읽어와 바이트 배열로 변환하고 그 크기만큼 buffer의 크기를 정해줬다.
그 후 [UInt8(0)]를 추가하는데, 이는 버퍼의 끝에 0 값을 추가하여 인덱스 범위 초과를 방지하는 역할을 한다.
이 방식에 대해서 조금 더 자세히 설명하자면,
UInt8(0)의 값은 '0'을 가리킨다.
데이터를 읽을 때 이 값을 만나면 데이터의 끝에 도달했다고 판단하거나, 버퍼의 끝을 넘어서 읽지 않게 한다.
이를 센티넬 값이라고 한다고 하는데.. 나중에 자세히 찾아봐야겠다.
그리고, readDataToEndFile()은 파일끝까지 또는 최대가능한 메모리까지 읽어들인다는 뜻인데, 끝까지 읽어들이면 FileIO()만 생성해도 입력을 하면 아무 반응이 없는 것 처럼 나온다. 즉, 생성자에게 계속 입력을 읽어들이라고 하기때문에 FileIO() 생성 이후 작성된 코드들은 실행이 되지 않는다.
index
버퍼의 index를 나타내는 값
@inline(__always) private func read() -> UInt8 {
defer { index += 1 }
return buffer.withUnsafeBufferPointer { $0[index] }
}
@inline(__always) func readInt() -> Int {
var sum = 0
var now = read()
var isPositive = true
while now == 10 || now == 32 { now = read() } // 공백과 줄바꿈 무시
if now == 45{ isPositive.toggle(); now = read() } // 음수 처리
while now >= 48, now <= 57 {
sum = sum * 10 + Int(now-48)
now = read()
}
return sum * (isPositive ? 1:-1)
}
defer
defer 는 현재 스코프가 종료되기 직전 실행되는 코드로, 여기서는 read 함수가 return 되기 전에 index를 증가시켜서 다음 버퍼를 읽어들일 수 있도록 보장하기 위해서 사용한듯하다.
buffer.withUnsafeBufferPointer
withUnsafeBufferPointer 는 안전하지 않은 접근을 허용한다. 성능최적화를 위한 방법으로, 직접 메모리에 적급하게 된다.
여기서는 { $0[index] } 클로저 내에서 현재 인덱스에 해당하는 바이트를 반환한다.
read()
- 이 함수는 내부 버퍼에서 한 바이트를 읽고 해당하는 바이트를 반환하기 위한 함수이다.
defer와 클로저의 코드는 위에서 설명한 것과 같다.
readInt()
- readInt()는 실질적으로 우리가 사용할 정수들을 return 한다.
readInt()가 불리는 시점에는 읽어들인 버퍼가 꽉찼거나, 특정한 행위를 통해 종료된 경우이다. - 버퍼에 담긴 바이트 단위들을 읽어들여서 우리가 원하는 정수로 변환해야 한다.
123을 입력했다면,
버퍼에는 1에 해당하는 10진수, 2에 해당하는 10진수, 3에 해당하는 10진수가 담긴다.
즉 1은 정수 1이 아닌 문자 1이다.
123을 하나씩 읽어들여 아스키코드표에서 찾아보면 10진수로 각각
49, 50, 51 이다.
sum은 누적된 숫자 값을 저장하며, now에는 현재 바이트 값이 저장된다.
readInt() 에서는 버퍼(now)에 담긴 10진수를 read()를 통해 계속 읽어들인다.
while now == 10 || now == 32 { now = read() }
백준에서는 띄어쓰기, 개행문자 로 데이터를 구분한다고 하는데, 이는 각각 아스키코드로
공백(32)과 개행,줄바꿈(10), 음수(-)는 45로 되어 있다.
이를 사용하여 입력된 값을 정수로 변환한다.
readString()
- 버퍼에서 문자열을 return 한다. 마찬가지로,
while now != 10 && now != 32 && now != 0 { ... }
버퍼를 읽어들이고, 공백과 줄바꿈을 무시하고
now != 0
센티넬 값의 검증도 하고나서 (= 문자열의 끝을 만날때까지) 버퍼를 읽는 과정을 계속 반복한다.
str += String(bytes: [now], encoding: .ascii)!
여기서는 현재 now 에 있는 바이트 값을 ASCII 값을 이용해서 문자로 변환시킨다.
이렇게 끝까지 읽어들인 후 문자열을 반환하는 원리이다.
느낀점 및 추가 의문
솔직히 이러한 코드를 직접 구현할 수 있을 때 까지 얼마나 시간이 걸릴지 모르겠지만
CS의 부족과 이 때문에 코딩을 할 때 생각의 폭이 많이 좁아 질 수 있음을 느꼈다.
아직 갈 길이 멀지만 절망적이고 부정적인 생각이 들수록...
'그냥 해야할 일을 하자' 가 내 좌우명이다.
오늘도 하나 더 알았다!
- 의문
- 아스키코드를 왜 사용하지?
- 아스키코드의 원리 및 기원?
지금 포스팅을 마치는 시간은
2023.10.3 17:37 이다
딱 두시간 정도 걸린 것 같다.
참조링크
'Swift' 카테고리의 다른 글
[Swift / iOS ] 디자인 패턴 (MVVM, MVP, MVI에 대한 모든 것 ) - Hyeon's iOS 개발 (0) | 2023.12.14 |
---|---|
[Swift] 고차함수 Flatmap, CompatMap, map 의 사용 (0) | 2023.11.21 |
[Swift] ClosedRange (1) | 2023.10.23 |
[Swift]iOS 17 업데이트로 인한 Apple의 URL Parsing 변경 이슈 (1) | 2023.10.06 |
[Swift] 정렬 메소드 sort( ), sorted( ) (0) | 2023.05.14 |