跳转至

使用类组织代码

你的数据录入表单进展得很顺利!你的老板和同事们看到你取得的进展非常兴奋,并且已经提出了一些可以添加的其他功能的想法。说实话,这让你有点紧张!虽然他们看到的是一个外观专业的表单,但你知道底层的代码变得越来越臃肿和重复。里面还有一些瑕疵,比如全局变量和非常混乱的全局命名空间。在开始添加更多功能之前,你想先整理一下这段代码,并将其分解成一些易于管理的部分。为此,你需要创建类。在本章中,我们将涵盖以下主题:

  • Python类入门中,我们将回顾如何创建Python类和子类。
  • 在“在Tkinter中使用类”中,我们将探索如何在Tkinter代码中有效地利用类。
  • 在“使用类重写我们的应用程序”中,我们将把这些技术应用到ABQ数据录入应用程序中。

Python 类入门

虽然类的概念在表面上看起来很简单,但类带来了许多让许多初学者感到困惑的术语和概念。在本节中,我们将讨论使用类的优点,探索类的不同特性,并回顾在Python中创建类的语法。

使用类的优点

许多初学者甚至中级Python程序员都会避免或忽视在Python中使用类;与函数或变量不同,类在简短、简单的脚本中没有明显的用途。然而,随着我们的应用程序代码的增长,类成为了将我们的代码组织成可管理单元的不可或缺的工具。让我们来看看类如何帮助我们构建更简洁的代码。

类是Python不可分割的一部分

类本质上是创建对象的蓝图。什么是对象?在Python中,一切都是对象:整数、字符串、浮点数、列表、字典、Tkinter小部件,甚至函数都是对象。每种类型的对象都由一个类定义。如果你在Python提示符下使用type命令,可以很容易地看到这一点,如下所示:

1
2
3
>>> type('hello world') <class 'str'>
>>> type(1) <class 'int'>
>>> type(print) <class 'builtin_function_or_method'>

type函数会显示用于构造相关对象的类。当一个对象由特定的类构建时,我们说它是该类的一个实例。

Tip

“实例”和“对象”经常互换使用,因为每个对象都是某个类的实例。

因为Python中的一切都是类,所以创建我们自己的类允许我们使用与内置对象相同的语法来处理自定义对象。

明确数据和函数之间的关系

在代码中,我们经常有一组数据与同一事物相关。例如,在一个多人游戏中,你可能会有每个玩家的分数、生命值或进度的变量。操作这些变量的函数需要确保操作的是引用同一玩家的变量。类允许我们在这些变量和操作它们的函数之间建立明确的关系,这样我们就可以更容易地将它们作为一个单元进行组织。

创建可复用代码

类是减少代码冗余的强大工具。假设我们有一组表单,它们在提交时具有相似的行为,但输入字段不同。使用类继承,我们可以创建一个具有所需通用行为的基础表单;然后,我们可以从这个基础表单派生出各个表单类,只需要实现每个表单中独特的部分。

创建类的语法

创建类与创建函数非常相似,只是我们使用class关键字,如下所示:

1
2
3
class Banana:
    """一种美味的热带水果"""
    pass

注意,我们还包含了一个文档字符串,Python工具(如内置的help函数)会使用它来生成关于类的文档。在Python中,类名传统上使用帕斯卡命名法,即每个单词的首字母大写;然而,有时第三方库会使用其他约定。一旦我们定义了一个类,就可以像调用函数一样调用它来创建类的实例:

1
my_banana = Banana()

在这个例子中,my_banana是一个对象,它是Banana类的一个实例。当然,一个更有用的类会在类体内定义一些东西;具体来说,我们可以定义属性和方法,它们统称为成员。

属性和方法

属性只是变量,它们可以是类属性或实例属性。类属性在类体的顶层范围内定义,如下所示:

1
2
3
4
5
6
7
class Banana:
    """一种美味的热带水果"""
    food_group = 'fruit'
    colors = [
        'green', 'green-yellow', 'yellow',
        'brown spotted', 'black'
    ]

类属性由类的所有实例共享,通常用于设置默认值、常量和其他只读值。

Tip

注意,按照约定,成员名称与类名称不同,使用蛇形命名法,即小写单词之间用下划线分隔。

实例属性存储特定于类单个实例的值;要创建一个实例属性,我们需要访问一个实例。我们可以这样做:

1
2
my_banana = Banana()
my_banana.color = 'yellow'

然而,更理想的是能够在类定义内部定义一些实例属性,而不是像这样在外部定义。为了做到这一点,我们需要在类定义内部获得类实例的引用。这可以通过实例方法来实现。

方法是附加到类上的函数。实例方法是一种自动接收实例引用作为其第一个参数的方法。我们可以这样定义一个实例方法:

1
2
3
class Banana:
    def peel(self):
        self.peeled = True

如你所见,定义实例方法只需在类体内定义一个函数。这个函数接收的第一个参数是对类实例的引用;你可以随意命名它,但按照Python的长期约定,我们将其命名为self。在函数内部,self可以用于对实例进行操作,例如分配实例属性。请注意,实例(self)也可以访问类属性(例如,self.colors),如下所示:

1
2
3
4
5
6
def set_color(self, color):
    """设置香蕉的颜色"""
    if color in self.colors:
        self.color = color
    else:
        raise ValueError(f'香蕉不能是{color}色!')

当我们使用实例方法时,不需要显式传递self;它是隐式传递的,如下所示:

1
2
3
my_banana = Banana()
my_banana.set_color('green')
my_banana.peel()

Tip

self的隐式传递常常会在传递错误数量的参数时导致令人困惑的错误信息。例如,如果你调用my_banana.peel(True),你会得到一个异常,说期望一个参数但传递了两个。从你的角度来看,你只传递了一个参数,但方法收到了两个,因为实例引用是自动添加的。

除了实例方法,类还可以有类方法和静态方法。与实例方法不同,这些方法无法访问类的实例,也不能读取或写入实例属性。

类方法是在方法定义之前使用一个装饰器创建的,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@classmethod
def check_color(cls, color):
    """测试颜色字符串以查看其是否有效。"""
    return color in cls.colors

@classmethod
def make_greenie(cls):
    """创建一个绿色的香蕉对象"""
    banana = cls()
    banana.set_color('green')
    return banana

就像实例方法会隐式地传递一个对实例的引用一样,类方法会隐式地传递一个对类的引用作为第一个参数。同样,你可以随意命名这个参数,但按照惯例,它被称为cls。类方法通常用于与类变量进行交互。例如,在上面的check_color()方法中,该方法需要引用类变量colors。类方法还用作便捷函数,用于生成特定配置的类实例;例如,上面的make_greenie()方法使用其类引用创建颜色预设为绿色的Banana实例。

静态方法也是附加到类上的一个函数,但它不会获得任何隐式参数,方法内的代码也无法访问类或实例。就像类方法一样,我们使用一个装饰器来定义静态方法,如下所示:

1
2
3
4
@staticmethod
def estimate_calories(num_bananas):
    """根据`num_bananas`估计卡路里数"""
    return num_bananas * 105

静态方法通常用于定义类内部使用的算法或实用函数。

Tip

