LoginSignup
2
3

More than 5 years have passed since last update.

文字列をPHPのsubstr関数風にSubstringする

Last updated at Posted at 2016-06-19

役に立つかどうかよくわからないTipsシリーズ

今回は substring すなわち「文字列の部分取得」です

String には

  • substringToIndex(_:)
  • substringFromIndex(_:)
  • substringWithRange(_:)

といった部分取得を行えるメソッドが用意されていますが

特に substringWithRange(_:) の場合は
引数に Range<Index> を取ります

NSString である場合は NSRangeNSMakeRange() してやることで
簡単に範囲指定できましたが
この Range<Index> は個人的には何だか直感的にわかりづらいというか
なんだか無駄な実装が増えてしまって困りものなのです

そこで、もっとシンプルに文字列の部分取得ができるように
一工夫してみようかと思います

substringとその仕様

自分は元々PHPerであるので、
PHPの substr関数 を模倣した仕様で使えると助かるのです

PHPの substr関数 のドキュメント PHP: substr - Manual には
こんな風に仕様が書かれています

// 関数定義
string substr ( string $string , int $start [, int $length ] )
// 引数 string
入力文字列最低 1 文字以上を指定しなければなりません
// 引数 start
start が正の場合返される文字列は
string  0 から数えて start番目から始まる文字列となります
例えば文字列'abcdef'において位置 0にある文字は'a'であり 位置2には'c'があります

start が負の場合返される文字列は
stringの後ろから数えて start番目から始まる文字列となります

string の長さが start 文字より短い場合は FALSE が返されます
// 引数 length (省略可能)
length が指定されかつ正である場合
返される文字列は start (string の長さに依存します) から数えてlength文字数分となります

length が指定されかつ負である場合
string の終端からその文字数分の文字が省略されます
(start が負の場合は 開始位置を算出したあとで)
もし start が切り出し位置を超える場合 FALSE が返されます

length が指定されかつ 0NULLもしくは FALSE であれば
空の文字が返されます

length を省略した場合は 
start の位置から文字列の最後までの部分文字列を返します
// 戻り値
string の一部を返します
失敗した場合に FALSE を返しますあるいは空文字列を返します

基本的にはこのルールを踏襲するのですが
PHPと何もかもすべて同じというわけにも行かないので
少しずつ変えたいと思います

「FALSEを返します」
こう書かれた部分は、空文字列が返るように実装します
オプショナルの戻り値にして nil を返すということも考えましたが
空文字で返ってきたほうが使う時に使いやすいかと思うのでそういう仕様にします
このあたりは好みで使い分けてもらえればいいかなと

「length が指定され、かつ 0、NULL、もしくは FALSE であれば、空の文字が返されます」
PHPと違ってswiftは型に厳しいので、Int を渡すところに false を渡すことはしません
なので、この仕様はドロップです
代わりに下記のような仕様になるようになります

  • length が 0 の場合は、負の数を渡されたものとして扱う
  • lengthnil を渡すと、返される文字は「start から終端まで」とする

この辺りだけPHPの挙動とは微妙に変わると思いますのでご注意を

開始位置と終了位置で部分取得する

extension String {

    /// 文字列の部分取得を行う
    private func substring(startIndex start: Int, endIndex end: Int) -> String {
        let max = self.length - 1
        var s = start, e = end
        if max < 0 || e < s || max < s {
            return ""
        } else if s < 0 {
            s = 0
        } else if e < 0 {
            e = 0
        } else if max < e {
            e = max
        }
        let range = Range(self.startIndex.advancedBy(s)...self.startIndex.advancedBy(e))
        return self.substringWithRange(range)
    }
}

最初に挙げたPHPのsubstr関数とは定義が違いますが
substring(startIndex:endIndex:) というメソッドをStringに追加しました

文字列の中から開始位置と終了位置を指定することで
その部分だけを抜き取るメソッドです

"abcdef".substring(startIndex: 2, endIndex: 4)

例えばこうすると
"abcdef"のインデックス2の"c"から、インデックス4の"e"までの
"cde"を取得します

メソッドの仕様として
開始位置が終了位置を上回ると、空文字を返すようにしています
これが意外と後々でミソになります

