天天看點

實際工程中的 C++ 模闆按版本号過濾配置Data blob 操作輔助類強類型别名小結

C++ 的模闆是 C++ 的一個重要的語言特性,我們使用的 STL 就是 Standard Template Library 的縮寫,但是在很多情況下,開發者都對其敬而遠之,有些團隊甚至是直接在工程中禁用模闆。模闆常被當作洪水猛獸的一個原因是許多人提起模闆就要提 C++ 模闆圖靈完備,甚至還要再秀一段編譯期排序,這種表現模闆強大的方式不僅不會讓人覺得模闆有用,反而讓人覺得模闆難以了解而且不應該使用。在這篇文章裡,我将聊一下最近實際工程中的一些模闆的應用,希望可以讓更多人了解到模闆并不是一個可怕的存在,以及一些常見的使用方式。

按版本号過濾配置

我所在的項目組前背景的複雜配置現在都用 protobuf 進行承載,然後生成 Excel 進行配置,生成 C++ 代碼進行加載。例如這樣的 message:

message ConfigItem1 {
  int32 id = 1;
  string text = 2;
}

message Config {
  repeated ConfigItem1 items1 = 1;
}           

複制

這裡的

Config

會被映射為一個 Excel,裡面有一個表

items1

,其中,這個表有兩列,一列

id

,一列

text

。這個表的每一行都是一個具體的配置項。也就是我們可以這樣擷取配置:

cout << cfg.items1(0).id() << ": " << cfg.items1(0).text();           

複制

現在有個需求是這樣的,在加載某些配置的時候,前台需要根據版本号進行配置的過濾,部配置設定置項隻會在某些版本中可見,例如這樣:

message VersionRange {
  int32 lo = 1;
  int32 hi = 2;
}

message ConfigItem2 {
  repeated VersionRange version_ranges = 1;
  int32 id = 2;
  int32 value = 3;
}

message Config {
  repeated ConfigItem1 items1 = 1;
  repeated ConfigItem2 items2 = 2;
}           

複制

加載的時候大概有這樣的代碼:

// 加載配置時進行過濾
for (auto iter = cfg.items2().begin(); iter != cfg.items2().end();) {
  if (!IsAvailableVersion(*iter, ver)) {
    iter = cfg.mutable_items2()->erase(iter);
  } else {
    iter++;
  } 
}           

複制

這個

IsAvailableVersion

要怎麼實作呢?我們當然可以對每個配置項類型都實作一個函數重載,但是我們也可以使用函數模闆來生成這些代碼,非常簡單:

template<class CfgItem>
bool IsAvailableVersion(CfgItem const &item, int ver) {
  auto const &ranges = item.version_ranges();
  if (std::begin(ranges) == std::end(ranges)) {
    return true; // 如果 version range 清單為空,預設傳回 true
  }
  for (auto const& range : ranges) {
    if (ver >= range.lo() && ver <= range.hi()) {
      return true; // 如果目前版本在範圍内,就傳回 true
    }
  }
  return false; // 如果 version range 清單不為空,預設傳回 false
}           

複制

但這裡有個問題,不是每一個配置項的類型裡都有

version_range

字段,例如

ConfigItem1

就沒有。這就導緻了

IsAvailableVersion

不能對所有的配置項對象進行使用,這不利于我們統一 code gen 上面加載配置時進行過濾的代碼。

這時候,我們想要做的,是對

IsAvailableVersion

的類型參數進行限制,根據這個類型是否帶有

version_range

字段來決定是否進行過濾:

template<class CfgWithVerRange>
bool IsAvailableVersion(CfgWithVerRange const &item, int ver) { /* 實作同上 */ }

template<class CfgWithoutVerRange>
bool IsAvailableVersion(CfgWithoutVerRange const &item, int ver) { return true; }           

複制

可惜編譯器沒法通過類型參數的名字明白我們的意圖,是以我們需要用一些技巧達到我們的目的:

template<class CfgItem, class = void>
struct IsAvailableVersionHelper {
  static bool Check(CfgItem const&, int) { return true; }
};

template<class CfgItem>
struct IsAvailableVersionHelper<
  CfgItem,
  lib::void_t<
    decltype(std::begin(std::declval<CfgItem>().version_ranges())->lo()),
    decltype(std::begin(std::declval<CfgItem>().version_ranges())->hi())
  >
> { /* 實作同上 */ };

