流畅的 Python 第二版(GPT 重译)(十二)(1)

简介: 流畅的 Python 第二版(GPT 重译)(十二)

第五部分:元编程

第二十二章:动态属性和属性

属性的关键重要性在于,它们的存在使得将公共数据属性作为类的公共接口的一部分完全安全且确实可取。

Martelli、Ravenscroft 和 Holden,“为什么属性很重要”¹

在 Python 中,数据属性和方法统称为属性。方法是可调用的属性。动态属性呈现与数据属性相同的接口——即,obj.attr——但是根据需要计算。这遵循 Bertrand Meyer 的统一访问原则

模块提供的所有服务都应通过统一的表示法可用,这种表示法不会泄露它们是通过存储还是计算实现的。²

在 Python 中有几种实现动态属性的方法。本章涵盖了最简单的方法:@property装饰器__getattr__特殊方法。

实现__getattr__的用户定义类可以实现我称之为虚拟属性的动态属性变体:这些属性在类的源代码中没有明确声明,也不在实例__dict__中存在,但可能在用户尝试读取不存在的属性时在其他地方检索或在需要时动态计算,例如obj.no_such_attr

编写动态和虚拟属性是框架作者所做的元编程。然而,在 Python 中,基本技术很简单,因此我们可以在日常数据整理任务中使用它们。这就是我们将在本章开始的方式。

本章的新内容

本章大部分更新的动机来自对@functools.cached_property(Python 3.8 中引入)的讨论,以及@property@functools.cache(3.9 中新引入)的联合使用。这影响了出现在“计算属性”中的RecordEvent类的代码。我还添加了一项重构以利用PEP 412—共享键字典优化。

为了突出更相关的特性,同时保持示例的可读性,我删除了一些非必要的代码——将旧的DbRecord类合并到Record中,用dict替换shelve.Shelve,并删除了下载 OSCON 数据集的逻辑——示例现在从Fluent Python代码库中的本地文件中读取。

使用动态属性进行数据整理

在接下来的几个示例中,我们将利用动态属性处理 O’Reilly 为 OSCON 2014 会议发布的 JSON 数据集。示例 22-1 展示了该数据集中的四条记录。³

示例 22-1。来自 osconfeed.json 的示例记录;一些字段内容已缩写
{ 
    "Schedule": { 
        "conferences": [{"serial": 115 }], 
        "events": [ 
            { 
                "serial": 34505, 
                "name": "Why Schools Don´t Use Open Source to Teach Programming", 
                "event_type": "40-minute conference session", 
                "time_start": "2014-07-23 11:30:00", 
                "time_stop": "2014-07-23 12:10:00", 
                "venue_serial": 1462, 
                "description": "Aside from the fact that high school programming...", 
                "website_url": "http://oscon.com/oscon2014/public/schedule/detail/34505", 
                "speakers": [157509], 
                "categories": ["Education"] 
            } 
        ], 
        "speakers": [ 
            { 
                "serial": 157509, 
                "name": "Robert Lefkowitz", 
                "photo": null, 
                "url": "http://sharewave.com/", 
                "position": "CTO", 
                "affiliation": "Sharewave", 
                "twitter": "sharewaveteam", 
                "bio": "Robert ´r0ml´ Lefkowitz is the CTO at Sharewave, a startup..." 
            } 
        ], 
        "venues": [ 
            { 
                "serial": 1462, 
                "name": "F151", 
                "category": "Conference Venues" 
            } 
        ] 
    } 
}

示例 22-1 展示了 JSON 文件中的 895 条记录中的 4 条。整个数据集是一个带有键"Schedule"的单个 JSON 对象,其值是另一个具有四个键"conferences""events""speakers""venues"的映射。这四个键中的每一个都映射到一个记录列表。在完整数据集中,"events""speakers""venues"列表有几十个或几百个记录,而"conferences"只有在示例 22-1 中显示的那一条记录。每条记录都有一个"serial"字段,这是记录在列表中的唯一标识符。

我使用 Python 控制台来探索数据集,如示例 22-2 所示。

示例 22-2. 交互式探索 osconfeed.json
>>> import json
>>> with open('data/osconfeed.json') as fp:
...     feed = json.load(fp)  # ①
>>> sorted(feed['Schedule'].keys())  # ②
['conferences', 'events', 'speakers', 'venues'] >>> for key, value in sorted(feed['Schedule'].items()):
...     print(f'{len(value):3} {key}')  # ③
...
 1 conferences 484 events 357 speakers
 53 venues >>> feed['Schedule']['speakers'][-1]['name']  # ④
