crystal
CrystalDay 2

CrystalのKernel#putsの行く末を追う ver:0.24.0

More than 1 year has passed since last update.

こんにちは。
今日はCrystalのIOの実装について簡単に紹介していきます。
バッファリングまわりにも簡単に触れます。

Kernel#putsの行く末を追う

もっとも馴染み深いメソッドであるKernel#putsがどこにたどり着くのか追ってみましょう。

なんか前にだれかがやってた気がするけど資料みつけられなかったし、かなり前なのでちょっと変わっているだろうと思うのでやってみます。

Kernel#puts(*objects)

source: https://github.com/crystal-lang/crystal/blob/0.24.0/src/kernel.cr#L82

# Prints objects to `STDOUT`, each followed by a newline.
#
# See also: `IO#puts`.
def puts(*objects) : Nil
  STDOUT.puts *objects
end

STDOUT.puts に移譲している。

STDOUT

source: https://github.com/crystal-lang/crystal/blob/0.24.0/src/kernel.cr#L4

STDOUT = (IO::FileDescriptor.new(1, blocking: LibC.isatty(1) == 0)).tap { |f| f.flush_on_newline = true }

STDOUTIO::FileDescriptor のインスタンスのようだ。
IO::FileDescriptor#puts(*objects : _) は無いので、親クラスのIO#puts(*objects : _)が呼ばれる。

IO#puts(*objects : _)

source: https://github.com/crystal-lang/crystal/blob/0.24.0/src/io.cr#L265-L277

# Writes the given objects, each followed by a newline character.
#
# ```
# io = IO::Memory.new
# io.puts 1, '-', "Crystal"
# io.to_s # => "1\n-\nCrystal\n"
# ```
def puts(*objects : _) : Nil
  objects.each do |obj|
    puts obj
  end
  nil
end

引数毎にIO#putsを呼び出している。なんと、Crystalの型システムではここでオーバーロードも効くので文字列を渡した場合はオーバーロードされて IO#puts(String) が呼び出さるようだ。

IO#puts(String)

source: https://github.com/crystal-lang/crystal/blob/0.24.0/src/io.cr#L225-L238

# Writes the given string to this `IO` followed by a newline character
# unless the string already ends with one.
#
# ```
# io = IO::Memory.new
# io.puts "hello\n"
# io.puts "world"
# io.to_s # => "hello\nworld\n"
# ```
def puts(string : String) : Nil
  self << string
  puts unless string.ends_with?('\n')
  nil
end

# Writes a newline character.
#
# ```
# io = IO::Memory.new
# io.puts
# io.to_s # => "\n" # ```
def puts : Nil
  print '\n'
  nil
end

IO#<<(obj)に移譲している。
引数なしのputsは改行コードをIO#printに渡しているがIO#printIO#<<(obj)に移譲しているので結局そっちに行く。

IO::<<

source: https://github.com/crystal-lang/crystal/blob/0.24.0/src/io.cr#L181-L194

# Writes the given object into this `IO`.
# This ends up calling `to_s(io)` on the object. #
# ```
# io = IO::Memory.new
# io << 1
# io << '-'
# io << "Crystal"
# io.to_s # => "1-Crystal"
# ```
def <<(obj) : self
  obj.to_s self
  self
end

Object#to_s(IO)に移譲される。abstract methodなので String#to_s(IO) を見てみる。

String#to_s(IO)

source: https://github.com/crystal-lang/crystal/blob/0.24.0/src/string.cr#L4138-L4140

def to_s(io)
  io.write_utf8(to_slice)
end

つまり、文字列からスライスを作ってそれを io.write_utf8 に渡すようだ。
IO::FileDescriptor#write_utf8はないのでIO#write_utf8を呼ぶことになる。

IO#write_utf8(Bytes)

source: https://github.com/crystal-lang/crystal/blob/0.24.0/src/io.cr#L482-L498

# Writes a slice of UTF-8 encoded bytes to this `IO`, using the current encoding.
def write_utf8(slice : Bytes)
  if encoder = encoder()
    encoder.write(self, slice)
  else
    write(slice)
  end
  nil
end

private def encoder
  if encoding = @encoding
    @encoder ||= Encoder.new(encoding)
  else
    nil
  end
end

encoding がセットされていれば Encoder 経由でwriteするようだ。
どのみち最終的に呼ばれるのは write なのでそちらを見よう。
IO::FileDescriptorIO::Bufferedinclude しており、writeはそこに定義されている。

IO::Buffered#write(Bytes)

source: https://github.com/crystal-lang/crystal/blob/0.24.0/src/io/buffered.cr#L105-L138

# Buffered implementation of `IO#write(slice)`.
def write(slice : Bytes)
  check_open

  count = slice.size

  if sync?
    return unbuffered_write(slice)
  end

  if flush_on_newline?
    index = slice[0, count.to_i32].rindex('\n'.ord.to_u8)
    if index
      flush
      index += 1
      unbuffered_write slice[0, index]
      slice += index
      count -= index
    end
  end

  if count >= BUFFER_SIZE
    flush
    return unbuffered_write slice[0, count]
  end

  if count > BUFFER_SIZE - @out_count
    flush
  end

  slice.copy_to(out_buffer + @out_count, count)
  @out_count += count
  nil
end

元気よくバッファリングしている。
少々複雑で解説が大変なのでネタバレをすると最終的には unbuffered_write が呼び出されます。

IO::Buffered の実装の解説は要望が多ければ別の記事で紹介してもいいかも。

IO::FileDescriptor#unbuffered_write

source: https://github.com/crystal-lang/crystal/blob/0.24.0/src/io/file_descriptor.cr#L186-L194

private def unbuffered_write(slice : Bytes)
  write_syscall_helper(slice, "Error writing file") do |slice|
    LibC.write(@fd, slice, slice.size).tap do |return_code|
      if return_code == -1 && Errno.value == Errno::EBADF
        raise IO::Error.new "File not open for writing"
      end
    end
  end
end

write_syscall_helper とかは防御機構なのでさておき、
めでたく LibC.write が呼び出されました! :tada:

LibC.write はその名前から分かる通りlibcのwrite関数のbindingです。
Linux/UNIXの場合はシステムコールの write(2) が呼び出されると思います。

まとめ

CrystalのIOの振る舞いはIOというabstract classで定義されています。
また、標準入出力はSTDIN/STDOUT/STDERRとしてKernel classに定義されており、それぞれがIO::FileDescriptorのインスタンスになっています。

Kernel#puts(*objects) -> IO#puts(*objects : _) -> IO#puts(String)/IO#puts(obj) といった深い呼び出しでもメソッドオーバーロードが行われて型に応じて呼び出されるメソッドが変わるようになっています。Crystalのオーバーロードがかなり強力なものであることがわかりました。
そして、IO::FileDescriptorIO::Bufferedをベースに実装されているため、バッファリング機能が実装されています。

libによるFFIでLibCを触るところまでCrystalで書かれているのでなかなか実装が追いやすいことがおわかり頂けたかと思います。
ときにはこうやってCrystalの中のコードを追ってみるのも楽しいかと思います。

明日は @TobiasGSmollett さんです!お楽しみに!