template<class CfgItem>
bool IsAvailableVersion(CfgItem const &item, int ver) {
  return IsAvailableVersionHelper<CfgItem>::Check(item, ver);
}           

複制

這時候我們就可以放心地寫

IsAvailableVersion(*iter, ver)

了,如果傳入的是

ConfigItem1

,則使用的是上面原始的實作,而

ConfigItem2

則使用的是下面特化的實作。

這是如何做到的呢?我們知道,C++ 的模闆有個規則是 SFINAE,這不是一個單詞,而是 Substitution Failure Is Not An Error 的縮寫,也就是說,編譯器在基于模闆生成代碼時,如果将模闆的類型參數置換為給定的類型時,如果失敗,編譯器不會報錯,而是将這個結果從可選的集合裡丢棄,并從剩下的中進行選擇。

當我們将

ConfigItem1

放入時,上面的版本能夠正确替換,而下面的版本則因為

ConfigItem1

沒有

version_range

字段而失敗,此時,編譯器會将這個失敗的版本抛棄,由于隻剩下原始版本了,是以選擇了原始版本。

這裡的

lib::void_t

是什麼?

std::void_t

是 C++ 17 之後才在 STL 中提供的模闆,它很簡單也非常有用,功能是将任意的類型序列映射到

void

上,也就是忽略掉這些類型。由于我們在使用 C++ 11,是以需要自己實作一下:

// C++11 中這樣簡單實作可能會有 bug,參考 en.cppreference.com/w/cpp/types/void_t
// template<class...>
// using void_t = void;
template<class... Ts> struct make_void { using type = void; };
template<class... Ts> using void_t = typename make_void<Ts...>::type;           

複制

這裡使用

void_t

将多個類型聲明忽略掉以适應

template<class CfgItem, class = void>

中的第二個類型參數:

decltype(std::begin(std::declval<CfgItem>().version_ranges())->lo()),
decltype(std::begin(std::declval<CfgItem>().version_ranges())->hi())           

複制

雖然說這兩個類型聲明被忽略了,但是它們還是會參與替換,

decltype

可以根據括号裡的表達式計算出其類型,而

std::declval<T>()

則相反,給定一個類型,它可以獲得該類型的值,雖然這個值并不是有效的,但是在這個類型聲明裡我們可以用它來填寫表達式。如果一個類型沒有帶

version_ranges

字段,則

std::declval<CfgItem>().version_ranges()

會失敗,如果這個

version_range()

傳回的對象不支援

std::begin

,則

std::begin(...)

會失敗,若這個

std::begin

計算出來的疊代器不支援

lo

函數,則

std::begin(...)->lo()

會失敗,這裡的結合就確定了

CfgItem

類型必須有

version_range

,且每一個

version_range

都是可疊代的,且每一個 range 都有

lo

成員。下面的

hi

也是類似的。當然,我們還可以通過

std::is_same

之類的 type trait 進一步確定

lo

hi

傳回的類型,這個就不在此示範了,對于我們的需求而言,這樣就足夠了。

那說回來,如果我們填入的是

ConfigItem2

會怎樣?在這個時候,兩個類型替換都會成功,但由于原始版本中,第二個類型參數是預設值

void

,而特化版本中,則填入了自定義的一個類型

lib::void_t...

,雖然這個類型最後計算出來的類型還是

void

,但它依然是比原始版本更「特殊」的版本,是以編譯器會選擇這個版本,這就達到了我們的目的。

Data blob 操作輔助類

在公司中,我們有自己的 NoSQL 資料庫服務,我們在使用的過程中常常有這樣的模式:

MyDataBlob data{};
data.key1 = ...;
data.key2 = ...;
DbApi api(...);
int const res = api.Get("tablename-x", &data, sizeof(data));
if (res == RSP_ERROR && api.GetDbErr() == NOT_EXIST) {
  LOGDBG(...); // 資料不存在,列印調試日志
} else if (res != 0) {
  LOGERR(...); // 其他錯誤,列印錯誤日志,傳回錯誤
  return ERR_DB_GET_FAIL;
} 
// 正常邏輯,使用 data ...           

複制

這裡先建立一個空白的資料對象,填入它的 key 值,然後調用 API 拉取資料。由于 DB 會将拉取不存在的資料這種情況也認為是一個錯誤,而資料不存在對于業務而言又往往不是一個錯誤,是以我們一般是要對這種情況單獨進行處理。

這種重複的工作顯然可以抽象一個更加友善的 API 類型出來,希望能更輕松地進行使用。一個簡單的想法是這樣的:

