【C++】拆分详解 - 模板

文章目录

  • 一、泛型编程
  • 二、函数模板
    • 1. 概念
    • 2. 语法
    • 3. 函数模板的原理
    • 4. 函数模板的实例化
    • 5. 模板参数的匹配原则
  • 三、类模板
    • 1. 语法
    • 2. 实例化
  • 四、模板的特化
    • 1. 概念
    • 2. 函数模板特化
    • 3. 类模板特化
      • 3.1 全特化
      • 3.2 偏特化 / 半特化
      • 3.3 应用示例
    • 4. 小结
  • 五、模板的分离编译
      • 1. 分离编译的概念
      • 2. 模板不能分离编译
  • 六、模板总结
  • 总结

一、泛型编程

void Swap(int& left, int& right)
{int temp = left;left = right;right = temp;
}
void Swap(double& left, double& right)
{double temp = left;left = right;right = temp;
}
void Swap(char& left, char& right)
{char temp = left;left = right;right = temp;
}
  • 可以看到,为了实现一个尽可能通用的交换函数,我们需要进行多次函数重载,但是有缺陷

    1. 重载的函数仅仅是类型不同,代码复用率比较低,只要有新类型出现时,就需要用户自己增

      加对应的函数

    2. 代码的可维护性比较低,一个出错可能所有的重载均出错

  • 于是模板就被发明出来了,我们只需要造一个模具,编译器就能根据不同的类型生成不同的代码(本质上就是函数重载的活交给编译器干了)

    泛型编程:编写与类型无关的通用代码,是代码复用的一种手段。模板是泛型编程的基础。

    在这里插入图片描述


二、函数模板

1. 概念

函数模板代表了一个函数家族,该函数模板与类型无关,在使用时被参数化,根据实参类型产生函数的特定类型版本

2. 语法

  • template<typename T1, typename T2,…,typename Tn>
  • 注意:typename是用来定义模板参数关键字,也可以使用class(切记:不能使用struct代替class)
template<typename T>
void Swap( T& left, T& right)
{T temp = left;left = right;right = temp;
}

3. 函数模板的原理

函数模板是一个蓝图,它本身并不是函数,是编译器用使用方式产生特定具体类型函数的模具。所以其实模板就是将本来应该我们做的重复的事情交给了编译器。在编译器编译阶段,对于模板函数的使用,编译器需要根据传入的实参类型来推演生成对应类型的函数以供调用
在这里插入图片描述

4. 函数模板的实例化

  • 用不同类型的参数使用函数模板时,称为函数模板的实例化。模板参数实例化分为:隐式实例化和显式实例化。

    1. 隐式实例化:让编译器根据实参推演模板参数的实际类型

      template<class T>
      T Add(const T& left, const T& right)
      {return left + right;
      }int main()
      {int a1 = 10, a2 = 20;Add(a1, a2);return 0;
      }
      
      • 模板参数个数 与 传参类型个数 不一致

        int main()
        {int a3 = 30;double d1 = 10.0;Add(a3, d1);return 0;
        }
        /*该语句不能通过编译,因为在编译期间,当编译器看到该实例化时,需要推演其实参类型通过实参a1将T推演为int,通过实参d1将T推演为double类型,但模板参数列表中只有一个T,编译器无法确定此处到底该将T确定为int 或者 double类型而报错注意:在模板中,编译器一般不会进行类型转换操作,因为一旦转化出问题,编译器就需要背黑锅
        */
        

        解决:

        1. 用户自己来强制转化
          Add(a3, (int)d1); 如果类型不匹配,编译器会尝试进行隐式类型转换,如果无法转换成功编译器将会报错
        2. 使用显式实例化
    2. 显式实例化:在函数名后的<>中指定模板参数的实际类型

      int main()
      {int a = 10;int b = 20;// 显式实例化Add<int>(a, b); return 0;
      }int main()
      {int a3 = 30;double d1 = 10.0;// 显式实例化Add<int>(a3, d1); //隐式类型转换return 0;
      }
      

