Modern C++ 中枚舉與字符串轉(zhuǎn)換技巧

在 Java、C# 這樣的語言中,從枚舉轉(zhuǎn)換成字符串,或者從字符串轉(zhuǎn)換成枚舉,都是很常見的操作,也很方便。比如下面是 C# 的例子:

public enum Color { red, green, blue }

static void Main(string[] args) {
  Console.WriteLine("This color is {0}.", Color.red);
}

之所以可以這么用,是因?yàn)樵?IL 中以元數(shù)據(jù)方式保存了整個(gè)枚舉類型的各類信息,包括其內(nèi)部實(shí)際值和類型名稱字符串。

C++ 中就沒有那么容易了,因?yàn)?C++ 直接將源代碼編譯成目標(biāo)機(jī)的機(jī)器語言,也就是最終執(zhí)行的指令序列,枚舉類型的名稱字符串在指令序列中是不存在的。但是,現(xiàn)實(shí)應(yīng)用中確實(shí)可能存在這樣的場合,即需要從枚舉名稱字符串找到它對(duì)應(yīng)的枚舉值,有沒有辦法實(shí)現(xiàn)呢?

有人說這還不簡單,手工建立一個(gè)查詢字典不就可以了么?確實(shí)是可以,但是不得不說,這個(gè)方法它確實(shí)是既低效又丑陋,對(duì)于講究代碼美學(xué)的高等級(jí)碼農(nóng)來說,肯定是不能忍受啊,我們要的就是不管看起來還是用起來,都無比簡潔自然的那種實(shí)現(xiàn)。

如果只從 C++ 標(biāo)準(zhǔn)來看是沒有直接辦法的,但事實(shí)上每一種 C++ 編譯器都在 C++ 標(biāo)準(zhǔn)之外有所拓展,充分利用好這些拓展,就能輕松實(shí)現(xiàn)上述需求。本文就是筆者在 Github 上沖浪時(shí),無意中發(fā)現(xiàn)的一個(gè)名叫 magic_enum 的 C++ 項(xiàng)目,相當(dāng)完美地解決了這個(gè)問題。隨后筆者重新 C++20 的 concept,并使用 doctest 重新寫了一個(gè)相對(duì)簡單的示例程序。下面進(jìn)行簡要介紹和技術(shù)解析。

使用示例

先看最常用的使用場景:

enum class Color : int { RED = -10, BLUE = 0, GREEN = 10 };
//場景一:枚舉值轉(zhuǎn)換成字符串
CHECK_EQ(enum_name(Color::RED), "RED");
//場景二:字符串轉(zhuǎn)換成枚舉值
CHECK_EQ(enum_cast<Color>("BLUE").value(), Color::BLUE);

場景一,從枚舉值轉(zhuǎn)換為字符串,這個(gè)相對(duì)簡單,只要找到辦法能將枚舉值的表示字符串,轉(zhuǎn)化為實(shí)際的字符串類型就可以。

場景二,從字符串轉(zhuǎn)換成枚舉值,這個(gè)來說要復(fù)雜得多。首先,得知道要轉(zhuǎn)換成哪一個(gè)枚舉類型,因?yàn)橐粋€(gè)字符串可能與多個(gè)枚舉類型相對(duì)應(yīng),所以必須要指定轉(zhuǎn)換類型,可以用模板參數(shù)來表示,就像上面例子中那樣;其次,一個(gè)字符串未必一定能夠成功轉(zhuǎn)換成指定枚舉類型中的值,比如上面例子中如果使用 "CYAN" 來作為參數(shù),那么是沒辦法轉(zhuǎn)換成 RED、BLUE、GREEN 三者之一的,換句話說,從字符串轉(zhuǎn)換到枚舉值是有可能沒有結(jié)果的。

枚舉值轉(zhuǎn)換為字符串

閑話少說,直接上代碼(簡化版):

template <typename E>
concept Enum = std::is_enum_v<E>;

template <Enum E, E V>
constexpr auto n() noexcept {
#  if defined(__clang__) || defined(__GNUC__)
  constexpr auto name = pretty_name({ __PRETTY_FUNCTION__, sizeof(__PRETTY_FUNCTION__) - 2 });
#  elif defined(_MSC_VER)
  //auto __cdecl magic_enum::detail::n<enum Color, Color::RED>(void) noexcept 去掉末尾17個(gè)再過濾開頭
  constexpr auto name = pretty_name({ __FUNCSIG__, sizeof(__FUNCSIG__) - 17 });
#  endif
  return static_string<name.size()>{name};
}
template <Enum E, E V>
inline constexpr auto enum_name_v = n<E, V>();

