僕も13桁のISBNコードを10桁に変換するコードを(実質)1行で書いてみた


はじめに

Qiitaを見ていたらこちらの記事が目に止まりました。

Rubyでisbn13からisbn10へ1行で変換する - Qiita

上の記事ではこんな感じで、13桁のISBNコードを10桁に変換するプログラムの例が紹介されていました。

13桁 => 10桁
'9784063842760' => "4063842762" 
'9784774183619' => "477418361X"
'9784797386295' => "4797386290"

isbn13からisbn10に変換する仕様についてはこちらのブログ記事が参考になりました。

ISBN13をISBN10に変換するロジックの解説 · DQNEO起業日記

手順1:まず、 ISBN13の先頭3文字と末尾1文字を捨てます。
すると9桁の数字が得られます。
"9784063842760" => "406384276"

あとはこの9桁の数字から、ISBN10のチェックディジットを算出するだけです。
そう、これは「変換」問題ではなくて、単に「ISBN10のチェックディジットを求める」問題にすぎないのです。
そして「ISBN10のチェックディジットを求める方法」はWikipediaに書いてあるので、わざわざここでこんな記事を書く必要はなく、あなたもこんなブログ記事を読む必要はないわけです、こんにちは。

手順2:それぞれの数にについて、下記のように計算します。
4*10 + 0*9 + 6*8 + 3*7 + 8*6 + 4*5 + 2*4 +7*3 + 6*2 = 218

手順3:手順2の結果を11で割った余りを求めます。
218 を11で割った余りは9

手順4:「11 - 手順3の答え」 を計算します。
11 - 9 = 2

手順5:手順4の答えが 11なら0に,10ならxに置き換えます。それ以外ならそのまま。
手順4の結果が"2"なのでそのまま"2"

手順6:手順5の結果がチェックディジットです。これを手順1の末尾に付けます。
"40638427602" ←これがISBN10です!

(注:一番最後の"40638427602"は、"4063842762"が正だと思われます)

なるほど、これはプログラミングの練習問題としてはなかなか面白そうだと思い、僕もやってみることにしました。

僕が書いたコード

僕は最終的にこんなコードを書きました。
冒頭のQiita記事が「1行で書く」という縛りを付けていたので僕も実質1行で書いています(可読性のため改行を入れたが、改行がなくても動く)。

def convert_isbn(isbn13)
  (body = isbn13[3..-2])
    .each_char.with_index
    .inject(0) { |sum, (c, i)| sum + c.to_i * (10 - i) }
    .then { |sum| 11 - sum % 11 }
    .then { |raw_digit| { 10 => 'X', 11 => 0 }[raw_digit] || raw_digit }
    .then { |digit| "#{body}#{digit}" }
end

ついでに、テストコードも書いています。
(というか、積極的にリファクタリングをするためにはテストコードは必須ですね)

require './lib/isbn_converter'
require 'minitest/autorun'

class IsbnConverterTest < Minitest::Test
  def test_convert_isbn
    assert_equal '4063842762', convert_isbn('9784063842760')
  end

  def test_convert_isbn_x
    assert_equal '477418361X', convert_isbn('9784774183619')
  end

  def test_convert_isbn_0
    assert_equal '4797386290', convert_isbn('9784797386295')
  end
end

コードはGitHubに置いています。

JunichiIto/isbn-converter: Convert ISBN13 to ISBN10.

簡単なコード解説

最初の実装では愚直に前述の「手順」をコードに落としました。

def change_isbn(isbn13)
  # 手順1:まず、 ISBN13の先頭3文字と末尾1文字を捨てます。
  body = isbn13[3..-2]

  # 手順2:それぞれの数にについて、下記のように計算します。
  # 4*10 + 0*9 + 6*8 + 3*7 + 8*6 + 4*5 + 2*4 +7*3 + 6*2 = 218
  sum = body.each_char.map.with_index{|a,b| a.to_i * (10-b) }.sum

  # 手順3:手順2の結果を11で割った余りを求めます。
  mod = sum % 11

  # 手順4:「11 - 手順3の答え」 を計算します。
  raw_digit = 11 - mod

  # 手順5:手順4の答えが 11なら0に,10ならxに置き換えます。それ以外ならそのまま。
  digit =
    case raw_digit
    when 11 then '0'
    when 10 then 'X'
    else raw_digit.to_s
    end

  # 手順6:手順5の結果がチェックディジットです。これを手順1の末尾に付けます。
  "#{body}#{digit}"
end

thenを活用して処理を連結する

