Ruby

「Rubyで英語記事に含まれてる英単語を数えて出現数順にソートする」をカッコよく書いてみた

More than 1 year has passed since last update.


はじめに

こちらの記事が面白そうだったので、僕もコードを考えてみました。

Rubyで英語記事に含まれてる英単語を数えて出現数順にソートする(改良1) - Qiita

記事の最後に「かっこ良く書けるようになりたい」とあったので、カッコ良く書いたつもりです!(注:自分基準)


どんな問題なの?

テキストファイルの中から英熟語や英単語を抜き出してカウントする、という問題です。

入力に使うテキストファイルはこんな感じです。


input.txt

Interior design and decorating resource Houzz is the overall best Android app of the year, according to Google, which this evening announced the results of the first-ever Google Play Awards at its developer conference, Google I/O. 

(略)


出力結果はこんな感じになります。


expected_output.txt

単語数(熟語以外):331

英熟語?------------------------------------------------------------------
2 Google I/O
2 Google Play Awards
1 And Google
1 Best App
1 Best Game
1 Best of
1 Best Standout Startup
1 Best Use of Google Play Game Services
1 Best Use of Material Design
(略)
英単語------------------------------------------------------------------
22 the
11 and
11 of
8 a
6 apps
5 app
5 best
5 for
5 Google
5 to
4 that
4 this
(略)


僕が書いたコード

僕はこんなコードを書いてみました。


extract_word.rb

class ExtractWord

def self.execute(file_path)
self.new.execute(file_path)
end

def execute(file_path)
text = File.read(file_path)
words = count_words(text)
compound_words, single_words = words.partition { |word, _| word.include?(' ') }
output_result(single_words, compound_words)
end

private

def count_words(text)
# 単語の構成文字
word_char = '[\w’\/-]'
# Google Play Awards や Clash of Kings のような複合語を検索する
compound_words = /[A-Z]#{word_char}*(?: of| [A-Z]#{word_char}*)+/
# 英単語を検索する
words = /#{word_char}+/
# 複合語が優先的に検索されるように正規表現を結合する
regex = Regexp.union(compound_words, words)
text.scan(regex).each_with_object(Hash.new(0)) do |word, count_table|
count_table[word] += 1
end
end

def output_result(single_words, compound_words)
word_count = single_words.inject(0) { |sum, (_, count)| sum + count }
puts "単語数(熟語以外):#{word_count}"
output_words(compound_words, '英熟語?')
output_words(single_words, '英単語')
end

def output_words(count_table, header)
puts "#{header}------------------------------------------------------------------"
sorted_table = count_table.sort_by { |word, count| [-count, word.downcase] }
sorted_table.each do |word, count|
puts '%3d %s' % [count, word]
end
end
end


テストコードも書いています。(Minitestを使用)


extract_word_test.rb

require 'minitest/autorun'

require_relative '../lib/extract_word'

class ExtractWordTest < Minitest::Test
def test_execute
assert_output(expected_output) { ExtractWord.execute(input_file_path) }
end

private

def input_file_path
File.expand_path('../input.txt', __FILE__)
end

def expected_output
file_path = File.expand_path('../expected_output.txt', __FILE__)
File.read(file_path)
end
end


コードの全体像はGitHubリポジトリをご覧ください。

(記事の公開後にもリファクタリングしているので、この記事とコードは少し異なります)

https://github.com/JunichiIto/extract-word-sandbox


仕様を変えたところ

元の記事では "Xxx Xxx" のようなパターンだけを英熟語と扱っていましたが、僕のコードでは "Xxx of Xxx" や "Xxx Xxx's" のように、"of" や "'" が入るケースも英熟語として扱っています。

同じ考えで、"Google I/O" や "millennial-focused" の "/" や "-" も単語の文字に含めています。


基本的なロジックの考え方

こんな処理フローになっています。


  1. ファイルを読み込む

  2. テキストから英熟語と単語を一気に抜き出し、カウントする(このとき、英熟語を優先的に抜き出すようにする)

  3. 英熟語と単語にグループ分けする

  4. 結果を標準出力に出力する