template<class Db>
struct Result {
  int code{};
  int subCode{};
  Db data{};
  
  bool IsError() { return code != 0 && subCode != NOT_EXIST; }
  bool NoExist() { return code != 0 && subCode == NOT_EXIST; }
}

template<class Db>
struct NewDbApi {
  Result Get() {
    DbApi api(...);
    Result res{};
    res.data.SetKey(???); // 1
    res.code = api.Get(res.data.TableName(), &res.data, sizeof(res.data)); // 2
    res.subCode = api.GetDbErr();
    if (res.IsError()) {
      LOGERR(...);
    }
    return res;
  }
}           

複制

這裡我們碰到了一點麻煩的問題,首先,在 1 處,這個

data.SetKey()

我們不知道應該怎麼填。當然,我們可以像原先一樣在外部自行設定 key,然後再将

data

傳進來,但是我們更加希望能夠免去這一個步驟,直接通過

Get

函數的參數傳入對應的 key,然後轉交給

data

。但我們又不知道這個

Db

類型的 key 是什麼,那我們該怎麼辦呢?也許我們可以這樣做:

template<class ... Args>
Result Get(Args &&...args) {
  ...
  res.data.SetKey(std::forward<Args>(args)...); // 1
  ...
}           

複制

呃……這确實可以實作我們要的效果,但是這個實作方法并不好,它帶來了不必要的複雜度。最讓人難受的一點是,我們丢失了

data.SetKey

所需參數的類型資訊,這讓調用者完全不知道這裡應該填什麼資料。為了解決這個問題,我們可以添加一層抽象,讓

Db

類型告訴我們 key 的類型是什麼:

Result Get(typename Db::key_type const &key) {
  res.data.SetKey(key); // 1
  ...
}           

複制

這樣簡單多了,

Get

函數的調用者可以獲知對應的 key 的類型。

另外一個問題是,1 和 2 處我們直接調用了

data

SetKey

TableName

成員函數,但是我們的

MyDataBlob

是一個用另外一個工具基于 XML 描述生成出來的代碼,主要實作的是序列化和反序列化功能,我們沒法去通過修改這個工具來添加新的接口。是以我們隻能使用 adapter 模式解決這個問題:

struct MyDataBlobAdapter {
  using key_type = ...;
  void SetKey(key_type const &) { ... }
  std::string TableName() const { ... }
  MyDataBlob myDataBlob{};
}           

複制

這就可以解決上面提到的問題了,給

Get

函數的實作提供了

SetKey

TableName

。我們可以發現,

Result

裡的資料類型不再是

MyDataBlob

了,而是

MyDataBlobAdapter

,使用者拿到了這個對象後,使用的方法不再是

res.data

而是

res.data.myDataBlob

了。這在大多數情況下不是什麼大問題,但是,如果一個資料不隻是被這一個接口操作,而是被多個接口操作那該怎麼辦呢?這時候,為了适配多個接口,我們可能需要多個 adapter。例如:

NewDbApi<MyDataBlobAdapter> api1{};
OtherAPI<OtherAdapter> api2{};

Result res = api1.Get(...);
OtherAdapter other(res.data.myDataBlob);
api2.Modify(&other);
res.data.myDataBlob = other.myDataBlob;
api1.Put(res.data);           

複制

這不僅麻煩,而且會造成比較大的開銷,在這裡,為了适配兩個接口,我們不得不進行兩次資料的複制。我們能否做得更好呢?

首先注意到

TableName

這個函數其實和對象無關,我們可以實作為一個靜态的函數:

struct MyDataBlobAdapter {
  static std::string TableName() const { ... }
  ...
}           

複制

是以上面 2 處的代碼可以改為:

res.code = api.Get(Db::TableName(), ...); // 2           

複制

類似地,對于

SetKey

,我們也可以進行類似的改造,雖然它需要操作自己的成員變量,但是,我們可以将

this

指針手動傳遞一下,也就是這樣:

struct MyDataBlobAdapter {
  static void SetKey(key_type const &key, MyDataBlobAdapter *adapter) { ... }
  ...
}           

複制

對于使用者,1 處的代碼可以改為:

Db::SetKey(key, &res.data);           

複制

這個時候,我們可以發現,這裡

SetKey

的第二個參數根本不需要是

MyDataBlobAdapter*

,我們可以直接将其換為

MyDataBlob*

!同時,類似于

key_type

,為了能告訴使用者這個資料的類型,我們加一個

type