あとは基本的にこれを全部つなげて1行にしただけですが、「手前で作成した変数を次の処理で使う」という処理が中心だったので、Ruby 2.6で追加されたthenを使ってみました。

参考:サンプルコードでわかる!Ruby 2.6の主な新機能と変更点 - Qiita

実はthenをちゃんと使ったのは今回が初めてなんですが、こうやって使ってみるとなかなか便利ですね。

ハッシュを利用して0やXの置き換えを実装する

手順5の「手順4の答えが 11なら0に,10ならxに置き換えます。それ以外ならそのまま」というところは、最初はcase文で実装していたのですが、最終的にはハッシュを活用して書きました。

.then { |raw_digit| { 10 => 'X', 11 => 0 }[raw_digit] || raw_digit }

10と11の場合だけハッシュから値を取得し、それ以外の場合はnilになるので、||raw_digit(手順4の値)をそのまま返すようにしています。

追記

ここはfetchメソッドを使っても良かったかもしれませんね。
結果は上と同じになります。

.then { |raw_digit| { 10 => 'X', 11 => 0 }.fetch(raw_digit, raw_digit) }

変数への代入をそのまま式として利用する

前述の手順では最初と最後に「ISBN13の先頭3文字と末尾1文字を捨てた文字列」が登場します。

愚直にDRYを実現しようとすると次のようになりますが、これだと1行になりません。

body = isbn13[3..-2]

body
  .each_char.with_index
  # ...
  .then { |digit| "#{body}#{digit}" }

しかし変数を使わずに1行で書こうとすると、isbn13[3..-2]のコードがDRYになりません。

isbn13[3..-2]
  .each_char.with_index
  # ...
  .then { |digit| "#{isbn13[3..-2]}#{digit}" }

そこで今回は変数への代入をそのまま式として後続のメソッド呼び出しで利用するようにしました。

(body = isbn13[3..-2])
  .each_char.with_index
  # ...
  .then { |digit| "#{body}#{digit}" }

若干技巧的でお行儀がよくない気もしますが、これぐらいなら許容範囲かな〜と思います。

その他

文字列から先頭3文字と末尾1文字を捨てる処理は、範囲を使って実現できます。
Rubyはマイナスの値で「うしろから何番目」を指定できるので便利ですね。

isbn13 = "9784063842760"
isbn13[3..-2]
#=> "406384276"

そのあとの2行は拙著「プロを目指す人のためのRuby入門」(通称・チェリー本)の知識があれば読めるコードです。

.each_char.with_index
.inject(0) { |sum, (c, i)| sum + c.to_i * (10 - i) }

持っている方は4.4.4項、4.8.2項、4.8.4項をご覧ください。
持ってない方はお近くの書店でご確認ください😄

ただし、each_charだけはチェリー本に登場しないので、知らない方はAPIドキュメントを参照してください。

まとめ

というわけで、この記事では13桁のISBNコードを10桁に変換するプログラムを(実質)1行で実装する例を紹介しました。
あらためて完成形のコードを載せておきますね。

def convert_isbn(isbn13)
  (body = isbn13[3..-2])
    .each_char.with_index
    .inject(0) { |sum, (c, i)| sum + c.to_i * (10 - i) }
    .then { |sum| 11 - sum % 11 }
    .then { |raw_digit| { 10 => 'X', 11 => 0 }[raw_digit] || raw_digit }
    .then { |digit| "#{body}#{digit}" }
end

今回のコードでは「1行で書く」といっても、手順を上から下へ素直に実装して、できるだけ可読性を維持したいと考えたのですが、いかがでしょうか?
「難しそう、よくわからん」という人も、元の仕様とRubyの言語仕様、それにRubyの標準API仕様を理解すれば、だんだんとコードの仕組みが見えてくるんじゃないかなーと思います。

プログラミングの練習問題としてはなかなか面白い問題だと思うので、みなさんも一度「1行で書くisbnコード変換プログラム」にトライしてみてください!

あわせて読みたい

こちらのブログ記事には、今回と同じような比較的簡単なプログラミング問題を集めています。

アウトプットのネタに困ったらこれ!?Ruby初心者向けのプログラミング問題を集めてみた(全10問) - give IT a try

また、Railsに依存しない、ピュアなRubyプログラムについて勉強したい方は、拙著「プロを目指す人のためのRuby入門」を読んでみてください。
今回の紹介したような問題を解く上では欠かせないRubyの知識が満載ですので、きっと役に立つと思います!

プロを目指す人のためのRuby入門 言語仕様からテスト駆動開発・デバッグ技法まで:書籍案内|技術評論社