Rubyで全角空白も対象としたstrip(trim)

rubyではtrimじゃなくてstripというそうです。 stripは先頭と末尾の半角空白やタブ、改行などを除去するメソッドです。

全角空白は除去されないので、独自で定義する必要があります。

うまくいく例

class String
  def strip_all_space!
    gsub!(/(^[[:space:]]+)|([[:space:]]+$)/, '')
  end

  def strip_all_space
    self_clone = clone
    self_clone.strip_all_space!
    self_clone
  end
end
puts '   前後空白 全角半角混合   '.strip_with_2byte_space
# => '前後空白 全角半角混合'

str = '   前後空白 全角半角混合   '
str.strip_with_2byte_space!
puts str
# => '前後空白 全角半角混合'

仕様

strip_all_spaceメソッドは、文字列の先頭と末尾の空白文字を除去した新しい文字列を返します。 空白文字は、正規表現の[:space:]の文字と同じで、全角空白などユニコードの空白文字も含まれています。

strip_all_space!メソッドは、レシーバ自身の文字列から先頭と末尾の空白文字を除去します。変更があった場合はレシーバ自身を、変更がない場合はnilを返します。

正規表現

gsub!(/(^[[:space:]]+)|([[:space:]]+$)/, '')

こちらのメソッド呼び出しですが、まず gsub!は、正規表現でマッチした部分を繰り返し置換するメソッドです。

マッチさせる正規表現(^[[:space:]]+)|([[:space:]]+$)の前半の(^[[:space:]]+)は、「文字列の先頭に続き、[:space:]クラスの1文字以上の繰り返し」を表します。 後半の([[:space:]]+$)は、「[:space:]クラスの1文字以上の繰り返し、その直後に終端(行末)」を表します。その2つを|で結合することによって、そのどちらにもマッチするようになります。

マッチした部分を空文字に置き換えることにより、行頭と行末の空白文字を除去しています。

strip_all_space(非破壊的メソッド)からstrip_all_space!(破壊的メソッド)を呼び出す

def strip_all_space
  self_clone = clone
  self_clone.strip_all_space!
  self_clone
end

cloneで自身のコピーを作成し、コピーした方に破壊的変更をすることで、自身の情報には変更を加えず、またコードの重複なく目的のメソッドを作成できました。

Stringクラスの破壊的メソッドの戻り値

strip!sub!gsub!tr!などのStringクラスに定義されている破壊的メソッドの戻り値は、 変更があった場合はレシーバ自身を、変更がない場合はnilを返します。

変更がないときにnilを返すのは少し奇妙に思いましたが、これは、変更があった時(もしくはなかった時)に処理をするためだと思います。

str = ' test '
if str.strip!
  # 変更があったとき
  puts '文字列が破壊的に変更されました'
else
  # 変更がなかったとき
  puts '文字列は変更されませんでした'
end

独自にStringクラスを拡張する場合もこの規則に従い、破壊的変更をする場合は、変更があった場合はレシーバ自身を、変更がない場合はnilを返すようにするべきと考えます。

誤った例

全角空白も対象としたstripはweb上にいくつか情報が存在し、参考にさせてもらったのですが、いくつか求める仕様と異なっていることに気づかず使おうとしてしまったものもありました。同じ間違いをする人が出ないように、例とどの場合にうまくいかないかを示します。

誤った例 (その1)

def strip_all_space!
  sub!(/^[[:space:]]*([^[:space:]]+)[[:space:]]*$/, '\1')
end

行頭に続き、[:space:]クラスの0回以上の繰り返し、[:space:]クラスでない文字の1回以上の繰り返し、[:space:]クラスの0回以上の繰り返し、行末、にマッチさせ、中間の部分を抜き出す表現です。

うまくいかない場合ですが、

まず、' 'のように空白のみで構成される文字列にマッチしません。 また、' 文 字 列 'のように空白でない文字と文字の間に空白が入る文字列にもマッチせず、前後の空白が除去されません。

誤った例 (その2)

def strip_all_space!
   gsub!(/^[[:space:]]*(.*?)[[:space:]]*$/, '\1')
end

行頭に続き、[:space:]クラスの0回以上の繰り返し、任意の文字列の0回以上の繰り返し、[:space:]クラスの0回以上の繰り返し、行末、にマッチさせ、中間の部分を抜き出す表現です。(.*?)の部分で最短マッチを行なっています。

' 文 字 列 'のように空白でない文字と文字の間に空白が入る文字列も問題なく'文 字 列'のように前後の空白が除去されます。

ただ、'文字列'のように変更がない文字列にも正規表現がマッチしてしまい、「変更がない場合はnilを返す」という仕様に合いませんでした。

