やってみる

アウトプットすべく己を導くためのブログ。その試行錯誤すらたれ流す。

組込ライブラリ(IO)

 入出力クラス。

成果物

情報源

IO

基本的な入出力機能のためのクラスです。

File::Constants は、File から IO へ移動しました。

言語化と IO のエンコーディング

IO オブジェクトはエンコーディングを持ちます。エンコーディングの影響を受けるメソッドと受けないメソッドがあります。

 Rubyエンコードが面倒そうなイメージ。OS間の差異を簡単に吸収できるのだろうか?

影響を受けるメソッドでは、IO のエンコーディングに従い読み込まれた文字列のエンコーディングが決定されます。また IO のエンコーディングを適切に設定することにより、読み込み時・書き込み時に文字列のエンコーディングを変換させることもできます。

エンコーディングの影響を受けるメソッドと受けないメソッド

IO の読み込みメソッドは2種類存在します。テキスト読み込みメソッドとバイナリ読み込みメソッドです。

テキスト読み込みメソッドは IO のエンコーディングの影響をうけます。詳しくは「IO のエンコーディングエンコーディングの変換」を参照して下さい。以下がテキスト読み込みメソッドです。

IO.foreach
IO.readlines
IO#each_line
IO#gets
IO#getc
IO#ungetc
IO#read
IO#readchar
IO#readline
IO#readlines

バイナリ読み込みメソッドは IO のエンコーディングの影響を受けません。返す文字列のエンコーディングは常に ASCII-8BIT になります。以下がバイナリ読み込みメソッドです。

IO#read(size)
IO#read_nonblock
IO#readpartial
IO#sysread

また書き込みメソッド IO#write も IO のエンコーディングの影響を受けます。 IO のすべての書き込みメソッドは内部で IO#write を呼びますので、書き込みメソッドはすべて IO のエンコーディングの影響を受けます。

 IOの書き込みAPIwriteのみってことか?

シーク関連のメソッドはエンコーディングの影響を受けません。常に1バイトを単位として動作します。

例:

f = File.open('t.txt', 'r+:euc-jp')
p f.getc.encoding                             #=> Encoding::EUC_JP
p f.read(1).encoding                          #=> Encoding::ASCII_8BIT

IO のエンコーディングエンコーディングの変換

IO オブジェクトは外部エンコーディングと内部エンコーディングを持ちます。外部エンコーディングとは IO が表すファイルなどの文字エンコーディングです。内部エンコーディングとは IO から読み込まれた文字列、あるいは IO の書き込みメソッドへ渡す文字列の文字エンコーディングです。

 面倒くさすぎる。

以下の三通りがあります。

IO のエンコーディングが指定されていない場合

IO からテキスト読み込みメソッドによって読み込まれた文字列のエンコーディングは Encoding.default_external に設定されます。このとき実際の文字エンコーディングは検査されず、変換もされません。

外部エンコーディングのみが指定されている場合

IO からテキスト読み込みメソッドによって読み込まれた文字列のエンコーディングは外部エンコーディングに設定されます。このとき実際の文字エンコーディングは検査されず、変換もされません。

IO へ書き込まれる文字列は外部エンコーディングへと変換されます。外部エンコーディングへの変換方法が分からない場合は例外が発生します。

外部エンコーディングと内部エンコーディング(あるいは default_internal)が指定されている場合

IO からテキスト読み込みメソッドによって読み込まれた文字列は、外部エンコーディングから内部エンコーディング(あるいは default_internal)へと変換されます。指定された文字エンコーディングと実際の文字エンコーディングが違っていた場合、例外が発生します。内部エンコーディングと Encoding.default_internal が両方とも指定されている場合は、内部エンコーディングが優先されます。

IO へ書き込まれる文字列は外部エンコーディングへと変換されます。外部エンコーディングへの変換方法が分からない場合は例外が発生します。

IO に対してエンコーディングを指定する方法には、生成時に IO.open や File.open に渡すモードとともに指定するものと生成後に IO#set_encoding を使って指定するものの二通りがあります。詳しくはそれぞれのメソッドの項を参照して下さい。通常は前者の方法を使います。

例1:

f = File.open('file1')
p f.getc.encoding        #=> Encoding::EUC_JP

