☰
三秒判断UML类图关系:依赖、关联、聚合、组合、泛化、实现
2026/9/30 1:38:47 网站建设 项目流程

上周做代码评审,同事在白板上画了一张类图,空心菱形和实心菱形画反了,会议室里两派人为"这到底是聚合还是组合"争了二十分钟,最后谁也没说服谁。散会以后我翻了翻项目里的旧设计文档,发现依赖、泛化、实现、关联、聚合、组合这六个词被混用的次数,远比画错的次数多。更麻烦的是,很多人嘴上讲得头头是道,落到代码里就露馅——把依赖写成关联,把组合写成聚合,把泛化当成万能胶水到处粘。

这篇东西我打算把六个关系从头捋一遍。不是复述课本定义,而是回答一个更实际的问题:你在写代码、看别人的代码、或者被面试官追着问的时候,凭什么判断这一段是依赖而不是关联,凭什么说这两个类之间是组合而不是聚合。我会用 Java 举例,因为大部分后端项目绕不开它,但结论对所有面向对象语言都通用。无论你是刚学完 UML 画图的学生,还是干了几年却一直靠感觉判断的老手,看完之后应该能在三秒内说出一个关系的名字,并且讲得出理由。

1. 先把关系强弱这条线拉直:六种关系其实坐在两把椅子上

大多数人学这六个关系的时候,是当成一个列表背下来的,背完就乱。真正好用的办法是先承认一件事:它们不在同一个维度上。依赖、关联、聚合、组合这四个,描述的是两个类之间耦合从松到紧的一条连续谱;泛化和实现描述的是另一件事——子类型和父类型之间的替代关系。两把椅子,坐的人不一样,硬塞到一条排序里当然会晕。

1.1 用耦合度给依赖、关联、聚合、组合排个座次

耦合度这个词听起来玄,说白了就是"A 变了,B 要不要跟着改"以及"A 的生命周期,B 管不管"。按这个标准排下来,从松到紧是:依赖、关联、聚合、组合。

  • 依赖:B 只在某个瞬间用到 A,用完就忘。A 换了实现,B 顶多改一行方法调用。
  • 关联:B 长期知道 A 的存在,持有一个 A 的引用,但两者生死无关。
  • 聚合:A 是整体,B 是部分,整体由多个部分组成,但部分能脱离整体活着。
  • 组合:A 是整体,B 是部分,部分的生命周期被整体牢牢攥在手里,整体没了部分也就没了。

你会发现这条线里藏着两个不同的判断标准在交替起作用:前两个看的是"持有时间",后两个看的是"生命周期归属"。这也是为什么"依赖还是关联"和"聚合还是组合"这两组问题最容易混,因为它们的判别依据根本不是同一套。

我在实际项目里的做法是,先判断这属于"持有时间"这一类还是"生命周期"这一类,再看具体细节。上来就问"这是聚合还是关联",等于没想清楚问题。

1.2 泛化和实现为什么不能塞进强弱排序里

泛化是 is-a:学生是人,猫是动物。实现是 can-do:可飞行、可序列化、可支付。它们描述的是类型之间的替代能力,不是对象之间的持有关系。

这两者的代码痕迹非常明显,一眼就能认出来:泛化对应extends,类是白盒复用,子类能拿到父类的 protected 成员,也能覆写父类方法;实现对应implements,接口是黑盒契约,实现类只能看到接口暴露的方法签名,里面怎么写的完全无所谓。

为什么说它们不能塞进强弱排序?因为耦合度这个尺子在这儿不适用。你没法说"继承比聚合耦合更紧",这话只在特定场景下成立——经典的组合优于继承说的是复用方式的选择,不是 UML 关系的耦合排序。把这两个维度搅在一起,是很多人后面越学越乱的根源。

真正需要排序的只有那四种持有关系,泛化和实现单独拿出来讨论"该不该用"。

1.3 一张表先把直觉建立起来

先看这张表,建立第一层直觉,后面每一节再展开讲细节:

关系图示记号代码形态生命周期语义
依赖虚线箭头方法参数、局部变量、静态调用无临时使用
关联实线(可带箭头)成员字段各自独立长期知道
聚合空心菱形成员字段,外部注入各自独立整体-部分,可分离
组合实心菱形成员字段,内部创建同生共死整体-部分,强拥有
泛化空心三角实线extends无关is-a
实现空心三角虚线implements无关can-do