誤った例 (その3)

class String
  def strip_all_space!
    gsub!(/(^[[:space:]]+)|([[:space:]]+$)/, '')
  end

  def strip_all_space
    clone.strip_all_space!
  end
end

非破壊的メソッドからcloneメソッドを使用して破壊的メソッドを呼ぶ方法をwebで発見したのですが、cloneに対して破壊的メソッドを呼び、戻り値をそのまま返しています。

strip_all_space!メソッドは変更がないときにnilを返すので、戻り値をそのまま返すとバグになります。cloneを一旦別の変数に保存し、破壊的メソッドを呼び出した後に、cloneしたものを返す必要があります。


Railsでの標準ライブラリの拡張方法

Stringを拡張する際のrailsでの記述方法はこちらが参考になりました。 ただし、この方法では、起動時にしかStringクラスが読み込まれず、書き換えた場合に再起動が必要という問題があります。

qiita.com

テストケース

簡単ですが、rspecでテストを書きました。正規表現のテストケース書くの難しい。

describe 'Strip all space behavior' do
  describe '#strip_all_space!' do
    subject { str.strip_all_space! }

    context 'When all space string' do
      let(:str) { '  ' }
      it { is_expected.to eq ''}
      it 'should remove spaces' do
        subject
        expect(str).to eq ''
      end
    end

    context 'When all space string including 2-byte space' do
      let(:str) { '   ' }
      it { is_expected.to eq ''}
      it 'should be empty string' do
        subject
        expect(str).to eq ''
      end
    end

    context 'When space string including 2-byte space at start of string' do
      let(:str) { '   文字列' }
      it { is_expected.to eq '文字列'}
      it 'should remove spaces' do
        subject
        expect(str).to eq '文字列'
      end
    end

    context 'When space string including 2-byte space at end of string' do
      let(:str) { '文字列    ' }
      it { is_expected.to eq '文字列'}
      it 'should remove spaces' do
        subject
        expect(str).to eq '文字列'
      end
    end

    context 'When space string including 2-byte space around string' do
      let(:str) { '    文字列    ' }
      it { is_expected.to eq '文字列'}
      it 'should remove spaces on start/end of string' do
        subject
        expect(str).to eq '文字列'
      end
    end

    context 'When space around string including white space' do
      let(:str) { '    文 字 列    ' }
      it { is_expected.to eq '文 字 列'}
      it 'should remove start/end spaces. Spaces between string are preserved' do
        subject
        expect(str).to eq '文 字 列'
      end
    end

    context 'When empty string' do
      let(:str) { '' }
      it { is_expected.to eq nil}
      it 'should not be changed' do
        subject
        expect(str).to eq ''
      end
    end

    context 'When string does NOT have any white space' do
      let(:str) { '文字列' }
      it { is_expected.to eq nil}
      it 'should not be changed' do
        subject
        expect(str).to eq '文字列'
      end
    end
  end

  describe '#strip_all_space' do
    subject { str.strip_all_space }

    context 'When all space string' do
      let(:str) { '  ' }
      it { is_expected.to eq ''}
      it 'should be original string' do
        subject
        expect(str).to eq '  '
      end
    end

    context 'When all space string including 2-byte space' do
      let(:str) { '   ' }
      it { is_expected.to eq ''}
      it 'should be original string' do
        subject
        expect(str).to eq '   '
      end
    end

    context 'When space string including 2-byte space at start of string' do
      let(:str) { '   文字列' }
      it { is_expected.to eq '文字列'}
      it 'should be original string' do
        subject
        expect(str).to eq '   文字列'
      end
    end

    context 'When space string including 2-byte space at end of string' do
      let(:str) { '文字列    ' }
      it { is_expected.to eq '文字列'}
      it 'should be original string' do
        subject
        expect(str).to eq '文字列    '
      end
    end

    context 'When space string including 2-byte space around string' do
      let(:str) { '    文字列    ' }
      it { is_expected.to eq '文字列'}
      it 'should be original string' do
        subject
        expect(str).to eq '    文字列    '
      end
    end

    context 'When space around string including white space' do
      let(:str) { '    文 字 列    ' }
      it { is_expected.to eq '文 字 列'}
      it 'should be original string' do
        subject
        expect(str).to eq '    文 字 列    '
      end
    end

    context 'When empty string' do
      let(:str) { '' }
      it { is_expected.to eq ''}
      it 'should be original string' do
        subject
        expect(str).to eq ''
      end
    end

    context 'When string does NOT have any white space' do
      let(:str) { '文字列' }
      it { is_expected.to eq '文字列'}
      it 'should be original string' do
        subject
        expect(str).to eq '文字列'
      end
    end
  end
end
環境