类和静态方法可以在类本身上调用;例如,我们可以调用 Banana.estimate_calories()Banana.check_color(),而无需实际创建 Banana 的实例。然而,实例方法必须在类的实例上调用。调用 Banana.set_color()Banana.peel() 是没有意义的,因为这些方法旨在操作一个实例。相反,我们应该创建一个实例并在该实例上调用这些方法(例如,my_banana.peel())。

魔法属性和方法

所有 Python 对象都会自动获得一组称为魔术属性的属性和一组称为魔术方法的方法,也称为特殊方法或双下方法,因为它们在属性或方法名称周围用双下划线表示(“dunder”是“double under”的合成词)。魔术属性通常存储有关对象的元数据。例如,任何对象的 __class__ 属性都存储对该对象类的引用:

1
2
>>> print(my_banana.__class__)
<class '__main__.Banana'>

魔术方法定义了 Python 对象如何响应运算符(如 +%[])或内置函数(如 dir()setattr())。例如,__str__() 方法定义了当对象传递给 str() 函数时(无论是显式传递还是隐式传递,例如通过传递给 print())返回的内容:

1
2
3
4
5
class Banana:
    # ....
    def __str__(self):
        # “魔术属性”包含有关对象的元数据
        return f'A {self.color} {self.__class__.__name__}'

在这里,我们不仅访问了实例的 color 属性,还使用 __class__ 属性检索其类,然后使用类对象的 __name__ 属性获取类名。

Tip

尽管这听起来有些令人困惑,但类本身也是一个对象。它是 type 类的一个实例。记住,Python 中的一切都是对象,而所有对象都是某个类的实例。

因此,当打印一个 Banana 对象时,它看起来像这样:

1
2
3
4
>>> my_banana = Banana()
>>> my_banana.set_color('yellow')
>>> print(my_banana)
A yellow Banana

到目前为止,最重要的魔术方法是初始化方法 __init__()。每当我们调用类对象以创建实例时,都会执行此方法,并且我们为其定义的参数就是在创建实例时可以传递的参数。例如:

1
2
3
4
5
6
def __init__(self, color='green'):
    if not self.check_color(color):
        raise ValueError(
            f'A {self.__class__.__name__} cannot be {color}'
        )
    self.color = color

在这里,我们用一个名为 color 的可选参数创建了初始化器,允许我们在创建对象时设置 Banana 对象的颜色值。因此,我们可以这样创建一个新的 Banana

1
2
3
>>> my_new_banana = Banana('green')
>>> print(my_new_banana)
A green Banana

理想情况下,类中使用的任何实例属性都应该在 __init__() 中创建,以确保它们存在于类的所有实例中。例如,我们应该这样创建 peeled 属性:

1
2
3
def __init__(self, color='green'):
    # ...
    self.peeled = False

如果我们不在这里定义这个属性,那么在调用 peel() 方法之前,它是不存在的。在调用该方法之前,查找 my_banana.peel 值的代码将引发异常。

最终,初始化器应该使对象处于程序可以使用的状态。

Tip

在其他面向对象的语言中,设置类对象的方法被称为构造函数,它不仅初始化新对象,还返回该对象。有时,Python 开发人员会随意地将 __init__() 称为构造函数。然而,Python 对象的实际构造函数方法是 __new__(),在 Python 类中我们通常不会修改它。

public、protect和private成员

类是进行抽象的强大工具,即通过对复杂对象或过程进行封装,为应用程序的其他部分提供一个简单的高层接口。为了帮助实现这一点,Python 程序员使用一些命名约定来区分公共成员、私有成员和受保护成员:

  • 公共成员是那些旨在被类外部的代码读取或调用的成员。它们使用普通的成员名称。

  • 受保护成员仅供在类或其子类内部使用。它们的名称前加一个下划线。

  • 私有成员仅供在类内部使用。它们的名称前加两个下划线。

Python 实际上并不强制执行公共成员、受保护成员和私有成员之间的任何区别;这些只是约定,其他程序员通过这些约定来理解类的哪些部分可以被外部访问,以及哪些部分是类的内部实现,不应在类外部使用。Python 会通过自动将私有成员的名称更改为 _classname__member_name 来协助执行私有成员的限制。例如,让我们在 Banana 类中添加以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
__ripe_colors = ['yellow', 'brown spotted']

def _is_ripe(self):
    """受保护方法,用于查看香蕉是否成熟。"""
    return self.color in self.__ripe_colors

def can_eat(self, must_be_ripe=False):
    """检查我是否可以吃香蕉。"""
    if must_be_ripe and not self._is_ripe():
        return False
    return True

在这里,__ripe_colors 是一个私有属性。如果你尝试访问 my_banana.__ripe_colors,Python 会引发 AttributeError 异常,因为它已将此属性隐式重命名为 my_banana._Banana__ripe_colors。方法 _is_ripe() 是一个受保护成员,但与私有成员不同,Python 不会更改其名称。它可以作为 my_banana._is_ripe() 执行,但使用你的类的程序员会明白,此方法仅供内部使用,外部代码不应依赖它。相反,应该调用公共方法 can_eat()

你可能有多种原因希望将成员指示为私有或受保护,但一般来说,这是因为该成员是某个内部过程的一部分,在外部代码中使用会没有意义、不可靠或缺乏上下文。

Tip

尽管“私有”和“受保护”这两个词似乎暗示了一种安全特性,但这并非它们的本意,使用它们并不会为类提供任何安全性。其目的仅仅是区分类的公共接口(外部代码应该使用的部分)和类的内部机制(应该保持不变的部分)。

继承和子类

构建自己的类确实是一个强大的工具,但既然 Python 中的一切都是由类构建的对象,那么如果我们能够拿一个现有的类并简单地修改它以满足我们的需求,岂不是很好?这样,我们就不必每次都从头开始了。

幸运的是,我们可以这样做!当我们创建一个类时,Python 允许我们从现有的类派生它,像这样:

1
2
3
class RedBanana(Banana):
    """红色品种的香蕉"""
    pass

我们创建了 RedBanana 类作为 Banana 类的子类或派生类。在这种情况下,Banana 被称为父类或超类。最初,RedBananaBanana 的一个完全副本,行为也完全相同,但我们可以通过简单地定义成员来修改它,像这样:

1
2
3
4
5
6
7
class RedBanana(Banana):
    colors = ['green', 'orange', 'red', 'brown', 'black']
    botanical_name = 'red dacca'

    def set_color(self, color):
        if color not in self.colors:
            raise ValueError(f'红香蕉不能是 {color}!')

指定现有的成员,如 colorsset_color,将掩盖超类中这些成员的版本。因此,在 RedBanana 实例上调用 set_color() 将调用 RedBanana 版本的方法,而该方法在引用 self.colors 时又会咨询 RedBanana 版本的 colors。我们还可以添加新的成员,例如 botanical_name 属性,该属性将仅存在于子类中。

在某些情况下,我们可能希望子类方法扩展超类方法,但仍然执行超类版本方法中的代码。我们可以将超类代码复制到子类代码中,但有一种更好的方法:使用 super()。在实例方法中,super() 为我们提供了对我们实例的超类版本的引用,像这样:

1
2
3
def peel(self):
    super().peel()
    print('里面看起来就像普通的香蕉!')

