문학동네판

20.07.19 ~ 08.31(43일)

440쪽

 

롤리타의 작가인 블라디미르 나보코프의 장편 역작 소설. 읽기 대단히 어려운 편이. 덕분에 400쪽대의 장편소설이지만 40일 넘게 걸렸다. 그럼에도 읽을만한 가치가 있는 대단한 작품이다. 또한 갖가지 장치들이 많은데, 혹시라도 이러한 장치들을 전혀 모른 상태로 읽고 싶다면, 아래는 스포일러가 될 수 있다.

 

더보기

이 책은 기본적으로 머리말 / 시 "창백한 불꽃" / 주석 / 색인으로 구성되어 있다. 목차를 읽는 독자라면 이미 여기에서부터 이상함을 느낄 수 있는데, 시의 분량에 비해 주석이 너무나도 길기 때문이다. 더군다나 머리말을 읽고 나면 더더욱 혼란하다. 머리말에서 우선 시인과 주석자에 대해 간략히 소개를 한다. 시인은 이미 죽었고, 주석자는 그의 절친으로서 다른이들을 뿌리치고 이 시를 출판하고 주석을 쓰게 되었다고. 그런데 주석자가 지나치게 자신만만하질 않나, 자꾸 괄호 참조로 어디를 참조하라 하질 않나, 끝내 이런말도 한다. "좋든 나쁘든 최후의 말을 하는 이는 바로 주석자다" 또한 독자들에게 어떤 식으로 읽으라고 다양하게 권하는데, 시를 먼저 읽고 주석을 읽거나, 시와 주석을 번갈아 가며 읽거나 등등 다양한 선택지를 준다. 주석 속에도 괄호 참조가 끝도 없이 있어, 독자는 이 책을 무궁무진한 방법으로 읽어나갈 수 있다. 

 

시 자체는 큰 감상은 없었다. 그런데 주석에서는 시 자체의 이야기도 하지만 뭔지 모를 "젬블라 왕국"에서 도망쳐나온 왕과 그를 암살하려는 국왕 시해자의 이야기를 자꾸 꺼낸다. 게다가 주석에서 틈틈히 언급되는 주석자와 시인간의 관계도 대단히 이상하다. 국왕의 이야기, 암살자의 이야기, 주석자의 이야기 총 3가지가 시의 해석은 뒷전으로 두고 주석의 메인 내용을 차지하며 번갈아가면서 전개되는 식이다. 주석을 읽어나갈 수록, 주석자가 미치광이라는 점이 점점 명백해져가는데, 결국 전작인 롤리타와 마찬가지로 서술자를 믿을 수 없게 된다. 하지만 3개의 이야기가 나름대로 흡입력이 있어서 계속 읽어나가게 되는데, 결국에는 하나의 이야기로 이어지고, 거기서 약간의 후기와 더불어 주석은 끝난다.

 

주석 이후에 색인 부분이 있는데, abc순서로 지명이나 인물등을 설명한다. 이 색인도 굉장히 독특한데, 자꾸 어디를 참조하라 하고, 본문에 언급되지 않은(혹은 내가 놓친) 인물이나 지명도 계속 나온다. 무엇보다 주석자 본인과 시인의 색인이 기묘하다. 마지막에 z로 시작하는 젬블라(Zembla)의 색인으로 끝난다 : "머나먼 북쪽의 나라".

 

 

전반적으로 나는 이 소설의 50%도 채 이해하지 못한것 같다. 독서 외적인 요인으로 집중력이 떨어진 상태로 읽어서이기도 하지만, 소설 자체가 대단히 해석의 여지가 많고 쉽게 읽기는 어렵다. 그럼에도 불구하고 천재성을 느낄 수 있는 매우 뛰어난 작품이란 점은 충분히 느껴졌다. 게임적인 요소가 굉장히 많고 찾아낼만한 떡밥들이 대단히 많은 작품인데, 이 부분에 대해 크게 이해는 못하고 넘어가서 아쉽다. 그나마 문학동네판의 역자 해설이 잘 정리되어 있어 어느정도는 이해할 수 있었다. 나중에 다시 읽어보면 또 느낌이 확 다를만한 작품. 

 

 