1. ファイルを読み込む

元の記事ではファイルパスが固定されていましたが、僕のコードでは外部から引数で指定できるようにしました。

def execute(file_path)

text = File.read(file_path)
# ...


2. テキストから英熟語と単語を一気に抜き出し、カウントする

このプログラムの一番のキモはcount_wordsメソッドです。

def count_words(text)

# 単語の構成文字
word_char = '[\w’\/-]'
# Google Play Awards や Clash of Kings のような複合語を検索する
compound_words = /[A-Z]#{word_char}*(?: of| [A-Z]#{word_char}*)+/
# 英単語を検索する
words = /#{word_char}+/
# 複合語が優先的に検索されるように正規表現を結合する
regex = Regexp.union(compound_words, words)
text.scan(regex).each_with_object(Hash.new(0)) do |word, count_table|
count_table[word] += 1
end
end

正規表現を使って、テキスト中の英熟語と単語を抜き出し、出現回数をカウントしています。

英熟語用の正規表現を先に連結することで、英熟語を優先的に抜き出している点もポイントです。

(逆にすると普通の単語が優先的にカウントされてしまいます)

# 複合語が優先的に検索されるように正規表現を結合する

regex = Regexp.union(compound_words, words)

このメソッドを呼び出すと次ような戻り値(ハッシュ)が返ってきます。

{"Interior"=>1, "design"=>2, "and"=>11, ..., "Best Use of Material Design"=>1}

正規表現が怖い呪文にしか見えない人はこちらの記事をどうぞ。

初心者歓迎!手と目で覚える正規表現入門・その1「さまざまな形式の電話番号を検索しよう」 - Qiita


3. 英熟語と単語にグループ分けする

partitionメソッドを使って、英熟語と単語にグループ分けします。

文字列にスペースが含まれていれば英熟語、それ以外は単語と見なしています。

compound_words, single_words = words.partition { |word, _| word.include?(' ') }


4. 結果を標準出力に出力する

データができあがったので、あとはこれを加工して画面に出力します。

def output_result(single_words, compound_words)

word_count = single_words.inject(0) { |sum, (_, count)| sum + count }
puts "単語数(熟語以外):#{word_count}"
output_words(compound_words, '英熟語?')
output_words(single_words, '英単語')
end

def output_words(count_table, header)
puts "#{header}------------------------------------------------------------------"
sorted_table = count_table.sort_by { |word, count| [-count, word.downcase] }
sorted_table.each do |word, count|
puts '%3d %s' % [count, word]
end
end

元の仕様にあわせてword_countは英熟語を除いた単語数を数えていますが、英熟語を除く理由はちょっとよくわかりません。。

出力順は「出現回数が多い順 => アルファベット順(大文字小文字無視)」で出力しています。


元記事のコードへのアドバイス

実は僕が書いたコードは元記事のコードをどんどんリファクタリングして作っていったものです。(コミットの履歴を見ると分かります)

元記事のコードを見ながら、「ここは直した方がいいな~」と思った部分があるので、僭越ながらいくつかコメントさせてもらいます。


参考:元記事のコード

ちなみに元記事のコードはこんな感じでした。

class ExtractWord

def initialize
@word = Hash.new(0) # 通常の英単語
@word_sp = Hash.new(0) # 複数の単語で意味をなす英単語 ex) "Google Play Awards"
@file = File.open('test.txt', 'r').read
end

def get_word_sp # 複数の単語で意味をなす英単語を取得するメソッド ex) "Google Play Awards"
reg2 = /([A-Z]\w*\s)([A-Z]\w*\s)/
reg3 = /([A-Z]\w*\s)([A-Z]\w*\s)([A-Z]\w*\s)/
reg4 = /([A-Z]\w*\s)([A-Z]\w*\s)([A-Z]\w*\s)([A-Z]\w*\s)/
reg5 = /([A-Z]\w*\s)([A-Z]\w*\s)([A-Z]\w*\s)([A-Z]\w*\s)([A-Z]\w*\s)/