5. 模板参数的匹配原则

  1. 一个非模板函数可以和一个同名的函数模板同时存在,而且该函数模板还可以被实例化为这个非模板函数

    思考一下,会不会发生重定义问题?答案是不会,详细解释请跳转到 “本文五、2.2 解决方法处”

    // 专门处理int的加法函数(现成的)
    int Add(int left, int right)
    {return left + right;
    }// 通用加法函数(模板)
    template<class T>
    T Add(T left, T right)
    {return left + right;
    }void Test()
    {Add(1, 2); // 调用现成的Add<int>(1, 2); // 显式实例化,调用编译器特化的Add版本(根据现成的,用模板生成一份相同的)
    }
    
  2. 对于非模板函数和同名函数模板,如果其他条件都相同,在调动时会优先调用非模板函数而不会从该模板产生出一个实例。如果模板可以产生一个具有更好匹配的函数, 那么将选择模板。(人话:有现成用现成的,没有就再造一个)

    • 如何界定到底是匹配模板还是匹配现成的?

      这里涉及到参数匹配的优先级: 完全匹配 > 模板替换后匹配 > 隐式类型转换后匹配

    // 专门处理int的加法函数
    int Add(int left, int right)
    {return left + right;
    }
    // 通用加法函数
    template<class T1, class T2>
    T1 Add(T1 left, T2 right)
    {return left + right;
    }void Test()
    {Add(1, 2); // 调用现成的Add(1, 2.0); // 模板函数可以生成更加匹配的版本,匹配优先级:// 函数模板 > 隐式类型转换(现成的)
    }
    

三、类模板

1. 语法

template<class T1, class T2, ..., class Tn>
class 类模板名
{// 类内成员定义
};
#include<iostream>
using namespace std;
// 类模版
template<typename T>
class Stack
{
public:Stack(size_t capacity = 4){_array = new T[capacity];_capacity = capacity;_size = 0;}void Push(const T& data);
private:T* _array;size_t _capacity;size_t _size;
};// 模版不建议声明和定义分离到两个文件.h 和.cpp会出现链接错误,具体原因后面会讲
template<class T>
void Stack<T>::Push(const T& data)
{// 扩容_array[_size] = data;++_size;
}int main()
{Stack<int> st1; // intStack<double> st2; // doublereturn 0;
}

2. 实例化

类模板实例化与函数模板实例化不同,类模板实例化需要在类模板名字后跟<>,然后将实例化的类型放在<>中即可

// Stack是类名,Stack<int>才是类型
Stack<int> st1; // int
Stack<double> st2; // double

四、模板的特化

1. 概念

在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。模板特化中分为函数模板特化与类模板特化。

2. 函数模板特化

  • 语法:

    1. 关键字template后面接一对空的尖括号<>
    2. 函数名后跟一对尖括号,尖括号中指定需要特化的类型
    // 函数模板 -- 参数匹配
    template<class T>
    bool Less(T left, T right)
    {return left < right;
    }// 对Less函数模板进行特化
    template<>
    bool Less<Date*>(Date* left, Date* right)
    {return *left < *right;
    }int main1()
    {cout << Less(1, 2) << endl;Date d1(2022, 7, 7);Date d2(2022, 7, 8);cout << Less(d1, d2) << endl;Date* p1 = &d1;Date* p2 = &d2;cout << Less(p1, p2) << endl; // 调用特化之后的版本,而不走模板生成了return 0;
    }
    
  • 缺陷(“大坑”):

    如果基础模板参数是const类型,而特化参数为指针,要特别小心注意将const放在指针的*号后面( const 在 * 前修饰的指针指向的对象,*后修饰的是指针本身。我们要的当然是对指针本身进行const修饰)

    //基础模板
    template<class T>
    bool Less(const T& left, const T& right)
    {return left < right;
    }//特化
    template<>
    bool Less<Date*>(Date* const& left, Date* const& right) //注意Date*与const的位置关系
    {return *left < *right;
    }void less_test()
    {Date* p1;Date* p2;Less(p1, p2);
    }
    
    • 因此,对于上述情况,不建议使用函数模板。建议直接重载一份现成的函数,手动控制逻辑,以免出错。(使用模板时,我们很容易直接将T替换为Date*,而进行重载也许可以避开使用const,就算避不开也可以强迫我们进行代码逻辑的梳理,比较容易发现“坑点”)

      //函数重载
      bool Less(Date* left, Date* right)
      {return *left < *right;
      }
      