在这种情况下,调用 super().peel() 会导致在 RedBanana 实例上执行 Banana.peel() 中的代码。然后,我们可以在子类的 peel() 版本中添加额外的代码。

Tip

如你在下一节中将看到的,super() 经常在 __init__() 方法中使用,以运行超类的初始化程序。这对于 Tkinter GUI 类尤其如此,因为它们的初始化方法中进行了大量关键的外部设置。

Python 类的内容比我们在这里讨论的要多得多,包括多重继承的概念,我们将在第 5 章“通过验证和自动化减少用户错误”中学习。然而,我们到目前为止所学的内容已经足够应用于我们的 Tkinter 代码中了。让我们看看类如何在 GUI 上下文中帮助我们。

TKinter 中使用类

图形用户界面(GUI)框架和面向对象代码是相辅相成的。虽然 Tkinter 相较于大多数框架,允许你使用过程式编程来创建 GUI,但这样做会让我们失去很多组织上的优势。尽管在整本书中,我们会发现许多在 Tkinter 代码中使用类的方法,但在这里我们将重点介绍三种主要的使用方式:

  • 增强或扩展 Tkinter 类以获得更强大的功能
  • 创建复合小部件以节省重复输入
  • 将我们的应用程序组织成自包含的组件

增强Tkinter类

让我们正视这个问题:有些 Tkinter 对象在功能上略显不足。我们可以通过子类化 Tkinter 类并创建我们自己的改进版本来解决这个问题。例如,虽然我们已经看到 Tkinter 控制变量类很有用,但它们仅限于字符串、整数、双精度和布尔类型。如果我们想要这些变量的功能,但用于字典或列表等更复杂的对象,该怎么办呢?我们可以通过子类化和 JSON 的一些帮助来实现。

Tip

JavaScript 对象表示法(JSON)是一种标准化格式,用于将列表、字典和其他复合对象表示为字符串。Python 标准库附带了一个 json 库,允许我们将这些对象转换为字符串格式,然后再转换回来。我们将在第 7 章《使用 Menu 和 Tkinter 对话框创建菜单》中更多地使用 JSON。

打开一个新的脚本文件,命名为 tkinter_classes_demo.py,让我们从一些导入开始,如下所示:

1
2
3
# tkinter_classes_demo.py
import tkinter as tk
import json

除了 Tkinter,我们还导入了标准库中的 json 模块。这个模块包含两个函数,我们将使用它们来实现我们的变量:

  • json.dumps() 接受一个 Python 对象,如列表、字典、字符串、整数或浮点数,并返回一个 JSON 格式的字符串。
  • json.loads() 接受一个 JSON 字符串,并返回一个 Python 对象,如列表、字典或字符串,具体取决于 JSON 字符串中存储的内容。

现在,开始创建新的变量类,通过子类化 tk.StringVar 来创建一个名为 JSONVar 的类:

1
2
class JSONVar(tk.StringVar):
    """一个可以存储字典和列表的 Tk 变量"""

为了使我们的 JSONVar 正常工作,我们需要在传递 value 参数给对象的任何地方拦截它,并使用 json.dumps() 方法将其转换为 JSON 字符串。第一个这样的地方是 __init__(),我们将这样重写它:

1
2
3
    def __init__(self, *args, **kwargs):
        kwargs['value'] = json.dumps(kwargs.get('value'))
        super().__init__(*args, **kwargs)

在这里,我们只是从关键字参数中检索 value 参数,并使用 json.dumps() 将其转换为字符串。转换后的字符串将覆盖 value 参数,然后该参数将被传递给超类的初始化器。如果未提供 value 参数(记住,它是一个可选参数),kwargs.get() 将返回 None,这将被转换为 JSON 的 null 值。

Tip

在重写你未编写的类中的方法时,最好包含 *args**kwargs 以捕获你没有明确列出的任何参数。这样,该方法将继续允许超类版本中的所有参数,而你不必显式地列出它们所有。

下一个需要拦截 value 的地方是 set() 方法,如下所示:

1
2
3
  def set(self, value, *args, **kwargs):
      string = json.dumps(value)
      super().set(string, *args, **kwargs)

再次,我们拦截了 value 参数,并在将其传递给超类的 set() 方法之前,将其转换为 JSON 字符串。最后,让我们修复 get()

1
2
3
  def get(self, *args, **kwargs):
      string = super().get(*args, **kwargs)
      return json.loads(string)

在这里,我们做了与其他两个方法相反的操作:首先,我们从超类获取字符串,然后使用 json.loads() 将其转换回对象。完成这些后,我们就准备好了!现在我们有了一个变量,它可以像其他 Tkinter 变量一样存储和检索列表或字典。让我们测试一下:

1
2
3
4
5
6
7
root = tk.Tk()
var1 = JSONVar(root)
var1.set([1, 2, 3])

var2 = JSONVar(root, value={'a': 10, 'b': 15})
print("Var1: ", var1.get()[1])  # 应该打印 2
print("Var2: ", var2.get()['b'])  # 应该打印 15

如你所见,子类化 Tkinter 对象为我们的代码打开了全新的可能性。我们将在本章稍后以及第 5 章《通过验证和自动化减少用户错误》中更广泛地应用这一概念。不过,首先,让我们看看另外两种在 Tkinter 代码中使用类的方法。

创建复合控件

许多图形用户界面(特别是数据输入表单)包含需要大量重复性样板代码的模式。例如,输入控件通常伴有一个标签,告诉用户需要输入什么。这通常需要多行代码来创建和配置每个对象,并将它们添加到表单中。我们可以通过创建一个可重用的复合控件来节省时间,并确保输出的一致性,该控件将这两者组合到一个类中。

让我们通过创建一个 LabelInput 类来组合输入控件和标签,从以下代码开始:

1
2
3
# tkinter_classes_demo.py
class LabelInput(tk.Frame):
    """标签和输入组合在一起的控件"""

tk.Frame 控件是一个空的控件,没有任何内容,是创建复合控件的理想子类对象。在开始定义类之后,接下来我们需要考虑控件所需的所有数据,并确保这些数据可以传递到 __init__() 方法中。

对于一个基本控件,最小的参数集可能如下所示:

  • 父控件
  • 标签的文本
  • 要使用的输入控件类型
  • 传递给输入控件的参数字典

让我们在 LabelInput 类中实现这一点:

1
2
3
4
5
6
def __init__(
    self, parent, label, inp_cls, inp_args, *args, **kwargs
):
    super().__init__(parent, *args, **kwargs)
    self.label = tk.Label(self, text=label, anchor='w')
    self.input = inp_cls(self, **inp_args)

在这里,我们首先要调用超类初始化方法,以便构造 Frame 控件。注意,我们传递了 parent 参数,因为这将是 Frame 本身的父控件;Label 和输入控件的父控件是 self,即 LabelInput 对象本身。

Tip

不要混淆“父类”和“父控件”。“父类”指的是子类从其继承成员的超类。“父控件”指的是我们的控件所附加的控件(可能属于无关的类)。为了避免混淆,在本书讨论类继承时,我们将坚持使用超类/子类的术语。

