【C++】CSVファイルを読み込む

 C++からPythonのcsvモジュールを呼び出して、CSVファイルを読み込む方法を説明します。後半では、C++のみの方法も説明します。

開発環境Visual Studio 2022
言語C++
Python
動作確認Windows 11
Windows 10
Windows 7
Ubuntu (g++)
FreeBSD (clang++)

※Python 3.11にて確認しました。(Windows 7のみ、Python 3.8.10)

 CSVファイルは、フィールドをカンマで区切ったテキストファイルですが、歴史が古いのでローカルな実装がいろいろあります。後付けの公式仕様はRFC4180(https://www.ietf.org/rfc/rfc4180.txt)ですが、実際の開発の現場では「Excelで読み込み可能な形式」が事実上の標準と言えます。

 Pythonには、標準にてExcelに準拠したCSVファイル読み書き機能があります。それをC++から呼び出すことで、簡単にCSVファイルを読み込むことが可能です。私のPC(第9世代Core i7、Windows 11)では、日本郵便が公開している約12万行*15列の郵便番号データ(KEN_ALL.CSV)を読み込むのにかかる時間は、約0.4秒です。

 これだけ速いと、C++でロジックを組む気が無くなるし、Pythonが流行っているのも理解できます。

Pythonのインストールと、開発環境の設定

Windows

 https://www.python.org/downloads/windows/からインストーラーをダウンロードして、実行します。ダウンロードの際、32bit版と64bit版の選択は、C++から呼び出す場合のソリューションに合わせます。インストーラーの最初の画面にて「Customize installation」を選択して、途中の「Advanced Options」画面では、

  • Add Python to environment variables
  • Download debugging symbols
  • Download debug binaries

の3項目を選択します。その他の項目は任意です。

 他のPCに実行環境を作成する場合は、上記リンクからembeddable packageをダウンロードして、展開後、パスを通します。

Visual Studio 2022

 プロジェクトのプロパティに以下の設定を追加します。インストール先のフォルダーがデフォルトと異なる場合は、そちらに合わせてください。(3.11.Xの例)

[C/C++]-[全般]-[追加のインクルードディレクトリ]
C:\Users\ユーザー名\AppData\Local\Programs\Python\Python311\include;
[リンカー]-[全般]-[追加のライブラリディレクトリ]
C:\Users\ユーザー名\AppData\Local\Programs\Python\Python311\libs;

Ubuntu


# インストール (3.11.Xの例)
> sudo apt install python3.11
> sudo apt install python3.11-dev

# 確認
> python3.11 -V
> ls /usr/include/python3.11/Python.h

# コンパイル例
> g++ -Wall -I/usr/include/python3.11 test.cpp -lpython3.11 -o test

 Ubuntuに最初から入っているPythonは、OSが使用するものなので、絶対に削除しないでください。

FreeBSD


# インストール (3.11.Xの例)
> pkg install python311

# 確認
> python3.11 -V
> ls /usr/local/include/python3.11/Python.h

# コンパイル例
> clang++ -Wall -I/usr/local/include/python3.11 test.cpp -L/usr/local/lib -lpython3.11 -o test

サンプルソース

Python


import csv

def csvread(f, e):
    data = []

    with open(f, encoding = e, newline = '') as csvfile:
        reader = csv.reader(csvfile)
        for row in reader:
            data.append(row)

    return data

 上記のソースファイルをcsv_read.pyとします。ファイル名、関数名は、呼び出し側と合っているなら任意です。

C++


#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <string>
#include <vector>


    // 読み込んだフィールドを格納するvector
    // その際の文字コードは、CSVファイルの文字コードとは関係無い
    // UTF-8
    std::vector<std::string> row;
    std::vector<std::vector<std::string>> data;

    // UTF-16(Windows)、UTF-32(UNIX系OS)
    //std::vector<std::wstring> row;
    //std::vector<std::vector<std::wstring>> data;

    PyObject *pArgs{};
    PyObject *pValue{};
    PyObject *pRow{};
    PyObject *pField{};

    Py_Initialize();

    // カレントフォルダーをPythonのパスに追加
    PyRun_SimpleString(
        "import os\n"
        "import sys\n"
        "sys.path.append(os.getcwd())"
    );

    // モジュール名(Pythonソースファイルから、拡張子を除外)
    PyObject *pName = PyUnicode_DecodeFSDefault("csv_read");

    // モジュールをimport
    PyObject *pModule = PyImport_Import(pName);
    Py_DECREF(pName);

    if (!pModule)
    {
        // モジュール指定に誤り
        PyErr_Print();
        Py_FinalizeEx();
        return 1;
    }

    // Python関数
    PyObject *pFunc = PyObject_GetAttrString(pModule, "csvread");

    if (pFunc && PyCallable_Check(pFunc))
    {
        // 引数の設定
        pArgs = PyTuple_New(2);

        // CSVファイル名
        // 日本語が含まれる場合、UTF-8にて指定する
        pValue = PyUnicode_FromString(u8"ファイルのパス");
        PyTuple_SetItem(pArgs, 0, pValue);

        // CSVファイルの文字コード指定
        // (改行コードは、CR+LF,LF,CRのいずれにも対応している)
        // Shift_JIS → cp932
        // UTF-8 → utf_8_sig
        // UTF-16 BOM有 → utf_16
        // UTF-16 BOM無 ビッグエンディアン → utf_16_be
        // UTF-16 BOM無 リトルエンディアン → utf_16_le
        // 16を32に変更すると、UTF-32についても同様
        pValue = PyUnicode_FromString("cp932");
        PyTuple_SetItem(pArgs, 1, pValue);

        // Python関数の呼び出し
        pValue = PyObject_CallObject(pFunc, pArgs);
        Py_DECREF(pArgs);

        // pValueに、CSVファイルをパースした結果が入っている
        if (pValue)
        {
            // 行数
            Py_ssize_t rc = PyList_Size(pValue);

            for (Py_ssize_t i = 0; i < rc; i++)
            {
                // 行の読み取り
                pRow = PyList_GetItem(pValue, i);

                // 列数
                Py_ssize_t cc = PyList_Size(pRow);

                for (Py_ssize_t j = 0; j < cc; j++)
                {
                    // フィールドの読み取り
                    pField = PyList_GetItem(pRow, j);

                    // 文字コードを変換して、vectorに格納
                    // UTF-8
                    row.push_back(PyUnicode_AsUTF8(pField));

                    // UTF-16(Windows)、UTF-32(UNIX系OS)
                    //row.push_back(PyUnicode_AsWideCharString(pField, nullptr));
                }

                data.push_back(row);
                row.clear();
            }

            Py_DECREF(pValue);
        }
        else
        {
            // CSVファイル名、文字コード指定に誤り
            Py_DECREF(pFunc);
            Py_DECREF(pModule);
            PyErr_Print();
            Py_FinalizeEx();
            return 1;
        }
    }
    else
    {
        // 関数指定に誤り
        Py_XDECREF(pFunc);
        Py_DECREF(pModule);

        if (PyErr_Occurred())
        {
            PyErr_Print();
        }

        Py_FinalizeEx();
        return 1;
    }

    Py_DECREF(pFunc);
    Py_DECREF(pModule);

    if (Py_FinalizeEx() < 0)
    {
        return 1;
    }

    // フィールドは、data[i][j]にて取り出す。iは行、jは列のインデックス(ゼロ開始)

 C++のソースが非常に長いですが、よく読むと、C++からPythonを呼び出す際の定型文であることが理解できると思います。つまり、一部を変更するだけで、Pythonのいろいろな機能を呼び出すことが可能です。

CSVファイルの文字コード指定

 上記ソースのコメントに書いた文字コード以外は、https://docs.python.org/3/library/codecs.html#standard-encodingsを参照してください。

Shift_JISに「cp932」を指定する理由

  • Shift_JISファイルが作成される可能性が高いのは、Windows環境
  • Windowsにて、一般的に「Shift_JIS」と呼ばれる文字コードは、正確には「CP932」
  • WindowsのIMEにて、「から」を入力して変換される全角のニョロは「全角チルダ」
Shift_JIS
/ CP932
Python
codec
UnicodeIME
「から」候補
符号位置名称
0x8160cp932U+FF5E全角チルダ~    全
shift_jisU+301C波ダッシュ〜 環境依存

 以上より、「shift_jis」を指定すると作成者の意図とは別の文字に変換されるため、「cp932」を指定します。

PyObject変数の解放

 Python/C APIの戻り値として使用されるPyObject変数は、最後にPy_DECREFまたはPy_XDECREFにて解放します(実際の解放はPy_FinalizeEx実行時)。Py_DECREFは引数のnull判定無し、Py_XDECREFはnull判定有りです。

 対象となるAPIは、Python/C API Reference Manual(https://docs.python.org/3/c-api/index.html)にて「Return value: New reference.」と記載されているAPIです。「Return value: Borrowed reference.」は対象外です。上記ソースでは、PyList_GetItemは対象外になります。

リファレンス

1.3. Pure Embedding
https://docs.python.org/3/extending/embedding.html#pure-embedding
C++からPythonの呼び出しは、このソースを参考にしました。

郵便番号データダウンロード
https://www.post.japanpost.jp/zipcode/download.html

C++のみの方法

 ここからはサンプルソースを簡単にするため、文字コードはUTF-8(BOM無し)、Windowsの改行コードはCR+LFまたはLF、UNIX系OSの改行コードはLFを前提とします。その他の文字コードや改行コード、BOM有りに対応する場合は、「【C++】テキストファイルを1行毎に読み取る」を参照してください。

 測定用のKEN_ALL.CSVは、予めテキストエディターにてUTF-8(BOM無し)に変換しています。

単純なカンマ区切り


#include <fstream>
#include <sstream>
#include <vector>


    std::vector<std::string> row;
    std::vector<std::vector<std::string>> data;

    std::ifstream ifs("ファイルのパス");

    if (!ifs)
    {
        // オープン失敗
        return 1;
    }

    std::string r;
    std::string f;

    // 1行毎に読み込む
    while (std::getline(ifs, r))
    {
        std::istringstream iss(r);

        // カンマ区切りで読み込む
        while (std::getline(iss, f, ','))
        {
            if (f.front() == '"' && f.back() == '"')
            {
                // ダブルクォートを除外して格納
                row.push_back(f.substr(1, f.size() - 2));
            }
            else
            {
                row.push_back(f);
            }
        }

        data.push_back(row);
        row.clear();
    }

    ifs.close();

    // フィールドは、data[i][j]にて取り出す。iは行、jは列のインデックス(ゼロ開始)

 フィールドに「カンマ」「改行」「括る以外のダブルクォート」が無い、単純なCSVファイルに対応します。KEN_ALL.CSVの読み込みは、約0.3秒です。

正規表現


#include <fstream>
#include <regex>
#include <vector>


    std::vector<std::string> row;
    std::vector<std::vector<std::string>> data;

    std::ifstream ifs("ファイルのパス");

    if (!ifs)
    {
        // オープン失敗
        return 1;
    }

    std::string r;
    std::smatch s;

    // KEN_ALL.CSVの正規表現
    // (読み込むCSVファイルのフォーマットに合わせて書き換える)
    std::regex reCsv{R"((\d{5}),\"([\d ]{5})\",\"(\d{7})\",\"(.+?)\",\"(.+?)\",\"(.+?)\",\"(.+?)\",\"(.+?)\",\"(.+?)\",(\d),(\d),(\d),(\d),(\d),(\d))"};

    // 1行毎に読み込む
    while (std::getline(ifs, r))
    {
        // 正規表現の判定
        if (std::regex_match(r, s, reCsv))
        {
            for (std::size_t i = 1; i < s.size(); i++)
            {
                // s.str(0)     1行全体
                // s.str(1)~   各フィールド
                row.push_back(s.str(i));
            }
        }
        else
        {
            // フォーマット不正
            continue;
        }

        data.push_back(row);
        row.clear();
    }

    ifs.close();

    // フィールドは、data[i][j]にて取り出す。iは行、jは列のインデックス(ゼロ開始)

 テキストから、検索条件に該当するテキストを取り出す場合、正規表現を使用するとソースが短くなります。ただし、テキストの中身や正規表現の書き方によって、実行時間がかかることもあります。KEN_ALL.CSVは4~9列目の桁数が不定で、次のダブルクォートを探すまでの手数がかかる(この現象をバックトラックと言います)ため、読み込みは約2.3秒です。

汎用的なロジック


#include <fstream>
#include <string>
#include <vector>


    std::vector<std::string> row;
    std::vector<std::vector<std::string>> data;

    std::ifstream ifs("ファイルのパス");

    if (!ifs)
    {
        // オープン失敗
        return 1;
    }

    std::string r;
    std::string f;

    // CSVの1レコード読み取り中フラグ
    // フィールドに改行が含まれる場合、ファイルの1行とCSVの1レコードは異なる
    bool isRead = false;

    // 1行毎に読み込む
    while (std::getline(ifs, r))
    {
        if (isRead)
        {
            // フィールドの中に改行が有り、改行コードの代わりに印を付ける場合は、ここで付ける。
            // 例えば、「[改行]」というテキストを入れる場合、次のコメント解除
            //f += u8"[改行]";
        }
        else
        {
            isRead = true;
        }

        // カンマ検索開始位置のインデックス
        std::size_t iStart = 0;

        while (isRead)
        {
            // カンマのインデックス
            std::size_t iFind = r.find(',', iStart);

            if (iFind == std::string::npos)
            {
                // フィールド読み取り
                // (行の最後)
                f += r.substr(iStart);

                // 行末の空白を削除する場合、次のコメント解除
                //f.erase(f.find_last_not_of(" \t") + 1);

                isRead = false;
            }
            else
            {
                // フィールド読み取り
                // (行の最後以外)
                f += r.substr(iStart, iFind - iStart);
            }

            // フィールドがダブルクォートで括られている場合
            if (f.front() == '"')
            {
                // ダブルクォートの直後に改行がある場合の対策として、
                // f.size()>1の判定を追加
                if (f.size() > 1 && f.back() == '"')
                {
                    // ダブルクォートの削除
                    f.pop_back();
                    f.erase(f.begin());
                }
                else
                {
                    iStart = iFind + 1;

                    if (isRead)
                    {
                        // ダブルクォートで括られた中にカンマがある
                        f += ',';
                        continue;
                    }
                    else
                    {
                        // ダブルクォートで括られた中に改行がある
                        isRead = true;
                        break;
                    }
                }
            }

            // フィールドの中に、ダブルクォートが2個連続する場合、1個を削除
            std::size_t pos{std::string::npos};

            do
            {
                pos = f.find("\"\"");

                if (pos != std::string::npos)
                {
                    f.erase(pos, 1);
                }
            }
            while (pos != std::string::npos);

            // フィールドを格納
            row.push_back(f);
            f.clear();

            // カンマ検索開始位置の更新
            iStart = iFind + 1;
        }

        if (!isRead)
        {
            // レコードを格納
            data.push_back(row);
            row.clear();
        }
    }

    ifs.close();

    // フィールドは、data[i][j]にて取り出す。iは行、jは列のインデックス(ゼロ開始)

 「Ez CSV Viewer」のソースの一部です。フィールドに「カンマ」「改行」「括る以外のダブルクォート」が存在する場合にも対応しています。KEN_ALL.CSVの読み込みは、約0.2秒です。

 できるだけRFC4180に準拠するように考えましたが、以下のパターンには対応できませんでした。


"Joan ""the bone"", Anne"

 これは「Joan "the bone", Anne」という、1個のフィールドとして解釈するのが正しいですが、上記ソースでは「Joan "the bone"」と「 Anne"」に分離します。ちなみに、ExcelやPythonでは正しく読み込まれます。C++でロジックを考えるのと、C++からPythonを呼び出すのでは、どちらが早いでしょうか。