give IT a try

プログラミング、リモートワーク、田舎暮らし、音楽、etc.

Excel列名変換問題をRubyとPerlとC#とF#で書いてみた

はじめに

こちらは第2回社内プログラミングコンテストの番外編です。


Excel列名変換問題で第2回社内プログラミングコンテストを開催してみた(後編) - give IT a try


実はこの問題、コンテストの前にF#とC#で解いていました。また、コンテストが終わった後でRubyでも書いてみました。
どの言語も基本的に同じロジックで書いています。
ロジックは同じでも、違う言語で書くとそれぞれの言語の特徴が出ているようで興味深いかもしれません。

問題のおさらい

Excel列名変換問題はこんな問題です。

  • Excelの列名のようにアルファベットを数字に変換する
    • A=1, Z=26, AA=27 ...
  • 反対に数字をアルファベットに変換する処理も用意する
    • 1=A, 26=Z, 27=AA

この問題を解くにあたって、僕は以下のようなロジックを考えました。

XFD(16384)の場合
  1. アルファベットは全部で26文字であることを定数で表現する。
  2. 文字列XFDを配列[X, F, D]に変換する。
  3. (再帰1)配列[X, F, D]を文字Xと配列[F, D]に分解する。
  4. アスキーコードから文字Xがアルファベットの24番目であることを割り出す。
  5. 配列[F, D]の長さが2であることを割り出す。
  6. 26の2乗 * 24 = 16224を保存する。
  7. (再帰2)次に配列[F, D]の処理に移る。
  8. 配列[F, D]を文字Fと配列[D]に分解する。
  9. アスキーコードから文字Fがアルファベットの6番目をであること割り出す。
  10. 配列[D]の長さが1であることを割り出す。
  11. 26の1乗 * 6 = 156を保存する。
  12. (再帰3)次に配列[D]の処理に移る。
  13. 配列[D]を文字Dと配列[]に分解する。
  14. アスキーコードから文字Dがアルファベットの4番目をであること割り出す。
  15. 配列[]の長さが0であることを割り出す。
  16. 26の0乗 * 4 = 4を保存する。
  17. (再帰4)次に配列[]の処理に移る。
  18. 配列の長さが0なので、これまでに計算した値の合計を求める。
  19. 16224 + 156 + 4 = 16384
  20. 16384をXFDの列番号として呼び出し元に返す。
1354(AZB)の場合
  1. アルファベットは全部で26文字であることを定数で表現する。
  2. (再帰1)1354を26で割った商とあまりを求める。
  3. 1354 / 26 = 52 あまり 2
  4. アスキーコードからあまりの2をアルファベットのBに変換する。
  5. (再帰2)商の52を26で割った商とあまりを求める。
  6. 52 / 26 = 2 あまり 0
  7. アスキーコードからあまり0をアルファベットに変換するとAより小さい@になってしまう。
  8. そこであまりが0になる場合だけ、商を一つ減らし、あまりを26と考える。
  9. 52 / 26 = 1 あまり 26
  10. アスキーコードからあまりの26をアルファベットのZに変換する。
  11. (再帰3)商の1を26で割った商とあまりを求める。
  12. 1 / 26 = 0 あまり 1
  13. アスキーコードからあまりの1をアルファベットのAに変換する。
  14. (再帰4)商が0になったので、これまでに割り出したアルファベットを連結する。
  15. A + Z + B = AZB
  16. AZBを1354の列名として呼び出し元に返す。


文章で書くとかなりまどろっこしいですね。
たぶんコードで見る方が早いと思います。
それでは各言語で書いたプログラムを見ていきましょう。

Ruby

まずはRubyで書いた場合。結構シンプルで読みやすいと思います。