创建标签和输入控件后,我们可以根据自己的意愿在 Frame 上对它们进行布局;例如,我们可能希望标签位于输入的旁边,如下所示:

1
2
3
self.columnconfigure(1, weight=1)
self.label.grid(sticky=tk.E + tk.W)
self.input.grid(row=0, column=1, sticky=tk.E + tk.W)

或者,我们可能更喜欢标签位于输入控件的上方,如下所示:

1
2
3
self.columnconfigure(0, weight=1)
self.label.grid(sticky=tk.E + tk.W)
self.input.grid(sticky=tk.E + tk.W)

在这两种情况下,如果我们使用 LabelInput 创建表单上的所有输入,那么我们只需三行代码就可以更改整个表单的布局。我们也可以为每个实例单独添加一个初始化参数来配置布局。

让我们看看这个类的实际应用。由于 inp_args 参数将直接扩展到我们对 inp_cls 初始化方法的调用中,因此我们可以为其填充任何希望输入控件接收的参数,如下所示:

1
2
3
# tkinter_classes_demo.py
li1 = LabelInput(root, 'Name', tk.Entry, {'bg': 'red'})
li1.grid()

我们甚至可以传入一个变量来绑定到控件:

1
2
3
4
5
6
age_var = tk.IntVar(root, value=21)
li2 = LabelInput(
    root, 'Age', tk.Spinbox,
    {'textvariable': age_var, 'from_': 10, 'to': 150}
)
li2.grid()

复合控件不仅节省了几行代码,更重要的是,它将输入表单代码提升到了一个更高层次的描述。我们不再关注每个标签相对于每个控件如何放置的细节,而是可以从这些更大的组件角度来思考表单。

封装组件

创建复合控件对于我们计划在应用程序中重复使用的结构非常有用,但同样的概念也可以有益地应用于应用程序中更大的部分,即使它们只出现一次。

这样做允许我们将方法附加到应用程序的组件上,以构建更易于管理的独立功能单元。例如,让我们创建一个 MyForm 类来容纳一个简单的表单:

1
2
3
4
5
# tkinter_classes_demo.py
class MyForm(tk.Frame):
    def __init__(self, parent, data_var, *args, **kwargs):
        super().__init__(parent, *args, **kwargs)
        self.data_var = data_var

就像我们创建复合控件一样,我们继承了 tk.Frame 并定义了一个新的初始化方法。parent*args**kwargs 参数将传递给超类的初始化方法,但我们还将接受一个 data_var 参数,它将是我们的新 JSONVar 类型的实例。我们将使用这个参数来将表单数据传出表单。

接下来,我们将创建一些内部控制变量来绑定到我们的表单控件:

1
2
3
4
    self._vars = {
        'name': tk.StringVar(self),
        'age': tk.IntVar(self, value=2)
    }

正如我们在数据输入应用程序中已经看到的,将表单数据变量保存在字典中将简化后续从它们中提取数据的过程。然而,我们没有使用全局变量,而是通过将字典添加到 self 并以下划线开头来创建它,将其作为受保护的实例变量。这是因为这个字典仅供表单内部使用。

现在,让我们利用 LabelInput 类来为我们的表单创建实际的控件:

1
2
3
4
5
6
7
8
    LabelInput(
        self, 'Name', tk.Entry,
        {'textvariable': self._vars['name']}
    ).grid(sticky=tk.E + tk.W)
    LabelInput(
        self, 'Age', tk.Spinbox,
        {'textvariable': self._vars['age'], 'from_': 10, 'to': 150}
    ).grid(sticky=tk.E + tk.W)

你可以看到,LabelInput 大大简化了我们的 GUI 构建代码!现在,让我们为表单添加一个提交按钮:

1
    tk.Button(self, text='Submit', command=self._on_submit).grid()

提交按钮被配置为调用一个名为 _on_submit 的受保护实例方法。这展示了使用类来构建 GUI 组件的一个强大特性:通过将按钮绑定到实例方法,该方法可以访问所有其他实例成员。例如,它可以访问我们的 _vars 字典:

1
2
3
    def _on_submit(self):
        data = {key: var.get() for key, var in self._vars.items()}
        self.data_var.set(data)

如果不使用类,我们将不得不依赖全局变量,就像我们在第3章“使用 Tkinter 和 Ttk 控件创建基本表单”中编写的 data_entry_app.py 应用程序中所做的那样。相反,我们的回调方法只需要隐式传递的 self 对象即可访问它所需的所有对象。在这种情况下,我们使用字典推导式从控件中提取所有数据,然后将结果字典存储在我们的 JSONVar 对象中。

Tip

字典推导式与列表推导式类似,但创建的是字典而不是列表;其语法为 { key: value for expression in iterator }。例如,如果你想创建一个数字及其平方的字典,可以写成 { n: n**2 for n in range(100) }

因此,每当点击提交按钮时,data_var 对象将会用输入小部件的当前内容进行更新。

继承子类

我们可以将这种组件构建的概念一直扩展到我们的顶层窗口,即 Tk 对象。通过继承 Tk 并在各自的类中构建应用程序的其他组件,我们可以以高层次的方式组合应用程序的布局和行为。让我们在当前的演示脚本中尝试一下:

1
2
3
4
5
# tkinter_classes_demo.py
class Application(tk.Tk):
    """一个简单的表单应用程序"""
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

请记住,Tk 对象不仅是我们的顶层窗口,还代表应用程序本身的核心。因此,我们将子类命名为 Application,以表示它是我们整个应用程序的基础。我们的初始化方法以必须的 super().__init__() 调用开始,将任何参数传递给 Application.__init__() 方法。

接下来,我们将创建一些变量来跟踪应用程序中的数据:

1
2
    self.jsonvar = JSONVar(self)
    self.output_var = tk.StringVar(self)

如你所料,JSONVar 将被传递给我们的 MyForm 对象以处理其数据。output_var 只是一个 StringVar,我们将用它来显示一些输出。接下来,我们在窗口中添加一些小部件:

1
2
3
4
5
    tk.Label(self, text='Please fill the form').grid(sticky='ew')
    MyForm(self, self.jsonvar).grid(sticky='nsew')
    tk.Label(self, textvariable=self.output_var).grid(sticky='ew')
    self.columnconfigure(0, weight=1)
    self.rowconfigure(1, weight=1)

在这里,我们为表单添加了一个简单的标题标签、一个 MyForm 对象和另一个用于显示输出的标签。我们还配置了框架,使第一列(也是唯一一列)扩展到额外空间,第二行(包含表单的行)扩展到额外的垂直空间。

由于 MyForm 的提交会更新我们传递给它的 JSONVar 对象,我们需要一种方法,在变量内容发生变化时执行提交处理回调。我们可以通过在 jsonvar 上设置一个追踪来实现这一点,如下所示:self.jsonvar.trace_add('write', self._on_data_change)trace_add() 方法可以用于任何 Tkinter 变量(或变量子类),以便在发生与变量相关的事件时执行回调函数。让我们花点时间详细检查一下它。

trace_add() 的第一个参数指定追踪将触发的事件;它可以是以下之一:

  • read:变量值被读取(例如,通过 get() 调用)。
  • write:变量值被修改(例如,通过 set() 调用)。
  • unset:变量被删除。
  • array:这是 Tcl/Tk 的遗留物,在 Python 中没有实际意义,但仍然是有效的语法。你可能永远不会使用它。

