NEO.K / MSSP 開源專案考古019-dict-get-sentinel
專案CPython dict.get and the subscript operator
授權PSF-2.0
檢視版本measured at run time
日期2026-08-19
來源upstream ↗

019 — CPython dict.get:鍵不在,跟鍵在而且是 None,是同一個回傳值

原專案 CPython Objects/dictobject.c,PSF-2.0。版本由執行時量出來。 這一則是這 20 則裡上游最普通的一個——一行任何人都寫過的 d.get(k)

python src/main.py            # 三個 reader,同一個鍵
python src/main.py --strict   # 這個部署分不出 unset 跟 null 就 exit 1
python src/island_test.py     # 36 項檢查,全部直接跑真的 dict,數字由它自己印

為什麼選它

同日的範例 019 主張一個量測回傳的是值加上它的適用性

d.get(k) 是那句話的反面在標準庫裡最短的樣子:它只回傳值。d.get(k, SENTINEL) 就是那個提案本身,早就在語言裡,只是不是預設。

原專案的結構地圖

    mapping         reader                  value    present   raised
    {'a': None}     d.get(key)              None     -         -
    {}              d.get(key)              None     -         -
    {'a': None}     d.get(key, SENTINEL)    None     True      -
    {}              d.get(key, SENTINEL)    None     False     -
    {'a': None}     d[key]                  None     True      -
    {}              d[key]                  None     False     KeyError('a')

前兩列是重點:同一個值、同一個型別、同一個物件is 為真)。

而這兩件事在設定檔語境裡是相反的指令:鍵不存在 → 落回內建預設;鍵被明確設成 null → 用「沒有」把預設蓋掉。d.get(k) 對兩者交回同一個答案。

語言裡有三個判別器,而沒有一個是大家會寫的key in dd.get(key, SENTINEL)、以及會拋的 d[key]

注意第三個——寬容的取值器跟嚴格的取值器住在同一個型別上。那跟考古 017zlib 完全同形:一次收完的 decompress 對截斷輸入拋例外,串流的 decompressobj 安靜地交回前綴。同一個模組/型別裡兩個答案,兩個都是對的,而預設的那個比較安靜。

第二個發現:慣用寫法折得更凶

    case                        not d.get('a')   'a' in d
    missing                     True             False
    present None                True             True
    zero                        True             True
    empty string                True             True
    False                       True             True
    empty list                  True             True
    present truthy (control)    False            True
    6 of 7 take the same branch, and 5 of those 6 have the key

六種情況走同一個分支,其中五種有那個鍵。 所以 if not d.get(key): 這行在六次裡只有一次講對了「不存在」。

對照組(有鍵、值為真)是這段能不能說話的關鍵:沒有它,「它們都走同一個分支」是一句關於測試的話,不是關於的話。

MSSP 重切

集合 裡面是什麼
FMS 每個 reader 分不分得開、拋不拋,以及 units 對照
SCL 這個部署用哪一個、「unset 被讀成 null」在這裡是不是致命的,以及它伸不到哪裡
SMS 直接跑真 dict 的探針,包含那個真值對照
TMS 一個 reader 一個檔——各自宣告分不分得開,而且 import 任何東西都沒有
DMS valuepresent 永遠一起印——單看 value 就是這一則在講的那個缺陷

重切加的只有一件事:reader 要宣告它分不分得開,而那份宣告用跑的驗。 第 3b 節是鑽孔——一個宣稱分得開、實際呼叫普通 get 的 reader 必須被抓到。四個變異跑過,每一個都讓套件變紅,其中一個(SCL 改用寬容的 reader)也讓 main --strict 從 exit 0 變成 exit 1,所以那條路不是死的。

SCL 這裡的 what_this_cannot_reach 值得記:判別在呼叫點被銷毀,不是在載入時。 載入器可以規定自己怎麼讀,管不到拿到那個 mapping 之後自己寫 d.get(k) 的下游。

什麼不適合拆

d.get 寬容是對的。 「給我值,沒有就給我預設」是這個方法存在的理由,而且它做到了。

