Java Tips | 文字列処理:制御文字除去

Java Java
スポンサーリンク

制御文字除去は「“見えないゴミ”を取り除いてトラブルを防ぐ」技

業務で外部システムからファイルを受け取ったり、コピー&ペーストされたテキストを扱っていると、
画面には見えないのに、処理だけおかしくなる“謎の文字” に出会うことがあります。

CSVがうまくパースできない。
比較すると違うことになっているのに、目で見ると同じに見える。
ログに出すと、変な位置で改行されたり、文字化けしたりする。

その正体の一つが 制御文字(コントロールキャラクタ) です。
これを「見つけて」「必要に応じて除去する」のが、制御文字除去ユーティリティの役割です。


制御文字ってそもそも何者?

「表示されるための文字」ではなく「制御のための文字」

文字には、大きく分けて二種類あります。

画面に表示される文字(A, あ, 1, ! など)
表示されず、動作を制御するための文字(改行、タブ、ベルなど)

後者が 制御文字 です。

典型的には、ASCIIコードの 0x00〜0x1F(0〜31)と 0x7F(DEL)が制御文字とされます。
この中には、次のようなものが含まれます。

改行(LF, \n
復帰(CR, \r
タブ(TAB, \t
ベル(BEL)
フォームフィード(FF, \f
その他、今ではほとんど使われない制御用のコード

ここで大事なのは、「全部を無条件に消していいわけではない」 ということです。
改行やタブは、テキストとして意味を持つことも多いからです。

だからこそ、制御文字除去では、

「どの制御文字を残して、どれを消すか」

を最初に決めることがとても重要になります。


方針を決める:「何を残して、何を消すか」

典型的なパターンを三つだけ押さえる

制御文字除去の方針は、用途によって変わりますが、
よくあるパターンは次のようなものです。

テキストとして扱いたいので、改行とタブだけ残して、それ以外の制御文字を消す
1行テキストとして扱いたいので、改行もタブも含めて、制御文字を全部消す
ログやCSVなどで「改行はLFに統一し、それ以外の制御文字は消す」(改行統一とセット)

ここでは、まず「改行とタブは残して、それ以外を消す」パターンで実装してみます。
そのうえで、「全部消す」バージョンも見ていきます。


実装1:改行とタブを残して、それ以外の制御文字を除去する

ループで1文字ずつ見ていくシンプル実装

まずは、文字コードを見ながら「残す/消す」を判定するユーティリティです。

public final class ControlChars {

    private ControlChars() {}

    public static String removeExceptNewlineAndTab(String text) {
        if (text == null) {
            return null;
        }
        StringBuilder sb = new StringBuilder(text.length());
        for (int i = 0; i < text.length(); i++) {
            char c = text.charAt(i);
            if (isAllowed(c)) {
                sb.append(c);
            }
        }
        return sb.toString();
    }

    private static boolean isAllowed(char c) {
        // 改行(LF)、復帰(CR)、タブは許可
        if (c == '\n' || c == '\r' || c == '\t') {
            return true;
        }
        // 0x20 未満は制御文字なので、それ以外は許可しない
        if (c < 0x20) {
            return false;
        }
        // DEL (0x7F) も制御文字扱いで除去
        if (c == 0x7F) {
            return false;
        }
        // それ以外は表示可能文字として許可
        return true;
    }
}
Java

使い方はこうなります。

String dirty = "ABC\u0007DEF\nGHI\tJKL\u0001MNO";
String cleaned = ControlChars.removeExceptNewlineAndTab(dirty);

System.out.println(dirty);
System.out.println(cleaned);
Java

ここで \u0007 はベル(BEL)、\u0001 は制御文字の一種です。
出力イメージとしては、ベルと \u0001 が消え、改行とタブだけが残ります。

ここで深掘りしたい重要ポイントは三つです。

一つ目は、「制御文字かどうかを“数値範囲”で判定している」ことです。
c < 0x20c == 0x7F を制御文字とみなし、それ以外を表示可能文字としています。
この範囲は、ASCIIの制御文字の定義に基づいた、よくある実務的な線引きです。

二つ目は、「改行(LF/CR)とタブだけは特別扱いで残している」ことです。
テキストとしての意味を持つことが多いので、
「制御文字だけど残す」という例外を先に判定しています。

三つ目は、「null をそのまま null で返している」ことです。
空文字と null を区別したい場面は多いので、
「入力が null なら処理せず null を返す」という挙動にしておくと扱いやすくなります。


実装2:制御文字を全部除去する(1行テキスト用)

チャットのニックネームやIDなど「改行禁止」のケース

今度は、「改行もタブも含めて、制御文字を全部消したい」ケースです。
例えば、ユーザー名、ID、コード値など、
1行テキストとして扱う前提の項目に向いています。

public final class ControlChars {

    private ControlChars() {}

    public static String removeAllControlChars(String text) {
        if (text == null) {
            return null;
        }
        StringBuilder sb = new StringBuilder(text.length());
        for (int i = 0; i < text.length(); i++) {
            char c = text.charAt(i);
            if (isControl(c)) {
                continue; // スキップして追加しない
            }
            sb.append(c);
        }
        return sb.toString();
    }

    private static boolean isControl(char c) {
        // 0x20 未満は制御文字
        if (c < 0x20) {
            return true;
        }
        // DEL (0x7F) も制御文字扱い
        if (c == 0x7F) {
            return true;
        }
        return false;
    }
}
Java

使い方はこうです。

String dirty = "User\nName\tWith\u0007Controls";
String cleaned = ControlChars.removeAllControlChars(dirty);

System.out.println("[" + dirty + "]");
System.out.println("[" + cleaned + "]");
// [User
// Name    WithControls]
// [UserNameWithControls]
Java

ここでのポイントは、「用途によって“どこまで消すか”を変える」ということです。
1行テキストとして扱うなら、改行やタブも邪魔になるので全部消す。
複数行テキストとして扱うなら、改行は残しておく。
この判断を、ユーティリティメソッドのレベルで分けておくと、
呼び出し側のコードがとても読みやすくなります。


例題:外部ファイル取り込み前の“クリーニング”として使う

CSVや固定長ファイルに紛れ込んだ制御文字を落とす

外部システムから受け取ったCSVや固定長ファイルに、
コピー&ペースト由来の制御文字(謎の0x1Fなど)が紛れ込んでいると、
パーサがエラーになったり、項目の長さが合わなくなったりします。

その前処理として、「1行読み込んだら制御文字を除去する」という使い方がよくあります。

public final class CsvCleaner {

    private CsvCleaner() {}

    public static String cleanLine(String line) {
        // 改行はすでに行単位で切れている前提なので、全部の制御文字を除去
        return ControlChars.removeAllControlChars(line);
    }
}
Java

使い方はこうです。

try (BufferedReader br = Files.newBufferedReader(path)) {
    String line;
    while ((line = br.readLine()) != null) {
        String cleaned = CsvCleaner.cleanLine(line);
        // ここで CSV パース
    }
}
Java

ここでのポイントは、「“読み込んだ瞬間にクリーニングする”ことで、後続の処理をシンプルに保つ」ことです。
後ろの処理は、「制御文字はもう存在しない」という前提で書けるので、
バグの原因になる“見えないゴミ”を気にしなくて済みます。


例題:ログ出力前に制御文字を落としておく

ログが“読めない”“ツールで開けない”問題を防ぐ

ログにユーザー入力や外部データをそのまま出していると、
制御文字のせいでログビューアが崩れたり、
1行のはずが複数行に見えたりすることがあります。

そこで、「ログに出す前に制御文字を除去する」というユーティリティを挟むのも有効です。

public final class LogSafe {

    private LogSafe() {}

    public static String sanitize(String text) {
        // ログは1行で見たいことが多いので、制御文字は全部除去
        return ControlChars.removeAllControlChars(text);
    }
}
Java

使い方はこうです。

String userInput = getUserInput();
logger.info("userInput={}", LogSafe.sanitize(userInput));
Java

ここでのポイントは、「ログは“人間が読むもの”なので、多少情報が削れても“読めること”を優先する」という割り切りです。
制御文字を残してログが読めなくなるくらいなら、
制御文字を落としてでも、テキストとして読めるほうが実務的にはありがたいことが多いです。


正規表現で一気に除去する方法との比較

replaceAll で書くとどうなるか

制御文字除去は、正規表現でも書けます。

public static String removeAllControlCharsRegex(String text) {
    if (text == null) {
        return null;
    }
    // \\p{Cntrl} は制御文字クラス
    return text.replaceAll("\\p{Cntrl}", "");
}
Java

これはこれでシンプルですが、
「改行だけ残したい」「タブは残したい」といった細かい制御を入れ始めると、
正規表現が一気に読みにくくなります。

// 改行とタブを残して、それ以外の制御文字を除去(例)
return text.replaceAll("[\\p{Cntrl}&&[^\\n\\t\\r]]", "");
Java

こうなると、初心者にはかなりハードルが高いです。

そのため、「細かいルールを入れたいときはループ+ifで書く」「全部消すだけなら正規表現でもOK」
くらいの感覚で使い分けると、コードの読みやすさと柔軟性のバランスが取りやすくなります。


まとめ:制御文字除去ユーティリティで身につけたい感覚

制御文字除去は、「目に見えないゴミを取り除いて、テキスト処理を安定させる」ためのテクニックです。

押さえておきたい感覚は、まず「制御文字とは何か(0x00〜0x1Fと0x7F)」をざっくり理解すること。
次に、「用途ごとに“何を残して、何を消すか”を決め、そのルールをメソッドとして名前付きで表現する」こと。
そして、「ファイル読み込み直後・ログ出力直前など、“境界”で一度クリーニングしておくと、後続の処理がシンプルになる」という設計の感覚です。

もしあなたの現場で、「なぜかCSVがパースできない」「ログが変なところで折れる」といった謎現象が起きているなら、
そのテキストを16進ダンプしてみて、0x00〜0x1Fあたりの制御文字が紛れ込んでいないかを見てみてください。
そして、それを題材にして、ここで作った ControlChars.removeExceptNewlineAndTabremoveAllControlChars を差し込んでみる。
それだけで、「見えないゴミに振り回されない、安定した文字列処理」に一歩近づけます。

タイトルとURLをコピーしました