3. 类模板特化

3.1 全特化

  1. 语法:

    1. 关键字template后面接一对空的尖括号<>
    2. 类名后跟一对尖括号,尖括号中指定需要特化的类型
  2. 概念:

    将模板参数列表中所有的参数都确定化。

    //基础模板
    template<class T1, class T2>
    class Data
    {
    public:Data() { cout << "Data<T1, T2>" << endl; }
    private:T1 _d1;T2 _d2;
    };//全特化类模板
    template<>
    class Data<int, char>
    {
    public:Data() { cout << "Data<int, char>" << endl; }
    private:int _d1;char _d2;
    };void TestVector()
    {Data<int, int> d1; //调用基础模板Data<int, char> d2; //调用全特化模板
    }
    

3.2 偏特化 / 半特化

  1. 部分参数特化

    // 将第二个参数特化为int
    template <class T1>
    class Data<T1, int>
    {
    public:Data() { cout << "Data<T1, int>" << endl; }
    private:T1 _d1;int _d2;
    };
    
  2. 特化某个宽泛类型(如所有的指针、所有的引用)

    //两个参数偏特化为指针类型
    template <typename T1, typename T2>
    class Data <T1*, T2*>
    {
    public:Data() { cout << "Data<T1*, T2*>" << endl; }
    private:T1 _d1;T2 _d2;
    };//两个参数偏特化为引用类型
    template <typename T1, typename T2>
    class Data <T1&, T2&>
    {
    public:Data(const T1& d1, const T2& d2): _d1(d1), _d2(d2){cout << "Data<T1&, T2&>" << endl;}
    private:const T1& _d1;const T2& _d2;
    };void test2()
    {Data<int*, int*> d3; // 调用特化的指针版本Data<int&, int&> d4(1, 2); // 调用特化的指针版本
    }
    

    注意:

    1. class Data <T1*, T2*> 中的<T1*, T2*> 只是个标识符,并不是只指一级指针,而是指所有指针

    2. T1,T2的类型就是传入参数的类型,但是在类内使用时会被替换为T1去掉一个*号后的类型(一级指针变为非指针,二级指针变为一级指针)

      • 运行如下代码可以发现,T1,T2大小为4或者8,说明是一个指针,而打印它们的类型却显示为去掉了一个*后的类型,说明在类中使用时经过了处理
      //两个参数偏特化为指针类型
      template <typename T1, typename T2>
      class Data <T1*, T2*>
      {
      public:Data(){cout << "Data<T1*, T2*>" << endl;cout << sizeof(T1) << " " << sizeof(T2) << endl;cout << typeid(T1).name() << " " << typeid(T2).name() << endl;cout << typeid(_d1).name() << " " << typeid(_d2).name() << endl;}
      private:T1 _d1;T2 _d2;
      };void test2()
      {Data<int*, int*> d3; // 调用特化的指针版本Data<int**, int**> d5; // 调用特化的指针版本
      }
      

3.3 应用示例

  1. 场景引入:

    STL中的Priority_queue默认的仿函数只能对最基础的类型进行大小比较,然后按堆规则排序,如果需要对特定类型进行大小比较,需要用户手动传入一个专用的仿函数进行特殊处理。

    同样都是特殊处理,我们能不能使用特化代替专用的仿函数?

  2. 代码对比:

    • 传入专门的仿函数:

      priority_queue < Date*, vector<Date*>, PDateless > q1;

      // 模拟priority_queue中的缺省仿函数
      template<class T>
      class myless
      {
      public:bool operator()(const T& x, const T& y){return x < y;}
      };//针对指针类型作对象时 手动传入的仿函数
      struct PDateLess
      {bool operator()(Date* p1, Date* p2){return *p1 < *p2;}
      };
      
    • 特化缺省仿函数:

      // 缺省提供的仿函数
      template<class T>
      class myless
      {
      public:bool operator()(const T& x, const T& y){return x < y;}
      };//对其特化版本,使之符合指针对象作元素时的比较
      template<>
      class myless<Date*>
      {
      public:bool operator()(Date* const & x, Date* const & y) //也存在函数模板特化时的问题{return *x < *y;}
      };
      