ちなみに
ここに出てくるself.lengthというのは、オリジナルプロパティです
これについては Stringの文字列長の取得はextensionでラップしておく を参照ください

もうひとつちなみに・・・ですが
このメソッドは private で定義しています
これは単純に個人的にですが、このメソッド単独で使うシーンがないので隠蔽しました
もちろんこれだけでも充分使えるメソッドなので
public で宣言しても差し支えは無いと思います

本題のsubstringをする

下準備のメソッドができたので
最初に書いたPHPの substr関数 を模倣した substring メソッドを作成します

public func substring(location location: Int, length: Int? = nil) -> String {
    return ""
}

このように定義しました
(とりあえずエラーにならないように空文字を返してます)

引数のラベル名は、 substr関数 よりもむしろ NSRange に合わせたほうが
iOS開発者には馴染み深いと思い
start:length: ではなく、location:length: にしています
lengthsubstr関数 と同じく省略可能です

さて、実装をする前に
このメソッドをどう使って何が返ってくるかを整理しておいたほうがいいですね

もういちど PHP: substr - Manual の戻り値の箇所を確認します

<?php
echo substr('abcdef', 1);     // bcdef
echo substr('abcdef', 1, 3);  // bcd
echo substr('abcdef', 0, 4);  // abcd
echo substr('abcdef', 0, 8);  // abcdef
echo substr('abcdef', -1, 1); // f
?>
<?php
$rest = substr("abcdef", -1);    // "f" を返す
$rest = substr("abcdef", -2);    // "ef" を返す
$rest = substr("abcdef", -3, 1); // "d" を返す
?>
<?php
$rest = substr("abcdef", 0, -1);  // "abcde" を返す
$rest = substr("abcdef", 2, -1);  // "cde" を返す
$rest = substr("abcdef", 4, -4);  // false を返す
$rest = substr("abcdef", -3, -1); // "de" を返す
?>

これを、先ほど定義したメソッドを使ってswift的に再現するなら
こんな感じです

"abcdef".substring(location:  1)             // bcdef
"abcdef".substring(location:  1, length: 3)  // bcd
"abcdef".substring(location:  0, length: 4)  // abcd
"abcdef".substring(location:  0, length: 8)  // abcdef
"abcdef".substring(location: -1, length: 1)  // f

"abcdef".substring(location: -1)             // f
"abcdef".substring(location: -2)             // ef
"abcdef".substring(location: -3, length: 1)  // d

"abcdef".substring(location:  0, length: -1) // abcde
"abcdef".substring(location:  2, length: -1) // cde
"abcdef".substring(location:  4, length: -4) // (false) => 空文字
"abcdef".substring(location: -3, length: -1) // de

今からこのコメント通りの戻り値が返るように実装していきます
いくつかのブロックに分けながら説明を書きます

        let strlen = self.length
        let max = strlen - 1, min = strlen * -1

メソッドの仕様に基づくのであれば
location は文字列長の範囲を超えてはいけません

たとえば

"abc".substring(location: 10)

だと開始位置が不明なのでNGですね

この考えから"abc"という文字列ならば-3〜2
"abcdef"という文字列ならば-6〜5 が範囲ということになります

次に

        var s = location

        if s < min || max < s {
            return ""
        } else if s < 0 {
            s = strlen + s
        }

変数 s は開始位置インデックスです
正の数であれば location と同値のはずです
ただし負の数であれば、メソッドの仕様に基づいて「後ろから数えた数」です

範囲外の開始位置インデックスであれば
メソッドの仕様に基づき、空文字を返して処理を抜けます

次に

        let len = length ?? strlen
        var e = 0

        if len > 0 {
            e = s + (len - 1)
            e = (e > max) ? max : e
        } else {
            e = max + len
        }

変数 e は終了位置インデックスです
原則として開始位置から length 分の位置が終了位置になりますが
終了位置は範囲を超えることはありえませんから
もし超えたら「終了位置は範囲の最大インデックス」という風にしておきます

length が負の値の場合のメソッドの仕様として

length が指定され、かつ負である場合
string の終端からその文字数分の文字が省略されます
(start が負の場合は、 開始位置を算出したあとで)
もし start が切り出し位置を超える場合、 FALSE が返されます