提示:菱形永远画在"整体"那一端。这一点很多人画反,画反了整张图的意思就反了。

记号这块还有个小细节值得说:聚合和组合的菱形都贴在整体一侧,箭头方向在关联里表示"谁知道谁"。如果你用的是只画线不画箭头的简化风格,那至少要保证菱形的方向正确,因为这决定了读者理解谁拥有谁。

2. 依赖和关联的分界线:临时借用还是长期持有

这一组是我见过被混得最狠的。原因很简单:在代码里,它们看起来都是"A 用了 B",但一个是"顺手借一下",另一个是"我认识他很久了"。判断依据其实只有一条,但这条依据经常被忽略——看 B 是不是作为字段长期存在。

2.1 依赖:出现在参数、局部变量、静态调用里

依赖的信号非常明确,只要出现下面任意一种,基本就可以判定:

public class OrderService { // 1. 方法参数里出现 public void submit(Order order, PayChannel channel) { channel.pay(order.getAmount()); } // 2. 方法体里的局部变量 public String render(Long orderId) { Order order = orderRepository.findById(orderId); return order.toString(); } // 3. 静态方法调用 public long now() { return System.currentTimeMillis(); } }

方法执行完,这个引用就断了。OrderService不会记住PayChannel,下次调用可能换一个完全不同的实现。这就是依赖的本质:用完即弃的临时关系。

这里有个容易被忽略的点:如果 B 是通过方法参数传进来的接口,那么依赖关系其实发生在"调用方"和"接口"之间,而不是和具体实现类之间。很多人在画图的时候把箭头指向实现类,这是错的——运行时确实落到实现上,但编译期的依赖是冲着接口去的。这一点在讲通"面向接口编程"的时候很关键。

还有一类常被漏掉的依赖:静态方法调用和new出来的临时对象。你可能会觉得new了一个对象就算是关联了,其实不是,只要它没被存进字段,出了方法就没了,仍然是依赖。判断标准永远是"生命周期是否跨越方法调用",不是"有没有 new"。

2.2 关联:字段里躺着对方的引用

一旦 B 变成了 A 的成员字段,关系立刻升级为关联:

public class Order { private Customer customer; // 关联:订单始终知道是谁下的单 private List<OrderItem> items; public Customer getCustomer() { return customer; } }

关键在于:这个引用会一直躺在对象里,直到对象自己被回收。订单对象只要活着,它就知道客户是谁。客户对象被销毁了,订单还能拿到一个失效的引用——两者生命周期互不干涉,这是关联区别于组合和聚合最重要的一条。

关联还分单向和双向。上面这个例子是单向的,订单知道客户,客户不知道订单。如果Customer里也放了一个List<Order>,那就是双向关联。双向关联在代码里写起来方便,但维护成本高,两边都得保证同步更新,否则很容易出现"订单里有这个客户,客户里却没有这张订单"的不一致。我的经验是,能做成单向就别做双向,实在需要反向导航,用查询代替字段持有。

2.3 多重性:关联上那个容易忽略的数字

关联线上通常还会标注多重性,像1、0..1、*。这个数字不是装饰,它直接决定了字段是单个对象还是集合:

多重性字段形态说明
1private Customer customer;必须有且只有一个
0..1private Customer customer;可能为 null
*private List<OrderItem> items;0 到多个
1..*private List<OrderItem> items;至少一个,通常用校验保证

很多人画图的时候随手写个*,代码里却写成单个字段,图和代码两张皮,时间一长没人信图。我现在的习惯是,图上的多重性必须和字段类型一一对上,对不上就说明设计还没想清楚。

2.4 我判断依赖还是关联用的三步法

评审代码的时候,我用一套很机械的流程来判断,基本不会错:

  1. 看 B 有没有作为字段出现。有字段,直接跳关联;没字段,往下走。
  2. 看 B 是不是只在方法签名、局部变量、静态调用里出现。是,判依赖。
  3. 看这个引用会不会跨越多次方法调用存活。会,就得回到第 1 步重新确认,说明你可能漏了一个字段。

这套流程听起来啰嗦,用起来大概三秒钟。真正需要动脑子的情况只有一种:B 存在某个缓存里、线程局部变量里、或者框架的上下文里。这时候严格来说它仍然是"关联",因为生命周期跨越了方法调用,只是持有方式比较隐蔽。框架代码里这种写法很多,读的时候要特别留意。

3. 泛化与实现:继承树上的"是什么"与"能做什么"

这两者的代码痕迹太明显了,反而容易让人停止思考——看到extends就写泛化,看到implements就写实现,机械填词。真正难的从来不是识别,而是判断该不该这么设计。识别只是第一步,选型才是要命的地方。

3.1 泛化就是 is-a,但 is-a 不等于就该继承

泛化的判定标准是 is-a:SavingsAccount是Account,所以SavingsAccount extends Account。但现实里诱惑太多,很多人会忍不住把"能共享代码"当成继承的理由:

// 反面例子:为了复用几个工具方法而继承 public class OrderService extends BaseService { // BaseService 里有一堆 log、checkParam、toJson 方法 }

OrderService不是BaseService的一种,它只是借用了一些方法。这种继承会让类图变得极其难看,而且一旦BaseService改了,所有子类都要跟着重新测。正确的做法是把这些方法抽成工具类或者通过组合引入,代码虽然多写几行,但耦合度大幅下降。

我在项目里立过一条规矩:新增一处继承,必须能说出一句"子类是一种父类"且这句话读起来不别扭。如果这句话需要加"严格来说""在某种场景下"这种修饰词,那就不是 is-a。

3.2 实现是接口契约,策略模式就是它的样板间

实现的语义是 can-do。接口定义一组方法签名,任何声明实现该接口的类都必须提供具体实现。策略模式是最典型的例子:

public interface DiscountStrategy { BigDecimal apply(BigDecimal price); } public class VipDiscount implements DiscountStrategy { @Override public BigDecimal apply(BigDecimal price) { return price.multiply(new BigDecimal("0.8")); } } public class NormalDiscount implements DiscountStrategy { @Override public BigDecimal apply(BigDecimal price) { return price; } }

从类图上看,VipDiscount到DiscountStrategy是虚线加空心三角,也就是实现关系。调用方只依赖接口,运行时注入哪个实现由配置或工厂决定。这就是实现关系最大的价值:把变化点从调用方挪到装配层。

你会发现,实现关系在代码里天然就是"多态入口"。凡是看到接口被实现多次,大概率就是一个可替换的扩展点。反过来,如果一个接口全项目只有一个实现类,还没人会去替换它,那这个接口大概率是多余的——除非是为了将来做单元测试打桩。

3.3 接口被当目录用之后,实现关系就烂了

见过不少项目,把所有对外方法都塞进一个接口,叫XxxService,然后XxxServiceImpl去实现它。这个接口和实现类一一对应,没有任何第二个实现,也没有任何测试替身。这种实现关系在类图上看起来正确,在设计上其实是在浪费一层抽象。

更糟的是,当接口变成"目录"以后,本来该用关联表示的关系被记成了实现。比如某个类只是持有了UserService的引用用来查数据,它并不"是"一种UserService,但由于大家都在implements,画图的人顺手就画成了实现关系。这就是机械识别的后果——只看关键字,不看语义。

我的判别方式很土:如果一个关系可以用"A 认识 B"来描述,那是关联;只有当"A 是一种 B"或者"A 能完成 B 承诺的所有动作"时,才写实现或者泛化。

3.4 抽象类还是接口:四条我常用的经验

选型这块我总结过四条经验,写下来给自己用:

