本日內容為gt核心思維分享,如果只是想學習如何使用gt的朋友,可以略過。
[Day01]至[Day17]我們一起學習了gt的各種功能,接下來幾天將進入實務製表的環節。
[Day18]將作為一個緩衝帶,帶領大家看看gt的核心思維,期許能讓您對其有更多的認識,在往後的使用上能夠更佳得心應手。
今天的內容分為六大部份(註1):
singledispatch()。databackend。gt如何運用虛擬繼承來選擇依賴Pandas或Polars。gt如何運用singledispatch()來分離Pandas或Polars的運算邏輯。gt的Fluent API原理並客製化一個格式化函數。Python abc模組中的abc.ABCMeta或abc.ABC,有提供一個register()及一個__subclasshook__()方法,可以幫助我們將某個class視為繼承自某個class,在使用issubclass()進行繼承判斷時,格外有用。這邊需留意,此功能只能視為虛擬繼承(virtual subclass),並非真正繼承。
常見的寫法共有以下四種:
abc.ABCMeta + register()。abc.ABC + register()。abc.ABCMeta + __subclasshook__()。abc.ABC + __subclasshook__()。abc.ABCMeta + register()from abc import ABCMeta
class MyABC1(metaclass=ABCMeta): ...
class Dummy1: ...
MyABC1.register(Dummy1)
# Dummy1 is a "virtual subclass" of MyABC1
print(issubclass(Dummy1, MyABC1)) # True
print(isinstance(Dummy1(), MyABC1)) # True
abc.ABCMeta是一個metaclass,使用方法是於定義class時,使用關鍵字metaclass將其傳入。例如定義MyABC1時,使用metaclass=ABCMeta將MyABC1的metaclass設定為abc.ABCMeta。
接著我們定義一個Dummy1,其是直接由class這個Python保留字所定義的,很明顯不是繼承自MyABC1。但是我們可以使用MyABC1.register(Dummy1)來讓Python將Dummy1視為自MyABC1繼承而來。
我們可以使用issubclass()及isinstance()來確認此虛擬繼承的確成功設定。
abc.ABC + register()from abc import ABC
class MyABC2(ABC): ...
class Dummy2: ...
MyABC2.register(Dummy2)
# Dummy2 is a "virtual subclass" of MyABC
print(issubclass(Dummy2, MyABC2)) # True
print(isinstance(Dummy2(), MyABC2)) # True
由於metaclass對於大部份Python開發者是比較陌生的,所以Python特別提供了abc.ABC這個語法糖,讓我們可以使用繼承來設定metaclass為abc.ABCMeta。此處MyABC2繼承abc.ABC,即是將其metaclass設定為abc.ABCMeta,其餘細節與上小節相同。
abc.ABCMeta + __subclasshook__()from abc import ABCMeta
class MyABC3(metaclass=ABCMeta):
@classmethod
def __subclasshook__(cls, subclass):
return True
class Dummy3: ...
# Dummy3 is a "virtual subclass" of MyABC3
print(issubclass(Dummy3, MyABC3)) # True
print(isinstance(Dummy3(), MyABC3)) # True
如果說register()是一種主動讓某個class視為繼承自某個class的方法,那麼__subclasshook__()就可以說是一種被動的方法。
在定義MyABC3時,我們除了使用metaclass=ABCMeta將MyABC3的metaclass設定為abc.ABCMeta外,還多加了一個__subclasshook__()的classmethod。__subclasshook__()可以返回True、False及NotImplemented,因此我們可以在其內編寫,在哪些情況下某class會被視為繼承自MyABC3。這裡我們直接將返回值設定為True,也就是任何class都會被視為繼承自MyABC3。
我們可以使用issubclass()及isinstance()來確認此虛擬繼承的確成功設定。
abc.ABC + __subclasshook__()from abc import ABC
class MyABC4(ABC):
@classmethod
def __subclasshook__(cls, subclass):
return True
class Dummy4: ...
# Dummy4 is a "virtual subclass" of MyABC4
print(issubclass(Dummy4, MyABC4)) # True
print(isinstance(Dummy4(), MyABC4)) # True
此小節只是改使用繼承abc.ABC的方式來建立MyABC4,其餘細節與上小節相同。
singledispatch()singledispatch()是python的functools提供的一個裝飾器,其可以依據傳入參數的型別,來呼叫不同的函數。例如:
from functools import singledispatch
@singledispatch
def my_print(arg):
print(f"{type(arg)=}, {arg=}")
raise NotImplementedError(f"{type(arg)=} is not supported yet.")
@my_print.register
def _(arg: int):
print(f"{type(arg)=}, {arg=}")
@my_print.register
def _(arg: str):
print(f"{type(arg)=}, {arg=}")
my_print(123) # type(arg)=<class 'int'>, arg=123
my_print("abc") # type(arg)=<class 'str'>, arg='abc'
my_print(1.23) # NotImplementedError: type(arg)=<class 'float'> is not supported yet.
我們定義一個my_print()函數,其接收一個參數arg。
接下來我們定義兩個函數(由於其函數名,在此情況下不重要,大家會習慣性地命名為下底線),分別使用@my_print.register來裝飾。兩個函數的arg參數之型別提示不同,分別為int及str,這就是singledispatch()藉以選擇呼叫函數的依據。
當執行my_print(123)時,由於「123」為int型別,所以會呼叫arg型別標註為int的函數。
當執行my_print("abc")時,由於「"abc"」為str型別,所以會呼叫arg型別標註為str的函數。
當執行my_print(1.23)時,由於「1.23」為float型別,是一個沒有被@my_print.register所裝飾的型別,所以會呼叫原始的my_print(),最終raise NotImplementedError。
databackend 說明databackend的程式碼不多,所以gt選擇直接將其複製進來,不需另外安裝。其總共有_load_class()、_AbstractBackendMeta及AbstractBackend三個部份,以下分別說明。
_load_class()from __future__ import annotations
import sys
import importlib
from typing import Type, List, Tuple
from abc import ABCMeta
def _load_class(mod_name: str, cls_name: str) -> Type[object]:
mod = importlib.import_module(mod_name)
return getattr(mod, cls_name)
_load_class()的功用是希望能夠透過mod_name及cls_name兩個str型別的參數,來取得某個模組內的某個class。
_AbstractBackendMetaclass _AbstractBackendMeta(ABCMeta):
def register_backend(cls, mod_name: str, cls_name: str):
cls._backends.append((mod_name, cls_name))
cls._abc_caches_clear()
_AbstractBackendMeta是一個metaclass,其繼承自abc.ABCMeta,並提供一個類似register()功能的register_backend()。
AbstractBackendclass AbstractBackend(metaclass=_AbstractBackendMeta):
"""Represent a class, without needing to import it."""
_backends: List[Tuple[str, str]]
@classmethod
def __init_subclass__(cls):
if not hasattr(cls, "_backends"):
cls._backends = []
@classmethod
def __subclasshook__(cls, subclass: Type[object]):
for mod_name, cls_name in cls._backends:
if mod_name not in sys.modules:
continue
else:
parent_candidate = _load_class(mod_name, cls_name)
if issubclass(subclass, parent_candidate):
return True
return NotImplemented
AbstractBackend使用_AbstractBackendMeta為其metaclass。其中有一個class變數_backends,可以用來記錄所有繼承AbstractBackend的class之資訊。
AbstractBackend有兩個classmethod:
__init_subclass__()是當有class繼承AbstractBackend時,如果_backends變數還未建立的話,就指定其為空list型別。__subclasshook__()則是針對_backends中所記錄的資訊,看看是否能夠透過_load_class()來取得記錄資訊中所描述的class。如果能找到且能夠通過issubclass()測試的話,則返回True。最終。如果無法於資訊中確認繼承關係的話,則return NotImplemented。考慮df_pandas及df_polars如下:
import pandas as pd
import polars as pl
from great_tables import GT
df_pandas = pd.DataFrame({"x": ["a", "b"], "y": [1.01, 2.0]})
df_polars = pl.from_pandas(df_pandas)
假如我們想取得兩個DataFrame的欄位名稱,需要使用不同的語法:
df_pandas.columns.tolist() # pandas
df_polars.columns # polars
一般直覺的解決方法會是如下面這樣,使用isinstance()來區分邏輯:
def get_column_names(data) -> list[str]:
if isinstance(data, pd.DataFrame):
return data.columns.tolist()
elif isinstance(data, pl.DataFrame):
return data.columns
raise TypeError(f"Unsupported type {type(data)}")
但是這麼寫有兩個缺點:
class來作為isinstance()判斷之用。gt支援更多格式的話,還得修改此函數,將更多的邏輯加進來。聽起來不太妙,對吧?
此時,我們可以利用databackend中的AbstractBackend來定義PdDataFrame及PlDataFrame。請留意_backends變數,這即是databackend可以自動利用__subclasshook__()來將df_pandas或df_polars視為PdDataFrame或PlDataFrame的虛擬繼承的關鍵(註2)。
from great_tables._databackend import AbstractBackend
class PdDataFrame(AbstractBackend):
_backends = [("pandas", "DataFrame")]
class PlDataFrame(AbstractBackend):
_backends = [("polars", "DataFrame")]
這麼一來,我們就可以在不安裝Polars的情況下,仍能確定df_pandas來自Pandas。例如:
if isinstance(df_pandas, PdDataFrame):
print("I'm a pandas DataFrame!!!")
I'm a pandas DataFrame!!!
在解決完依賴問題後,我們可以利用PdDataFrame與PdDataFrame來作為singledispatch()呼叫函數時的參數型別提示。這麼一來,我們就可以將各自的運算邏輯分離至不同的函數。例如:
from functools import singledispatch
@singledispatch
def get_column_names(data) -> list[str]:
raise TypeError(f"Unsupported type {type(data)}")
@get_column_names.register
def _(data: PdDataFrame):
return data.columns.tolist()
@get_column_names.register
def _(data: PlDataFrame):
return data.columns
此時,get_column_names()就可以依照傳入的DataFrame種類來呼叫適合的函數,處理各自的運算邏輯。例如:
get_column_names(df_pandas) # pandas version
get_column_names(df_polars) # polars version
['x', 'y']
gt的Fluent API為了說明gt的Fluent API,我們需要觀察其中的great_tables資料夾(註3)。
其中_gt_data.py中的GTData及gt.py中的GT是整個架構的核心。
您可以將GTData想做是表格中各個component的集合。
# _gt_data.py
@dataclass(frozen=True)
class GTData:
_tbl_data: TblData
_body: Body
_boxhead: Boxhead
_stub: Stub
_spanners: Spanners
_heading: Heading
_stubhead: Stubhead
_source_notes: SourceNotes
_footnotes: Footnotes
_styles: Styles
_locale: Locale | None
_formats: Formats
_substitutions: Formats
_options: Options
_has_built: bool = False
def _replace(self, **kwargs: Any) -> Self:
new_obj = copy.copy(self)
missing = {k for k in kwargs if k not in new_obj.__dict__}
if missing:
raise ValueError(f"Replacements not in data: {missing}")
new_obj.__dict__.update(kwargs)
return new_obj
@classmethod
def from_data(
cls,
data: TblData,
rowname_col: str | None = None,
groupname_col: str | None = None,
auto_align: bool = True,
id: str | None = None,
locale: str | None = None,
):
data = validate_frame(data)
stub, boxhead = _prep_gt(data, rowname_col, groupname_col, auto_align)
if id is not None:
options = Options(
table_id=OptionsInfo(True, "table", "value", id)
)
else:
options = Options()
return cls(
_tbl_data=data,
_body=Body.from_empty(data),
_boxhead=boxhead, # uses get_tbl_data()
_stub=stub, # uses get_tbl_data
_spanners=Spanners([]),
_heading=Heading(),
_stubhead=None,
_source_notes=[],
_footnotes=[],
_styles=[],
_locale=Locale(locale),
_formats=[],
_substitutions=[],
_options=options,
)
至於GT則繼承GTData而來,並作為提供Fluent API的介面,也就是說所有Fluent API,例如GT.fmt_number()及GT.opt_stylize()等等,都可視為GT的instance method(2024/10/27修訂)。
# gt.py
class GT(GTData):
def __init__(
self,
data: Any,
rowname_col: str | None = None,
groupname_col: str | None = None,
auto_align: bool = True,
id: str | None = None,
locale: str | None = None,
):
gtdata = GTData.from_data(
data,
rowname_col=rowname_col,
groupname_col=groupname_col,
auto_align=auto_align,
id=id,
locale=locale,
)
super().__init__(**gtdata.__dict__)
...
fmt_number = fmt_number
opt_stylize = opt_stylize
cols_align = cols_align
tab_header = tab_header
tab_stub = tab_stub
save = save
...
在了解了Fluent API後,我們其實可以自己添加想要的功能。例如下面我們展示了如何實做一個fmt_double_plus_n_as_integer()的formatter,其功能是能夠將傳入的數字乘以2再加上使用者指定的數字後,以整數型別呈現於表格。
def fmt_double_plus_n_as_integer(
self: GT,
columns: "SelectExpr" = None,
rows: int | list[int] | None = None,
plus_n: str | int | float = 1,
):
def fmt_fn(x: str | int | float | None, plus_n=plus_n):
print(f"fmt_fn called: current {x=}")
if x is None:
return x
return int(float(x) * 2 + float(plus_n))
return self.fmt(fns=fmt_fn, columns=columns, rows=rows)
class GT2(GT):
fmt_double_plus_n_as_integer = fmt_double_plus_n_as_integer
fmt_double_plus_n_as_integer()可以利用GT.fmt()這個公開的Fluent API來處理格式,其要旨是將運算的邏輯寫於fmt_fn()這個函數中並傳遞給GT.fmt()。事實上,大部份的GT.fmt_*()也都是依靠GT.fmt()完成格式化
雖然實務上您可能需要接收更多的參數、做更多型別確認或是缺失值處理,但相信現在您已經更加理解Fluent API的原理了。
最後我們可以定義GT2(繼承GT而來),並將fmt_double_plus_n_as_integer加入其中。
我們使用之前的df_demo來作為測試:
首先,我們針對「"int"」欄位進行格式化,plus_n設為「10」:
(GT2(df_demo).fmt_double_plus_n_as_integer(columns="int", plus_n=10))