類型聲明,結果是這樣:

struct MyDataBlobAdapter {
  using type = MyDataBlob;
  static void SetKey(key_type const &key, type *blob) { ... }
  ...
}           

複制

這時我們重新回來看一下

NewDbApi

的實作:

template<class Db>
struct Result {
  typename Db::type data{};
  ...
}

template<class Db>
struct NewDbApi {
  Result Get(typename Db::key_type const &k, typename Db::type *db) {
    ...
    Db::SetKey(k, db); // 1
    res.code = api.Get(Db::TableName(), &res.data, sizeof(res.data)); // 2
    ...
  }
}           

複制

使用的時候,隻需要這樣寫:

NewDbApi<MyDataBlobAdapter> api1{};
OtherAPI<OtherAdapter> api2{};

Result res = api1.Get(...);
api2.Modify(&res.data);
api1.Put(res.data);           

複制

這樣一來,我們就實作了既能直接操作資料,又避免給原始類型添加新接口,而且還做到了足夠的泛化靈活,甚至不需要用到虛函數導緻性能損失。

不過,這種形式的實作有個小缺點,這裡的

Db

類型的限制非常不明确,對于使用者而言,可能會碰到非常難讀的編譯錯誤,這可能是許多人害怕模闆的另一個原因。到 C++ 20,我們才能用上 Concept,能夠直接指名模闆參數的限制,但現實情況是,我們可能将長期被鎖在 C++ 11 裡,在這種情況下,我們也可以盡力去給使用者清晰的提示:

// 示例:
// struct LegalDb {
//   struct type;
//   struct key_type;
//   static void SetKey(key_type const &key, type *db);
//   static std::string TableName();
// }
template<class Db>
struct NewDbApi {
  ...
  static_assert(IsLegalDb<Db>::value,
                "Db must match requirements of LegalDb, see comments above");
}           

複制

這樣一來,一旦使用者填入了不合法的類型,編譯期立刻就能收到上面的提示,并且可以基于示例來了解應該如何實作這個類型。這個

IsLegalDb

的實作也用到了 SFINAE,大緻可以實作為這樣:

template<class T, class = void>
struct IsLegalDb: std::false_type {}; // 3

template<class T>
struct IsLegalDb<T,
  lib::void_t<
    typename T::type,
    typename T::key_type,
    decltype(T::SetKey(std::declval<typename T::key_type const &>(),
                       std::declval<typename T::type *>())),
    typename std::enable_if<
      std::is_convertible<decltype(T::TableName()), std::string>::value>
    >::type // 4
>: std::true_type {}; // 5           

複制

這裡也用到了前面實作的

void_t

,總體思路是類似的,也是基于類型聲明來讓編譯器選擇我們想要的模闆實作,這裡可能和上一個例子不太一樣的有兩點。第一是我們這裡的類型在 3 和 5 處繼承了

std::true_type

std::false_type

,這兩個類型可以認為是類型級别的

true

false

,在頭檔案

<type_traits>

裡有很多

is_

開頭的模闆就是基于這兩個類的,如果一個類型符合它的限制,它就是

true_type

否則就是

false_type

。這裡用到的

std::is_convertible

就是這樣的 type trait,它判定的是第一個類型參數能被轉換為第二個類型參數。我們可以用

value

成員的來獲得它們對應的

bool

值。這裡用到了另一個基礎工具是

std::enable_if

,它可以接受一個編譯期計算出來的

bool

值,如果這個值為

true

,那麼我們就能獲得其

type

成員類型,否則就擷取不到,可能直接用一個簡單實作來說明更加友善:

template<bool B, class T = void>
struct enable_if {};
 
template<class T>
struct enable_if<true, T> { using type = T; };           

複制

是以說,4 處的代碼的實作了如果

std::is_convertible

判定為

true

,那麼

std::enable_if

裡就會有

type

,那麼模闆的類型置換就會成功,否則則是失敗,這就實作了我們想要的判定

T::TableName()

傳回類型可以轉換為

std::string

的效果。

IsLegalDb

的實作相對而言可能會有點麻煩,但是它可以帶來清晰的錯誤提示,是一個很好的文檔,是以對于一個有特定限制的模闆類型參數,尤其是無法從名字上直接看出來限制内容的模闆類型參數,最好配套加上這樣一個檢查,配合注釋說明,給使用者明确的限制,以友善使用者實作合法的類型。

強類型别名