None 也不能不是合法的值。 它是,所以它不可能同時當缺席標記——這正是 sentinel 必須是呼叫端自己帶的 object() 的原因。而它連在簽名裡都沒有:dict.get 的 default 參數就是一個普通參數,語言沒有提供一個「不可能是使用者資料」的預設哨兵。

缺陷不在任何一邊,在於最短的那條路是資訊最少的那條,而三個判別器都要多打字。

這次沒有解決什麼

量得到但這次沒量: 真實程式碼裡 d.get(k) 的呼叫點有多少比例的 mapping 允許 None 當值;dict.getos.environ.getgetattr 三者的預設哨兵處境是否相同。

這一則量不到: 任何一位呼叫端當初以為 None 代表什麼。它量的是介面讓什麼通過,不是誰誤解了什麼——跟考古 015、016、017、018 同一句話,第五次。

沒有做的: collections.defaultdictdict.setdefault。兩者都會在讀的時候寫,所以它們是另一個形狀——判別器不只是被銷毀,是被讀取這個動作本身消滅——需要新的探針,不是重讀既有輸出。

重切原始碼

FMS

FMS/__init__.py
FMS/architecture.json
{
  "name": "019-dict-get-sentinel",
  "upstream": "CPython dict.get and the subscript operator, measured at run time",
  "what_is_being_examined": "Three ways of reading one key, and which of them can tell a missing key from a key whose value is None.",

  "the_finding": "{'a': None}.get('a') and {}.get('a') return the same value, the same type, and the same object. A key that is absent and a key that was explicitly set to nothing are one answer.",

  "second_finding": "Three discriminators exist in the language and none of them is what people write: `key in d`, `d.get(key, SENTINEL)`, and `d[key]`, which raises. The forgiving accessor and the strict one live on the same type - the same shape archaeology 017 found in zlib, where the one-shot raises on a truncated stream and the incremental reader returns a prefix in silence.",

  "third_finding": "The idiomatic `if not d.get(key):` collapses further. Six distinct situations take that branch and five of the six have the key, so the test is right about absence in one case out of six.",

  "readers": {
    "d.get(key)":            {"separates_missing_from_none": false, "raises_on_missing": false},
    "d.get(key, SENTINEL)":  {"separates_missing_from_none": true,  "raises_on_missing": false},
    "d[key]":                {"separates_missing_from_none": true,  "raises_on_missing": true}
  },

  "the_control": "A key present with a truthy value. It must come out differently from the six, or `they all take the same branch` would be a statement about the test rather than about the values.",

  "why_it_belongs_beside_example_019": "d.get(key, SENTINEL) is exactly the repair example 019 argues for - an answer that carries its own applicability - and it has been in the language the whole time without being the default. The sentinel is not in the signature either; the caller has to bring an object().",

  "sets": {
    "FMS": "this file: what each reader separates and what it raises, and the units map",
    "SCL": "which reader this deployment uses, what an unset key read as null means here, and what the deployment cannot reach",
    "SMS": "the probes that run real dicts, including the truthy control",
    "TMS": "one file per reader - each declares what it separates, and imports nothing",
    "DMS": "value and present printed together, never one without the other"
  },

  "units": {"TMS/readers": ["forgiving.py", "subscript.py", "with_sentinel.py"]},

  "the_recut_adds": "A reader declares whether it separates the two situations, and the declaration is verified by running it against both. Section 3b is the drill: a reader claiming to separate them while calling plain get must be caught."
}

SCL

SCL/__init__.py
SCL/policy.json
{
  "deployment": "config-loader",
  "reader": "d.get(key, SENTINEL)",
  "an_unset_key_read_as_null_is": "fatal",
  "why": "A configuration key that is absent must fall back to the built-in default; a key explicitly set to null must override it with nothing. Those are opposite instructions, and d.get(key) hands back the same value for both, so this deployment refuses to use it.",
  "what_this_cannot_reach": "Any consumer that receives the loaded mapping and reads it with d.get(key) of its own. The distinction is destroyed at the call site, not at load time, so the loader cannot protect a caller it does not own.",
  "not_a_general_rule": "A mapping in which None is not a legal value loses nothing by using d.get(key). SCL is where that position lives."
}