第二个参数指定事件的回调函数,在此例中为实例方法 _on_data_change(),当 jsonvar 被更新时将触发该方法。我们将这样处理它:

1
2
3
4
5
6
7
    def _on_data_change(self, *args, **kwargs):
        data = self.jsonvar.get()
        output = ''.join([
            f'{key} = {value}\n'
            for key, value in data.items()
        ])
        self.output_var.set(output)

该方法简单地遍历从 jsonvar 检索到的字典中的值,然后将它们连接成一个格式化的字符串。最后,将格式化的字符串传递给 output_var,它将更新主窗口底部的标签以显示表单中的值。在实际应用程序中,你可能会将检索到的数据保存到文件或将其作为批处理操作的参数使用。

Tip

在实例方法中,何时应该使用实例变量(例如,self.jsonvar),何时应该使用普通变量(例如,data)?方法中的普通变量在其作用域内是局部的,这意味着方法一旦返回,这些变量就会被销毁。此外,类中的其他方法无法引用它们。实例变量的作用域则贯穿实例的整个生命周期,并且任何实例方法都可以读取或写入它们。在Application类中,data变量仅在_on_data_change()方法内部需要,而jsonvar则需要在__init__()_on_data_change()两个方法中都能访问。

由于我们已经从Tk类继承,因此不应该再用root = tk.Tk()这行代码来启动脚本。请确保删除这行代码,以及之前引用root的代码行。相反,我们将这样执行我们的应用程序:

1
2
3
if __name__ == "__main__":
    app = Application()
    app.mainloop()

注意,这些代码行、我们的类定义和导入语句是我们执行的唯一顶级代码。这大大清理了我们的全局作用域,将代码的详细部分限制在了一个更有限的范围内。

Tip

在Python中,if __name__ == "__main__":是一个常见的惯用法,用于检查脚本是否正在被直接运行,例如当我们在命令行提示符下输入python3 tkinter_classes_demo.py时。如果我们将这个文件作为模块导入到另一个Python脚本中,这个检查将为假,并且该块内的代码不会被执行。将程序的主要执行代码放在这个检查下面是一个良好的实践,这样你就可以在更大的应用程序中安全地重用你的类和函数。

使用类重写应用

现在我们已经学会了在代码中使用类的这些技巧,让我们将其应用到我们的ABQ数据录入应用程序中。我们将从一个新的文件data_entry_app.py开始,并添加我们的导入语句,如下所示:

1
2
3
4
5
6
# data_entry_app.py
from datetime import datetime
from pathlib import Path
import csv
import tkinter as tk
from tkinter import ttk

现在,让我们看看如何应用一些基于类的技巧来重写一个更简洁的应用程序代码版本。

为Text组件添加StringVar

在创建应用程序时,我们发现一个令人烦恼的问题是,Text 小部件不允许使用 StringVar 来存储其内容,这要求我们必须与其他所有小部件不同地对待它。这是有充分理由的:Tkinter 的 Text 小部件不仅仅是多行 Entry 小部件,它能够包含富文本、图像以及其他 StringVar 无法存储的内容。话虽如此,我们并没有使用这些特性中的任何一个,因此对我们来说,拥有一个可以绑定到变量的功能更有限的 Text 小部件会更好。

让我们创建一个名为 BoundText 的子类来解决这个问题;从以下代码开始:

1
2
class BoundText(tk.Text):
    """一个绑定变量的 Text 小部件。"""

我们的类需要在 Text 类的基础上添加三样东西:

  • 它需要允许我们传入一个 StringVar,这个小部件将与之绑定。
  • 它需要在变量更新时更新小部件的内容;例如,当从文件加载或由另一个小部件更改时。
  • 它需要在小部件更新时更新变量的内容;例如,当用户在小部件中键入或粘贴内容时。

传入一个变量

我们将从重写初始化方法开始,以允许传入一个控制变量:

1
2
3
def __init__(self, *args, textvariable=None, **kwargs):
    super().__init__(*args, **kwargs)
    self._variable = textvariable

按照 Tkinter 的惯例,我们将使用 textvariable 参数来传入 StringVar 对象。将其余参数传递给 super().__init__() 后,我们将该变量存储为类的受保护成员。接下来,如果用户提供了变量,我们将继续将其内容插入到小部件中(这处理了分配给变量的任何默认值):

1
2
if self._variable:
    self.insert('1.0', self._variable.get())

请注意,如果没有传入变量,textvariable(以及因此的 self._variable)将为 None

组件与变量同步

接下来,我们需要做的是将控制变量的修改绑定到一个实例方法,该方法将更新小部件。

继续在我们的 __init__() 方法中工作,让我们在刚创建的 if 块内添加一个 trace,如下所示:

1
2
3
if self._variable:
    self.insert('1.0', self._variable.get())
    self._variable.trace_add('write', self._set_content)

我们 trace 的回调函数是一个受保护的成员函数,名为 _set_content(),它将用变量的内容更新小部件的内容。让我们继续创建这个回调函数:

1
2
3
4
def _set_content(self, *_):
    """将文本内容设置为变量的值"""
    self.delete('1.0', tk.END)
    self.insert('1.0', self._variable.get())

首先,注意我们回调函数的参数列表中包含 *_。这种表示法只是将传递给函数的所有位置参数打包到一个名为 _(下划线)的变量中。单个下划线或一系列下划线是命名 Python 变量的传统方式,当我们需要提供但不打算使用的变量时会使用这种方式。在这种情况下,我们使用它来消耗 Tkinter 在响应事件调用此函数时传递给它的任何额外参数。每当我们打算将回调函数绑定到 Tkinter 事件时,你会在其他回调方法中看到使用相同的技术。在方法内部,我们将简单地使用 delete()insert() 方法修改小部件的内容。

变量与组件同步

当 Text 小部件被修改时,更新变量会稍微复杂一些。我们需要找到一个事件,该事件会在 Text 小部件每次被编辑时触发,以便绑定到我们的回调函数。我们可以使用 <Key> 事件,它在每次按下键时触发,但它无法捕获基于鼠标的编辑操作,比如粘贴操作。不过,Text 小部件确实有一个 <<Modified>> 事件,它在首次被修改时发出。我们可以从这个事件开始;在 __init__() 中的 if 语句末尾添加另一行代码,如下所示:

1
2
3
4
if self._variable:
    self.insert('1.0', self._variable.get())
    self._variable.trace_add('write', self._set_content)
    self.bind('<<Modified>>', self._set_var)

然而,不太直观的是,<<Modified>> 仅在小部件首次被修改时触发。之后,我们需要通过更改小部件的修改标志来重置该事件。我们可以使用 Text 小部件的 edit_modified() 方法来做到这一点,该方法还允许我们检索修改标志的状态。为了了解这将如何工作,让我们编写 _set_var() 回调函数:

1
2
3
4
5
6
def _set_var(self, *_):
    """将变量设置为文本内容"""
    if self.edit_modified():
        content = self.get('1.0', 'end-1chars')
        self._variable.set(content)
        self.edit_modified(False)