class ExcelColConv
    AZ_LENGTH = 'Z'.ord - 'A'.ord + 1 #26
    OFFSET_NUM = 'A'.ord - 1 #64

    # アルファベットから数字
    def to_col_number(col_str)
       convert_str 0, col_str.split('') 
    end

    def convert_str(ret, str_array)
        if str_array.empty?
            ret
        else
            c = str_array.shift
            convert_str calc_decimal(c, str_array.length) + ret, str_array # 再帰
        end
    end
    
    def calc_decimal(c, times)
        AZ_LENGTH ** times * (c.ord - OFFSET_NUM)
    end

    # 数字からアルファベット
    def to_col_string(col_num)
        convert_num '', col_num 
    end

    def convert_num(ret, n)
        if n == 0
            ret
        else
            quo, rem = excel_divmod n
            convert_num (rem + OFFSET_NUM).chr + ret, quo # 再帰           
        end
    end    

    def excel_divmod(n)
        quo, rem = n.divmod AZ_LENGTH
        rem == 0 ? [quo - 1, AZ_LENGTH] : [quo, rem]
    end
end
Perl

次にPerlで書いた場合です。
だいたいRubyと同じように書けますが、Rubyよりもちょっと冗長な感じがします。

package ExcelUtil;
use strict;
use warnings;
use base qw( Exporter );
our @EXPORT_OK = qw( to_col_number to_col_string );

my $AZ_LENGTH  = ord('Z') - ord('A') + 1;    #26
my $OFFSET_NUM = ord('A') - 1;               #64

# アルファベットから数字
sub to_col_number {
    my ($col_str) = @_;
    my @chars = split //sm, $col_str;
    return convert_str( 0, \@chars );
}

sub convert_str {
    my ( $ret, $chars_ref ) = @_;

    if ( scalar( @{$chars_ref} ) == 0 ) {
        return $ret;
    }

    my $c            = shift @{$chars_ref};
    my $chars_length = scalar @{$chars_ref};
    return convert_str( calc_decimal( $c, $chars_length ) + $ret, $chars_ref ); # 再帰
}

sub calc_decimal {
    my ( $c, $times ) = @_;
    return $AZ_LENGTH**$times * ( ord($c) - $OFFSET_NUM );
}

# 数字からアルファベット
sub to_col_string {
    my ($n) = @_;
    return convert_num( q{}, $n );
}

sub convert_num {
    my ( $ret, $n ) = @_;
    if ( $n == 0 ) {
        return $ret;
    }
    else {
        my ( $quo, $rem ) = excel_divmod($n);
        return convert_num( chr( $rem + $OFFSET_NUM ) . $ret, $quo ); # 再帰
    }
}

sub excel_divmod {
    my ($n) = @_;
    my ( $quo, $rem ) = ( int( $n / $AZ_LENGTH ), $n % $AZ_LENGTH );
    if ( $rem == 0 ) {
        return $quo - 1, $AZ_LENGTH;
    }
    else {
        return $quo, $rem;
    }
}

1;
C#

次にC#で書いた場合です。かなり長くなりますね。
ただしこれはC# 2.0です!2世代前のバージョンなのでちょっと古いです。

using System;
using System.Collections.Generic;

public class ExcelUtil
{
    private static readonly int AzLength = (int)'Z' - (int)'A' + 1; // 26
    private static readonly int OffsetNum = (int)'A' - 1; // 64

    #region アルファベットから数字
    public int ToColNumber(string colStr)
    {
        return ConvertString(0, new Queue<char>(colStr.ToCharArray()));
    }

    private int ConvertString(int ret, Queue<char> chars)
    {
        if (chars.Count == 0)
        {
            return ret;
        }
        else
        {
            char c = chars.Dequeue();
            return ConvertString(CalcDecimal(c, chars.Count) + ret, chars); // 再帰
        }
    }

    private int CalcDecimal(char c, int times)
    {
        return (int)Math.Pow(AzLength, times) * ((int)c - OffsetNum);
    }
    #endregion

    #region 数字からアルファベット
    public string ToColString(int colNum)
    {
        return ConvertNumber(string.Empty, colNum);
    }

    private string ToChar(int n)
    {
        return ((char)(n + OffsetNum)).ToString();
    }

    private struct Tuple
    {
        public int quo;
        public int rem;
        public Tuple(int quo, int rem)
        {
            this.quo = quo;
            this.rem = rem;
        }
    }

    private Tuple ExcelDivmod(int n)
    {
        int quo = n / AzLength;
        int rem = n % AzLength;
        return (rem == 0) ? new Tuple(quo - 1, AzLength) : new Tuple(quo, rem);
    }