'' 카테고리의 다른 글

엠마 - 제인 오스틴  (0) 2020.11.22
여학생 - 다자이 오사무  (0) 2020.09.27
사양 - 다자이 오사무  (0) 2020.07.19
질병이 바꾼 세계의 역사 - 로날트 D. 게르슈테  (0) 2020.07.12
싯다르타 - 헤르만 헤세  (0) 2020.06.21

[0]

C에서 printf 함수가 호출되면, 어떤 과정을 걸쳐 콘솔에 프린트되는지 알아보자. 우선 printf는 stdio.h의 함수임을 상기할 필요가 있다. 이 라이브러리는 대부분의 운영체제에 C runtime이라는 이름으로 대부분 포함되어 있다. 이러한 라이브러리 코드들은 링커가 연결을 시켜주게 된다.(물론 이전에 컴파일되어 오브젝트 파일이 생성되는 게 먼저이다.) 여기에서는 링킹이나 컴파일 등은 제쳐두고, 순수하게 printf 함수 호출 시 실행되는 코드 플로우를 따라가 보도록 하자. 하드웨어 dependent한 부분은 최대한 제외하고, general한 부분만을 간략히 다루도록 하겠다.

 

[1]

printf가 호출되면 다음의 복잡한 매크로와 함께 _vfprintf_l()이 호출된다. 다음의 코드는 x86 windows 10의 C runtime stdio.h의 코드이다.

    _Check_return_opt_
    _CRT_STDIO_INLINE int __CRTDECL printf(
        _In_z_ _Printf_format_string_ char const* const _Format,
        ...)
    #if defined _NO_CRT_STDIO_INLINE
    ;
    #else
    {
        int _Result;
        va_list _ArgList;
        __crt_va_start(_ArgList, _Format);
        _Result = _vfprintf_l(stdout, _Format, NULL, _ArgList);
        __crt_va_end(_ArgList);
        return _Result;
    }
    #endif

vfprintf_l()이 호출됨을 알 수 있다. 운영체제에서 기본으로 설치된 C runtime마다 다르겠지만 stdio.h에서 보통 몇 단계의 함수호출이 더 이루어지게 된다. 여기에서는 생략한다.

 

[2]

runtime 이후에는 운영체제에 따라 시스템콜이 호출된다. 시스템콜이 호출 시 보통 커널 컨텍스트로 스위칭이 이루어지고(이 부분은 운영체제 구현에 따라 상이할 수 있다.) 커널 코드가 실행된다. 여기에서는 x86 리눅스 커널을 기반으로 함수 호출 플로우를 따라가 보자. 우선 strace로 어떤 시스템콜이 호출되는지 알아보자.

#include <stdio.h>