在这个方法中,我们首先通过调用 edit_modified() 来检查小部件是否已被修改。如果是,我们将使用小部件的 get() 方法获取内容。注意,get 的结束索引是 end-1chars。这意味着“内容末尾之前的一个字符”。请记住,Text 小部件的 get() 方法会自动在内容末尾附加一个换行符,因此通过使用这个索引,我们可以消除额外的换行符。

获取小部件的内容后,我们需要通过向 edit_modified() 方法传递 False 来重置修改标志。这样,当用户下次与小部件交互时,它就可以准备好触发 <<Modified>> 事件。

创建一个更高级的 LabelInput()

我们之前在“创建复合小部件”部分创建的 LabelInput 类看起来很有用,但如果我们想在程序中使用它,还需要进一步完善。

让我们再次从类定义和初始化方法开始:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# data_entry_app.py
class LabelInput(tk.Frame):
    """一个包含标签和输入框的小部件。"""
    def __init__(
        self, parent, label, var, input_class=ttk.Entry,
        input_args=None, label_args=None, **kwargs
    ):
        super().__init__(parent, **kwargs)
        input_args = input_args or {}
        label_args = label_args or {}
        self.variable = var
        self.variable.label_widget = self

与之前一样,我们有用于父小部件、标签文本、输入类和输入参数的参数。由于我们希望使用的每个小部件现在都可以绑定一个变量,因此我们也将此作为必需参数,并且我们将添加一个可选参数,用于传递给标签小部件的参数字典,以防我们需要这样做。我们将 input_class 默认设置为 ttk.Entry,因为我们有多个这样的组件。

Tip

注意,input_argslabel_args 参数的默认值是 None,并且在方法中如果它们是 None,我们会将它们设为字典。为什么不直接使用空字典作为默认参数呢?在 Python 中,默认参数是在函数定义首次运行时进行评估的。这意味着在函数签名中创建的字典对象每次运行函数时都会是同一个对象,而不是每次都有一个新的空字典。由于我们希望每次都有一个新的空字典,因此我们在函数体内创建字典,而不是在参数列表中。这一点同样适用于列表和其他可变对象。

在方法中,我们像往常一样调用 super().__init__(),然后确保 input_argslabel_args 是字典。最后,我们将 input_var 保存到一个实例变量中,并将标签小部件本身保存为变量对象的一个属性。这样做意味着我们不需要存储对 LabelInput 对象的引用;如果需要,我们可以通过变量对象直接访问它们。接下来,设置标签,如下所示:

1
2
3
4
5
if input_class in (ttk.Checkbutton, ttk.Button):
    input_args["text"] = label
else:
    self.label = ttk.Label(self, text=label, **label_args)
    self.label.grid(row=0, column=0, sticky=(tk.W + tk.E))

CheckbuttonButton 小部件内置了标签,因此我们不需要单独的标签。相反,我们只需将小部件的 text 参数设置为传入的值。(Radiobutton 对象也内置了标签,但我们稍后会以稍微不同的方式处理它们。)对于所有其他小部件,我们将在 LabelInput 的第一行和第一列中添加一个 Label 小部件。

接下来,我们需要设置输入参数,以便输入的控制变量以正确的参数名称传入:

1
2
3
4
if input_class in (ttk.Checkbutton, ttk.Button, ttk.Radiobutton):
    input_args["variable"] = self.variable
else:
    input_args["textvariable"] = self.variable

请记住,按钮类使用 variable 作为参数名称,而其他所有类都使用 textvariable。通过在类中处理这一点,我们在构建表单时就不需要担心这个区别。

现在,让我们设置输入小部件。大多数小部件的设置都很简单,但对于 Radiobutton,我们需要做一些不同的事情。我们需要为每个传入的可能值(使用 input_args 中的 values 键)创建一个 Radiobutton 小部件。记住,我们通过让按钮共享相同的变量来链接它们,这里我们也会这样做。我们将这样添加:

1
2
3
4
5
6
7
8
9
if input_class == ttk.Radiobutton:
    self.input = tk.Frame(self)
    for v in input_args.pop('values', []):
        button = ttk.Radiobutton(
            self.input, value=v, text=v, **input_args
        )
        button.pack(
            side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x'
        )

首先,我们创建一个 Frame 对象来容纳按钮;然后,对于 values 中传入的每个值,我们在 Frame 布局中添加一个 Radiobutton 小部件。注意,我们调用 pop() 方法从 input_args 字典中获取 values 项。dict.pop() 几乎与 dict.get() 相同,如果存在给定的键,则返回其值,否则返回第二个参数。区别在于,pop() 还会从字典中删除检索到的项。我们这样做是因为 values 不是 Radiobutton 的有效参数,因此我们需要在将 input_args 传递给 Radiobutton 初始化器之前将其移除。input_args 中剩余的项应该是小部件的有效关键字参数。

对于非 Radiobutton 小部件,设置相当简单:

1
2
else:
    self.input = input_class(self, **input_args)

我们只需调用传入的 input_class 类,并传入 input_args。现在我们已经创建了 self.input,只需将其添加到 LabelInput 布局中:

1
2
self.input.grid(row=1, column=0, sticky=(tk.W + tk.E))
self.columnconfigure(0, weight=1)

columnconfigure 的最终调用告诉 LabelWidget 小部件用第 0 列填充其整个宽度。

在创建自己的小部件(无论是自定义子类还是复合小部件)时,我们可以做的一件方便的事情是为几何布局设置一些合理的默认值。例如,我们希望所有 LabelInput 小部件都粘贴到其容器的左右两侧,以便它们填充可用的最大宽度。与其每次定位 LabelInput 小部件时都传入 sticky=(tk.E + tk.W),不如将其设为默认值,如下所示:

1
2
3
def grid(self, sticky=(tk.E + tk.W), **kwargs):
    """重写 grid 以添加默认的 sticky 值"""
    super().grid(sticky=sticky, **kwargs)

我们重写了 grid,只是将参数传递给超类版本,但添加了 sticky 的默认值。如果需要,我们仍然可以覆盖它,但设置默认值可以省去很多麻烦。我们的 LabelInput 现在相当健壮了;是时候让它发挥作用了!

创建一个Form类

既然我们的构建模块已经准备就绪,是时候构建应用程序的主要组件了。将应用程序分解为合理的组件需要我们仔细思考如何合理划分职责。初步看来,我们的应用程序可以分为两个组件:数据输入表单和根应用程序本身。但各项功能应该如何分配呢?

一种合理的评估可能是如下:

  • 数据输入表单本身当然应该包含所有小部件。它还应该包含保存和重置按钮,因为这些按钮与表单分开没有意义。
  • 应用程序标题和状态栏属于全局级别,因为它们将适用于应用程序的所有部分。文件保存功能可以与表单一起,但它也必须与一些应用程序级别的项目(如状态栏或已保存记录变量)进行交互。这是一个棘手的问题,但我们现在先将其放在应用程序对象中。

让我们从构建数据输入表单类 DataRecordForm 开始。

1
2
3
4
5
# data_entry_app.py
class DataRecordForm(ttk.Frame):
    """我们组件的输入表单"""
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)  # 调用父类构造函数

