INIReader.h 解析:INI 格式解析器

本篇定位

這是 Phase 7 的第 2 篇。 INIReader.h 是一個輕量的 C++ 單頭標頭檔 INI 解析器,讓 MainMain::LoadConfig() 能用幾行程式碼讀取 dinput8.ini 的所有設定。


一、什麼是 INI 格式?

INI 是一種極簡單的設定檔格式,由 Windows 3.x 時代流傳下來,規則只有三條:

[Section]       ; 1. 方括號包住的是「區段(Section)」名稱
Key = Value     ; 2. 等號左邊是鍵名,右邊是值
; 這是注釋      ; 3. 分號開頭的行是注釋,忽略

範例:

[Connection]
ServerIP = 127.0.0.1
Port = 8484

二、為什麼不用 Windows 內建的 GetPrivateProfileString?

Windows API 有 GetPrivateProfileString / GetPrivateProfileInt 可以讀取 INI 檔案,但有幾個問題:

Windows APIINIReader
路徑限制需要絕對路徑接受相對路徑
編碼ANSI 或 Unicode 二選一自動處理
型別支援只有字串和整數另外支援布林、浮點數
可攜性僅 Windows跨平台(在 Linux/Mac 上編譯也能用)
標頭依賴需要 <windows.h>僅需 C++ 標準庫

三、INIReader.h 的完整實作

#pragma once
#include <string>
#include <map>
#include <fstream>
#include <sstream>
#include <algorithm>
#include <cctype>
 
class INIReader {
public:
    // 建構時立刻解析整個檔案
    explicit INIReader(const std::string& filename) {
        ParseFile(filename);
    }
 
    // 讀取字串值(找不到時回傳 defaultValue)
    std::string Get(const std::string& section,
                    const std::string& key,
                    const std::string& defaultValue) const
    {
        std::string lookupKey = MakeKey(section, key);
        auto it = m_values.find(lookupKey);
        return (it != m_values.end()) ? it->second : defaultValue;
    }
 
    // 讀取整數值
    long GetInteger(const std::string& section,
                    const std::string& key,
                    long defaultValue) const
    {
        std::string val = Get(section, key, "");
        if (val.empty()) return defaultValue;
        try { return std::stol(val); }
        catch (...) { return defaultValue; }
    }
 
    // 讀取浮點數值
    double GetReal(const std::string& section,
                   const std::string& key,
                   double defaultValue) const
    {
        std::string val = Get(section, key, "");
        if (val.empty()) return defaultValue;
        try { return std::stod(val); }
        catch (...) { return defaultValue; }
    }
 
    // 讀取布林值("true", "yes", "1", "on" 視為 true)
    bool GetBoolean(const std::string& section,
                    const std::string& key,
                    bool defaultValue) const
    {
        std::string val = Get(section, key, "");
        if (val.empty()) return defaultValue;
        std::string lower = ToLower(val);
        if (lower == "true" || lower == "yes" || lower == "1" || lower == "on")
            return true;
        if (lower == "false" || lower == "no" || lower == "0" || lower == "off")
            return false;
        return defaultValue;
    }
 
    // 取得解析錯誤的描述(空字串 = 無錯誤)
    std::string ParseError() const { return m_parseError; }
 
private:
    std::map<std::string, std::string> m_values;
    std::string m_parseError;
 