  • 需要共享状态(字段)和部分实现,用抽象类。接口里放字段很别扭,也不是它的本意。
  • 只是定义一组能力供不同体系实现,用接口。比如Comparable、Serializable,任意类都能实现。
  • 需要多重继承能力,只能选接口。Java 里一个类只能继承一个父类,但可以实现多个接口。
  • 预计未来接口还会长方法,优先抽象类,因为接口加方法会波及所有实现类(Java 8 之后的默认方法缓解了这个问题,但别把它当成万能补丁)。

还有一条经验是给团队用的:接口一旦发布出去被外部依赖,加方法就是破坏性变更。所以在设计初期宁可少放几个方法,也不要为了"以后可能用到"塞一堆进去。

4. 聚合与组合:生命周期归属才是那道真正的分水岭

终于到最难的一组了。聚合和组合在代码里长得几乎一模一样——都是成员字段,都是整体持有部分。唯一的区别藏在一个你写代码时经常忽略的地方:部分对象是由谁创建的,又由谁负责销毁。这一条抓住了,判断就能立刻分出胜负。

4.1 聚合:整体散了,部分还能独立存在

聚合的语义是"拥有,但不独占"。最典型的例子是部门和员工:

public class Department { private List<Employee> employees; public Department(List<Employee> employees) { this.employees = employees; // 从外部传入,不自己创建 } public void remove(Employee e) { employees.remove(e); // 移除之后,员工对象依然存在 } }

员工对象是由外部创建好再传进来的,部门解散了,员工还在,还能被分配到别的部门,或者干脆作为独立对象继续存在于内存里。这就是聚合:部分可以脱离整体独立生存。

判断聚合只要问一句话:把整体删了,部分还能不能用?能,就是聚合。统考这个标准,链路聚合这种网络概念跟 UML 聚合其实没关系,名字撞了而已。数据库里的GROUP BY聚合函数也是同理,别被同一个词骗了。

4.2 组合:整体销毁,部分跟着一起走

组合的语义是"强拥有,同生共死"。订单和订单项是标准答案:

public class Order { private final List<OrderItem> items = new ArrayList<>(); public Order() { // 订单项由订单自己在内部创建 } public void addItem(String sku, int qty, BigDecimal price) { this.items.add(new OrderItem(sku, qty, price)); } }

这里的关键差异在于:OrderItem是Order自己在内部new出来的,外部拿不到也不该拿到它的独立引用。订单被删除,订单项就失去了存在的意义——它没有独立的业务身份,离开订单毫无价值。这就是组合。

判断组合也是问一句话:把整体删了,部分还有意义吗?没有,就是组合。

注意:组合的"销毁"在 Java 里不代表对象立刻被回收,垃圾回收是另一回事。这里说的是语义上的生命周期归属,不是内存管理。指望通过组合来避免内存泄漏是想多了,该断的引用还是得断。

4.3 从构造和销毁代码看两者的真实差别

把两种写法并排放在一起,差别一目了然:

维度聚合组合
部分对象的创建者外部整体内部
部分对象能否被外部引用能,通常是共享的通常不暴露
整体销毁后部分的状态依然可用失去意义
构造函数形态部分作为参数传入整体内部自行new
典型例子部门与员工、班级与学生订单与订单项、人与心脏

我在代码评审时会重点看构造函数:如果构造函数接收了一组子对象并直接赋值给字段,这是聚合的信号;如果构造函数里自己new出子对象并且不提供替换入口,那是组合的信号。当然也有例外,比如为了测试方便把子对象作为参数传进来,但运行时行为上仍然是组合语义。这种情况我会在注释里写清楚,免得后人看图疑惑。

4.4 订单与订单项、部门与员工这两个经典例子的边界

这两个例子被用烂了,但边界其实比想象中模糊。举个实际场景:电商系统里,运营想把某个订单里的某件商品"拆出来"生成售后单。如果按严格的组合设计,订单项不该被外部引用,那你拆单就得复制一份数据。这种情况下,业务上要的是"部分可独立存在",那就该往聚合靠。

反过来,有些团队把员工设计成组合,理由是"员工离职了这条记录就不该存在"。但员工在现实世界里显然是可以独立存在的实体,只是在这套系统的某一段时间里和部门绑定。硬套组合会导致数据模型扭曲,比如把员工表的所有字段冗余进部门表。

我的判断顺序是这样的:

  1. 先问业务:这个"部分"有没有自己独立的身份标识和查询入口?有,多半是聚合。
  2. 再问生命周期:整体删除时,业务上要不要一并清理部分?要,倾向组合。
  3. 最后问实现:部分对象是从外部传进来的,还是内部造出来的?这一条通常能验证前两条。

三条都对得上,判断基本不会错。

5. 一段代码把六种关系全串起来

光看片段容易记住,串成一整段才看得清全貌。下面这个例子是简化过的订单支付流程,六种关系全都出现了。

5.1 完整示例

// 泛化:VipUser 是一种 User public class VipUser extends User { private int level; } // 实现:两种支付方式各自实现支付契约 public interface Payable { boolean pay(BigDecimal amount); } public class WalletPay implements Payable { @Override public boolean pay(BigDecimal amount) { return true; } } public class CardPay implements Payable { @Override public boolean pay(BigDecimal amount) { return true; } } // 组合:订单项由订单内部创建 public class OrderItem { private String sku; private int quantity; } // 关联 + 组合 + 依赖 public class Order { private User owner; // 关联 private final List<OrderItem> items = new ArrayList<>(); // 组合 public Order(User owner) { this.owner = owner; } public void addItem(String sku, int qty) { items.add(new OrderItem(sku, qty)); } public boolean checkout(Payable payable) { // 依赖 BigDecimal total = BigDecimal.ZERO; for (OrderItem item : items) { total = total.add(BigDecimal.valueOf(item.getQuantity())); } return payable.pay(total); } } // 聚合:购物车与商品,商品来自外部 public class Cart { private List<OrderItem> items; public Cart(List<OrderItem> items) { this.items = items; } }

5.2 逐行对照关系

把上面的代码和六种关系一一对应起来:

  • VipUser extends User:泛化,is-a。
  • WalletPay implements Payable:实现,can-do。
  • Order.owner字段:关联,订单长期持有用户引用,用户和订单各自独立。
  • Order.items字段配合内部的new OrderItem:组合,订单项由订单自己造,订单没了订单项没意义。
  • checkout(Payable payable)参数:依赖,只在方法执行期间使用。
  • Cart.items字段配合构造传入:聚合,购物车清空后商品仍然存在。

同一段代码里,OrderItem同时出现在组合和聚合两个场景里。这说明什么?关系不是类的固有属性,而是两个类之间那条边的属性。同样两个类,在不同上下文里可能是聚合,也可能是组合。很多人卡在这就是因为把关系当成了类本身的标签。

5.3 容易判错的四个位置

结合实际评审经验,这四个位置最容易判错:

  • 购物车和商品:很多人写组合,理由是"购物车删了商品就没了"。错。商品在商品库里活得好好的,购物车里的只是一个引用,这是聚合。
  • 订单和支付记录:写成组合的人也不少。但支付记录有独立的审计价值,订单删了记录也得留,这是聚合。
  • 用户和用户详情:这个通常是组合,详情数据离开用户没有独立身份,且一般是一对一强绑定。
  • 线程池和任务:任务是外部提交进来的,虽然执行完就消失,但线程池不"拥有"它,这是聚合偏依赖的边界情况。严格说,任务作为字段存在任务队列里时是聚合,只是队列和任务的生命周期差异很大。

判断的时候把"这个部分有没有独立存在的理由"这句话默念一遍,八成能想清楚。

6. 工程里那些同名不同义的"依赖"和"组合"

这六个词之所以让人糊涂,还有个外部原因:它们在工程其他领域的含义和 UML 里完全不同。你要是没意识到这一点,就会在脑海里把几个概念搅成一锅粥。我把常见的几个同名概念拉出来对一下。

6.1 Spring 依赖注入里的"依赖"和 UML 依赖不是一回事

Spring 里说"这个 Bean 依赖那个 Bean",指的是运行时需要另一个对象协作,通常是通过构造函数注入或者字段注入实现的。这种依赖在 UML 里绝大多数对应的是关联而不是依赖——因为它是作为字段长期持有的。

@Service public class OrderService { private final UserRepository userRepository; // Spring 说是"依赖",UML 说是关联 public OrderService(UserRepository userRepository) { this.userRepository = userRepository; } }

我在面试里遇到过有人因为这个说错了。正确答案是:Spring 的依赖注入是"装配方式",UML 的依赖是"关系类型",两者描述的不是同一个层面的事。一个类通过构造注入持有另一个类的引用,这在 UML 里就是一条普通的单项关联,只是它的实现手段恰好是注入而已。

顺便说一句,"依赖倒置"里的依赖也是这个意思——高层模块不直接依赖低层实现,而是依赖抽象,描述的是模块间的依赖方向,不是 UML 的具体关系类型。

6.2 Maven 依赖管理是构建期的事,别往类图上搬

Maven、Gradle 里的依赖指的完全是另一回事:构建期需要哪些 jar 包。pom.xml里写一段dependency,说的是编译和运行阶段要把哪个库放到 classpath 上,和类图上的依赖关系没有任何对应关系。

真正和 UML 有关联的是"包依赖"这个概念——A包用到了B包里的类,这就构成包级别的依赖。但粒度完全不同:类图上的依赖是两个类的关系,Maven 的依赖是两个构件的关系。有些工具能从字节码里分析出包依赖图,那个图倒是和 UML 的包图思路相通,但和pom.xml里写的东西仍然不是一回事。

6.3 组合优于继承这条原则,和 UML 组合是近亲不是同一个人

"组合优于继承"是设计原则,"组合"在 UML 里是一种具体关系。它们确实有关系,但不完全等同。

这条原则里的"组合"是广义的,指的是把对象作为成员持有,也就是 UML 里关联、聚合、组合的统称。它对立的是"用继承来复用代码"。举个例子:与其让Stack extends ArrayList,不如让Stack内部持有一个List。后面这种做法在 UML 里可能表现为聚合(如果 List 是外部传入的)或者组合(如果内部创建的),取决于具体实现。

所以当有人问你"组合优于继承里的组合是不是 UML 组合",准确回答是:它指的是"对象持有"这一类复用方式,具体落到哪种关系要看实现细节。

7. 我在项目里踩过的几个判断坑

理论讲完了,说几个真实踩过的坑。这部分的经验基本没有文档会写,但每一条都让我改过至少一次设计。

7.1 把聚合写成组合,删主对象的时候连带删了不该删的

早年的一个后台系统,我把"标签"设计成了"文章"的内部对象,字段是private List<Tag> tags,创建文章的时候顺手new出来。设计的时候觉得挺干净,后来运营提需求:标签库要能单独维护,同一批标签要能复用到多篇文章上。这时候问题就暴露了——标签被文章独占,改一篇的标签会影响另一篇(因为大家共享的都是同一个对象?不,恰恰相反,因为当初是内部 new 的,每篇文章的标签都是副本,改一处不会同步)。

最后只能推倒重来,把标签改成从标签库查出来再注入,关系从组合改成了聚合加关联。这次教训让我明白一件事:在判断聚合还是组合之前,先问业务上这个"部分"会不会被共享。只要存在共享的可能,组合基本就是不合适的。

7.2 关联写成依赖,导致每次调用都重新查一次库

另一个坑出现在查询服务里。有个方法每次调用都会userRepository.findById(id)查一次用户。单次调用没问题,但在一个循环里反复调,就变成了典型的 N+1 查询。这个问题本身和 UML 关系不大,但我发现根源恰恰是关系判断错了:我潜意识里把这个用户当成了"临时用一下"的依赖,所以每次用都重新取。如果当初就明确这是关联——服务在这一次业务操作里持续需要这个用户对象——就会自然地把它取出一次、放在方法上下文里复用。

改法很简单,把对象在方法开头取一次,后面都复用。加一句缓存,QPS 上去了,数据库压力下来了。这件事让我意识到,UML 关系不只是画图用的,它会反过来影响你的代码结构。

7.3 被追问时的回答思路

面试被问到"这是聚合还是组合"的时候,我的建议是别急着给结论,先说判断依据。可以这样组织:

  1. 说明这个部分对象是谁创建的,是内部new还是外部传入。
  2. 说明整体销毁后部分的状态,能不能独立存活。
  3. 给出关系名称,并补一句"如果业务上允许共享,我会改成聚合"。

这种回答方式的好处是,即使你的结论和面试官预期不太一样,只要依据说得通,对方也能看出你是真懂而不是背的。我面别人的时候,最怕的就是上来直接喊"组合",然后问为什么就答"因为菱形是实心的"。这是纯背图,没有任何判断能力。

再补一个日常沟通里的小技巧:和产品经理讨论对象归属的时候,别用"聚合""组合"这种词,对方听不懂。直接问"这个东西删了,那个东西还要不要留?"用业务语言对齐,再回代码里翻译成 UML 关系,沟通效率会高很多。

实际操作中还有一个我常用的验证手段:画完类图之后,把图上的关系逐条翻译成代码,看能不能对上。对不上的地方,八成是图错了,也有可能是代码写得不符合设计。不管哪种情况,都说明你发现了问题。这个来回翻译的过程,比重读一遍课本有用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询