和往常一样,我们从继承 Frame 类并调用父类的初始化方法开始。目前我们不需要添加任何自定义参数。现在,让我们创建一个字典来存储所有的变量对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
self._vars = {
    'Date': tk.StringVar(),
    'Time': tk.StringVar(),
    'Technician': tk.StringVar(),
    'Lab': tk.StringVar(),
    'Plot': tk.IntVar(),
    'Seed Sample': tk.StringVar(),
    'Humidity': tk.DoubleVar(),
    'Light': tk.DoubleVar(),
    'Temperature': tk.DoubleVar(),
    'Equipment Fault': tk.BooleanVar(),
    'Plants': tk.IntVar(),
    'Blossoms': tk.IntVar(),
    'Fruit': tk.IntVar(),
    'Min Height': tk.DoubleVar(),
    'Max Height': tk.DoubleVar(),
    'Med Height': tk.DoubleVar(),
    'Notes': tk.StringVar()
}

这些直接来自我们的数据字典。注意,由于我们有了 BoundText 类,我们可以将 StringVar 对象分配给 Notes。现在,我们准备开始向 GUI 中添加小部件。在我们应用程序的当前版本中,我们使用类似以下代码块为每个应用程序部分添加了一个 LabelFrame 小部件:

1
2
3
4
r_info = ttk.LabelFrame(drf, text='Record Information')
r_info.grid(sticky=(tk.W + tk.E))
for i in range(3):
    r_info.columnconfigure(i, weight=1)

对于每个框架,这段代码都会重复,只是变量名和标签文本有所不同。为了避免这种重复,我们可以将这个过程抽象为一个实例方法。让我们创建一个方法,它可以为我们添加一个新的标签框架;将以下代码添加到 __init__() 定义的正上方:

1
2
3
4
5
6
7
def _add_frame(self, label, cols=3):
    """向表单中添加一个标签框架"""
    frame = ttk.LabelFrame(self, text=label)
    frame.grid(sticky=tk.W + tk.E)  # 将框架添加到网格中,并使其左右贴边
    for i in range(cols):
        frame.columnconfigure(i, weight=1)  # 为框架的每一列配置权重
    return frame

这个方法只是以通用的方式重写了之前的代码,因此我们只需传入标签文本,以及可选的列数。回到 DataRecordForm.__init__() 方法中我们之前的位置,让我们通过创建一个“记录信息”部分来使用这个方法,如下所示:

1
r_info = self._add_frame("Record Information")

现在我们有了框架,让我们尝试一下 LabelInput,并开始构建表单的第一部分,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
LabelInput(
    r_info, "Date", var=self._vars['Date']
).grid(row=0, column=0)
LabelInput(
    r_info, "Time", input_class=ttk.Combobox,
    var=self._vars['Time'],
    input_args={"values": ["8:00", "12:00", "16:00", "20:00"]}
).grid(row=0, column=1)
LabelInput(
    r_info, "Technician", var=self._vars['Technician']
).grid(row=0, column=2)

如你所见,LabelInput 已经为我们节省了很多冗余的代码!

让我们继续第二行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
LabelInput(
    r_info, "Lab", input_class=ttk.Radiobutton,
    var=self._vars['Lab'],
    input_args={"values": ["A", "B", "C"]}
).grid(row=1, column=0)
LabelInput(
    r_info, "Plot", input_class=ttk.Combobox,
    var=self._vars['Plot'],
    input_args={"values": list(range(1, 21))}
).grid(row=1, column=1)
LabelInput(
    r_info, "Seed Sample", var=self._vars['Seed Sample']
).grid(row=1, column=2)

请记住,要在 LabelInput 中使用 RadioButton 小部件,我们需要像对 Combobox 那样,将值列表传递给输入参数。完成“记录信息”部分后,让我们继续下一个部分:“环境数据:”

以下是翻译后的中文代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
e_info = self._add_frame("环境数据")
LabelInput(
    e_info, "湿度 (g/m³)",
    input_class=ttk.Spinbox, var=self._vars['Humidity'],
    input_args={"from_": 0.5, "to": 52.0, "increment": .01}
).grid(row=0, column=0)
LabelInput(
    e_info, "光照 (klx)", input_class=ttk.Spinbox,
    var=self._vars['Light'],
    input_args={"from_": 0, "to": 100, "increment": .01}
).grid(row=0, column=1)
LabelInput(
    e_info, "温度 (°C)",
    input_class=ttk.Spinbox, var=self._vars['Temperature'],
    input_args={"from_": 4, "to": 40, "increment": .01}
).grid(row=0, column=2)
LabelInput(
    e_info, "设备故障",
    input_class=ttk.Checkbutton, var=self._vars['Equipment Fault']
).grid(row=1, column=0, columnspan=3)

同样,我们使用 _add_frame() 方法添加并配置了一个 LabelFrame,并在其中填充了四个 LabelInput 小部件。现在,让我们添加“植物数据”部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
p_info = self._add_frame("植物数据")
LabelInput(
    p_info, "植物数量", input_class=ttk.Spinbox,
    var=self._vars['Plants'],
    input_args={"from_": 0, "to": 20}
).grid(row=0, column=0)
LabelInput(
    p_info, "花朵数量", input_class=ttk.Spinbox,
    var=self._vars['Blossoms'],
    input_args={"from_": 0, "to": 1000}
).grid(row=0, column=1)
LabelInput(
    p_info, "果实数量", input_class=ttk.Spinbox,
    var=self._vars['Fruit'],
    input_args={"from_": 0, "to": 1000}
).grid(row=0, column=2)
LabelInput(
    p_info, "最小高度 (cm)",
    input_class=ttk.Spinbox, var=self._vars['Min Height'],
    input_args={"from_": 0, "to": 1000, "increment": .01}
).grid(row=1, column=0)
LabelInput(
    p_info, "最大高度 (cm)",
    input_class=ttk.Spinbox, var=self._vars['Max Height'],
    input_args={"from_": 0, "to": 1000, "increment": .01}
).grid(row=1, column=1)
LabelInput(
    p_info, "平均高度 (cm)",
    input_class=ttk.Spinbox, var=self._vars['Med Height'],
    input_args={"from_": 0, "to": 1000, "increment": .01}
).grid(row=1, column=2)

我们几乎完成了;接下来让我们添加Notes部分:

1
2
3
4
5
LabelInput(
    self, "Notes",
    input_class=BoundText, var=self._vars['Notes'],
    input_args={"width": 75, "height": 10}
).grid(sticky=tk.W, row=3, column=0)

在这里,我们利用了 BoundText 对象,以便可以绑定一个变量。除此之外,这与对其他 LabelInput 的调用看起来都一样。现在,是时候添加按钮了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
buttons = tk.Frame(self)
buttons.grid(sticky=tk.W + tk.E, row=4)

self.savebutton = ttk.Button(
    buttons,
    text="Save",
    command=self.master._on_save
)
self.savebutton.pack(side=tk.RIGHT)

self.resetbutton = ttk.Button(
    buttons,
    text="Reset",
    command=self.reset
)
self.resetbutton.pack(side=tk.RIGHT)