理解這段代碼的關(guān)鍵,就是各種編譯器的自定義宏。以 Visual C++ 為例,它的內(nèi)部對(duì)每個(gè)函數(shù)都有一個(gè)自定義宏 FUNCSIG,意思差不多就是函數(shù)簽名。在 clang 或者 g++ 里就是 PRETTY_FUNCTION 宏。上面代碼中的 n() 函數(shù)里,使用條件編譯判斷當(dāng)前使用的是哪個(gè)編譯器,再根據(jù)不同的編譯器選擇不同的自定義宏,獲取編譯器內(nèi)部的函數(shù)簽名,再通過 pretty_name 函數(shù)截取到對(duì)應(yīng)的值名稱。

比如我們可以使用以下用法,獲取到 Color::RED 值所對(duì)應(yīng)的名稱字符串 “RED”:

constexpr std::string_view s = enum_name_v<Color, Color::RED>;
CHECK_EQ(s, "RED");

enum_name_v 直接獲取 n 函數(shù)的返回值,那么將模板參數(shù)代入 n 函數(shù)后,在 Visual C++ 編譯器里,其函數(shù)簽名就變成了

#define __FUNCSIG__ \
  "auto __cdecl magic_enum::detail::n<enum Color, Color::RED>(void) noexcept"

pretty_name 函數(shù)的調(diào)用參數(shù)只有一個(gè),就是 string_view,花括號(hào)內(nèi)是它的構(gòu)造參數(shù),將長度減去 17 之后(包含末尾的 \0),實(shí)際調(diào)用的參數(shù)值就成了:

"auto __cdecl magic_enum::detail::n<enum Color, Color::RED"

pretty_name 函數(shù)的作用,就是由后向前掃描整個(gè)字符串,一旦發(fā)現(xiàn)非標(biāo)識(shí)符字符就停止,然后截?cái)嘁呀?jīng)掃描過的字符串并返回:

constexpr std::string_view pretty_name(std::string_view name) noexcept {
  for (std::size_t i = name.size(); i > 0; --i) {
    if (!((name[i - 1] >= '0' && name[i - 1] <= '9') || (name[i - 1] == '_') ||
      (name[i - 1] >= 'a' && name[i - 1] <= 'z') || (name[i - 1] >= 'A' && name[i - 1] <= 'Z'))) {
      name.remove_prefix(i); //由后向前,發(fā)現(xiàn)非標(biāo)識(shí)符字符即啟動(dòng)截?cái)?,保留后半?      break;
    }
  }
  if (name.size() > 0 && ((name.front() >= 'a' && name.front() <= 'z') ||
    (name.front() >= 'A' && name.front() <= 'Z') || (name.front() == '_'))) {
    return name; //首字母不是數(shù)字
  }
  return {}; //否則就是非法名稱
}

因此,pretty_name 最后返回的就是 "RED" 這個(gè)枚舉值名稱,它向外傳遞到 enum_name_v 再賦值給 s,中間經(jīng)過了自定義類型 static_string 和 string_view 兩個(gè)類型的自動(dòng)轉(zhuǎn)換。所以,最后我們的測(cè)試斷言 CHECK_EQ 是順利通過的。

還要注意的一點(diǎn)就是,從 enum_name_v 到 pretty_name 這層層調(diào)用的一系列函數(shù),全部都是標(biāo)記了 constexpr 的,這就意味著它們都可以在編譯期就完成求值。換句話說,上面的調(diào)用在經(jīng)過編譯器處理后,最后實(shí)際變成的是以下代碼:

//這是我們?cè)瓉頃鴮懙拇a
constexpr std::string_view s = enum_name_v<Color, Color::RED>;
CHECK_EQ(s, "RED");
//這相當(dāng)于編譯器最后生成的代碼
CHECK_EQ("RED"sv, "RED");

這就是現(xiàn)代 C++ 編譯器,編譯期計(jì)算的能力已經(jīng)相當(dāng)強(qiáng)大,由它生成的代碼,毫無疑問其執(zhí)行效率要遠(yuǎn)高于 Java、C# 以及 Python 等語言。當(dāng)然,前提是首先得能熟練地掌握它。

字符串轉(zhuǎn)換為枚舉

如前所述,將字符串轉(zhuǎn)換為枚舉要麻煩許多。針對(duì)所轉(zhuǎn)換的枚舉類型,必須得要有一個(gè)完備的字符串列表,并與枚舉值一一對(duì)應(yīng),這樣才可以根據(jù)字符串去進(jìn)行查找。那么,需要準(zhǔn)備哪些數(shù)據(jù)呢?來作一下具體分析:

第一步,要有一個(gè)合法枚舉值列表,并且編譯器要能根據(jù)普通的枚舉聲明自動(dòng)列舉出來。這里需要注意的是,枚舉值是可以從負(fù)數(shù)開始的,也可以是稀疏的,就像前面的例子,Color 類型的三個(gè)枚舉值,對(duì)應(yīng)的內(nèi)部值分別是 -10、0、10。

為進(jìn)一步簡化示例代碼,先不考慮標(biāo)志位枚舉的情況,假定都是如 Color 這樣的簡單枚舉,取枚舉值列表可以這樣完成:

//V是否為指定枚舉的合法值
template <Enum E, auto V>
constexpr bool is_valid() noexcept { return n<E, static_cast<E>(V)>().size() != 0; }

//返回以O(shè)為基準(zhǔn)、指定序號(hào)的枚舉值
template <Enum E, int O, typename U = std::underlying_type_t<E>>
constexpr E value(std::size_t i) noexcept { return static_cast<E>(static_cast<int>(i) + O); }

template <Enum E, int Min, std::size_t... I>
constexpr auto values(std::index_sequence<I...>) noexcept {
  //遍歷指定取值檢查是否合法枚舉值
  constexpr bool valid[sizeof...(I)] = { is_valid<E, value<E, Min, IsFlags>(I)>()... };
  constexpr std::size_t count = values_count(valid); //共有多少個(gè)合法枚舉值
  if constexpr (count > 0) {
    E values[count] = {};
    for (std::size_t i = 0, v = 0; v < count; ++i) //將所有合法枚舉值填充入數(shù)組
      if (valid[i])
        values[v++] = value<E, Min, IsFlags>(i);
    return std::to_array(values); //再轉(zhuǎn)換成array后返回
  } else {
    return std::array<E, 0>{}; //無合法枚舉值,返回空array
  }
}

//返回取值范圍中的所有合法值,是一個(gè)基于最小值的索引序列
template <Enum E, typename U = std::underlying_type_t<E>>
constexpr auto values() noexcept {
  constexpr auto min = reflected_min_v<E>; //枚舉范圍最小值
  constexpr auto max = reflected_max_v<E>; //枚舉范圍最大值
  constexpr auto range_size = max - min + 1;
  return values<E, IsFlags, reflected_min_v<E>>(std::make_index_sequence<range_size>{});
}

上面例子中,reflected_min_v 和 reflected_max_v 兩個(gè)模板函數(shù),是根據(jù)枚舉內(nèi)部類型值以及用戶自定義設(shè)定,來確定枚舉值的取值范圍。在遍歷整個(gè)取值范圍后,將所有合法的枚舉值存入一個(gè) std::array。注意這里所有函數(shù)仍然都是帶 constexpr 標(biāo)記的。

第二步,要有一個(gè)枚舉值字符串列表,與上面的合法枚舉值一一對(duì)應(yīng)。這個(gè)相對(duì)好辦,解決了第一步之后,可以依次遍歷每個(gè)枚舉值生成字符串,組成列表就可以了:

template <Enum E>
inline constexpr auto count_v = values_v<E>.size(); //size_t類型

template <Enum E, std::size_t... I>
constexpr auto names(std::index_sequence<I...>) noexcept {
  return std::array<std::string_view, sizeof...(I)>{ { enum_name_v<E, values_v<E>[I]>... }};
}

template <Enum E>
inline constexpr auto names_v = names<E>(std::make_index_sequence<count_v<E>>{});

下面可以基本完成 enum_cast 主功能了:

template <Enum E>
constexpr auto enum_cast(std::string_view value) noexcept -> std::optional<E>
{
  for (std::size_t i = 0; i < count_v<E>; ++i) //逐個(gè)比較,相等則返回對(duì)應(yīng)枚舉值
    if (value == names_v<E>[i])
      return enum_value<E>(i);
  return {};
}

注意返回的是 std::optional 模板類型,如果對(duì)應(yīng)的枚舉值沒有找到,則返回空值。

更進(jìn)一步的設(shè)計(jì)

上文中我們完全沒有考慮標(biāo)志位枚舉的情況,這種情況要復(fù)雜得多,看以下的使用示例:

enum class AnimalFlags : std::uint64_t {
  HasClaws = 1 << 10,
  CanFly = 1 << 20,
  EatsFish = 1 << 30,
  Endangered = std::uint64_t{ 1 } << 40
};

constexpr AnimalFlags f1 = AnimalFlags::HasClaws | AnimalFlags::EatsFish;
CHECK_EQ(enum_name(f1), "HasClaws|EatsFish");

constexpr auto f2 = magic_enum::flags::enum_cast<AnimalFlags>("EatsFish|CanFly");
CHECK_EQ(f2.value(), AnimalFlags::EatsFish | AnimalFlags::CanFly);

還有最常用的流操作符:

std::ostringstream str;
str << Color::RED;
CHECK_EQ(str.str(), "RED");

此外,還應(yīng)當(dāng)允許用戶自定義枚舉值的字符串名稱、自定義字符串比較算法等等,作為一個(gè)相對(duì)完整的功能,這些都是必要的。具體的實(shí)現(xiàn)本文就不再詳述了,感興趣的可以點(diǎn)擊 這里 查看筆者改寫的源碼,也可以點(diǎn)擊 magic_enum 查看原始項(xiàng)目的完整源碼。

歡迎關(guān)注微信公眾號(hào),一起交流
兆華雜記
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容