'Carina C. Zona' >>> feed['Schedule']['speakers'][-1]['serial']  # ⑤
141590 >>> feed['Schedule']['events'][40]['name']
'There *Will* Be Bugs' >>> feed['Schedule']['events'][40]['speakers']  # ⑥
[3471, 5199]

feed是一个包含嵌套字典和列表、字符串和整数值的dict

列出"Schedule"内的四个记录集合。

显示每个集合的记录计数。

浏览嵌套的字典和列表以获取最后一个演讲者的姓名。

获取相同演讲者的序列号。

每个事件都有一个带有零个或多个演讲者序列号的'speakers'列表。

使用动态属性探索类似 JSON 的数据

示例 22-2 足够简单,但是feed['Schedule']['events'][40]['name']这样的语法很繁琐。在 JavaScript 中,您可以通过编写feed.Schedule.events[40].name来获取相同的值。在 Python 中,可以很容易地实现一个类似dict的类来做同样的事情——网络上有很多实现。⁴ 我写了FrozenJSON,比大多数方案更简单,因为它只支持读取:它只是用于探索数据。FrozenJSON也是递归的,自动处理嵌套的映射和列表。

示例 22-3 是FrozenJSON的演示,源代码显示在示例 22-4 中。

示例 22-3. FrozenJSON来自示例 22-4,允许读取属性如name,并调用方法如.keys().items()
>>> import json
    >>> raw_feed = json.load(open('data/osconfeed.json'))
    >>> feed = FrozenJSON(raw_feed)  # ①
    >>> len(feed.Schedule.speakers)  # ②
    357
    >>> feed.keys()
    dict_keys(['Schedule'])
    >>> sorted(feed.Schedule.keys())  # ③
    ['conferences', 'events', 'speakers', 'venues']
    >>> for key, value in sorted(feed.Schedule.items()): # ④
    ...     print(f'{len(value):3} {key}')
    ...
      1 conferences
    484 events
    357 speakers
     53 venues
    >>> feed.Schedule.speakers[-1].name  # ⑤
    'Carina C. Zona'
    >>> talk = feed.Schedule.events[40]
    >>> type(talk)  # ⑥
    <class 'explore0.FrozenJSON'>
    >>> talk.name
    'There *Will* Be Bugs'
    >>> talk.speakers  # ⑦
    [3471, 5199]
    >>> talk.flavor  # ⑧
    Traceback (most recent call last):
      ...
    KeyError: 'flavor'

从由嵌套字典和列表组成的raw_feed构建一个FrozenJSON实例。

FrozenJSON允许通过属性表示法遍历嵌套字典;这里显示了演讲者列表的长度。

也可以访问底层字典的方法,比如.keys(),以检索记录集合名称。

使用items(),我们可以检索记录集合的名称和内容,以显示每个集合的len()

一个list,比如feed.Schedule.speakers,仍然是一个列表,但其中的项目如果是映射,则转换为FrozenJSON

events列表中的第 40 项是一个 JSON 对象;现在它是一个FrozenJSON实例。

事件记录有一个带有演讲者序列号的speakers列表。

尝试读取一个不存在的属性会引发KeyError,而不是通常的AttributeError

FrozenJSON类的关键是__getattr__方法,我们已经在“Vector Take #3: Dynamic Attribute Access”中的Vector示例中使用过它,通过字母检索Vector组件:v.xv.yv.z等。需要记住的是,只有在通常的过程无法检索属性时(即,当实例、类或其超类中找不到命名属性时),解释器才会调用__getattr__特殊方法。

示例 22-3 的最后一行揭示了我的代码存在一个小问题:尝试读取一个不存在的属性应该引发AttributeError,而不是显示的KeyError。当我实现错误处理时,__getattr__方法变得两倍长,分散了我想展示的最重要的逻辑。考虑到用户会知道FrozenJSON是由映射和列表构建的,我认为KeyError并不会太令人困惑。