4. 小结

  1. 函数模板特化实例化后就相当于函数重载
  2. 类模板特化实例化就“相当于重载了一个类”,务必注意c++语法中没有类重载这个概念,只是效果看起来可以这么理解

五、模板的分离编译

1. 分离编译的概念

一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件 ,其中共用的代码(函数)一般采用声明和定义分离的方式,使用时源文件中只包含其头文件,最后将所有目标文件链接起来形成单一的可执行文件的过程称为分离编译模式。

2. 模板不能分离编译

  1. 分离编译的实现原理

    C/C++的编译过程分为预处理、编译、汇编、链接四个过程,其中编译是进行语法错误检查的,检查无误后才生成汇编代码。(头文件不参与编译)

    当函数经过编译时,如果只有声明,编译器此时是找不到函数地址的,但是认为后续链接时可以找到,所以会生成一个call xxxx的汇编指令,将其函数名放入到符号表中,让编译通过,生成 .obj文件。正常情况下,链接时 编译器会扫描各个符号表,寻找那些只有声明的那些函数的实际地址并将其替换到相应位置,然后再生成可执行文件。

    • 符号表:

      它是编译器在编译过程中创建的一种数据结构,用于存储变量名和内存地址之间的映射关系。主要作用是在编译时期将变量名转换为内存地址。当声明一个变量时,编译器会为其分配内存,并将变量名和分配的内存地址记录在符号表中。

      例如,声明 int a; 后,编译器可能会为 a 分配一个内存地址 0x0040,并将 a0x0040 的映射关系保存在符号表中。这样,在程序中对 a 进行操作时,编译器就可以通过符号表找到 a 的地址

    但对于模板来说,C++标准明确表示,当一个模板不被用到的时侯它就不该被实例化出来即 按需实例化,这会导致什么呢?假设我们在 main.cpp中调用了函数模板,但是只有该函数的声明,定义在text.cpp文件中实现。正常情况链接时编译器会从text.cpp的符号表中找到该函数的地址,然后给main.cpp调用,可是我们刚说了模板是按需实例化,即text.cpp文件中即使有定义,但是没有在其中调用就不会被实例化,没有实例化就没有地址(没有开辟物理空间),那编译器不就是找了个寂寞吗?自然就会报错说找不到地址。

    实际上就是想用的地方(main.cpp)调用不到,不需要用的地方(text.cpp)又需要调用才能实例化给想用的地方传地址

    • 按需实例化:

      模板、类成员函数,没有被调用时编译器不会对其进行实例化,不会对其进行编译。如果代码写的有问题则编译时不会语法报错,但是链接时如果被调用就可能会出错

      可以认为它们相较于普通函数,多了一步实例化的过程

    //-------------text.h----------------//void func(); // 函数声明//---------------text.cpp-------------//#include"text.h"template<class T>
    void func(T left, T right)  //函数定义 
    {cout << "func被实现" << endl;
    }int main()
    {return 0; //没有调用函数模板,不会进行实例化
    }//---------------main.cpp---------------//#include"text.h"int main()
    {func(1, 2); // 调用函数模板
    }
    
  2. 解决方法
    1. 将声明和定义放到一个文件 xxx.h 中

      • 会重定义吗?不会,因为c++标准中明确规定了编译器可以丢弃部分语句,其中就包括了相同的模板实例,会从中随机选取一份保留,其余丢弃。简而言之,就是编译器给模板开后门进行了特殊处理。

        那理论上普通函数也可以这么处理,不就不存在重定义问题了?是这样的,但是C++有C的历史包袱,祖宗之法不可变,不然会有兼容问题

    2. 显式实例化

      • 根据调用时的实际参数将模板实现,就相当于一个普通函数,自然会被实例化
      • 不建议这样使用,每使用一个新类型就要显示实例化一次(本来使用模板就是为了省事,这样不就是一个个进行函数重载吗?模板此时啥用没有,全是用户手动在操作)
      //---------------text.cpp-------------//#include"text.h"template<class T>
      void func(T left, T right)  //函数定义
      {//...
      }//显式实例化,每调用一个新类型都要补上一次显式实例化
      void func(int left, int right) 
      {//...
      }
      //---------------main.cpp---------------//#include"text.h"int main()
      {func(1, 2); // 调用函数模板
      }
      