array = @file.scan(reg5)
array.each do |word|
key = word.join(",").gsub(",", "").strip
@word_sp[key] += 1
end
array = @file.scan(reg4)
array.each do |word|
key = word.join(",").gsub(",", "").strip
@word_sp[key] += 1
end
array = @file.scan(reg3)
array.each do |word|
key = word.join(",").gsub(",", "").strip
@word_sp[key] += 1
end
array = @file.scan(reg2)
array.each do |word|
key = word.join(",").gsub(",", "").strip
@word_sp[key] += 1
end

get_word
end

def get_word
array = []
@word_sp.each do |word, frequency|
array = @file.gsub("#{word}", "")
end

array = array.split("\s").map{|m| m.gsub(/\.|\”|\,|\n|\“/, "")}
array.each do |word|
@word[word] += 1
end
puts "文字数:#{array.count}"
puts "英熟語?---------------------------------------------------------------"
@word_sp.sort_by{|_, v| -v}.each do |word, frequency|
puts "%3d %s" % [frequency, word]
end
puts "英単語------------------------------------------------------------------"
@word.sort_by{|_, v| -v}.each do |word, frequency|
puts "%3d %s" % [frequency, word]
end
end
end

extract_word = ExtractWord.new
extract_word.get_word_sp


不必要なインスタンス変数はバグの原因になるので避ける

@word@word_spというインスタンス変数を定義していますが、処理の過程でのみ使われる変数はローカル変数として宣言し、メソッドの引数で渡していくべきです。

そうでないと、extract_word.get_word_spを複数回呼びだしたときに、実行結果が変わってしまいます。

詳しくは以前書いたこちらの記事をご覧ください。

(あなたの周りでも見かけるかもしれない)インスタンス変数の間違った使い方 - Qiita


変数名に合致しない値を代入しない

以下のメソッドでは array = [] で配列の変数を宣言していますが、array = @file.gsub("#{word}", "")の部分で配列ではなく、文字列が代入されています。

その後、array = array.split("\s").map{|m| m.gsub(/\.|\”|\,|\n|\“/, "")}の部分でまた配列が代入されます。

def get_word

array = []
@word_sp.each do |word, frequency|
array = @file.gsub("#{word}", "")
end

array = array.split("\s").map{|m| m.gsub(/\.|\”|\,|\n|\“/, "")}
array.each do |word|
@word[word] += 1
end

# ...

一時的であれ、名前に合致しない値を代入するのはコードを読む人を混乱させる原因になります。

ローカル変数を節約せず、別のローカル変数を宣言しましょう。


1メソッドにつき、責務は1つにする

get_word_spメソッドは「複数の単語で意味をなす英単語を取得する」だけかと思いきや、最後にget_wordメソッドを呼びだしています。

また、get_wordメソッドは普通の単語を抜き出すだけかと思いきや、最後にputsで画面に文字を出力しています。

def get_word_sp # 複数の単語で意味をなす英単語を取得するメソッド ex) "Google Play Awards"

# ...

get_word
end

def get_word
# ...
puts "文字数:#{array.count}"
puts "英熟語?---------------------------------------------------------------"
@word_sp.sort_by{|_, v| -v}.each do |word, frequency|
puts "%3d %s" % [frequency, word]
end
puts "英単語------------------------------------------------------------------"
@word.sort_by{|_, v| -v}.each do |word, frequency|
puts "%3d %s" % [frequency, word]
end
end

メソッドの名前と挙動が一致していませんし(最後に余計なことをやっています)、1メソッドに複数の責務を持たせると再利用性が下がってしまうので、「1メソッドにつき、責務は1つ」を原則としましょう。


常にDRYを意識する