SMS

SMS/__init__.py
SMS/upstream.py
"""Probes that run real dicts. Nothing here is simulated."""
import sys

# The two situations the entry is about.
PRESENT_NONE = {"a": None}
MISSING = {}

# Everything the idiomatic falsy test folds together, with a control at the end
# that must come out differently. Without the control, "they all take the same
# branch" would be a statement about the test rather than about the values.
FALSY_CASES = {
    "missing": {},
    "present None": {"a": None},
    "zero": {"a": 0},
    "empty string": {"a": ""},
    "False": {"a": False},
    "empty list": {"a": []},
    "present truthy (control)": {"a": 7},
}


def versions():
    return f"python {sys.version.split()[0]}"


def through(reader, mapping, key="a"):
    return {"reader": reader.READER, **reader.read(mapping, key)}


def separates(reader, key="a"):
    """Measured, not read off the unit's own declaration."""
    on_none = reader.read(PRESENT_NONE, key)
    on_missing = reader.read(MISSING, key)
    return (on_none.get("present"), on_none.get("raised")) != (on_missing.get("present"),
                                                               on_missing.get("raised"))


def falsy_table(key="a"):
    rows = []
    for label, mapping in FALSY_CASES.items():
        rows.append({
            "case": label,
            "falsy": not mapping.get(key),
            "has_key": key in mapping,
        })
    return rows


def collapsed(rows):
    """How many distinct situations reach the same branch of `if not d.get(k)`."""
    return sum(1 for row in rows if row["falsy"])


def of_those_present(rows):
    return sum(1 for row in rows if row["falsy"] and row["has_key"])

TMS

TMS/__init__.py
TMS/readers/__init__.py
TMS/readers/forgiving.py
"""d.get(key) - the one everybody writes.

It declares that it does NOT separate a missing key from a key whose value is
None. The declaration is checked by running it, not by reading this line.
"""
READER = "d.get(key)"
SEPARATES_MISSING_FROM_NONE = False
RAISES_ON_MISSING = False


def read(mapping, key):
    return {"value": mapping.get(key), "raised": None}
TMS/readers/subscript.py
"""d[key] - the strict one, which refuses instead of guessing.

Same shape as zlib's one-shot in archaeology 017: within one type, the
forgiving accessor returns a value in silence and the strict one raises.
"""
READER = "d[key]"
SEPARATES_MISSING_FROM_NONE = True
RAISES_ON_MISSING = True


def read(mapping, key):
    try:
        return {"value": mapping[key], "raised": None, "present": True}
    except KeyError as raised:
        return {"value": None, "raised": f"KeyError({raised})", "present": False}
TMS/readers/with_sentinel.py
"""d.get(key, SENTINEL) - the value together with whether it was there.

This is the repair example 019 argues for, already in the language: an answer
that carries its own applicability. It is not the default and it costs one
object() the caller has to create.
"""
READER = "d.get(key, SENTINEL)"
SEPARATES_MISSING_FROM_NONE = True
RAISES_ON_MISSING = False

MISSING = object()


def read(mapping, key):
    found = mapping.get(key, MISSING)
    if found is MISSING:
        return {"value": None, "raised": None, "present": False}
    return {"value": found, "raised": None, "present": True}

DMS

DMS/__init__.py
DMS/report.py
"""What a person is shown.

`value` is never printed without `present`, because that pairing is the entire
subject of the entry.
"""


def reads(rows):
    lines = ["    mapping         reader                  value    present   raised"]
    for row in rows:
        present = "-" if row.get("present") is None else str(row["present"])
        lines.append(f'    {row["mapping"]:<15} {row["reader"]:<23} {repr(row["value"]):<8} '
                     f'{present:<9} {row["raised"] or "-"}')
    return "\n".join(lines)