六、模板总结

【优点】

  1. 模板复用了代码,节省资源,更快的迭代开发,C++的标准模板库(STL)因此而产生
  2. 增强了代码的灵活性

【缺陷】

  1. 模板会导致代码膨胀问题,也会导致编译时间变长
  2. 出现模板编译错误时,错误信息非常凌乱,不易定位错误

总结

本文讲解了模板的相关使用和常见误区。
尽管文章修正了多次,但由于水平有限,难免有不足甚至错误之处,敬请各位读者来评论区批评指正。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/455022.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Java:抽象类和接口

一.抽象类 1.抽象类概念和语法 ⨀概念&#xff1a; 在面向对象的概念中&#xff0c;所有的对象都是通过类来描绘的&#xff0c;但是并不是所有的类都是用来描绘对象的&#xff0c;如果一个类中没有包含足够的信息来描绘一个具体的对象&#xff0c;这样的类就是抽象类。 ⨀语…

JMeter使用不同方式传递接口参数

1、使用 HTTP 请求中的参数&#xff1a; 在 JMeter 的测试计划中&#xff0c;添加一个 "HTTP 请求" 元件。 在 "HTTP 请求" 元件的参数化选项中&#xff0c;可以添加参数的名称和值。可以手动输入参数&#xff0c;也可以使用变量来传递参数值。 如果要使…

Golang | Leetcode Golang题解之第497题非重叠矩形中的随机点

题目&#xff1a; 题解&#xff1a; type Solution struct {rects [][]intsum []int }func Constructor(rects [][]int) Solution {sum : make([]int, len(rects)1)for i, r : range rects {a, b, x, y : r[0], r[1], r[2], r[3]sum[i1] sum[i] (x-a1)*(y-b1)}return Sol…

自定义多级联动选择器指南(uni-app)

多端支持&#xff1a;可以运行在H5、APP、微信小程序还是支付宝小程序&#xff0c;都可以轻松使用改组件。自定义配置&#xff1a;您可以根据需要配置选择器的级数&#xff0c;使其适应不同的数据结构和用例。无限级联&#xff1a;此组件支持无限级联选择&#xff0c;使您能够创…

最好的ppt模板网站是哪个?做PPT不可错过的18个网站!

现在有很多PPT模板网站&#xff0c;但真正免费且高质量的不多&#xff0c;今天我就分享主流的国内外PPT模板下载网站&#xff0c;并且会详细分析这些网站的优缺点&#xff0c;这些网站都是基于个人实际使用经验的&#xff0c;免费站点会特别标注&#xff0c;让你可以放心下载&a…

信息安全工程师(64)其他恶意代码分析与防护

前言 恶意代码是指那些能够损害系统用户和系统所有者利益的软件&#xff0c;是故意在计算机系统上执行恶意任务的恶意代码的集合。 一、恶意代码分析 病毒&#xff08;Virus&#xff09; 定义&#xff1a;病毒是一种人为制造的、能够进行自我复制的、具有对计算机资源的破坏作用…

国家信息安全水平考试(NISP一级)最新题库-第十七章

目录 另外免费为大家准备了刷题小程序和docx文档&#xff0c;有需要的可以私信获取 1 受到了ARP欺骗的计算机&#xff0c;发出的数据包&#xff0c;     地址是错误的&#xff08;&#xff09; A.源IP&#xff1b;B.目的IP&#xff1b;C.源MAC&#xff1b;D.目的MAC 正…

rust入门基础总结

文章目录 前言1、输出格式规范一、占位符相关&#xff08;一&#xff09;{}与{:?} 二、参数替换方式&#xff08;一&#xff09;位置参数&#xff08;二&#xff09;具名参数 三、格式化参数&#xff08;一&#xff09;宽度&#xff08;二&#xff09;对齐&#xff08;三&…

基于K8S的StatefulSet部署mysql主从

StatefulSet特性 StatefulSet的网络状态 拓扑状态&#xff1a;应用的多个实例必须按照某种顺序启动&#xff0c;并且必须成组存在&#xff0c;例如一个应用中必须存在一个A Pod和两个B Pod&#xff0c;且A Pod必须先于B Pod启动的场景 存储状态&#xff1a;应用存在多个实例&…