    private string ConvertNumber(string ret, int n)
    {
        if (n == 0)
        {
            return ret;
        }
        else
        {
            Tuple t = ExcelDivmod(n);
            return ConvertNumber(ToChar(t.rem) + ret, t.quo); // 再帰
        }
    }
    #endregion
}
F#

最後にF#で書いた場です合。
一番短くなりますが、F#の知識がないと何がなんだか分からないかもしれません。。。

namespace ExcelColConv
module ExcelUtil = 
  let AzLength = int 'Z' - int 'A' + 1 // 26
  let OffsetNum = int 'A' - 1 // 64
  
  // アルファベットから数字
  let toColNumber (colStr : string) =
    let calcDecimal c times = (pown AzLength times) * (int c - OffsetNum) 
    let rec convertStr ret = function
      | [] -> ret
      | c::chars -> ((calcDecimal c chars.Length) + ret, chars)
                    ||> convertStr // 再帰
    convertStr 0 (Seq.toList colStr)
  
  // 数字からアルファベット
  let toColString colNum =
    let toChar n = n + OffsetNum |> char |> string
    let excelDivmod n = 
      match n / AzLength, n % AzLength with
      | quo, 0 -> quo - 1, AzLength
      | t -> t
    let rec convertNum ret = function
      | 0 -> ret
      | n -> excelDivmod n 
             ||> fun quo rem -> (toChar rem) + ret, quo
             ||> convertNum // 再帰
    convertNum "" colNum
補足説明

実際に僕がプログラムを書いた順番はF#、C#PerlRubyの順です。
F#で最初にロジックを考えたり、プログラムを書いたりしたので、他の言語からするとちょっとトリッキーなところがあるかもしれません。
F#だからトリッキー、というわけではありませんが、当時はF#の機能をできるかぎり取り込んでコードを短くしようと目論んでいたので、小難しいプログラムになったかもしれません。
他の言語はそのF#のロジックに引きずられているので、「何もそんな書き方にしなくても」と思われるようなところがあるかもしれません。

いろんな言語で書いてみた感想

簡潔さと読みやすさのバランスが一番取れているのはRubyだと思いました。
個人的な好みかもしれませんが、書いていて楽しいです。


F#は関数型言語の機能を活用するとかなり短く書けるので、ある意味非常に面白いのですが、やりすぎると第三者にはさっぱり分からないプログラムができあがる危険性があるかもしれません。
自己満足なプログラムには要注意です。(僕のプログラムのように・・・)


簡潔さや読みやすさの観点からすると、Perlはちょっと中途半端な印象です。
また、"@_"みたいな記号で引数を受け取るのもPerlに不慣れな人から見ると奇妙で、僕のように慣れていない人からするとちょっと書きにくいです。
PerlにしかないCPANモジュール等を使うのでなければ、Better PerlとしてのRubyを使えばいいんじゃないかと思います。


C#はこの程度の小さなプログラムでは構文の冗長さの方が強く目立ってしまい、ある意味かわいそうでした。
コンパイル型言語はもう少し大規模なプログラムを書く時に、変数やメソッドにしょうもないスペルミスとかが混入していないことをコンパイルで確認できる、みたいなところがメリットになるんじゃないかと思います。


というわけで、個人的には書きやすくて読みやすいRubyと、黒魔術的なコードが書けて面白いF#が気に入りました。
でも仕事で使っているのはC#Perl、というのが何とも皮肉な現実なんですけどね・・・。

あわせて読みたい

Excel列名変換問題の元ネタはこちらのエントリです。

Excel列名変換問題で第2回社内プログラミングコンテストを開催してみた(前編) - give IT a try
Excel列名変換問題で第2回社内プログラミングコンテストを開催してみた(後編) - give IT a try


動的型付け言語(スクリプト言語)と静的型付け言語(コンパイル言語)を自分なり比較してみたエントリもあります。

「動的型付言語は使い物にならない」か? - give IT a try


このエントリで紹介したF#のプログラムを詳しく掘り下げて解説してみました。

F#で解いたExcel列名変換問題を解説してみる - give IT a try


F#の本も書いている、いげ太さんの解答例。
さすがプロ。僕なんかよりもずっとエレガントなF#なプログラムです。


igeta's
gist: 1339173 — Gist