我們經常會碰到一個函數帶有幾個類型相同的參數的情況。以撲克牌舉例,一種表示方式是基于花色和數字的表示,使用一個

uint8_t

表示花色,同時一個

uint8_t

表示數字,另一種是直接基于牌編碼的方式,也就是将牌從 0 編号到 54,隻需要一個

uint8_t

就能實作。那麼,如果不同地方使用到了不同的表示方式,就需要有類似這樣的轉換函數:

uint8_t ConvertCardToCode(uint8_t shape, uint8_t number);           

複制

這個函數本身是沒什麼問題,但在使用的時候經常一不小心就寫歪了:

auto const num = uint8_t(13);
auto const shp = uint8_t(2);
auto const code = ConvertCardToCode(num, shp); // num 和 shp 的位置寫反了           

複制

我們可以通過類型别名聲明來使得函數類型更加明晰:

using CardCode = uint8_t;
using Shape = uint8_t;
using Number = uint8_t;

CardCode ConvertCardToCode(Shape shape, Number number);           

複制

這個寫法看起來很不錯,但是在函數調用處,我們仍然無法避免出現這種情況:

auto const num = Number(13);
auto const shp = Shape(2);
auto const code = ConvertCardToCode(num, shp); // 仍然能正常編譯           

複制

雖然我們聲明了類型别名,但是這個類型别名的本質上還是原來的類型,我們仍然無法避免出現前面的錯誤。在 Go 語言中,「type alias」(

type T = xxx

)和「type definition」(

type T xxx

)是兩種不同的文法,如果我們使用前者,則依然會遇到上面說的這個問題,但如果我們使用後者,則可以讓編譯器幫我們避免它:

type CardCode uint8;
type Shape uint8;
type Number uint8;

func ConvertCardToCode(shape Shape, number Number) CardCode { /*...*/ }           

複制

num := Number(13)
shp := Shape(2)
code := ConvertCardToCode(num, shp); // 編譯失敗           

複制

這樣的強類型别名非常好,使得函數簽名本來就成為了注釋的一部分,想要在 C++ 中實作類似的效果,我們可以不是用

using

起别名而是直接将類型包裹一層:

struct Shape {
  Shape() = default;
  explicit Shape(uint8_t val): v{val} {}
  uint8_t v;
};

struct Number {
  Number() = default;
  explicit Number(uint8_t val): v{val} {}
  uint8_t v{};
};

using CardCode = uint8_t;

CardCode ConvertCardToCode(Shape shape, Number number);           

複制

此時,函數調用者如果傳錯了參數,就完全沒法編譯通過了:

auto const num = Number(13);
auto const shp = Shape(2);
auto const code = ConvertCardToCode(num, shp); // 編譯出錯           

複制

可以發現這兩個類型是很類似的,我們會考慮用模闆來使得這個過程更加便利:

template<class T>
struct StrongAlias {
  StrongAlias() = default;
  explicit StrongAlias(T val): v{std::move(val)} {}
  T v{};
};

using Shape = StrongAlias<uint8_t>;
using Number = StrongAlias<uint8_t>;
using CardCode = uint8_t;
CardCode ConvertCardToCode(Shape shape, Number number);           

複制

但很可惜的是,這樣并不能達到我們想要的效果,因為

StrongAlias<uint8_t>

StrongAlias<uint8_t>

是同一個類型,是以使用

using

來聲明的

Shape

Number

也依然是同一個類型。是以我們需要用另一個标記将兩個類型完全區分開來,我們可以在類型參數清單裡加多一個類型參數來做到這一點,這個類型參數的唯一作用就是用來實作類型的區分:

template<class T, class Tag>
struct StrongAlias {
  StrongAlias() = default;
  explicit StrongAlias(T val): v{std::move(val)} {}
  T v{};
};

using Shape = StrongAlias<uint8_t, struct ShapeTag>;
using Number = StrongAlias<uint8_t, struct NumberTag>;
using CardCode = uint8_t;
CardCode ConvertCardToCode(Shape shape, Number number);           

複制

這個實作已經很實用了,但是我們可以讓它更好用一點,目前而言它的不足之處在于,我們包裹的類型往往是一些基礎類型,這些基礎類型自帶了一些操作符,比如我們之前想比較兩張牌是否相等的時候可以寫:

if (card1.shape == card2.shape && card1.number == card2.number) { ... }           

複制

但現在需要寫:

if (card1.shape.v == card2.shape.v && card1.number.v == card2.number.v) { ... }           

複制

