ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java基础语法实战指南:从核心概念到高频坑点解析

Java基础语法实战指南:从核心概念到高频坑点解析 1. 项目概述为什么“基础语法”是Java世界的基石聊到Java很多人第一反应是Spring全家桶、微服务、高并发这些听起来很“高大上”的词。但作为一个写了十几年Java的老码农我见过太多人包括一些工作了几年的朋友在面试或者解决一些诡异Bug时恰恰是栽在了最基础的语法上。比如一个Integer的自动拆装箱引发的空指针或者对String不可变性的理解偏差导致的性能问题。所以今天我们不聊框架不聊架构就沉下心来好好盘一盘Java的基础语法。这绝不是一本枯燥的教科书而是我结合了无数次“踩坑”和“填坑”经验为你梳理的一份实战指南。无论你是刚入门的新手还是想巩固基础、查漏补缺的“老鸟”相信都能从这里找到一些被忽略的细节和恍然大悟的瞬间。Java的基础语法就像是盖楼前打的地基它决定了你代码的健壮性、可读性和未来能堆砌的高度。2. 核心概念深度解析从“字面量”到“面向对象”的思维跨越很多人觉得语法就是记关键字、背格式其实大错特错。语法是编程语言与你沟通的逻辑规则理解其背后的设计哲学才能写出地道的Java代码。2.1 变量与数据类型不止是int和String变量是数据的容器数据类型决定了容器的大小和能装什么。Java是强类型语言这意味着你必须先声明类型这虽然看起来繁琐却是代码安全性的第一道屏障。1. 基本数据类型内存的精确掌控者Java的8大基本类型byte,short,int,long,float,double,char,boolean是直接存储在栈内存中的效率极高。这里有几个实战中容易忽略的点选择恐惧症int还是long我个人的经验法则是涉及数组索引、循环计数等无脑用int涉及金融金额单位分、时间戳毫秒、数据库主键自增ID可能很大时优先考虑long。别为了省那点内存用short或byte除非你在做嵌入式开发或者处理网络协议包现代JVM对int的优化非常好。浮点数的陷阱float与double。永远记住float和double无法精确表示所有小数如0.1。在需要精确计算的场景比如货币必须使用BigDecimal。double d 0.1 0.2; // 结果不是0.3而是0.30000000000000004这是一个经典的坑。char的本质它存储的是一个Unicode码元16位对于大多数基本多文种平面BMP的字符够用但遇到一些辅助平面的字符如某些emoji时一个char是存不下的需要用String或者int代码点来处理。2. 引用数据类型通往堆内存的钥匙类、接口、数组等都是引用类型。变量本身存储的是对象在堆内存中的地址引用。这里最关键的是理解“引用”和“对象”是两回事。String str1 new String(Hello); String str2 str1; // str2 和 str1 指向同一个堆内存对象改变str1指向的对象不会影响str2因为只是让str1这个“遥控器”换了个台。但通过str1修改对象内部状态如果对象可变str2看到的内容也会变。3. 类型转换自动与强制的艺术自动类型转换隐式小范围类型向大范围类型转换安全。如int转long。强制类型转换显式大范围转小范围需要(类型)可能丢失精度或溢出。int i 128; byte b (byte) i; // 结果是 -128因为byte范围是-128~127发生了溢出。注意强制转换时尤其是浮点数转整数是直接截断小数部分不是四舍五入。(int) 3.9的结果是3。2.2 运算符不仅仅是计算运算符是构建表达式的基本砖块但有些运算符的行为需要仔细琢磨。1. 算术运算符注意/对于整数是取整除法%是取余。-5 % 3的结果是-2这与数学定义有关。2. 关系与逻辑运算符和!比较的是基本类型的值或引用类型的地址。对于引用类型要比较内容是否相等必须用.equals()方法。和||是短路运算符这在条件判断和空指针预防中非常有用。if (obj ! null obj.isValid()) { // 如果obj为null后半句不会执行避免了空指针异常。 // ... }3. 位运算符,|,^,~,,,。在权限控制、状态标志、哈希计算、高性能计算中广泛应用。例如用实现乘以2的幂次方比直接乘法快。4. 三元运算符? :简洁的条件赋值工具但不要嵌套太深否则严重影响可读性。int max (a b) ? a : b; // 清晰 // 避免String result (ab) ? ((ac)? a最大 : c最大) : ((bc)? b最大 : c最大);5. 赋值运算符,-等复合赋值运算符隐含了强制类型转换。short s 1; // s s 1; // 编译错误因为1是ints1结果是int不能直接赋给short s 1; // 正确等价于 s (short)(s 1);2.3 流程控制代码的执行路径这是让代码“活”起来的关键决定了逻辑的走向。1. 分支结构if-else 与 switchif-else最灵活。写多层嵌套时优先考虑卫语句Guard Clauses提前返回减少嵌套深度提升可读性。switch从JDK 7开始支持StringJDK 12引入了更强大的switch表达式可以返回值和case L -语法无需break。对于多分支选择switch通常比一堆if-else更清晰。但要注意switch对null值的处理传统switch不支持null会抛NullPointerException。2. 循环结构for, while, do-whilefor循环最常用特别是遍历数组或集合。for-each循环增强for循环语法更简洁但无法获取当前索引也无法在遍历时直接修改集合结构会抛ConcurrentModificationException。while 和 do-whilewhile先判断后执行do-while先执行一次再判断。后者适用于至少需要执行一次的场景。循环控制break跳出当前循环continue跳过本次循环剩余部分。可以为循环添加标签实现从多层嵌套循环中指定跳出。outerLoop: for (int i 0; i 10; i) { for (int j 0; j 10; j) { if (i * j 50) { break outerLoop; // 直接跳出外层循环 } } }2.4 数组最基础的数据结构数组是内存中一段连续的、类型相同的存储空间。它的长度一旦确定就不可变。1. 声明与初始化int[] arr1; // 推荐风格类型是“int数组” int arr2[]; // C语言风格不推荐 arr1 new int[5]; // 动态初始化元素为默认值0 int[] arr3 {1, 2, 3}; // 静态初始化 int[] arr4 new int[]{1, 2, 3}; // 也是静态初始化可以重新赋值给变量2. 内存模型与多维数组Java中的多维数组其实是“数组的数组”。int[][]是一个引用数组其中每个元素又是一个int[]引用。这意味着多维数组的每一行长度可以不同锯齿数组。int[][] matrix new int[3][]; matrix[0] new int[2]; matrix[1] new int[3]; // 第二行有3个元素3. 数组工具类java.util.Arrays这是处理数组的神器提供了排序(sort)、二分查找(binarySearch)、填充(fill)、比较(equals)、转换为列表(asList)等方法。Arrays.asList()返回的是一个固定大小的列表视图不能进行add/remove操作这点常被忽略。2.5 方法与参数传递理解“值传递”的本质方法是代码复用的基本单元。Java中所有参数传递都是值传递。对于基本类型传递的是值的副本对于引用类型传递的是引用的副本即地址的副本。这意味着在方法内部无法改变外部基本类型变量的值但可以通过传入的引用副本修改其指向的对象内容。public void modify(int num, StringBuilder sb) { num 100; // 修改副本外部原值不变 sb.append( World); // 通过副本引用修改了堆中的对象外部可见 // sb new StringBuilder(New); // 让副本引用指向新对象外部原引用不变 }方法重载Overload同名不同参类型、数量、顺序。编译器在编译期根据静态类型确定调用哪个方法。与返回值类型无关。可变参数(String... args)本质上是一个数组。一个方法只能有一个可变参数且必须是最后一个参数。3. 面向对象编程OOP核心语法精讲OOP是Java的魂其语法特性都是为了实现封装、继承、多态这三大特性。3.1 类与对象蓝图与实例类是模板对象是根据模板创建的具体实例。new关键字在堆中分配内存并调用构造器。构造器用于初始化对象状态。如果没有显式定义编译器会提供一个无参构造器。一旦定义了任何构造器默认无参构造器就不再提供。构造器可以重载。构造器第一行可以使用this(...)调用本类其他构造器或使用super(...)调用父类构造器。this关键字指代当前对象的引用。用于区分成员变量和局部变量或在构造器中调用其他构造器。3.2 封装与访问控制封装的核心是隐藏内部细节暴露必要接口。通过访问修饰符实现private仅本类可见。字段通常设为private通过public的getter/setter访问。default(包私有)同包内可见。这是默认级别。protected同包内不同包子类可见。public所有地方可见。实操心得遵循“最小权限原则”。字段能private就private方法能不public就不public。这减少了类之间的耦合让代码更健壮也便于后续重构。Getter/Setter不是必须的只有真正需要对外提供访问或修改时才写。3.3 继承代码复用的利器extends关键字实现继承。子类拥有父类非private的成员。super关键字指代父类对象引用。用于访问父类的成员属性、方法特别是在子类重写父类方法后还想调用父类原有实现时。Override public void someMethod() { super.someMethod(); // 先执行父类逻辑 // ... 子类扩展逻辑 }方法重写Override子类重新定义父类中已有的方法。必须满足“两同两小一大”原则方法名、参数列表相同返回值类型或子类、抛出的异常或子类小于等于父类访问权限大于等于父类。Override注解是强烈推荐使用的它让编译器帮你检查重写是否正确。继承的初始化顺序父类静态 - 子类静态 - 父类实例代码块/构造器 - 子类实例代码块/构造器。3.4 多态同一接口不同实现多态是OOP最精髓的部分它允许父类引用指向子类对象并在运行时确定实际调用的方法动态绑定。向上转型UpcastingAnimal a new Cat();安全自动进行。向下转型DowncastingCat c (Cat) a;不安全需要强制转换运行时可能抛出ClassCastException。转型前务必用instanceof进行检查。if (a instanceof Cat) { Cat c (Cat) a; c.meow(); }多态的存在使得我们可以编写出更通用、更易扩展的代码。例如一个draw(Shape s)方法可以接受Circle,Rectangle等任何Shape子类对象。3.5 抽象类与接口定义契约抽象类abstract class包含抽象方法没有方法体的类不能实例化。它可以有构造器供子类调用、普通成员变量和具体方法。用于表示一种“is-a”关系并提供部分实现。接口interface在JDK 8之前它是一组纯抽象方法的集合。从JDK 8开始接口可以包含default方法默认方法有实现和static方法。JDK 9引入了private方法。接口表示一种“has-a”或“can-do”的能力契约。一个类可以实现多个接口。如何选择如果你需要定义一些类的共同状态字段和部分行为并且这些类有清晰的层级关系用抽象类。如果你只是定义一种能力或契约不关心具体实现类是否有继承关系或者需要实现多重“能力”用接口。现代Java开发中接口因其灵活性和支持多重继承使用频率越来越高。很多API设计都倾向于“面向接口编程”。3.6 内部类藏在类内部的类内部类提供了更好的封装和逻辑分组。成员内部类如同一个普通成员。可以访问外部类的所有成员包括private。外部类需要先实例化才能创建内部类对象。静态内部类使用static修饰。它不能直接访问外部类的非静态成员。创建时不依赖外部类实例。常用于与外部类关联但不依赖其状态的工具类。局部内部类定义在方法或作用域内。访问权限仅限于该作用域。可以访问外部类的成员以及所在方法的final或等效final的局部变量JDK 8变量实质上是final的即可。匿名内部类没有名字用于快速创建某个类或接口的一次性实现。语法略显晦涩在Java 8之后很多场景可以被Lambda表达式替代。// 匿名内部类 Runnable r new Runnable() { Override public void run() { System.out.println(Hello); } }; // Lambda表达式 (更简洁) Runnable r () - System.out.println(Hello);4. 核心类库与常用API实战要点Java强大的生态源于其丰富的核心类库。掌握这些常用类的特性和坑点能极大提升开发效率。4.1 String不可变性的利与弊String对象一旦创建其值就不能被改变。任何修改操作如concat,replace都会返回一个新的String对象。为什么设计成不可变安全性作为参数传递时不用担心被意外修改如用作HashMap的key。线程安全天生线程安全。缓存哈希值String常用于哈希集合HashMap,HashSet其hashCode()方法会缓存第一次计算的结果因为值不变哈希值也不会变提升了性能。字符串常量池允许缓存字面量节省内存提高性能。字符串拼接的性能陷阱// 低效在循环中会产生大量中间String对象和StringBuilder对象 String result ; for (int i 0; i 10000; i) { result i; } // 高效显式使用一个StringBuilder StringBuilder sb new StringBuilder(); for (int i 0; i 10000; i) { sb.append(i); } String result sb.toString();编译器会对简单的字面量拼接如a b进行优化但在循环中务必使用StringBuilder非线程安全或StringBuffer线程安全但性能稍差。4.2 包装类与自动拆装箱为了能让基本类型像对象一样操作Java为每个基本类型提供了对应的包装类Integer,Double等。自动装箱AutoboxingInteger i 10;(基本类型 - 包装类)自动拆箱Unboxingint n i;(包装类 - 基本类型)主要坑点空指针异常NullPointerException自动拆箱时如果包装类对象为null会抛出NPE。Integer num null; int n num; // 运行时抛出 NullPointerException数值比较比较对象引用equals比较值。对于Integer在-128~127之间的值Java会进行缓存所以Integer a127; Integer b127; ab为true但超出此范围ab就为false。包装类的比较一律使用equals()方法。性能开销频繁的拆装箱会创建大量短期对象增加GC压力。在性能敏感的循环或大量计算中应优先使用基本类型。4.3 日期时间API告别旧的Date和Calendar旧的java.util.Date和java.util.CalendarAPI设计混乱、可变、非线程安全饱受诟病。强烈建议使用Java 8引入的java.time包。LocalDate只包含日期年-月-日。LocalTime只包含时间时-分-秒-纳秒。LocalDateTime包含日期和时间。Instant时间戳从1970-01-01T00:00:00Z开始的秒/纳秒数。Duration两个时间点之间的间隔基于时间。Period两个日期之间的间隔基于日期。DateTimeFormatter用于格式化和解析。这些类都是不可变的、线程安全的API设计清晰直观。LocalDate today LocalDate.now(); LocalDate nextWeek today.plusWeeks(1); DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); String formatted LocalDateTime.now().format(formatter);4.4 集合框架Collection Framework数据的容器Java集合框架是面试和实战的重中之重。主要分为两大类Collection单值和Map键值对。1. List有序、可重复ArrayList基于动态数组。随机访问快O(1)但在中间插入/删除慢需要移动元素O(n)。默认初始容量10扩容为1.5倍。适合读多写少、随机访问频繁的场景。LinkedList基于双向链表。在头部/尾部插入/删除快O(1)但随机访问慢需要遍历O(n)。还实现了Deque接口可以作为队列或栈使用。适合频繁在头部/尾部进行插入/删除的场景。2. Set无序、不可重复HashSet基于HashMap实现。元素无序判断重复和查找效率高O(1)。要求元素正确重写equals()和hashCode()方法。LinkedHashSet在HashSet基础上维护了一个双向链表保持了元素的插入顺序。TreeSet基于红黑树实现。元素有序自然顺序或自定义比较器。增删查改效率为O(log n)。3. Map键值对映射HashMap最常用。基于数组链表/红黑树JDK 8。允许null键和null值。线程不安全。初始容量16负载因子0.75。扩容时容量翻倍。要求键正确重写equals()和hashCode()。LinkedHashMap在HashMap基础上维护了插入顺序或访问顺序用于实现LRU缓存。TreeMap基于红黑树键有序。Hashtable古老类线程安全方法用synchronized修饰但性能差不允许null键值。已被ConcurrentHashMap取代。ConcurrentHashMap高效的线程安全Map。采用分段锁JDK 7或CASsynchronizedJDK 8并发性能远优于Hashtable和Collections.synchronizedMap()。选择指南需要快速访问不关心顺序 -HashMap需要保持插入顺序 -LinkedHashMap需要键自然排序或自定义排序 -TreeMap线程安全且高并发 -ConcurrentHashMap只需要去重 -HashSet需要去重且保持顺序 -LinkedHashSet需要去重且排序 -TreeSet遍历集合for-each循环最简洁但遍历Map时需要entrySet()。迭代器Iterator可以在遍历时安全地删除元素使用迭代器的remove方法。Java 8 Stream API函数式风格适合进行过滤、映射、归约等复杂操作。4.5 异常处理优雅地应对错误Java的异常是Throwable类的子类主要分为Error系统错误程序通常无法处理和Exception程序可处理的异常。Exception又分为RuntimeException运行时异常非受检异常和其他Exception受检异常。异常处理机制try-catch-finally捕获并处理异常。finally块无论是否发生异常都会执行除非try或catch中调用了System.exit()常用于释放资源如关闭文件流、数据库连接。try-with-resourcesJava 7自动资源管理语法要求资源实现AutoCloseable接口。比手动finally更简洁安全。try (BufferedReader br new BufferedReader(new FileReader(file.txt))) { // 使用资源 } catch (IOException e) { // 处理异常 } // 无需finallybr会自动关闭throws在方法声明上抛出异常将异常处理责任传递给调用者。throw在方法体内抛出一个异常对象。最佳实践具体异常捕获最具体的异常类型而不是笼统的Exception。不要吞掉异常空的catch块是万恶之源。至少记录日志e.printStackTrace()在生产环境不够应使用日志框架如SLF4JLogback。受检异常 vs 非受检异常对于可恢复的、期望调用者处理的错误使用受检异常如IOException。对于编程错误、不可恢复的错误如空指针、数组越界使用运行时异常RuntimeException。自定义异常创建业务相关的异常类继承自Exception受检或RuntimeException非受检可以提供更清晰的错误信息。5. 泛型编写类型安全的通用代码泛型提供了编译时类型安全检查并消除了许多强制类型转换。核心概念类型参数ListString中的String。泛型类/接口public class BoxT { private T t; ... }泛型方法public T void printArray(T[] array) { ... }类型参数定义在方法返回类型之前。通配符增加灵活性?无界通配符表示未知类型。List?可以接受任何类型的List但只能从中读取为Object不能写入除了null。? extends T上界通配符表示T或其子类。生产者Producer适合读取。List? extends Number可以接受ListInteger,ListDouble等。你可以安全地从中读取Number。? super T下界通配符表示T或其父类。消费者Consumer适合写入。List? super Integer可以接受ListInteger,ListNumber,ListObject等。你可以安全地向其中写入Integer及其子类对象。PECS原则Producer-Extends, Consumer-Super如果你需要一个泛型集合提供生产元素使用extends如果你需要向泛型集合消费存入元素使用super。类型擦除Java的泛型是编译期的概念在运行时类型信息会被擦除。例如ListString和ListInteger在运行时都是List。这导致了一些限制比如不能创建泛型数组new T[]不能使用instanceof检查泛型类型obj instanceof ListString是错误的。6. 注解Annotation代码的元数据注解为代码添加元数据不影响程序逻辑但可以被编译器、开发工具或运行时框架读取并处理。内置常用注解Override标记方法重写。Deprecated标记过时的方法/类。SuppressWarnings抑制编译器警告。FunctionalInterfaceJava 8标记函数式接口。元注解注解的注解Target定义注解可以应用的目标如ElementType.METHOD,ElementType.TYPE。Retention定义注解的保留策略SOURCE源码级,CLASS类文件级,RUNTIME运行时。Documented注解是否包含在Javadoc中。Inherited注解是否可被继承。注解广泛应用于框架配置如Spring的Autowired,Service、单元测试JUnit的Test、代码生成Lombok的Data等场景。理解注解有助于你更好地使用主流框架。7. I/O流与序列化数据的输入输出Java I/O流是处理输入输出的基础分为字节流和字符流。字节流以字节为单位处理所有类型数据。顶层抽象类InputStream,OutputStream。文件操作FileInputStream,FileOutputStream缓冲提高效率BufferedInputStream,BufferedOutputStream对象序列化ObjectInputStream,ObjectOutputStream字符流以字符为单位处理文本数据更方便。顶层抽象类Reader,Writer。底层通常基于字节流但会自动处理字符编码。文件操作FileReader,FileWriter注意默认使用平台编码易乱码缓冲与编码控制InputStreamReader,OutputStreamWriter可以指定字符集如StandardCharsets.UTF_8配合BufferedReader,BufferedWriter是处理文本文件的最佳实践。try (BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(file.txt), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { // 处理每一行 } }序列化Serialization将对象状态转换为字节流的过程以便存储或传输。反序列化是其逆过程。实现Serializable接口标记接口的类即可被序列化。serialVersionUID一个私有静态常量用于标识序列化类的版本。如果类结构发生变化如增删字段反序列化时会根据此UID验证兼容性。强烈建议显式声明避免JVM自动生成导致版本不一致问题。transient关键字修饰的字段不会被序列化。注意Java内置序列化有性能和安全问题如序列化攻击。在生产环境中对于跨网络或持久化的对象传输更推荐使用JSON如Jackson/Gson、Protocol Buffers、Avro等格式。8. 常见问题与排查技巧实录在实际编码中语法层面的问题往往表现为诡异的运行时错误或不符合预期的行为。这里记录几个高频且容易让人困惑的“坑”。8.1 “” 与 equals() 的永恒之争这个问题面试必考实际编码也常错。对于基本类型比较值是否相等。对于引用类型比较内存地址是否是同一个对象。equals()定义在Object类中默认实现就是。但许多类如String,Integer重写了equals()方法用于比较对象的逻辑内容是否相等。黄金法则基本类型比较用。引用类型比较内容是否相等用equals()。引用类型比较是否是同一个对象用。对于可能为null的引用调用equals()前要么确保它不为null要么使用Objects.equals(a, b)JDK 7它内部处理了null值。8.2 String拼接导致的性能瓶颈与内存问题在循环中进行字符串拼接使用运算符会创建大量临时String和StringBuilder对象对性能尤其是GC影响巨大。务必使用StringBuilder单线程或StringBuffer多线程。8.3 集合遍历时的 ConcurrentModificationException在使用for-each循环或Iterator遍历List,Set,Map的values()/entrySet()时如果直接调用集合自身的add,remove方法修改集合结构就会抛出此异常。ListString list new ArrayList(Arrays.asList(a, b, c)); // 错误写法 for (String s : list) { if (b.equals(s)) { list.remove(s); // 抛出 ConcurrentModificationException } } // 正确写法1使用Iterator的remove方法 IteratorString it list.iterator(); while (it.hasNext()) { if (b.equals(it.next())) { it.remove(); // 安全删除 } } // 正确写法2使用Java 8的removeIf方法 list.removeIf(b::equals); // 正确写法3使用下标遍历仅适用于ArrayList等支持随机访问的列表且注意删除后下标变化 for (int i 0; i list.size(); i) { if (b.equals(list.get(i))) { list.remove(i); i--; // 重要删除后下标回退 } }8.4 自动拆装箱引发的空指针异常这是包装类使用中最常见的坑。任何可能为null的包装类对象在参与运算或赋值给基本类型前必须进行判空。Integer total getFromExternal(); // 可能返回null // 危险 int sum total 10; // 如果total为null这里抛出NPE // 安全做法 int sum (total ! null) ? total 10 : 10; // 或者使用Optional (Java 8) int sum Optional.ofNullable(total).orElse(0) 10;8.5 浮点数计算精度问题如前所述float和double的二进制表示无法精确对应所有十进制小数。商业计算特别是货币必须使用BigDecimal并且务必使用String构造器而不是double构造器。// 错误精度已丢失 BigDecimal bd1 new BigDecimal(0.1); // 正确 BigDecimal bd2 new BigDecimal(0.1); // 或者使用valueOf它内部调用了Double的toString相对好一些但不如String构造器明确 BigDecimal bd3 BigDecimal.valueOf(0.1);8.6 finally块中的return或异常finally块中的return语句会覆盖try或catch块中的返回值。finally块中抛出的异常会覆盖try或catch块中抛出的异常。这会导致预期的错误信息被掩盖给调试带来困难。因此尽量避免在finally块中使用return或抛出异常它应该只用于纯粹的清理工作。8.7 泛型与类型擦除带来的限制由于类型擦除你无法在运行时获取泛型的实际类型参数。例如不能创建T的数组new T[size]通常的解决方法是使用Object数组然后强制转换或者使用ArrayList等集合类。同样instanceof检查ListString是无效的只能检查instanceof List。掌握Java基础语法远不止是记住关键字和语法格式。它更是一种严谨的编程思维训练是对内存、对象、流程的深刻理解。这些知识平实无华却是你应对复杂系统、编写高效健壮代码的底气。我建议你经常回顾这些基础概念在写每一行代码时都想想它背后的原理。时间长了你就会发现很多所谓的高级问题其根因往往就藏在这些最基础的细节里。
返回列表