int main(void){
    printf("hello\n");
}
$ strace ./a.out 
execve("./a.out", ["./a.out"], 0x7fffe5eeb5f0 /* 18 vars */) = 0
brk(NULL)                               = 0x7fffe7f2f000
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=31764, ...}) = 0
mmap(NULL, 31764, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f8ba6816000
close(3)                                = 0
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\260\34\2\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=2030544, ...}) = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f8ba6810000
mmap(NULL, 4131552, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f8ba6000000
mprotect(0x7f8ba61e7000, 2097152, PROT_NONE) = 0
mmap(0x7f8ba63e7000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1e7000) = 0x7f8ba63e7000
mmap(0x7f8ba63ed000, 15072, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f8ba63ed000
close(3)                                = 0
arch_prctl(ARCH_SET_FS, 0x7f8ba68114c0) = 0
mprotect(0x7f8ba63e7000, 16384, PROT_READ) = 0
mprotect(0x7f8ba6c00000, 4096, PROT_READ) = 0
mprotect(0x7f8ba6627000, 4096, PROT_READ) = 0
munmap(0x7f8ba6816000, 31764)           = 0
fstat(1, {st_mode=S_IFCHR|0660, st_rdev=makedev(4, 1), ...}) = 0
ioctl(1, TCGETS, {B38400 opost isig icanon echo ...}) = 0
brk(NULL)                               = 0x7fffe7f2f000
brk(0x7fffe7f50000)                     = 0x7fffe7f50000
write(1, "hello\n", 6hello
)                  = 6
exit_group(0)                           = ?
+++ exited with 0 +++

 

여러 시스템콜들이 호출되고 있지만, 가장 핵심인 "hello"를 출력하는 시스템콜은 write()임을 확인할 수 있다. 여기에서부터는 리눅스 커널에서 실행 흐름을 따라가 보도록 하자. 커널 버전은 5.7이다. 

 

[3]

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
		size_t, count)
{
	return ksys_write(fd, buf, count);
}

우선 write 시스템 콜에서는 ksys_write()를 호출할 뿐 별다른 역할은 하지 않는다.

ssize_t ksys_write(unsigned int fd, const char __user *buf, size_t count)
{
	struct fd f = fdget_pos(fd);
	ssize_t ret = -EBADF;

	if (f.file) {
		loff_t pos, *ppos = file_ppos(f.file);
		if (ppos) {
			pos = *ppos;
			ppos = &pos;
		}
		ret = vfs_write(f.file, buf, count, ppos);
		if (ret >= 0 && ppos)
			f.file->f_pos = pos;
		fdput_pos(f);
	}

	return ret;
}

ksys_write()에서부터 중요한 동작들이 실행된다. 우선 fdget_pos()를 통해 파일 디스크립터 fd를 구조체의 형태로 가져온다.(fd구조체는 커널 코드 내에서만 사용되고, 유저 애플리케이션에는 integer 형태로 fd를 반환한다.) file_ppos() 함수를 호출하여 파일 포인터의 현 위치를 읽어오고, vfs_write()로 실제 write를 수행한 뒤에, 변경된 위치를 fdput_pos()로 저장한다. 다음으로 vfs_write()를 보자.

ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
{
	ssize_t ret;

	if (!(file->f_mode & FMODE_WRITE))
		return -EBADF;
	if (!(file->f_mode & FMODE_CAN_WRITE))
		return -EINVAL;
	if (unlikely(!access_ok(buf, count)))
		return -EFAULT;

	ret = rw_verify_area(WRITE, file, pos, count);
	if (!ret) {
		if (count > MAX_RW_COUNT)
			count =  MAX_RW_COUNT;
		file_start_write(file);
		ret = __vfs_write(file, buf, count, pos);
		if (ret > 0) {
			fsnotify_modify(file);
			add_wchar(current, ret);
		}
		inc_syscw(current);
		file_end_write(file);
	}

	return ret;
}

우선 f_mode를 체크하여 이 파일이 write 가능한 상태인지를 체크한다. rw_verify_area() 함수로 쓰기가 진행되는 영역이 안전한지 체크하고, __vfs_write()를 통해 write가 일어난다. 이때 __vfs_write() 전후로 file_start_write()와 file_end_write()를 통해 이 파일이 현재 write 작업 중임을 마킹하고 해제한다. 마지막으로 __vfs_write()를 보자.

static ssize_t __vfs_write(struct file *file, const char __user *p,
			   size_t count, loff_t *pos)
{
	if (file->f_op->write)
		return file->f_op->write(file, p, count, pos);
	else if (file->f_op->write_iter)
		return new_sync_write(file, p, count, pos);
	else
		return -EINVAL;
}

여기에선 단순하다. f_op에 등록된 각 디바이스의 write함수를 호출한다. 즉 여기서부터는 하드웨어 dependent한 구현의 코드가 들어가게 된다.

 

[참고]

https://www.maizure.org/projects/printf/

 

Tearing apart printf() – MaiZure's Projects

April 2018 If 'Hello World' is the first program for C students, then printf() is probably the first function. I've had to answer questions about printf() many times over the years, so I've finally set aside time for an informal writeup. The common questio

www.maizure.org

http://egloos.zum.com/rousalome/v/9993265

 

[리눅스커널][가상파일시스템] 파일 객체: write 연산 세부 동작 분석

파일 객체: write 연산 세부 동작 분석 유저 공간에서 write() 함수를 호출할 때 가상 파일시스템에서 어떤 흐름으로 파일 별 write 오퍼레이션을 수행하는지 살펴보겠습니다. 유저 공간에서 리눅스 �

egloos.zum.com

 

[0]

Rust에서는 기본적으로 오버로딩을 지원하지 않는다. 물론 generic type을 지원하기 때문에 함수 혹은 구조체가 여러 타입을 받도록 할 수는 있다. 하지만 타입 별로 동작을 다르게 하기 위해서는 trait object를 사용해야 한다.

 

[1]

trait object는 사실 처음에 굉장히 어색하게 느껴지는 개념이다. 아무래도 이름 때문에 그런데, 조금 더 직관적으로 표현하자면 어떤 trait을 구현(impl)한 구조체라고 할 수 있다.

struct Foo{
    x:i32,
}

trait Simple{
    fn check(&self);
}

impl Simple for Foo{
    fn check(&self){
        println!("check : {}",self.x);
    }
}

예를 들어 위의 Foo 구조체는 Simple trait을 구현하므로, Simple trait obejct로 취급될 수 있다.

 

[2]

이제 trait object를 이용해서 오버로딩을 구현하는 코드를 보자. 다음과 같이 Simple trait object를 매개변수로 받는 함수에 Foo 구조체를 넘겨줄 수 있다.

fn run(input: &dyn Simple){
    input.check();
}

fn main(){
    let obj = Foo{x:1};
    run(&obj);
}

dyn 키워드는 생략 가능하지만, 컴파일러에서는 가독성을 위해 붙일 것을 권장하고 있다. 아마도 dynamic dispatch의 약자일듯 싶다. 여기서 주의 깊게 봐야 할 점은 trait object를 직접 넘겨주는 것이 아니라 레퍼런스로 넘겨준다는 점이다. 그 이유는 &를 빼고 컴파일해보면 명확하게 알 수 있다. 

error[E0277]: the size for values of type `(dyn Simple + 'static)` cannot be known at compilation time
  --> src\main.rs:22:5
   |
22 |     run(&obj);
   |     ^^^ doesn't have a size known at compile-time
   |
   = help: the trait `std::marker::Sized` is not implemented for `(dyn Simple + 'static)`
   = note: to learn more, visit <https://doc.rust-lang.org/book/ch19-04-advanced-types.html#dynamically-sized-types-and-the-sized-trait>
   = note: all function arguments must have a statically known size
   = help: unsized locals are gated as an unstable feature

컴파일 타임에 파라미터의 사이즈를 알 수 없다고 에러가 발생한다. 같은 Simple trait object라고 하더라도  Foo struct가 아닌 다른 struct가 얼마든지 올 수 있으므로 사이즈를 알아낼 수 없기 때문이다. 따라서 레퍼런스나 Box 등의 스마트 포인터를 사용하여야 한다.

 

[3]

그럼 이를 이용해서 파라미터로 받은 타입에 따라 다르게 동작하는 오버로딩을 구현해보자. 여러 타입을 하기에는 귀찮으니 i32와 String 두 가지에 대해서만 구현해본다. 

trait Simple{
    fn check(&self);
}

impl Simple for i32{
    fn check(&self){
        println!("this is i32 : {}",self);
    }
}

impl Simple for String{
    fn check(&self){
        println!("this is String : {}",self);
    }
}

fn run(input: &dyn Simple){
    input.check();
}

fn main(){
    let num = 10 as i32;
    let my_string = "hello trait object!".to_string();

    run(&num as &dyn Simple);		// same as run(&num);
    run(&my_string as &dyn Simple);	// same as run(&my_string);
}
this is i32 : 10
this is String : hello trait object!

위와 같이 primitive type에 대해서도 trait을 구현하여 trait object로 취급하는 게 가능하다. 

 

[4]

trait object는 위에서 본 것처럼, 여러가지 타입을 하나의 trait object 타입으로 표현할 수 있게 해 준다. 때문에 오버로딩 외의 상황에서도 사용할 수 있다. 예를 들어 VecDeque를 사용한다고 할 때, trait object를 이용하여 여러 가지 타입이 하나의 큐에 들어가게 할 수 있다. enum을 통해서도 가능하지만, 이는 코딩 시에 알고 있는 타입에 대해서만 가능하다. api 유저가 정의한 구조체들을(즉 라이브러리 개발자 입장에서는 알 수 없는 타입들을) enum에 미리 포함시킬 수는 없기 때문이다. 

use std::collections::VecDeque;

fn main(){
    let mut queue:VecDeque<&dyn Any> = VecDeque::new();
    let num = 10 as i32;
    let my_string = "hello trait object!".to_string();

    queue.push_back(&num);
    queue.push_back(&my_string);
}

하지만 trait object를 이용하면 어떤 타입도(개발시에 미리 알지 못한 타입도 포함해서) 하나의 VecDeque에 넣을 수 있다. 단, 해당하는 타입이 Simple trait을 구현해야만 한다. 근데 api 유저에게 trait을 명시해줄 것을 요구하는 것도 피하고 싶다면?

 

[5]

rust std에서는 이를 위해 Any라는 trait을 제공한다. 이 trait은 'static lifetime을 가진 모든 타입에 대해 자동적으로 impl된다. Any가 어떻게 자동적으로 모든 타입에 대해 impl되는지와, 'static lifetime에 대해서는 다른 포스팅에서 다루도록 하겠다. 우선 지금은 lifetime을 유저가 따로 명시하지 않았으면, Any trait이 모두 적용된다고 생각해도 무방하다. 

use std::any::Any;
use std::collections::VecDeque;

struct Foo{
    x: i32,
}

fn main(){
    let mut queue:VecDeque<&dyn Any> = VecDeque::new();
    let num = 10 as i32;
    let my_string = "hello trait object!".to_string();
    let obj = Foo{x:10};

    queue.push_back(&num);
    queue.push_back(&my_string);
    queue.push_back(&obj);
}

Foo struct에서 impl Any를 명시하지 않았음에도, Any trait object로 취급되어 queue에 push가능함을 확인할 수 있다. 

 

Any trait은 또한 몇가지 더 유용한 기능을 제공하는데 다음의 코드를 보자. 

use std::any::Any;

fn check_if_string(input:&dyn Any)
{
    match input.downcast_ref::<String>() {
        Some(as_string) => {
            println!("This is string : {}", as_string);
        }
        None => {
            println!("not a string");
        }
    }
}

fn main(){
    let num = 10 as i32;
    let my_string = "hello trait object!".to_string();
    check_if_string(&num);
    check_if_string(&my_string);
}
not a string
This is string : hello trait object!

downcast_* 메소드를 통해 다운캐스팅을 할 수 있다. Any에서 다운캐스팅을 어떻게 구현하는지는 다른 포스팅에서 다루도록 하겠다.

 

[참고]

https://doc.rust-lang.org/book/ch17-02-trait-objects.html

 

Using Trait Objects That Allow for Values of Different Types - The Rust Programming Language

In Chapter 8, we mentioned that one limitation of vectors is that they can store elements of only one type. We created a workaround in Listing 8-10 where we defined a SpreadsheetCell enum that had variants to hold integers, floats, and text. This meant we

doc.rust-lang.org

 

 

'Computer Science > Rust' 카테고리의 다른 글

Rust의 Cell와 RefCell 코드 분석  (0) 2020.12.06
Unsafe Rust  (0) 2020.09.27
Rust의 async/await와 Future  (0) 2020.07.21
Rust의 Copy trait와 Clone trait  (2) 2020.06.30
Rust의 스마트 포인터  (0) 2020.05.20

+ Recent posts