fmt_fn called: current x=1
fmt_fn called: current x=2
fmt_fn called: current x=3
其表格可以正確呈現。另外,我們可以從內部印出的訊息觀察出,其實gt是使用迴圈的方式,依次對欄位內各行進行格式化。
接著針對「"float1"」欄位進行格式化,plus_n設為「"5"」:
(GT2(df_demo).fmt_double_plus_n_as_integer(columns="float1", plus_n="5"))

fmt_fn called: current x=4.4
fmt_fn called: current x=5.5
fmt_fn called: current x=6.6
表格也可以正確呈現。這邊需留意,此處的plus_n是str型別的「"5"」,但因為已經於fmt_fn()做了處理,所以可以正常執行。這就是我們前面提過的,為了讓使用者有更好的使用體驗,可能需要做更多的型別轉換或是缺失值處理等動作。
或許您會覺得這樣隱性幫使用者轉換型別不太好?但是根據小弟自己的觀察,在製作表格時,其實大多數人很容易忘記各個欄位的型別,潛意識裡會自動將所有欄位視為str。
註1:本日內容很大一部份為參考Michael所寫的databackend及其於gt發表的部落格文章。
註2:這裡展示的是使用_backends搭配__subclasshook__()的被動寫法。您也可以主動使用像是AbstractPandasFrame.register_backend("pandas", "DataFrame")來完成虛擬繼承的設定。
註3:great_tables資料夾中所包含的檔案名稱:
├── __init__.py
├── _body.py
├── _boxhead.py
├── _data_color
│ ├── __init__.py
│ ├── base.py
│ ├── constants.py
│ └── palettes.py
├── _databackend.py
├── _export.py
├── _footnotes.py
├── _formats.py
├── _formats_vals.py
├── _gt_data.py
├── _heading.py
├── _helpers.py
├── _locale.py
├── _locations.py
├── _modify_rows.py
├── _options.py
├── _render.py
├── _row_groups.py
├── _scss.py
├── _source_notes.py
├── _spanners.py
├── _stub.py
├── _stubhead.py
├── _styles.py
├── _substitution.py
├── _tab_create_modify.py
├── _tbl_data.py
├── _text.py
├── _types.py
├── _utils.py
├── _utils_nanoplots.py
├── _utils_render_html.py
├── css
│ ├── gt_colors.scss
│ └── gt_styles_default.scss
├── data
│ ├── 01-countrypops.csv
│ ├── ...
│ ├── metro_images
│ │ ├── metro_1.svg
│ │ ├── ...
│ ├── x-airquality.csv
│ ├── ...
├── gt.py
├── loc.py
├── py.typed
├── shiny.py
├── style.py
├── utils_render_common.py
└── vals.py