def falsy(rows, collapsed, present):
    lines = ["    case                        not d.get('a')   'a' in d"]
    for row in rows:
        lines.append(f'    {row["case"]:<27} {str(row["falsy"]):<16} {row["has_key"]}')
    lines.append(f"    {collapsed} of {len(rows)} take the same branch, and {present} of those "
                 f"{collapsed} have the key")
    return "\n".join(lines)

root

island_test.py
"""The island test, run against real dicts.

    python src/island_test.py

Section 3 is the control. Section 3b is the drill: a reader that DECLARES it
separates the two situations while calling plain get must be caught.
"""
import json
import pathlib
import re
import sys

from SMS import upstream
from TMS.readers import forgiving, subscript, with_sentinel

HERE = pathlib.Path(__file__).parent
FMS = json.loads((HERE / "FMS" / "architecture.json").read_text(encoding="utf-8"))
POLICY = json.loads((HERE / "SCL" / "policy.json").read_text(encoding="utf-8"))
FAILURES = []
RAN = [0]


def check(label, ok, detail=""):
    RAN[0] += 1
    print(f'  {"PASS" if ok else "FAIL"}  {label}{f" - {detail}" if detail else ""}')
    if not ok:
        FAILURES.append(label)


print(f"\n  {upstream.versions()}")

print("\n== 1. each reader is an island, and FMS matches the tree")
for unit, declared in FMS["units"].items():
    directory = HERE.joinpath(*unit.split("/"))
    on_disk = sorted(p.name for p in directory.glob("*.py") if p.name != "__init__.py")
    check(f"{unit}: FMS declares what is on disk", on_disk == sorted(declared),
          f'disk {", ".join(on_disk)} | FMS {", ".join(sorted(declared))}')
    for name in on_disk:
        body = directory.joinpath(name).read_text(encoding="utf-8")
        check(f"{unit}/{name} imports nothing at all",
              not re.search(r"^\s*(import|from)\s", body, re.M))
check("all three readers declare what they separate",
      all(isinstance(m.SEPARATES_MISSING_FROM_NONE, bool)
          for m in (forgiving, with_sentinel, subscript)))

print("\n== 2. two situations, one value")
on_none = forgiving.read(upstream.PRESENT_NONE, "a")
on_missing = forgiving.read(upstream.MISSING, "a")
check("d.get returns None for a key set to None", on_none["value"] is None)
check("and None for a key that is not there", on_missing["value"] is None)
check("the same value", on_none["value"] == on_missing["value"])
check("the same type", type(on_none["value"]) is type(on_missing["value"]))
check("and the same object", on_none["value"] is on_missing["value"])
check("neither raised", on_none["raised"] is None and on_missing["raised"] is None)

print("\n== 3. the control - a key that is present with a truthy value")
table = upstream.falsy_table()
control = [row for row in table if "control" in row["case"]]
check("the table carries a control", len(control) == 1)
check("and it comes out on the other branch", control[0]["falsy"] is False)
check("while every other case is falsy",
      all(row["falsy"] for row in table if "control" not in row["case"]))
collapsed = upstream.collapsed(table)
present = upstream.of_those_present(table)
check("so 6 situations reach one branch", collapsed == 6, f"{collapsed}")
check("and 5 of those 6 have the key", present == 5, f"{present}")
check("which makes the idiomatic test right about absence once in six",
      collapsed - present == 1)
check("without the control the sentence would be about the test, not the values",
      control[0]["falsy"] != table[0]["falsy"])

print("\n== 3b. DRILL - a reader that overclaims must be caught by running it")


class Liar:
    READER = "drill-liar"
    SEPARATES_MISSING_FROM_NONE = True  # the declaration

    @staticmethod
    def read(mapping, key):  # the implementation, which does not
        return {"value": mapping.get(key), "raised": None}