示例 22-4. explore0.py:将 JSON 数据集转换为包含嵌套FrozenJSON对象、列表和简单类型的FrozenJSON
from collections import abc
class FrozenJSON:
    """A read-only façade for navigating a JSON-like object
       using attribute notation
    """
    def __init__(self, mapping):
        self.__data = dict(mapping)  # ①
    def __getattr__(self, name):  # ②
        try:
            return getattr(self.__data, name)  # ③
        except AttributeError:
            return FrozenJSON.build(self.__data[name])  # ④
    def __dir__(self):  # ⑤
        return self.__data.keys()
    @classmethod
    def build(cls, obj):  # ⑥
        if isinstance(obj, abc.Mapping):  # ⑦
            return cls(obj)
        elif isinstance(obj, abc.MutableSequence):  # ⑧
            return [cls.build(item) for item in obj]
        else:  # ⑨
            return obj

mapping参数构建一个dict。这确保我们得到一个映射或可以转换为映射的东西。__data上的双下划线前缀使其成为私有属性

只有当没有具有该name的属性时才会调用__getattr__

如果name匹配实例__data dict中的属性,则返回该属性。这就是处理像feed.keys()这样的调用的方式:keys方法是__data dict的一个属性。

否则,从self.__data中的键name获取项目,并返回调用FrozenJSON.build()的结果。⁵

实现__dir__支持dir()内置函数,这将支持标准 Python 控制台以及 IPython、Jupyter Notebook 等的自动补全。这段简单的代码将基于self.__data中的键启用递归自动补全,因为__getattr__会动态构建FrozenJSON实例——对于交互式数据探索非常有用。

这是一个替代构造函数,@classmethod装饰器的常见用法。

如果obj是一个映射,用它构建一个FrozenJSON。这是鹅类型的一个例子——如果需要复习,请参阅“鹅类型”。

如果它是一个MutableSequence,它必须是一个列表,⁶因此我们通过递归地将obj中的每个项目传递给.build()来构建一个list

如果不是dictlist,则返回原样。

FrozenJSON实例有一个名为_FrozenJSON__data的私有实例属性,如“Python 中的私有和‘受保护’属性”中所解释的那样。尝试使用其他名称检索属性将触发__getattr__。该方法首先查看self.__data dict是否具有该名称的属性(而不是键!);这允许FrozenJSON实例处理dict方法,比如通过委托给self.__data.items()来处理items。如果self.__data没有具有给定name的属性,__getattr__将使用name作为键从self.__data中检索项目,并将该项目传递给FrozenJSON.build。这允许通过build类方法将 JSON 数据中的嵌套结构转换为另一个FrozenJSON实例。

请注意,FrozenJSON不会转换或缓存原始数据集。当我们遍历数据时,__getattr__会一遍又一遍地创建FrozenJSON实例。对于这个大小的数据集和仅用于探索或转换数据的脚本来说,这是可以接受的。

任何生成或模拟来自任意来源的动态属性名称的脚本都必须处理一个问题:原始数据中的键可能不适合作为属性名称。下一节将解决这个问题。

无效属性名称问题

FrozenJSON代码不处理作为 Python 关键字的属性名称。例如,如果构建一个这样的对象:

>>> student = FrozenJSON({'name': 'Jim Bo', 'class': 1982})

你无法读取student.class,因为class是 Python 中的保留关键字:

>>> student.class
  File "<stdin>", line 1
    student.class
         ^
SyntaxError: invalid syntax

当然你总是可以这样做:

>>> getattr(student, 'class')
1982

FrozenJSON的理念是提供对数据的便捷访问,因此更好的解决方案是检查传递给FrozenJSON.__init__的映射中的键是否是关键字,如果是,则在其末尾添加_,这样就可以像这样读取属性:

>>> student.class_
1982

通过用示例 22-5 中的版本替换示例 22-4 中的一行__init__,可以实现这一点。

示例 22-5. explore1.py:为 Python 关键字添加_作为属性名称的后缀
def __init__(self, mapping):
        self.__data = {}
        for key, value in mapping.items():
            if keyword.iskeyword(key):  # ①
                key += '_'
            self.__data[key] = value

keyword.iskeyword(…)函数正是我们需要的;要使用它,必须导入keyword模块,这在这个片段中没有显示。

如果 JSON 记录中的键不是有效的 Python 标识符,可能会出现类似的问题:

>>> x = FrozenJSON({'2be':'or not'})
>>> x.2be
  File "<stdin>", line 1
    x.2be
      ^
SyntaxError: invalid syntax