例2:

f = File.open('t.txt', 'w+:shift_jis:euc-jp')
f.write "\xB4\xC1\xBB\xFA"            # 文字列 "漢字" の EUC-JP リテラル
f.rewind
s = f.read(4)
puts s.dump                           #=> "\x8A\xBF\x8E\x9A"
                                      # エンコーディングがSJISへ変換されていることが分かる。

まとめ

以上をまとめると以下の表のようになります。Encoding.default_external は常に設定されているので、省略してあります。

読み込んだ文字列のエンコーディング

            状態          バイナリ読み込みメソッド      テキスト読み込みメソッド
--------------------------------------------------------------------------------
                                    指定無し    ASCII-8BIT  default_external
                       default_internal のみ    ASCII-8BIT  default_internal
                    外部エンコーディングのみ    ASCII-8BIT  外部エンコーディング
                内部エンコーディング指定あり    ASCII-8BIT  内部エンコーディング
内部エンコーディングと default_internal 両方    ASCII-8BIT  内部エンコーディング

エンコーディングの変換

                                              バイナリ読み込みメソッド   テキスト読み込みメソッド   書き込みメソッド
---------------------------------------------------------------------------------------------------------------------
                                  指定無し           変換なし                 変換なし                   変換なし
                  外部エンコーディングのみ           変換なし                 変換なし                   変換あり
                     default_internal のみ           変換なし                 変換あり                   変換あり
                  内部エンコーディングのみ           変換なし                 変換あり                   変換あり
外部エンコーディングと内部エンコーディング           変換なし                 変換あり                   変換あり
   外部エンコーディングと default_internal           変換なし                 変換あり                   変換あり

デフォルトの外部エンコーディングの指定

Encoding.default_external はコマンドオプション -E で指定します。 -E が指定されなかった場合は次のような優先順位で決定されます。

-E (最優先) > -K > locale

 つまりスクリプトは以下のように実行するのが安全ということか。

ruby -E UTF_8 main.rb

 あれ? RubyはデフォルトがUTF_8でいいんだよね? マジックコメントの影響は? 忘れたのでググった。

 スクリプトエンコーディングは、ソースコードに書かれたリテラル文字コードである。

 マジックコメントはスクリプトエンコーディングを設定する。デフォルトはUTF-8

# encode: utf-8

 スクリプトエンコーディングの優先度は以下。

magic comment(最優先) > -K > RUBYOPTの-K > shebang

上のどれもが指定されていない場合、通常のスクリプトなら UTF-8、-e や stdin から実行されたものなら locale がスクリプトエンコーディングになります。 -K オプションが複数指定されていた場合は、後のものが優先されます。

エンコーディング種別 影響範囲 設定方法 参照方法
スクリプトエンコーディング ソースコードリテラル マジックコメント __ENCODING__
外部エンコーディング ファイル、STDIN等 ruby -E Encoding.default_external
内部エンコーディング ファイル、STDIN等 ruby -E Encoding.default_internal

ファイル名のエンコーディング

ファイル名の文字エンコーディングはプラットフォームに依存します。ファイル名の文字エンコーディングが固定されているプラットフォーム(Win, Mac)では、エンコーディングは暗黙に変換されます(予定)。UNIX では変換されずそのままシステムコールに渡されます。

 怖すぎる。もしかしてRubyってクロスプラットフォームに書けないの?

Dir.glob, Dir.foreach などが返すファイル名のエンコーディングも同様にプラットフォーム依存です。 UNIX では ASCII-8BIT です。

 なぜロケールを使わないのか。それならUTF-8なのに。日本語アウトじゃん。マジか。本当に? ひどくない?

 いや、試しに日本語名のファイルを作成して確認したけど問題ないっぽい。どゆこと? ASCII-8BITって何者? まあいいや。

p Dir.glob('*')
["Ruby.docs.ruby.lang.org.20211028091759", "memo", "日本語"]

バイナリモード

Windows の IO にはテキストモードとバイナリモードという2種類のモードが存在します。これらのモードは上で説明した IO のエンコーディングとは独立です。改行の変換にしか影響しません。

EOF での読み込みメソッドの振る舞いの違い