更為麻煩的是,如果我們想将類型别名作為

std::map

的 key 時就會直接報錯:

// using Number = uint8_t;
std::map<Number, int> cardNumCount{}; // 編譯通過           

複制

// using Number = StrongAlias<uint8_t, struct NumberTag>;
std::map<Number, int> cardNumCount{}; // 編譯出錯           

複制

這是因為

std::map

要求 key 能夠使用

<

進行比較,而當我們直接使用

using

起類型别名時,這個

<

就是

uint8_t

<

,而

StrongAlias<uint8_t, struct NumberTag>

則沒有這個運算符。我們當然可以對每一個類型别名都自己實作一次所需的運算符,但我們還可以做得更加簡單:

template<class T, class Tag, template<class> class Op>
struct StrongAliasType: public Op<StrongAliasType<T, Tag, Op>> {
    StrongAliasType() = default;
    explicit StrongAliasType(T const &value): v(value) {}
    T v{};
};

template<class T>
struct Lt { 
  bool operator<(T const &other) const { 
    return static_cast<T const &>(*this).v < other.v; 
  } 
};           

複制

這裡用到了一個 C++ 裡的一個慣用法——奇異遞歸模闆模式,這個模式裡派生類被作為基類的模闆參數,這個聲明看着有點吓人,但是它實作的效果是很妙的:

using Number = StrongAlias<uint8_t, struct NumberTag, Lt>;           

複制

可以看到

StrongAlias<uint8_t, struct NumberTag, Lt>

本身繼承了

Lt<StrongAlias<uint8_t, struct NumberTag, Lt>>

,這意味着

Number

就繼承了

Lt

中的

<

運算符,而

Lt

<

實作中,使用了

T::v

<

運算符進行比較,是以

Number

就可以使用

uint8_t

<

運算符了。

當然,有時候我們可能不止需要這一個運算符,是以

Op

可能不止一個,要想要支援更多運算符,這裡可以使用模闆參數包來實作,使用

...

來辨別一個參數包,然後再用

...

展開:

template<class T, class Tag, template<class> class... Ops>
struct StrongAliasType: public Ops<StrongAliasType<T, Tag, Ops...>>... {
    StrongAliasType() = default;
    explicit StrongAliasType(T const &value): v(value) {}
    T v{};
};           

複制

這裡的

StrongAliasType

繼承了類型參數中的每一個

Ops

。然後,類似上面

Lt

的實作,我們可以實作一組這樣的運算符模闆:

template<class T>
struct Eq { bool operator==(T const &other) const { /*...*/ } };

template<class T>
struct Ne { bool operator!=(T const &other) const { /*...*/ } };

template<class T>
struct Lt { bool operator<(T const &other) const { /*...*/ } };

template<class T>
struct Le { bool operator<=(T const &other) const { /*...*/ } };

template<class T>
struct Gt { bool operator>(T const &other) const { /*...*/ } };

template<class T>
struct Ge { bool operator>=(T const &other) const { /*...*/ } };           

複制

有了這些運算符模闆,使用者就可以按需選擇自己需要的來進行使用了,例如:

using Number = StrongAlias<uint8_t, struct NumberTag, Eq, Ne, Lt, Le, Gt, Ge>;           

複制

這樣,我們就擁有了更加好用的強類型别名了。

小結

在這篇文章裡,我們看到了在實際工程中 C++ 模闆的一些應用。很顯然,這些功能脫離了模闆的能力是非常難以實作的。對于 C++ 開發者而言,不應該盲目地拒絕模闆,而是應該将它應用在正确的地方,以獲得更好的性能和更清晰可靠的代碼。

/* * * CONFIGURATION VARIABLES: EDIT BEFORE PASTING INTO YOUR WEBPAGE * * */ var disqus_shortname = 'ZhiruiLi'; // required: replace example with your forum shortname /* * * DON'T EDIT BELOW THIS LINE * * */ (function() { var dsq = document.createElement('script'); dsq.type = 'text/javascript'; dsq.async = true; dsq.src = 'https://' + disqus_shortname + '.disqus.com/embed.js'; (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(dsq); })(); /* * * DON'T EDIT BELOW THIS LINE * * */ (function () { var s = document.createElement('script'); s.async = true; s.type = 'text/javascript'; s.src = 'https://' + disqus_shortname + '.disqus.com/count.js'; (document.getElementsByTagName('HEAD')[0] || document.getElementsByTagName('BODY')[0]).appendChild(s); }()); comments powered by Disqus