Golang | Leetcode Golang题解之第500题键盘行

题目&#xff1a; 题解&#xff1a; func findWords(words []string) (ans []string) {const rowIdx "12210111011122000010020202" next:for _, word : range words {idx : rowIdx[unicode.ToLower(rune(word[0]))-a]for _, ch : range word[1:] {if rowIdx[unico…

【Redis】数据结构(下)

文章目录 QuickList概念QuickList结构QuickList的特点控制ZipList的大小对节点的ZipList进行压缩 总结 SkipList概念源码中结构分析总结 QuickList 概念 问题1:ZipList虽然节省内存,但是申请的内存必须是连续空间,如果内存占用过多,申请内存效率低,怎么办? 为了缓解这个问题,…

可编辑38页PPT | 柔性制造企业数字化转型与智能工厂建设方案

荐言分享&#xff1a;在数字化技术快速发展的今天&#xff0c;传统的大规模生产模式已难以满足市场对个性化、定制化产品的需求。柔性制造以其灵活多变、快速响应的特点&#xff0c;成为制造业转型升级的关键。通过数字化转型与智能工厂建设&#xff0c;企业可以构建高效、灵活…

Llama Tutor:开源 AI 个性化学习平台,根据主题自动制定学习计划

❤️ 如果你也关注大模型与 AI 的发展现状&#xff0c;且对大模型应用开发非常感兴趣&#xff0c;我会快速跟你分享最新的感兴趣的 AI 应用和热点信息&#xff0c;也会不定期分享自己的想法和开源实例&#xff0c;欢迎关注我哦&#xff01; &#x1f966; 微信公众号&#xff…

Centos7 将man手册内容转换为txt,pdf访问,并汉化

转换为txt格式 直接将man的输出导入到txt文档即可&#xff0c;使用col -b 删除掉特殊字符方便阅读 # man ps | col -b > ps.txt转换为pdf格式 使用 groff 将 man 页转换为 PostScript 格式&#xff0c;然后使用 ps2pdf 转换为 PDF&#xff1a; 下载ps2pdf 命令工具包&am…

8阻塞队列

阻塞队列能是⼀种 线程安全的数据结构 , 并且具有以下特性: • 当队列满的时候, 继续⼊队列就会阻塞, 直到有其他线程从队列中 取⾛ 元素. • 当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中 插⼊ 元素 标准库中的阻塞队列 在 Java 标准库中内置了阻塞队列. …

与ai一起作诗(《校园清廉韵》)

与ai对话犹如拷问自己的灵魂&#xff0c;与其说ai助力还不如说在和自己对话。 (笔记模板由python脚本于2024年10月19日 19:18:33创建&#xff0c;本篇笔记适合喜欢python和诗歌的coder翻阅) 【学习的细节是欢悦的历程】 Python 官网&#xff1a;https://www.python.org/ Free&…

NGINX 保护 Web 应用安全之基于 IP 地址的访问

根据客户端的 IP 地址控制访问 使用 HTTP 或 stream 访问模块控制对受保护资源的访问&#xff1a; location /admin/ { deny 10.0.0.1; allow 10.0.0.0/20; allow 2001:0db8::/32; deny all; } } 给定的 location 代码块允许来自 10.0.0.0/20 中的任何 IPv4 地址访问&#xf…

sql注入 --二次注入堆叠注入文件读取getshell

二次注入 二次注入原理&#xff1a; SQL二次注入&#xff0c;指的是在有些应用场景下&#xff0c;我们先把SQL注入的payload写入到目标站点数据库中&#xff0c;然后再在某些实际将该数据取出&#xff0c;使得我们写入的payload执行。 堆叠注入 条件&#xff1a; 因为堆叠注…

客户端工具xshell/finalshell连接Vagrant创建的虚拟机并上传本地文件

vagrant up #启动虚拟机 vagrant ssh #进入到虚拟机中 su root #切换超级管理员 vi /etc/ssh/sshd_config #修改PasswordAuthentication为yes; systemctl restart sshd #重启sshd服务修改之后 就可以使用xshell正常连接了