以下の処理はほぼ同じ事を繰り返しています。DRY(Don't repeat yourself)の精神で重複コードはメソッドにまとめる方が望ましいです。

def get_word

# ...
puts "英熟語?---------------------------------------------------------------"
@word_sp.sort_by{|_, v| -v}.each do |word, frequency|
puts "%3d %s" % [frequency, word]
end
puts "英単語------------------------------------------------------------------"
@word.sort_by{|_, v| -v}.each do |word, frequency|
puts "%3d %s" % [frequency, word]
end
end


正規表現で | ではなく [ ] を使う

m.gsub(/\.|\”|\,|\n|\“/, "") というコードがありますが、どれも「1文字」をORで連結しているので、[ ]を使って次のように書き直した方がいいです。

m.gsub(/[.”,\n“]/, "")

また、元の m.gsub(/\.|\”|\,|\n|\“/, "") も、.以外はエスケープしなくてよいので、m.gsub(/\.|”|,|\n|“/, "")でOKです。

シンプルに書ける部分は極力シンプルに書きましょう。


一般的でない略称を使わない

@word_spの"sp"には何か意味があるのだと思いますが、僕には何の意味かわかりません。

寿命の長い変数ほど、一般的でない略称を避け、誰にでもわかる名前を付けるべきです。


テストコードを書こう / テスト容易性を意識しよう

最初からいきなりカッコいいコードが書ける天才は滅多にいません。

カッコいいコードを書くためにはリファクタリングが必ず発生します。

そしてリファクタリングにはテストコードが必須です。

いや、実際にはテストコードがなくてもリファクタリングできるかもしれませんが、毎回目視で実行結果を確認したりするのは時間がかかりますし、しょうもないポカミスが混入する原因にもなります。

というわけで、カッコいいコードを書きたいのであれば、テストコードも書きましょう。

MinitestやRSpecには標準出力の内容をテストするアサーションメソッドやマッチャーがあるので、これを使えばテストできます。

def test_execute

# assert_outputで標準出力の内容を検証する
assert_output(expected_output) { ExtractWord.execute(input_file_path) }
end

しかし、標準出力は文字列になってしまうのでテストの小回りがききません。

理想的にはデータと見せ方は明確に分離し、テストはデータに対して実行する方が望ましいです。

たとえば実装をこんな感じでメソッドを分けるとテストがしやすくなります。

class ExtractWord

def self.execute(file_path)
self.new.execute(file_path)
end

def execute(file_path)
compound_words, single_words = count_and_group_words(file_path)
output_result(single_words, compound_words)
end

# データを返すメソッド
def count_and_group_words(file_path)
text = File.read(file_path)
words = count_words(text)
words.partition { |word, _| word.include?(' ') }
end

# ...

class ExtractWordTest < Minitest::Test

# ...

# 標準出力の出力内容ではなく、返ってきたデータを検証する
def test_count_and_group_words
extract_word = ExtractWord.new
compound_words, single_words = extract_word.count_and_group_words(input_file_path)

assert_equal 20, compound_words.size
assert_equal 200, single_words.size

assert_equal 2, compound_words.to_h['Google Play Awards']
assert_equal 11, single_words.to_h['and']

# etc...
end

このように「データと見せ方を分離する」「テストはデータに対して書く」とすると、メソッドの再利用性やテスト容易性が高くなります。


まとめ

というわけでこの記事では「Rubyで英語記事に含まれてる英単語を数えて出現数順にソートする」問題の僕の解答例と、元記事へのアドバイスを書いてみました。

いかがでしょうか?

同じ問題に対してもいろいろな解き方がありますし、コードのカッコよさ(もしくは読みやすさや再利用のしやすさ等)はさまざまな心がけで大きく変わってきますね。

カッコいいコードを書きたいと思っている方はこの記事を参考にして、自分の書いたコードを振り返ってみてください!


あわせて読みたい

先日、同じようなテーマでブログを書きました。こちらもあわせて読んでみてください。

シンプルでわかりやすいコードを書くためにあなたがすべきこと - give IT a try

Rubyにはいろいろな書き方が用意されています。こういったイディオムや便利メソッドを知っていると、コードがぐぐっとシンプルになったりします。

[初心者向け] RubyやRailsでリファクタリングに使えそうなイディオムとか便利メソッドとか - Qiita