我们像之前一样,在一个 Frame 上添加了按钮小部件。不过,这次我们将为按钮传入一些实例方法作为回调命令。重置按钮将使用一个我们将在此类中定义的实例方法,但由于我们决定保存文件是应用程序对象的责任,因此我们将保存按钮绑定到父对象上的一个实例方法(通过此对象的 master 属性访问)。

Tip

将 GUI 对象直接绑定到其他对象上的命令并不是处理对象间通信问题的良好方法,但就目前而言,它可以满足需求。在第 6 章《规划应用程序的扩展》中,我们将学习一种更优雅的方法来实现这一点。

这就结束了我们的 __init__() 方法,但在完成之前,我们还需要在这个类中实现几个方法。首先,我们需要实现 reset() 方法来处理表单重置;它看起来像这样:

1
2
3
4
5
6
7
def reset(self):
    """重置表单条目"""
    for var in self._vars.values():
        if isinstance(var, tk.BooleanVar):
            var.set(False)
        else:
            var.set('')

基本上,我们只需要将所有变量设置为空字符串。然而,对于 BooleanVar 对象,这样做会引发异常,因此我们需要将其设置为 False 以取消选中复选框。

最后,我们需要一个方法,让应用程序对象能够从表单中检索数据,以便保存这些数据。按照 Tkinter 的惯例,我们将这个方法称为 get()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def get(self):
    data = dict()
    fault = self._vars['Equipment Fault'].get()
    for key, variable in self._vars.items():
        if fault and key in ('Light', 'Humidity', 'Temperature'):
            data[key] = ''
        else:
            try:
                data[key] = variable.get()
            except tk.TclError:
                message = f'字段错误: {key}。数据未保存!'
                raise ValueError(message)
    return data

这里的代码与我们之前版本应用程序中的 on_save() 函数中的数据检索代码非常相似,但有几个不同之处。首先,我们从 self._vars 中检索数据,而不是从全局变量字典中。其次,在发生错误时,我们创建一个错误消息并重新引发 ValueError,而不是直接更新 GUI。我们需要确保调用此方法的代码能够处理 ValueError 异常。最后,与之前版本的应用程序直接保存数据不同,我们只是返回数据。

这样就完成了表单类!现在剩下的就是编写一个应用程序来包含它。

创建一个应用类

我们的应用程序类将处理应用程序级别的功能,并作为我们的顶级窗口。从 GUI 的角度来看,它需要包含:

  • 一个标题标签
  • 我们的 DataRecordForm 类的一个实例
  • 一个状态栏

它还需要一个将表单中的数据保存到 CSV 文件的方法。让我们开始编写我们的类:

1
2
3
4
class Application(tk.Tk):
  """应用程序根窗口"""
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)

这里没有什么新内容,除了现在我们继承的是 Tk 而不是 Frame。

让我们设置一些窗口参数:

1
2
self.title("ABQ Data Entry Application")     
self.columnconfigure(0, weight=1)

与程序版本的程序一样,我们已经设置了窗口标题并配置了网格的第一列以进行扩展。现在,我们将创建标题标签:

1
2
3
4
    ttk.Label(
      self, text="ABQ 数据输入应用程序",
      font=("TkDefaultFont", 16)
    ).grid(row=0)

这里没有什么真正的不同,只是要注意父对象现在是 self——不再会有根对象;self 是这个类中的 Tk 实例。让我们创建一个记录表单:

1
2
    self.recordform = DataRecordForm(self)
    self.recordform.grid(row=1, padx=10, sticky=(tk.W + tk.E))

尽管 DataRecordForm 尺寸较大且较为复杂,但将其添加到应用程序中就像添加任何其他小部件一样。现在,对于状态栏:

1
2
3
4
    self.status = tk.StringVar()
    ttk.Label(
      self, textvariable=self.status
    ).grid(sticky=(tk.W + tk.E), row=2, padx=10)

同样,这与程序版本类似,只是我们的状态变量是一个实例变量。这意味着它可以被我们类中的任何方法访问。最后,让我们创建一个受保护的实例变量来保存已保存记录的数量:

1
    self._records_saved = 0

完成了 __init__() 方法后,我们现在可以编写最后一个方法:_on_save()。这个方法将与我们之前编写的程序函数非常相似。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def _on_save(self):
    """Handles save button clicks"""
    # 获取当前日期并将其格式化为 "YYYY-MM-DD" 的字符串
    datestring = datetime.today().strftime("%Y-%m-%d")
    # 生成一个文件名,文件名格式为 "abq_data_record_YYYY-MM-DD.csv"
    filename = "abq_data_record_{}.csv".format(datestring)
    # 检查文件是否已经存在,若不存在则 newfile 为 True,否则为 False
    newfile = not Path(filename).exists()
    try:
        # 调用 self.recordform 的 get 方法获取数据,假设该方法返回一个字典
        data = self.recordform.get()
    except ValueError as e:
        # 如果获取数据时出现 ValueError 异常,将异常信息设置为状态信息并返回
        self.status.set(str(e))
        return
    # 以追加模式打开文件,newline='' 是为了正确处理 CSV 文件的换行符
    with open(filename, 'a', newline='') as fh:
        # 创建一个 DictWriter 对象,使用 data 的键作为 CSV 文件的列名
        csvwriter = csv.DictWriter(fh, fieldnames=data.keys())
        if newfile:
            # 如果是新文件,写入 CSV 文件的头部(列名)
            csvwriter.writeheader()
        # 将数据作为一行写入 CSV 文件
        csvwriter.writerow(data)
    # 已保存记录的数量加 1
    self._records_saved += 1
    # 更新状态信息,显示本次会话中已保存的记录数量
    self.status.set(
        "{} records saved this session".format(self._records_saved))
    # 调用 self.recordform 的 reset 方法重置表单
    self.recordform.reset()

同样,这个函数使用当前日期生成文件名,然后以追加模式打开文件。不过这次,我们只需调用 self.recordform.get() 就能获取数据,这将从其变量中获取数据的过程进行了抽象。请记住,我们必须处理 ValueError 异常,以防表单中存在错误数据,这里我们已经做了处理。如果出现错误数据,我们只需在状态栏中显示错误信息,并在该方法尝试保存数据之前退出。如果没有异常,数据就会被保存,所以我们会增加 _records_saved 属性的值并更新状态。

要使这个应用程序运行,我们最后需要做的是创建 Application 对象的实例并启动其主循环:

1
2
3
if __name__ == "__main__":
    app = Application()
    app.mainloop()
请注意,除了类定义和模块导入之外,这两行是在顶级作用域中仅有的被执行的代码。此外,由于 Application 负责构建图形用户界面(GUI)和其他对象,我们可以在应用程序末尾使用 if __name__ == "__main__" 保护语句,将其执行和 mainloop() 调用放在一起。

总结

在本章中,你学会了利用 Python 类的强大功能。你学会了创建自己的类,定义属性和方法,以及魔术方法的功能。你还学会了如何通过子类化来扩展现有类的功能。

我们探讨了如何将这些技术强大地应用于 Tkinter 类,以扩展它们的功能、构建复合小部件并将我们的应用程序组织成组件。

在下一章中,我们将学习 Tkinter 的验证特性,并进一步运用子类化使我们的小部件更加直观和健壮。我们还将学习如何自动输入以节省用户时间并确保数据输入的一致性。