在 Python 3 中,这些有问题的键很容易检测,因为str类提供了s.isidentifier()方法,告诉您s是否是根据语言语法的有效 Python 标识符。但将一个不是有效标识符的键转换为有效的属性名称并不是简单的。一个解决方案是实现__getitem__,允许使用x['2be']这样的表示法进行属性访问。为简单起见,我不会担心这个问题。

在考虑动态属性名称之后,让我们转向FrozenJSON的另一个重要特性:build类方法的逻辑。Frozen.JSON.build__getattr__使用,根据正在访问的属性的值返回不同类型的对象:嵌套结构转换为FrozenJSON实例或FrozenJSON实例列表。

相同的逻辑可以实现为__new__特殊方法,而不是类方法,我们将在下面看到。

使用__new__进行灵活的对象创建

我们经常将__init__称为构造方法,但这是因为我们从其他语言中采用了术语。在 Python 中,__init__self作为第一个参数,因此当解释器调用__init__时,对象已经存在。此外,__init__不能返回任何内容。因此,它实际上是一个初始化器,而不是构造函数。

当调用类以创建实例时,Python 在该类上调用的特殊方法来构造实例是__new__。它是一个类方法,但得到特殊处理,因此不适用@classmethod装饰器。Python 获取__new__返回的实例,然后将其作为__init__的第一个参数self传递。我们很少需要编写__new__,因为从object继承的实现对绝大多数用例都足够了。

如果必要,__new__方法也可以返回不同类的实例。当发生这种情况时,解释器不会调用__init__。换句话说,Python 构建对象的逻辑类似于这个伪代码:

# pseudocode for object construction
def make(the_class, some_arg):
    new_object = the_class.__new__(some_arg)
    if isinstance(new_object, the_class):
        the_class.__init__(new_object, some_arg)
    return new_object
# the following statements are roughly equivalent
x = Foo('bar')
x = make(Foo, 'bar')

示例 22-6 展示了FrozenJSON的一个变体,其中前一个build类方法的逻辑移至__new__

示例 22-6. explore2.py:使用__new__而不是build来构建可能是FrozenJSON实例的新对象。
from collections import abc
import keyword
class FrozenJSON:
    """A read-only façade for navigating a JSON-like object
       using attribute notation
    """
    def __new__(cls, arg):  # ①
        if isinstance(arg, abc.Mapping):
            return super().__new__(cls)  # ②
        elif isinstance(arg, abc.MutableSequence):  # ③
            return [cls(item) for item in arg]
        else:
            return arg
    def __init__(self, mapping):
        self.__data = {}
        for key, value in mapping.items():
            if keyword.iskeyword(key):
                key += '_'
            self.__data[key] = value
    def __getattr__(self, name):
        try:
            return getattr(self.__data, name)
        except AttributeError:
            return FrozenJSON(self.__data[name])  # ④
    def __dir__(self):
        return self.__data.keys()

作为类方法,__new__的第一个参数是类本身,其余参数与__init__得到的参数相同,除了self

默认行为是委托给超类的__new__。在这种情况下,我们从object基类调用__new__,将FrozenJSON作为唯一参数传递。

__new__的其余行与旧的build方法完全相同。

以前调用FrozenJSON.build的地方;现在我们只需调用FrozenJSON类,Python 会通过调用FrozenJSON.__new__来处理。

__new__方法将类作为第一个参数,因为通常创建的对象将是该类的实例。因此,在FrozenJSON.__new__中,当表达式super().__new__(cls)有效地调用object.__new__(FrozenJSON)时,由object类构建的实例实际上是FrozenJSON的实例。新实例的__class__属性将保存对FrozenJSON的引用,即使实际的构造是由解释器的内部实现的object.__new__在 C 中执行。

OSCON JSON 数据集的结构对于交互式探索并不有用。例如,索引为40的事件,标题为'There *Will* Be Bugs',有两位演讲者,34715199。查找演讲者的姓名很麻烦,因为那些是序列号,而Schedule.speakers列表不是按照它们进行索引的。要获取每位演讲者,我们必须遍历该列表,直到找到一个具有匹配序列号的记录。我们的下一个任务是重组数据,以准备自动检索链接记录。

我们在第十一章“可散列的 Vector2d”中首次看到@property装饰器。在示例 11-7 中,我在Vector2d中使用了两个属性,只是为了使xy属性只读。在这里,我们将看到计算值的属性,从而讨论如何缓存这些值。