というものがありました

複雑な仕様に見えますが、
実は最大インデックスに負の数のlength を足すことで終了位置が取得できます

ここまでで、開始と終了位置がとれました
試しに開始と終了位置を出力してみます

print("start:\(s), end:\(e)")
"abcdef".substring(location:  1))              // start:1, end:5 // bcdef
"abcdef".substring(location:  1, length: 3))   // start:1, end:3 // bcd
"abcdef".substring(location:  0, length: 4))   // start:0, end:3 // abcd
"abcdef".substring(location:  0, length: 8))   // start:0, end:5 // abcdef
"abcdef".substring(location: -1, length: 1))   // start:5, end:5 // f
"abcdef".substring(location: -1))              // start:5, end:5 // f
"abcdef".substring(location: -2))              // start:4, end:5 // ef
"abcdef".substring(location: -3, length: 1))   // start:3, end:3 // d
"abcdef".substring(location:  0, length: -1))  // start:0, end:4 // abcde
"abcdef".substring(location:  2, length: -1))  // start:2, end:4 // cde
"abcdef".substring(location:  4, length: -4))  // start:4, end:1 //
"abcdef".substring(location: -3, length: -1))  // start:3, end:4 // de

渡している引数に対して、見比べてもらえると
おおよそ開始位置と終了位置があっているかと思います

唯一合わないのが下から2番目のstart:4, end:1というものです
開始位置が終了位置を上回ってしまっています

ここで前項で「ミソ」と言っていたところが効いてきます
開始位置が終了位置を上回ったら空文字を返すようにしたのはこれをするためでした

最後にこのメソッドは、前項で作ったメソッドを使って文字列取得します

        return self.substring(startIndex: s, endIndex: e)

PHPのドキュメントと同じ結果が返されると思います

おさらい

今回のソースコードです

extension String {

    /// 文字列長
    public var length: Int { return self.characters.count }

    /// 文字列の部分取得を行う
    ///
    ///     "abcdef".substring(location: 1)              // bcdef
    ///     "abcdef".substring(location: 1, length: 3)   // bcd
    ///     "abcdef".substring(location: 0, length: 4)   // abcd
    ///     "abcdef".substring(location: 0, length: 8)   // abcdef
    ///     "abcdef".substring(location: -1, length: 1)  // f
    ///     "abcdef".substring(location: -1)             // f
    ///     "abcdef".substring(location: -2)             // ef
    ///     "abcdef".substring(location: -3, length: 1)  // d
    ///     "abcdef".substring(location: 0, length: -1)  // abcde
    ///     "abcdef".substring(location: 2, length: -1)  // cde
    ///     "abcdef".substring(location: 4, length: -4)  //
    ///     "abcdef".substring(location: -3, length: -1) // de
    /// - parameter location: 開始インデックス
    /// - parameter length: 文字列長
    /// - returns: 部分取得された文字列
    public func substring(location location: Int, length: Int? = nil) -> String {
        let strlen = self.length
        let max = strlen - 1, min = strlen * -1

        var s = location

        if s < min || max < s {
            return ""
        } else if s < 0 {
            s = strlen + s
        }

        let len = length ?? strlen
        var e = 0

        if len > 0 {
            e = s + (len - 1)
            e = (e > max) ? max : e
        } else {
            e = max + len
        }

        return self.substring(startIndex: s, endIndex: e)
    }

    /// 文字列の部分取得を行う
    ///
    ///     "abcdef".substring(startIndex: 2, endIndex: 4) // cde
    /// - parameter startIndex: 開始インデックス
    /// - parameter endIndex: 終了インデックス
    /// - returns: 部分取得された文字列
    private func substring(startIndex start: Int, endIndex end: Int) -> String {
        let max = self.length - 1
        var s = start, e = end
        if max < 0 || e < s || max < s {
            return ""
        } else if s < 0 {
            s = 0
        } else if e < 0 {
            e = 0
        } else if max < e {
            e = max
        }
        let range = Range(self.startIndex.advancedBy(s)...self.startIndex.advancedBy(e))
        return self.substringWithRange(range)
    }
}
2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3