check("the drill unit declares it separates them", Liar.SEPARATES_MISSING_FROM_NONE is True)
check("running it says otherwise", upstream.separates(Liar) is False)
check("so the declaration is refused", upstream.separates(Liar) != Liar.SEPARATES_MISSING_FROM_NONE)
for module in (forgiving, with_sentinel, subscript):
    check(f"and {module.READER}'s declaration holds under the same probe",
          upstream.separates(module) is module.SEPARATES_MISSING_FROM_NONE)

print("\n== 4. the discriminators that exist")
check("`key in d` separates them",
      ("a" in upstream.PRESENT_NONE) != ("a" in upstream.MISSING))
sent_none = with_sentinel.read(upstream.PRESENT_NONE, "a")
sent_missing = with_sentinel.read(upstream.MISSING, "a")
check("d.get(key, SENTINEL) separates them", sent_none["present"] != sent_missing["present"])
check("and still returns the same value in both", sent_none["value"] == sent_missing["value"],
      "which is why the answer needs two fields, not a better single one")
sub_missing = subscript.read(upstream.MISSING, "a")
check("d[key] raises on the missing one", sub_missing["raised"] is not None, sub_missing["raised"])
check("and returns quietly on the present-None one",
      subscript.read(upstream.PRESENT_NONE, "a")["raised"] is None)
check("so the forgiving and the strict accessor live on the same type",
      forgiving.RAISES_ON_MISSING is False and subscript.RAISES_ON_MISSING is True)
check("the sentinel is not in the signature - the caller has to bring an object()",
      "default" in (dict.get.__doc__ or "") and with_sentinel.MISSING is not None)

print("\n== 5. what this does not change")
check("d.get is right to be forgiving - a default is the common case",
      forgiving.read({"a": 7}, "a")["value"] == 7)
check("and None is a legal value, so it cannot be the missing marker",
      upstream.PRESENT_NONE["a"] is None)
check("which is exactly why the sentinel has to be an object the caller owns",
      with_sentinel.MISSING is not None and with_sentinel.MISSING is not False)
check("SCL names what it cannot reach", "call site" in POLICY["what_this_cannot_reach"])
check("and this deployment does refuse the forgiving reader",
      POLICY["reader"] != forgiving.READER)

print()
if FAILURES:
    print(f'  {len(FAILURES)} FAILED: {" | ".join(FAILURES)}')
    sys.exit(1)
print(f"  {RAN[0]} checks passed - every probe ran real dicts")
main.py
"""One key, three readers, and two situations that are one value.

    python src/main.py            what each reader can tell apart
    python src/main.py --strict   exit 1 if this deployment cannot separate unset from null
"""
import json
import pathlib
import sys

from DMS import report
from SMS import upstream
from TMS.readers import forgiving, subscript, with_sentinel

HERE = pathlib.Path(__file__).parent
POLICY = json.loads((HERE / "SCL" / "policy.json").read_text(encoding="utf-8"))
READERS = {module.READER: module for module in (forgiving, with_sentinel, subscript)}


def main(argv):
    print(f"\n  {upstream.versions()}")
    print(f'  {POLICY["deployment"]}: read with {POLICY["reader"]}\n')

    rows = []
    for module in (forgiving, with_sentinel, subscript):
        rows.append({"mapping": "{'a': None}", **upstream.through(module, upstream.PRESENT_NONE)})
        rows.append({"mapping": "{}", **upstream.through(module, upstream.MISSING)})
    print(report.reads(rows))
    print("\n  Rows 1 and 2 are the finding: same value, same type, same object.")
    print("  The next four are discriminators that exist and are not what people write.\n")

    table = upstream.falsy_table()
    collapsed = upstream.collapsed(table)
    present = upstream.of_those_present(table)
    print("  and the idiomatic falsy test collapses further:")
    print(report.falsy(table, collapsed, present))
    print(f"\n  So `if not d.get(key)` is right about absence in 1 case out of {collapsed}.")

    in_force = READERS[POLICY["reader"]]
    if "--strict" in argv and not upstream.separates(in_force) \
            and POLICY["an_unset_key_read_as_null_is"] == "fatal":
        print(f'\n  --strict: {in_force.READER} cannot separate unset from null and that is fatal here')
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))