OSCON JSON 数据中的'events'列表中的记录包含指向'speakers''venues'列表中记录的整数序列号。例如,这是会议演讲的记录(省略了描述):

{ 
    "serial": 33950, 
    "name": "There *Will* Be Bugs", 
    "event_type": "40-minute conference session",
    "time_start": "2014-07-23 14:30:00", 
    "time_stop": "2014-07-23 15:10:00", 
    "venue_serial": 1449, 
    "description": "If you're pushing the envelope of programming...", 
    "website_url": "http://oscon.com/oscon2014/public/schedule/detail/33950", 
    "speakers": [3471, 5199], 
    "categories": ["Python"] 
}

我们将实现一个具有venuespeakers属性的Event类,以便自动返回链接数据,换句话说,“解引用”序列号。给定一个Event实例,示例 22-7 展示了期望的行为。

示例 22-7。读取venuespeakers返回Record对象
>>> event # ①
 <Event 'There *Will* Be Bugs'> >>> event.venue # ②
 <Record serial=1449> >>> event.venue.name # ③
 'Portland 251' >>> for spkr in event.speakers: # ④
 ...     print(f'{spkr.serial}: {spkr.name}') ... 3471: Anna Martelli Ravenscroft 5199: Alex Martelli

给定一个Event实例…

…读取event.venue返回一个Record对象,而不是一个序列号。

现在很容易获取venue的名称。

event.speakers属性返回一个Record实例列表。

和往常一样,我们将逐步构建代码,从Record类和一个函数开始,该函数读取 JSON 数据并返回一个带有Record实例的dict

步骤 1:基于数据创建属性

示例 22-8 展示了指导这一步骤的 doctest。

示例 22-8. 测试 schedule_v1.py(来自示例 22-9)
>>> records = load(JSON_PATH)  # ①
    >>> speaker = records['speaker.3471']  # ②
    >>> speaker  # ③
    <Record serial=3471>
    >>> speaker.name, speaker.twitter  # ④
    ('Anna Martelli Ravenscroft', 'annaraven')

load一个带有 JSON 数据的dict

records中的键是由记录类型和序列号构建的字符串。

speaker是在示例 22-9 中定义的Record类的实例。

可以将原始 JSON 中的字段作为Record实例属性检索。

schedule_v1.py的代码在示例 22-9 中。

示例 22-9. schedule_v1.py:重新组织 OSCON 日程数据
import json
JSON_PATH = 'data/osconfeed.json'
class Record:
    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)  # ①
    def __repr__(self):
        return f'<{self.__class__.__name__} serial={self.serial!r}>'  # ②
def load(path=JSON_PATH):
    records = {}  # ③
    with open(path) as fp:
        raw_data = json.load(fp)  # ④
    for collection, raw_records in raw_data['Schedule'].items():  # ⑤
        record_type = collection[:-1]  # ⑥
        for raw_record in raw_records:
            key = f'{record_type}.{raw_record["serial"]}' # ⑦
            records[key] = Record(**raw_record)  # ⑧
    return records

这是一个常见的快捷方式,用关键字参数构建属性的实例(详细解释如下)。

使用serial字段构建自定义的Record表示,如示例 22-8 所示。

load最终将返回Record实例的dict

解析 JSON,返回本机 Python 对象:列表、字典、字符串、数字等。

迭代四个名为'conferences''events''speakers''venues'的顶级列表。

record_type是列表名称去掉最后一个字符,所以speakers变成speaker。在 Python ≥ 3.9 中,我们可以更明确地使用collection.removesuffix('s')来做到这一点——参见PEP 616—删除前缀和后缀的字符串方法

构建格式为'speaker.3471'key

创建一个Record实例,并将其保存在带有keyrecords中。

Record.__init__方法展示了一个古老的 Python 技巧。回想一下,对象的__dict__是其属性所在的地方——除非在类中声明了__slots__,就像我们在“使用 slots 节省内存”中看到的那样。因此,使用映射更新实例__dict__是一种快速创建该实例中一堆属性的方法。⁷

注意

根据应用程序的不同,Record类可能需要处理不是有效属性名称的键,就像我们在“无效属性名称问题”中看到的那样。处理这个问题会分散这个示例的关键思想,并且在我们正在读取的数据集中并不是一个问题。