    // ── 解析核心 ────────────────────────────────────────────────────────────
    void ParseFile(const std::string& filename) {
        std::ifstream file(filename);
        if (!file.is_open()) {
            m_parseError = "Cannot open file: " + filename;
            return;
        }
 
        std::string currentSection;
        std::string line;
        int lineNum = 0;
 
        while (std::getline(file, line)) {
            lineNum++;
            line = Trim(line);   // 去掉前後空白
 
            // 跳過空行和注釋
            if (line.empty() || line[0] == ';' || line[0] == '#')
                continue;
 
            // 區段標頭 [SectionName]
            if (line[0] == '[') {
                size_t end = line.find(']');
                if (end == std::string::npos) {
                    m_parseError = "Malformed section at line " + std::to_string(lineNum);
                    return;
                }
                currentSection = Trim(line.substr(1, end - 1));
                continue;
            }
 
            // 鍵值對 Key = Value
            size_t eqPos = line.find('=');
            if (eqPos == std::string::npos) continue;  // 沒有等號的行,忽略
 
            std::string key   = Trim(line.substr(0, eqPos));
            std::string value = Trim(line.substr(eqPos + 1));
 
            // 移除行內注釋(= 右邊的 ; 後面都是注釋)
            size_t commentPos = value.find(';');
            if (commentPos != std::string::npos)
                value = Trim(value.substr(0, commentPos));
 
            m_values[MakeKey(currentSection, key)] = value;
        }
    }
 
    // ── 輔助函式 ────────────────────────────────────────────────────────────
 
    // 把 section + key 組成唯一鍵(小寫,不區分大小寫)
    static std::string MakeKey(const std::string& section, const std::string& key) {
        return ToLower(section) + "." + ToLower(key);
    }
 
    // 轉小寫
    static std::string ToLower(std::string s) {
        std::transform(s.begin(), s.end(), s.begin(),
            [](unsigned char c) { return (char)std::tolower(c); });
        return s;
    }
 
    // 去掉字串前後的空白字元
    static std::string Trim(const std::string& s) {
        const char* ws = " \t\r\n";
        size_t start = s.find_first_not_of(ws);
        if (start == std::string::npos) return "";
        size_t end = s.find_last_not_of(ws);
        return s.substr(start, end - start + 1);
    }
};

四、資料流視覺化

dinput8.ini 檔案內容:
  [Connection]
  ServerIP = 127.0.0.1  ; 本機
  Port = 8484

ParseFile() 解析後的 m_values(std::map):
  "connection.serverip" → "127.0.0.1"
  "connection.port"     → "8484"

Get("Connection", "ServerIP", "") 呼叫流程:
  MakeKey("Connection", "ServerIP") → "connection.serverip"
  m_values.find("connection.serverip") → 找到 "127.0.0.1"
  回傳 "127.0.0.1"

為什麼要轉小寫?

INI 格式傳統上不區分大小寫([Connection][connection] 應該是同一區段)。MakeKey 把 section 和 key 都轉小寫,這樣 Get("connection", "serverip", "")Get("Connection", "ServerIP", "") 會查到同一個值。


五、行內注釋的處理

ServerIP = 127.0.0.1  ; 這是行內注釋,不是 IP 的一部分

ParseFile 在讀到值之後,額外搜尋 ; 並把它後面的內容截掉:

size_t commentPos = value.find(';');
if (commentPos != std::string::npos)
    value = Trim(value.substr(0, commentPos));

結果:value"127.0.0.1 ; 這是行內注釋" 變成 "127.0.0.1"

如果值本身包含分號

例如 Password = abc;def 會被截成 abc,這是 INI 格式的已知限制。如果需要支援值中含有 ;,需要改用引號包圍(Password = "abc;def")並修改解析邏輯。在 MapleEzorsia-v2 的使用情境中,所有設定值(IP、Port、數字、布林)都不含分號,不需要處理這個邊界情況。


六、常見問題

Q:整個 INIReader 都在 .h 裡,不需要 .cpp 嗎?

是的。這是「單頭標頭庫(Header-only library)」的設計模式。把所有程式碼放在 .h 裡,使用方只需要 #include "INIReader.h",不需要編譯額外的 .cpp。缺點是每個 include 這個標頭的翻譯單元(.cpp)都會編譯一份,增加編譯時間;優點是整合非常簡單。

Q: std::map vs std::unordered_map,哪個更好?

std::unordered_map 的平均查詢時間是 O(1),std::map 是 O(log n)。但設定檔通常只有幾十個鍵,這個差距微不足道。std::map 按字母排序,方便除錯時列出所有鍵值對,所以本實作選它。


延伸閱讀