空ファイルや EOF での各読み込みメソッドの振る舞いは以下のとおりです。ただし、length を指定できるメソッドに関しては、length に nil または 0 を指定した場合、 EOF であっても常に空文字列 "" を返します。

メソッド                      空のファイルに対して

IO.read(空ファイル)           ""
IO.read(空ファイル, length)   nil
IO.readlines(空ファイル)      []
IO.foreach(空ファイル)        何もしない
メソッド                      既にEOFだったら

IO#each_byte                  何もしない
IO#getc                       nil
IO#gets                       nil
IO#read()                     ""
IO#read(length)               nil
IO#read_nonblock              EOFError
IO#readchar                   EOFError
IO#readline                   EOFError
IO#readlines                  []
IO#readpartial                EOFError
IO#sysread                    EOFError
IO#bytes                      通常どおり
IO#lines                      通常どおり

 これは酷い。6パターンもある。絶対に覚えられない。テストケースでテストしておかないと想定外の実行時エラーが起きるバグになりそう。たぶんそのメソッドにおける最適な戻り値なんだろうけど慣れないとハマりそう。

  • 空文字""
  • nil
  • 空配列[]
  • なにもしない
  • 通常どおり(ってなんだよ)
  • EOFError

メンバ抜粋

特異メソッド

binread binwrite copy_stream for_fd foreach new open 
pipe popen read readlines select sysopen try_convert write

インスタンスメソッド

<< advise autoclose= autoclose? binmode binmode? clone close close_on_exec= close_on_exec? close_read close_write closed? dup each each_byte each_char each_codepoint each_line eof eof? external_encoding fcntl fdatasync fileno flush fsync getbyte getc gets internal_encoding ioctl isatty lineno lineno= pid pos pos= pread print printf putc puts pwrite read read_nonblock readbyte readchar readline readlines readpartial reopen rewind seek set_encoding set_encoding_by_bom stat sync sync= sysread sysseek syswrite tell to_i to_io tty? ungetbyte ungetc write write_nonblock

定数

SEEK_CUR SEEK_DATA SEEK_END SEEK_HOLE SEEK_SET

読込

単位 イテレータ 配列 文字列 整数
バイト each_byte read getbyte,readbyte
each_char getc,readchar
each, each_line readlines gets,readline

each

IO.write("testfile", "This is line one,\nThis is line two,\nThis is line three,\nAnd so on...")
f = File.new("testfile")
f.each { |line| p "#{f.lineno}: #{line}" }

 APIパターンが多い。

each(rs = $/, chomp: false) {|line| ... } -> self
each(limit, chomp: false) {|line| ... } -> self
each(rs, limit, chomp: false) {|line| ... } -> self
each(rs = $/, chomp: false) -> Enumerator
each(limit, chomp: false) -> Enumerator
each(rs, limit, chomp: false) -> Enumerator
each_line(rs = $/, chomp: false) {|line| ... } -> self
each_line(limit, chomp: false) {|line| ... } -> self
each_line(rs, limit, chomp: false) {|line| ... } -> self
each_line(rs = $/, chomp: false) -> Enumerator
each_line(limit, chomp: false) -> Enumerator
each_line(rs, limit, chomp: false) -> Enumerator

 chompは各行末から改行コード削除。limitは最大バイト数。

書込

  • print
  • printf
  • putc
  • puts
  • pwrite
  • write

puts

IO.write("testfile2", "")
f = File.new("testfile2", 'r+')
f.puts 'putsのテスト出力。'
f.puts '二行目。'
#f.flush
#f.fsync
f.each { |line| p "#{f.lineno}: #{line}" } # 位置が末尾のため何も出力されない
f.seek IO::SEEK_SET                        # 位置を先頭に戻す
f.each { |line| p "#{f.lineno}: #{line}" } # 出力される

 ファイルポインタの位置を意識している必要がある。書き込みが終了したときは末尾である。なので一旦先頭にシークしてやることで、再び先頭から読み取ることができるようになる。

所感

 文字コードとか面倒くさそうだな。内部コードだけでよろしくやってほしい。指定しないときはUTF-8だけでいいよ。

対象環境

$ uname -a
Linux raspberrypi 5.10.52-v7l+ #1441 SMP Tue Aug 3 18:11:56 BST 2021 armv7l GNU/Linux