最近在整理一份Python学习笔记正好写到类和对象这块。前面几篇把类的基本定义、继承、魔法方法都过了一遍这一篇我想单独谈谈类对象和属性的几个内置方法——classmethod、delattr、dir、hasattr、getattr、callable。这几个方法单独拆开看都不难难的是把它们放在真实场景里用顺。很多人学了点语法就扔一边等真写项目的时候要么手动写一堆if判断要么用点号访问属性一旦属性不存在就抛异常代码又丑又脆。这篇笔记就是冲着解决这个问题去的把每个方法的原理、适用场景和坑都捋一遍适合刚学完Python面向对象、想做点实际项目练手的同学。1. 属性访问为什么会乱成一锅粥先看一个真实的反面教材1.1 混乱的起点硬编码字典和一堆散落的工具函数先说说我为什么特意花一篇笔记来写这几个内置方法。前阵子在调试一个内部工具时看到一段让人血压升高的代码一个配置模块里到处是config_dict.get(timeout, 30)、if retry in config_dict、del config_dict[debug]这类写法密钥名写得到处都是改一个键名要全局替换还经常因为键名拼错拿不到配置整个模块维护起来非常痛苦。其实问题不在于字典本身而在于写法没有把属性操作这件事收拢成统一的口令。Python本身提供了一套现成的内置工具专门用来处理对象有没有某个属性怎么取到属性怎么删掉属性怎么发现对象有哪些属性怎么判断一个对象能不能被调用这就是getattr、hasattr、delattr、dir、callable再配合classmethod把类本身的逻辑组织好代码会干净非常多。1.2 把判断-取值-删除-查找拆成四个清晰的口令我后来把那堆散落的字典操作全部改成了对象属性的风格核心就是这四组操作hasattr(obj, name)判断对象有没有某个属性返回布尔值。getattr(obj, name, default)取出属性取不到就返回默认值。delattr(obj, name)删除某个属性。dir(obj)列出对象拥有的属性名列表。单看名字很好记但这几个方法的关系和边界很容易被忽略。比如hasattr内部到底干了什么很多人以为它是直接看一下属性在不在其实不是它会尝试调用getattr(obj, name)并捕获AttributeError。这个细节直接决定了它跟getattr是难兄难弟不是完全独立的两个东西。把属性访问从散落的字典键操作收敛成统一的对象属性方法之后代码的可读性和可维护性都会上一个台阶。接下来逐个拆开看每个方法的本质行为、适用场景以及很容易踩的边界情况。2. classmethod没那么简单它跟 staticmethod 差的不只是第一个参数2.1 先看classmethod的本质行为cls参数到底绑定了什么classmethod是装饰器作用是把一个方法变成类方法。类方法的第一个参数约定俗成叫cls它指向类本身而不是实例。这是它跟实例方法最大的分水岭。class Service: version 2.1.0 classmethod def show_version(cls): return fService version is {cls.version} print(Service.show_version()) # Service version is 2.1.0 print(Service().show_version()) # 实例也能调用类方法输出一样注意第二行实例调用类方法也没有问题Python会自动把实例所属的类传进去。这一点很关键因为很多初学者以为classmethod只能通过类名调用其实实例调用同样有效。那它跟staticmethod的区别在哪静态方法不接收任何隐含的第一个参数它只是一个恰好放在类命名空间里的普通函数。类方法则永远带着cls也就意味着它天然知道自己是谁。2.2 工厂方法与继承体系中的类方法子类调用时发生了什么classmethod最常见的应用场景是工厂方法。比如一个日志处理器接受不同的数据来源我不希望调用方去判断应该创建哪个子类而是让父类提供一个统一的入口返回对应子类的实例。class LogHandler: def __init__(self, source): self.source source classmethod def from_text(cls, text): return cls({type: text, data: text}) classmethod def from_json(cls, raw): return cls({type: json, data: raw}) class FileLogHandler(LogHandler): def __init__(self, source): super().__init__(source) self.source[type] file handler FileLogHandler.from_text(hello) print(type(handler)) # class __main__.FileLogHandler这里的关键是cls。我在基类里写的是cls(...)而不是LogHandler(...)。当子类继承并调用from_text时cls指向的是子类FileLogHandler所以创建出来的实例类型完全正确。如果写死了父类名子类就永远只能用父类实例整个继承体系就废了。这个特性对注册表类的设计也很香。比如某个框架希望插件类注册到一个全局字典里可以在基类里用__init_subclass__或者类方法实现。2.3 三种方法定义的选型对照表我平时写代码时是这样选的直接列个表方便以后查阅方法定义方式第一个参数能否通过实例调用典型用途实例方法self指向实例可以读写实例属性、执行实例级操作classmethodcls指向类可以工厂方法、操作类属性、提供备用构造函数staticmethod无可以跟类和实例都无强关联的工具函数staticmethod并不是没用的但如果你不确定该用哪个classmethod通常是更稳妥的选择因为它保留了类的上下文。什么时候我才用staticmethod只有当前后逻辑真的完全不需要知道类名也不需要访问类属性才考虑它。多一个cls参数有时候能省掉后面好多麻烦。3. getattr、hasattr、delattr属性三件套如何精准配合3.1 getattr带默认值的点号访问比字典.get更高级getattr(obj, name, default)本质上就是用字符串名字去取属性等价于obj.name但多了一个默认值兜底。我常说它是有关键词参数的访问方式。class Config: host 127.0.0.1 port 6379 cfg Config() host getattr(cfg, host, localhost) password getattr(cfg, password, None) print(host) # 127.0.0.1 print(password) # None注意getattr的默认值只在属性不存在时生效如果属性存在但值为None结果就是None不会帮你跳过。这一点跟字典的dict.get(key, default)行为是基本一致的但混用的时候容易产生歧义后面避坑清单里再细说。getattr更适合在动态场景里用。比如从配置字符串里读出属性名再决定取哪个值这时候属性名是运行时才确定的点号语法根本写不了。def read_config(obj, key): return getattr(obj, key, unknown)getattr还有一个硬核特性当属性本身定义了__getattr__魔法方法时访问不到属性会触发它。这样你就可以自定义属性不存在时的行为而不是直接抛异常。这是动态代理、懒加载之类设计的基础。3.2 hasattr的伪结果它只告诉你有没有不告诉你是什么hasattr(obj, name)用来判断对象有没有属性name。但注意它的内部行为是尝试getattr捕获AttributeError。所以如果getattr过程中抛了异常hasattr会把这个异常吞掉返回False。这句话看着简单实际影响很大。当对象实现了__getattr__或者某些属性是property时属性访问可能触发一段逻辑这段逻辑如果内部出错hasattr不会把错误抛出来只会默默返回False。class Wrapper: property def data(self): raise RuntimeError(backend unavailable) w Wrapper() print(hasattr(w, data)) # FalseRuntimeError 被吞掉了新手看到这个很容易懵明明代码里定义了data为什么hasattr返回False真实原因就是property读取时抛了异常。所以在做健壮性检查时hasattr的简单是有代价的它把属性不存在和读取属性时出错混在了一起。3.3 delattr删除属性时的边界行为delattr(obj, name)相当于del obj.name。它也有对应的魔法方法__delattr__删除属性时会触发。delattr最主要的坑在于类属性和实例属性的关系。假设类里定义了一个属性实例又定义了同名属性那实例删除这个属性后访问时会重新看到类属性。class Node: tag base n Node() n.tag instance_tag print(n.tag) # instance_tag delattr(n, tag) print(n.tag) # base类属性还在也就是说删除实例属性并不会删掉类属性只是让实例恢复到从类上找属性。如果你本意是想删除类的某个属性那应该操作类对象delattr(Node, tag)。这两个操作搞混调试起来非常烧脑。3.4 三件套与魔术方法之间的联动我一直建议把这三个方法当成一个整体来理解因为它们背后对应的是__getattr__、__getattribute__、__setattr__、__delattr__这一整套属性访问协议。getattr(obj, name)默认会走__getattribute__如果找不到再走__getattr__。hasattr(obj, name)本质上就是尝试调用getattr捕获异常。delattr(obj, name)会触发__delattr__可以在里面做删除前的校验或日志。理解这层联动关系遇到属性访问怎么不按预期走的问题时就能很快定位出是不是魔术方法影响了行为。后面实战案例里我特意用这些联动关系做了一个能拦截属性删除的类。4. dir与callable动态发现能力才是调试利器4.1 深入dir实例目录和类目录差了什么dir()用来列出对象的属性名。没有参数时列出当前作用域的变量名有参数时列出对象的属性名列表并且按字母排好序。先说一个很容易忽略的区别dir(instance)和dir(Class)的结果是不一样的。实例的目录除了类属性、实例属性还会包含继承链上的属性和特殊方法而类的目录则包含类的属性和方法定义但不包含实例化之后的实例属性。class Demo: class_attr 10 def __init__(self): self.instance_attr 20 def run(self): pass d Demo() print(instance_attr in dir(d)) # True print(instance_attr in dir(Demo)) # False print(class_attr in dir(Demo)) # True在调试的时候dir()比一个个猜属性名高效得多。比如拿到一个陌生的库返回的对象直接dir(obj)看有哪些可用方法再配合help()查签名基本就能摸清用法。还有一个隐藏用法dir()可以配合__dir__魔法方法定制。如果类里实现了__dir__dir(obj)的结果会被它影响这在做一些对外 API 可见性控制时很常用。4.2 callable判断能不能调的底层逻辑callable(obj)接收一个对象返回它能不能被调用。能调用的标志是内部实现了__call__方法。函数、方法、类甚至实现了__call__的实例都是可调用的。def func(): pass class CallableClass: def __call__(self, value): return value * 2 print(callable(func)) # True print(callable(CallableClass)) # True类可调用返回实例 print(callable(CallableClass())) # True实例实现了__call__ print(callable(123)) # False这个方法的用处在于当你写了一个回调分发逻辑不确定传进来的对象到底是函数还是实现了某个接口的实例用callable判断之后就可以大胆调用避免TypeError: xxx object is not callable。callable结合dir还有一个很妙的调试技巧遍历对象的方法列表只挑出可调用的方法看看这个对象到底暴露了哪些动作。action_names [name for name in dir(obj) if callable(getattr(obj, name, None))]这一行能快速筛选出对象可调用的属性对理解一个动态生成的插件接口特别有用。不过要注意dir出来的名字很多都是双下划线开头的魔术方法实际筛选时可以排除它们。5. 综合演练用六个内置方法做一个可动态扩展的任务注册器5.1 需求与设计用数据驱动的方式管理任务学得再多不如动手串一遍。我设计了一个小项目一个任务注册器任务可以通过类方法注册配置外部传入运行前检查任务是否存在运行后还能动态调用。这个项目规模不大但刚好能把classmethod、getattr、hasattr、dir、callable全部用上。核心需求有三个任务类通过classmethod暴露统一的注册入口子类注册时不丢失类型信息。外部按名称调用任务调用前判断任务是否存在。支持列出所有可调用任务方便做诊断和调试。5.2 完整实现与逐段解读直接上代码每段我配合说明。class TaskRegistry: _tasks {} classmethod def register(cls, nameNone): 注册任务。 def decorator(func): task_name name or func.__name__ cls._tasks[task_name] func return func return decorator classmethod def run(cls, task_name, *args, **kwargs): 运行任务任务不存在时返回提示信息。 if not hasattr(cls, _tasks): return fregistry missing: {cls.__name__} tasks getattr(cls, _tasks, {}) if task_name not in tasks: return ftask not found: {task_name} task tasks[task_name] if not callable(task): return ftask not callable: {task_name} return task(*args, **kwargs) classmethod def list_tasks(cls): 列出所有可调用任务。 tasks getattr(cls, _tasks, {}) return { name: callable(func) for name, func in tasks.items() if callable(func) }这段代码里有几个值得细说的点register用了classmethod里面的cls指向调用它的类。子类继承TaskRegistry调用register时cls._tasks会操作子类的类属性吗这里有个经典的继承陷阱如果子类没有单独定义_tasks那它访问的是父类的_tasks所有子类注册的任务会集中在同一个字典里。如果你想要每个子类有自己独立的任务表必须在子类里重新赋值_tasks {}。run方法里我用了hasattr(cls, _tasks)和getattr(cls, _tasks, {})的组合。正常情况下已经有_tasks所以hasattr返回Truegetattr拿到字典。这段兜底逻辑是为了演示当子类没有初始化_tasks时getattr的默认值{}能保证后续代码不崩溃。实际项目里这种先判断再取值的模式非常常用。callable(task)用来确认注册进来的对象确实能调用避免有人注册了一个普通字符串之后调用时抛TypeError。5.3 运行效果与扩展思路下面演示怎么使用这个注册器TaskRegistry.register() def ping(): return pong TaskRegistry.register(nameadd) def add(a, b): return a b print(TaskRegistry.run(ping)) print(TaskRegistry.run(add, 3, 5)) print(TaskRegistry.run(missing_task)) print(TaskRegistry.list_tasks())输出大致是pong 8 task not found: missing_task {ping: True, add: True}如果你想让某个任务类的注册行为更丰富比如校验函数名、记录注册时间可以重写register先调用父类的逻辑再追加额外处理。这种用classmethod搭出来的注册表比写一堆全局变量和if分支要清晰得多。6. 这些坑我替你们踩过了属性内置方法实战避雷清单6.1 hasattr会把property里抛的异常吞成False前面提到过hasattr吞掉AttributeError是为了实现判断属性是否存在但也把其他异常一起吞了。如果在property的 getter 里抛了别的异常hasattr依然返回False这会让你误以为属性不存在。class Circuit: property def status(self): raise ConnectionError(down)解决办法如果你关心属性访问时的异常不要用hasattr直接用getattr包在try/except里捕获具体的异常类型。hasattr适合我只需要知道能不能取到取不到就算了的宽松场景。6.2 getattr的默认值救不了属性存在但值为Nonegetattr(obj, setting, default)的默认值只在属性不存在时生效。如果属性存在但值是None返回的是None不是默认值。class AppConfig: debug None app AppConfig() print(getattr(app, debug, False)) # None不是False这跟dict.get(key, default)是一样的逻辑。做配置回填时如果不想要 None 覆盖默认配置记得先判断返回值是不是 None而不能依赖默认值。很多线上诡异的配置问题都是这么来的。6.3 删除实例属性会露出同名的类属性delattr(instance, name)删除的是实例属性类属性还在。删除之后再用点号访问又会看到类属性值。class Cache: ttl 60 cache Cache() cache.ttl 5 delattr(cache, ttl) print(cache.ttl) # 60如果你要删的是类属性本身必须对类对象操作delattr(Cache, ttl)。否则你会陷入删了还有再删还有的迷茫中。6.4 dir返回列表排过序别依赖它做精确定位dir()返回的列表是排序过的而且包含大量__xxx__魔术方法。直接用dir(obj)找方法通常得在几十个双下划线方法里翻。建议用列表推导式过滤把不关心的__方法去掉method_names [name for name in dir(obj) if not name.startswith(_)]这样列出的才是对外可见的方法做动态方法发现时不容易看花眼。6.5 callable的判断对类永远返回True别把类和实例搞混callable(SomeClass)永远返回True因为调用类会实例化。如果你要做回调分发期望传入的是函数或可调用实例那么类的名字也会被放行。拿到一个类名之后调用就会生成一个实例而不是执行逻辑。所以只靠callable判断还不够真正确认时还得看它的类型或者返回值。比如在注册器里我想区分这个注册项是函数还是类可以加一步import inspect print(inspect.isfunction(TaskRegistry._tasks[ping]))callable是安全调用的第一道门但很多场景下还需要inspect模块做更精确的类型判断。6.6 一个综合排错经验属性访问链路追踪最后分享一个排错思路。当你遇到属性明明存在但访问不到或者删除属性后行为诡异时不要急着改代码按这个顺序排查先dir(obj)确认属性名拼写是否正确。用getattr(obj, name, sentinel)看返回值是否为sentinel判断属性是否真的存在。检查类是否定义了__getattr__或__getattribute__它们会改变属性访问行为。检查实例是否有同名属性类是否有同名属性两者可能混淆。确认property的 getter 是否抛了异常导致hasattr返回False。这套链路我用了很多次基本能把九成属性访问问题定位出来。属性内置方法本身不难难的是在真实项目里快速判断到底是谁在搞鬼。把上面这些行为和边界背下来比死记方法签名有用得多。工具还是那六个关键是理解它们之间的协作方式和隐藏行为。多写几段动态操作的代码遇到几个坑你就能慢慢形成自己的判断节奏了。