Ruby と Squeak Smalltalk の行単位文字列操作について


SmalltalkRuby の類似性の高さに引きずられて、とんでもない勘違いをしていたので改めて自分用のまとめを兼ねてメモ。またおかしいところがあったらツッコミ、お願いします。


Ruby 1.8 の String の Enumerable なメソッドは Smalltalk のような文字ごとではなく、行を単位とする。その際、区切りの \n は取り払われない。

Ruby 1.8
>> "abc\n123\n".each{ |ea| p ea }
"abc\n"
"123\n"


#each_line も同じ。というか、#each は #each_line のエイリアスみたいな位置づけ?

>> "abc\n123\n".each_line{ |ea| p ea }
"abc\n"
"123\n"


区切りのデフォルトは \n で、変更したい場合は #each、#each_line の引数で指定する。

>> "abc\r123\r".each{ |ea| p ea }
"abc\r123\r"
>> "abc\r123\r".each("\r"){ |ea| p ea }
"abc\r"
"123\r"


CRLF の場合はどうするんだろう…? と一瞬、思ったけれど関係ないか("\r" は別に文字オブジェクトのコレクションじゃなくて文字列だから←どうもこの手の思いこみからくる勘違いから抜け出せない(^_^;)、直接 "\r\n" を指定できるし、そもそも末尾はいじらないので \r は無視してよく、デフォルトの "\n" のままで問題ない)。それで #chmp や #chop が活躍するわけですね。なるほど。

>> "abc\r\n123\r\n".each("\r\n"){ |ea| p ea }
"abc\r\n"
"123\r\n"
>> "abc\r\n123\r\n".each{ |ea| p ea }
"abc\r\n"
"123\r\n"
>> "abc\r\n123\r\n".each{ |ea| p ea.chomp }
"abc"
"123"

他方で、Squeak Smalltalk の #do:(Ruby の #each にあたる)では、文字列を単位とし、行は扱わない。

Squeak Smalltalk
| string |
World findATranscript: nil.
string := 'abc\123\' withCRs.
string do: [:each | Transcript cr; show: each]   "=> 各文字を改行後出力 "

なお、ここで #withCRs は文字列中の $\ を改行(Character cr)に置き換えるメソッド。


行単位で扱う場合は #linesDo: を使う。改行は Character cr(Ruby でいうところの \r)で、変更はできない。また、抽出した各行の末尾の改行は省かれる。

| string |
string := 'abc\123\' withCRs
string do: [:each | Transcript cr; show: each]   "=> 各行を改行後出力 "


\n な文字列の場合は #withSqueakLineEndings で \n を \r に置き換えるか、#subStrings: で指定して分解する。

| string |
string := 'abc', String lf, '123', String lf.
string withSqueakLineEndings linesDo: [:each | Transcript cr; show: each].
| string |
string := 'abc', String lf, '123', String lf.
string subStrings: String lf   "=> #('abc' '123') "


なお、#subStrings: の引数は本来は文字のコレクションを期待する。Smalltalk では文字列は文字の特殊な配列なので、上のように書けるけれど、本来「subStrings: String lf」は「subStrings: {Character lf}」といった意味合いを持つ。(念のため、String lf は Ruby でいうところの "\n" で、Character lf は文字オブジェクトとしての \n )

CRLF の場合はどうすればいいのだろう…? #findBetweenSubStrs: かな。

| string lines |
string := 'abc', String crlf, '123', String crlf.
lines := string findBetweenSubStrs: {String crlf}.   "=> an OrderedCollection('abc' '123') "
lines do: [:each | Transcript cr; show: each]


これは Ruby の #split に相当?

Ruby
>> "abc\r\n123\r\n".split("\r\n").each{ |ea| p ea }
"abc"
"123"


たとえばタブも改行扱いにする場合とか。

Squeak Smalltalk
| string |
string := 'abc', String crlf, '1', String tab, '23', String crlf.
string findBetweenSubStrs: {String crlf. String tab}   "=> an OrderedCollection('abc' '1' '23') "
Ruby
>> "abc\r\n1\t23\r\n".split(/\r\n|\t/).each{ |ea| p ea }
"abc"
"1"
"23"


#findBetweenSubStrs: のコメントやソースを見ていて、ちょっと面白いな…と思ったのは #findBetweenSubStrs: は引数に、文字列のコレクションの他にも、文字のコレクション(文字列を含む)を受け付けること。なので、上の subStrings: String lf と同じように書いて、同じことをさせてもよさげ。(私ですらちょっとどうかな…と感じられる振る舞いなので、静的型チェックを愛する向きにはこんなのは気持ち悪くてやってられんでしょうね(^_^;))

Squeak Smalltalk
| string |
string := 'abc', String lf, '123', String lf.
string findBetweenSubStrs: String lf   "=> an OrderedCollection('abc' '123') "
String >> findBetweenSubStrs: delimiters
    "Answer the collection of String tokens that result from parsing self.  Tokens are
     separated by 'delimiters', which can be a collection of Strings, or a collection of
     Characters.  Several delimiters in a row are considered as just one separation."

    | tokens keyStart keyStop |
    tokens := OrderedCollection new.
    keyStop := 1.
    [keyStop <= self size] whileTrue:
        [keyStart := self skipAnySubStr: delimiters startingAt: keyStop.
        keyStop := self findAnySubStr: delimiters startingAt: keyStart.
        keyStart < keyStop
            ifTrue: [tokens add: (self copyFrom: keyStart to: (keyStop - 1))]].
    ^tokens


行を単位として扱いたい場合は、ストリームのほうが便利だしコストもかからない(…はず)。Smalltalk のストリームは Ruby の StringIO や Enumerator に似たところのあるオブジェクトで、文字列(その他、配列などコレクション一般や、ファイルへのアクセス操作)をラップし、外部イテレータとして振る舞う。

| string stream |
string := 'abc\123\' withCRs.
stream := string readStream.
World findATranscript: nil.
[stream atEnd] whileFalse: [Transcript cr; show: stream nextLine]


#nextLine は #upTo: で引数を Character cr にしたときのエイリアスのようなものなので、\n を区切り文字にしたいときは、nextLine の代わりに upTo: Character lf を使えばいい…のか?汗

| string stream |
string := 'abc', String lf, '123', String lf.
stream := string readStream.
World findATranscript: nil.
[stream atEnd] whileFalse: [Transcript cr; show: (stream upTo: Character lf)]

余談ですが、ファイルアクセスを介して文字列を行ごとに扱いたい場合は、#lineEndConvention: で区切り文字を切り替えておくことで #nextLine がそのまま使えます。選択肢は #cr、#lf、#crlf の三つ。ただ、#wantsLineEndConversion: でフラグを立てておく必要があるようです。

| stream |
stream := FileStream fileNamed: 'hoge.txt'.
stream lineEndConvention: #lf; wantsLineEndConversion: true.
World findATranscript: nil.
[stream atEnd] whileFalse: [Transcript cr; show: stream nextLine]

おまけ。Squeak 環境内で、表示テキスト中に Character lf が含まれる場合は、キーコンビネーションコマンドの alt/cmd + shift + u で、選択テキスト中のすべての Character lf を Character cr に置き換えることが可能です。