在示例 22-9 中Record的定义是如此简单,以至于你可能会想为什么我没有在之前使用它,而是使用更复杂的FrozenJSON。有两个原因。首先,FrozenJSON通过递归转换嵌套映射和列表来工作;Record不需要这样做,因为我们转换的数据集中没有映射嵌套在映射或列表中。记录只包含字符串、整数、字符串列表和整数列表。第二个原因:FrozenJSON提供对嵌入的__data dict属性的访问——我们用它来调用像.keys()这样的方法——现在我们也不需要那个功能了。

注意

Python 标准库提供了类似于Record的类,其中每个实例都有一个从给定给__init__的关键字参数构建的任意属性集:types.SimpleNamespaceargparse.Namespacemultiprocessing.managers.Namespace。我编写了更简单的Record类来突出显示基本思想:__init__更新实例__dict__

重新组织日程数据集后,我们可以增强Record类,自动检索event记录中引用的venuespeaker记录。我们将在接下来的示例中使用属性来实现这一点。

第 2 步:检索链接记录的属性

下一个版本的目标是:给定一个event记录,读取其venue属性将返回一个Record。这类似于 Django ORM 在访问ForeignKey字段时的操作:您将获得链接的模型对象,而不是键。

我们将从venue属性开始。查看示例 22-10 中的部分交互作为示例。

示例 22-10. 从 schedule_v2.py 的 doctests 中提取
>>> event = Record.fetch('event.33950')  # ①
    >>> event  # ②
    <Event 'There *Will* Be Bugs'>
    >>> event.venue  # ③
    <Record serial=1449>
    >>> event.venue.name  # ④
    'Portland 251'
    >>> event.venue_serial  # ⑤
    1449

Record.fetch静态方法从数据集中获取一个Record或一个Event

请注意,eventEvent类的一个实例。

访问event.venue将返回一个Record实例。

现在很容易找出event.venue的名称。

Event实例还具有来自 JSON 数据的venue_serial属性。

EventRecord的一个子类,添加了一个venue来检索链接的记录,以及一个专门的__repr__方法。

本节的代码位于schedule_v2.py模块中,位于Fluent Python代码库中。示例有近 60 行,所以我将分部分呈现,从增强的Record类开始。

流畅的 Python 第二版(GPT 重译)(十二)(2)https://developer.aliyun.com/article/1485176

相关文章
|
7天前
|
数据采集 存储 人工智能
【Python+微信】【企业微信开发入坑指北】4. 企业微信接入GPT,只需一个URL,自动获取文章总结
【Python+微信】【企业微信开发入坑指北】4. 企业微信接入GPT,只需一个URL,自动获取文章总结
21 0
|
12天前
|
机器学习/深度学习 人工智能 自然语言处理
总结几个GPT的超实用之处【附带Python案例】
总结几个GPT的超实用之处【附带Python案例】
|
前端开发 JavaScript 算法
JavaScript 权威指南第七版(GPT 重译)(七)(3)
JavaScript 权威指南第七版(GPT 重译)(七)
32 0
|
前端开发 JavaScript 算法
JavaScript 权威指南第七版(GPT 重译)(七)(1)
JavaScript 权威指南第七版(GPT 重译)(七)
60 0
|
12天前
|
存储 前端开发 JavaScript
JavaScript 权威指南第七版(GPT 重译)(六)(4)
JavaScript 权威指南第七版(GPT 重译)(六)
90 2
JavaScript 权威指南第七版(GPT 重译)(六)(4)
|
12天前
|
前端开发 JavaScript API
JavaScript 权威指南第七版(GPT 重译)(六)(3)
JavaScript 权威指南第七版(GPT 重译)(六)
55 4
|
12天前
|
JSON 前端开发 JavaScript
JavaScript 权威指南第七版(GPT 重译)(五)(2)
JavaScript 权威指南第七版(GPT 重译)(五)
36 5
|
12天前
|
JSON JavaScript 前端开发
JavaScript 权威指南第七版(GPT 重译)(四)(4)
JavaScript 权威指南第七版(GPT 重译)(四)
67 6
|
12天前
|
Web App开发 前端开发 JavaScript
JavaScript 权威指南第七版(GPT 重译)(四)(1)
JavaScript 权威指南第七版(GPT 重译)(四)
35 2
|
12天前
|
存储 JavaScript 前端开发
JavaScript 权威指南第七版(GPT 重译)(三)(3)
JavaScript 权威指南第七版(GPT 重译)(三)
41 1