使用 c++ 模板显示实例化解决模板函数声明与实现分离的问题

开始正文之前,做一些背景铺垫,方便读者了解我的工程需求。我的项目是一个客户端消息分发中心,在连接上消息后台后,后台会不定时的给我推送一些消息,我再将它们转发给本机的其它桌面产品去做显示。后台为了保证消息一定可以推到客户端,它采取了一种重复推送的策略,也就是说,每次当我重新连接上后台时,后台会把一段时间内的消息都推给我、而不论这些消息之前是否已经推送过,如果我不加处理的直接推给产品,可能造成同一个消息重复展示多次的问题。为此,我在接收到消息后,会将它们保存在进程中的一个容器中,当有新消息到达时,会先在这个容器里检查有没有收到这条消息,如果有,就不再转发。

1namespace GCM { 2class server_msg_t  3    { 4public: 5voiddump(charconst* prompt);  6 7std::string appname;  8std::string uid;  9std::string msgid; 10time_t recv_first =0; 11time_t recv_last =0; 12intrecv_cnt =0; 13    };1415class WorkEngine16    {17public:18        WorkEngine();19~WorkEngine();2021private:22// to avoid server push duplicate messages to same client.23// note this instance is only accessed when single connection to server arrives message, so no lock needed..24std::vector m_svrmsgs;25    };26}

上面的是经过简化以后的代码,m_svrmsgs 成员存储的就是接收到的所有的后台消息,server_msg_t 代表的就是一个后台消息,appname、uid 用来定位发给哪个产品的哪个实例;msgid 用来唯一的标识一个消息;recv_first、recv_last、recv_cnt 分别表示消息接收的首次时间、最后时间以及重复接收次数。那么现在一个很现实的问题就是,我需要把这些消息序列化到永久存储上去,以便进程重启后这些信息还在。这里我使用了 sqlite 数据库,与此相关的代码封装在了 WorkEngine 的成员函数中,很容易想到的一种函数声明方式是这样:

1namespace GCM { 2class server_msg_t  3    { 4public: 5voiddump(charconst* prompt);  6 7std::string appname;  8std::string uid;  9std::string msgid; 10time_t recv_first =0; 11time_t recv_last =0; 12intrecv_cnt =0; 13    };1415class WorkEngine16    {17public:18        WorkEngine();19~WorkEngine();2021protected:22intdb_store_server_msg (std::vectorconst& vec); 23intdb_fetch_server_msg (std::vector & vec);2425private:26// to avoid server push duplicate messages to same client.27// note this instance is only accessed when single connection to server arrives message, so no lock needed..28std::vector m_svrmsgs;29    };30}31

像 line 22-23 展示的那样,直接使用 std::vector<server_msg_t> 这个容器作为参数(有的人可能觉得我多此一举,直接在函数里访问 m_svrmsgs 成员不就行了,为什么要通过参数传递呢?可能这个例子不太明显,但是确实存在一些情况容器是作为局部变量而非成员变量存在的,这里出于说明目的做了一些简化)。但是我觉得这样写太死板了,万一以后我换了容器呢,这里是不是还要改?也许是泛型算法看多了,总感觉这样写不够“通用”。但是如果写成下面这样,还是换汤不换药:

intdb_store_server_msg (std::vector::iterator beg, std::vector::iterator end);

参考标准库 std::copy 算法,将其改造一番,结果就成了这个样子:

template intdb_store_server_msg(InputIterator beg, InputIterator end);

叫成员函数模板,还是成员模板函数,还是模板成员函数……说不清楚,反正就是成员函数+模板函数。实现的话可以这样写:

1namespace GCM { 2template 3int WorkEngine::db_store_server_msg(InputIterator beg, InputIterator end) 4    { 5intret =0, rowid =0;  6        qtl::sqlite::database db(SQLITE_TIMEOUT); 7 8try 9        {10            db.open(get_db_path().c_str(), NULL);11writeInfoLog("open db for store server msg OK");1213            db.begin_transaction();1415for(auto it = beg; it != end; ++it)16            {17// 1th, insert or update user info18rowid = db.insert_direct("replace into server_msg (appname, uid, msgid, first_recv, last_recv, count) values (?, ?, ?, ?, ?, ?);", 19it->appname, it->uid, it->msgid, it->recv_first, it->recv_last, it->recv_cnt);2021ret++; 22            }2324            db.commit();25            db.close();26writeInfoLog("replace into %d records", ret); 27        }28catch(qtl::sqlite::error &e)29        {30writeInfoLog("manipute db for store server msg error: %s", e.what());31            db.rollback();32            db.close();33return-1;34        }3536return ret; 37    }38}

可以看到,核心代码就是对迭代器区间作遍历 (line 15)。调用方也是非常简洁:

db_store_server_msg(m_svrmsgs.begin(), m_svrmsgs.end());

一行搞定,看起来已经大功告成了,毫无难度可言,那么这篇文章想要说明什么呢?别着急,真正的难点在于从数据库恢复数据。首先直接使用迭代器是不行了,因为我们现在要往容器里插入元素,迭代器只能遍历元素,一点帮助也没有。但是相信读者一定看过类似这样的代码:

正在上传... 取消

1intmain (void) 2{ 3intarr[] = {1,3,5,7,11 };  4    std::vector vec;  5std::copy (arr, arr +sizeof(arr) /sizeof(int), std::back_inserter(vec)); 6for(auto it = vec.begin (); it != vec.end (); ++ it)  7printf ("%d\n", *it);  8 9return0; 10}

正在上传... 取消

为了在容器尾部插入元素,标准库算法借助了 back_inserter 这个东东。于是自然而然的想到,我们这里能不能声明 back_inserter 作为输入参数呢? 例如像这样:

template intdb_fetch_server_msg(OutputIterator it);

模板实现这样写:

正在上传... 取消

1namespace GCM { 2template 3int WorkEngine::db_fetch_server_msg(OutputIterator it) 4    { 5intret =0; 6        qtl::sqlite::database db(SQLITE_TIMEOUT); 7 8try 9        {10            db.open(get_db_path().c_str(), NULL);11writeInfoLog("open db for fetch server msg OK");1213db.query("select appname, uid, msgid, first_recv, last_recv, count from server_msg", 14[&ret, &it](std::stringconst& appname, std::stringconst& uid, std::stringconst& msgid, time_t first_recv, time_t last_recv,int count) {15                    server_msg_t sm; 16sm.appname = appname; 17sm.uid = uid; 18sm.msgid = msgid; 19sm.recv_first = first_recv; 20sm.recv_last = last_recv; 21sm.recv_cnt = count; 22*it = sm;23++ret; 24            }); 2526            db.close();27writeInfoLog("query %d records", ret);28        }29catch(qtl::sqlite::error &e)30        {31writeInfoLog("manipute db for store server msg error: %s", e.what());32            db.close();33return-1;34        }3536return ret;37    }38}

正在上传... 取消

其实核心就是一句对 back_inserter 的赋值语句 (line 22)。调用方同样是一行搞定:

db_fetch_server_msg (std::back_inserter(m_svrmsgs));

模板声明与模板实现的分离

上面的代码可以正常通过编译,但前提是模板实现与模板调用位于同一文件。考虑到这个类之前已经有许多逻辑,我决定将与数据库相关的内容,转移到一个新的文件(engine_db.cpp),来减少单个文件的代码量。调整后的文件结构如下:

+ engine.h: WorkEngine 声明

+ engine.cpp:WorkEngine 实现 (包含 engine.h)

+ engine_db.cpp:WorkEngine::db_xxx 模板实现 (包含 engine.h)

重新编译,报了一个链接错误:

1>workengine.obj : error LNK2001: 无法解析的外部符号 "protected: int __thiscall GCM::WorkEngine::db_fetch_server_msg<class std::back_insert_iterator<class std::vector<class GCM::server_msg_t,class std::allocator<class GCM::server_msg_t> > > >(class std::back_insert_iterator<class std::vector<class GCM::server_msg_t,class std::allocator<class GCM::server_msg_t> > >)" (??$db_fetch_server_msg@V?$back_insert_iterator@V?$vector@Vserver_msg_t@GCM@@V?$allocator@Vserver_msg_t@GCM@@@std@@@std@@@std@@@WorkEngine@GCM@@IAEHV?$back_insert_iterator@V?$vector@Vserver_msg_t@GCM@@V?$allocator@Vserver_msg_t@GCM@@@std@@@std@@@std@@@Z)

很明显是模板调用时找不到对应的链接所致。此时需要使用“模板显示实例化”在 engine_db.cpp 文件中强制模板生成对应的代码实体,来和 engine.cpp 中的调用点进行链接。需要在该文件开始处加入下面两行代码:

using namespace GCM;

templateintWorkEngine::db_fetch_server_msg > >(std::back_insert >);

注意模板成员函数显示实例化的语法,我专门查了下《cpp primer》,格式为:

template return_type CLASS::member_func (type1,type2, ……);

对应到上面的语句,就是使用 std::back_insert<std::vector<server_msg_t> > 代替原来的 OutputIterator 类型,来告诉编译器显示生成这样一个函数模板实例。注意这里相同的类型要写两遍,一遍是函数模板参数,一遍是函数参数。然而这个显示实例化语法却没有通过编译:

1>engine_db.cpp(15): error C2061: 语法错误: 标识符“back_inserter”

1>engine_db.cpp(15): error C2974: 'GCM::WorkEngine::db_fetch_server_msg' : 模板 对于 'OutputIterator'是无效参数,应为类型

1>          f:\gdpclient\src\gcm\gcmsvc\workengine.h(137) : 参见“GCM::WorkEngine::db_fetch_server_msg”的声明

1>engine_db.cpp(15): error C3190: 具有所提供的模板参数的“int GCM::WorkEngine::db_fetch_server_msg(void)”不是“GCM::WorkEngine”的任何成员函数的显式实例化

1>engine_db.cpp(15): error C2945: 显式实例化不引用模板类专用化

百思不得其解。出去转了一圈,呼吸了一点新鲜空气,脑袋突然灵光乍现:之前不是有一长串的链接错误吗,把那个里面的类型直接拿来用,应该能通过编译!说干就干,于是有了下面这一长串显示实例化声明:

templateintGCM::WorkEngine::db_fetch_server_msg > > >(classstd::back_insert_iterator > >)

过分的是 —— 居然通过编译了!再仔细看看这一长串类型声明,貌似只是把 vector 展开了而已,我用“浓缩版”的 vector 再声明一次试下有什么变化:

templateintGCM::WorkEngine::db_fetch_server_msg > >(std::back_insert_iterator >);

居然也通过了。看来只是用 back_insert_iterator 代替了 back_inserter 就好了,back_insert_iterator 又是一个什么鬼?查看 back_inserter 定义,有如下发现:

1template inline back_insert_iterator<_Container>back_inserter(_Container& _Cont)2{// return a back_insert_iterator3return(_STDback_insert_iterator<_Container>(_Cont));4}

貌似 back_inserter 就是一个返回 back_insert_iterator 类型的模板函数,与 std::make_pair(a,b) 和  std::pair <A,B> 的关系很像,因为这里要的是一个类型,所以不能直接传 back_inserter 这个函数给显示实例化的声明。好,到目前我止,我们实现了用一个 inserter 或两个 iterator 参数代替笨拙的容器参数、并可以将声明、调用、实现分割在三个不同的文件中,已经非常完美。美中不足的是,模板显示实例化还有一些啰嗦,这里使用 typedef 定义要实例化的类型,将上面的语句改造的更清晰一些:

typedef std::back_insert_iterator > inserter_t;

template intWorkEngine::db_fetch_server_msg(inserter_t);

同理,对 db_store_server_msg 进行同样的改造:

typedef std::vector ::iterator iterator_t;

template intWorkEngine::db_store_server_msg(iterator_t, iterator_t);

这样是不是更完美了?

使用 map 代替 vector

在使用过程中,发现使用 map 可以更快更方便的查询消息是否已经在容器中,于是决定将消息容器定义变更如下:

std::map m_servmsgs;

其中 map 的 value 部分与之前不变,增加的 key 部分为 msgid。这样改了之后,遍历时要使用 "it->second." 代替 "it->";插入元素时需要使用 “*it = std::make_pair (sm.msgid, sm)” 代替 “*it = sm”。做完上述修改,我发现程序仍然编译不通过。经过一番排查,发现原来是 back_inserter 不能适配 map 容器。因为 back_inserter 对应的 back_insert_iterator 在 = 操作符中会调用容器的 push_back 接口,而这个接口仅有 vector、list、deque 几个容器支持,map 是不支持的。怎么办呢,幸好已经有好心人写好了 map 的插入器 —— map_inserter:

正在上传... 取消

1#pragmaonce 2 3namespace std 4{ 5template 6class map_inserter { 7 8public: 9typedef std::map<_Key, _Value, _Compare> map_type;10        typedef typename map_type::value_type value_type;1112private:13map_type &m_;1415public:16map_inserter(map_type &_m)17            : m_(_m)18        {}1920public:21template22class map_inserter_helper {23public:24typedef map_inserter<_K, _V, _Cmp> mi_type;25            typedef typename mi_type::map_type map_type;26            typedef typename mi_type::value_type value_type;2728map_inserter_helper(map_type &_m)29                :m_(_m)30            {}3132constvalue_type &operator= (constvalue_type & v) {33m_[v.first] = v.second;34return v;35            }36private:37map_type&m_;38        };3940typedef map_inserter_helper<_Key, _Value, _Compare> mi_helper_type;41mi_helper_typeoperator* () {42return mi_helper_type(m_);43        }4445map_inserter<_Key, _Value, _Compare> &operator++() {46return*this;47        }4849map_inserter<_Key, _Value, _Compare> &operator++(int) {50return*this;51        }5253    };5455template56map_inserter<_K, _V, _Cmp> map_insert(std::map<_K, _V, _Cmp> &m) {57returnmap_inserter<_K, _V, _Cmp>(m);58    }59};

正在上传... 取消

这段代码我是从网上抄来的,具体请参考下面的链接:std::map 的 inserter 实现。然而不幸的是,这段代码“残疾”了,不知道是作者盗链、还是没有输入完整的原因,这段代码有一些先天语法缺失,导致它甚至不能通过编译,在我的不懈“脑补”过程下,缺失的部分已经通过高亮部位补齐了,众位客官可以直接享用~

特别需要说明的是,最有技术含量的缺失发生在 line 37 的一个引用符,如果没有加入这个,虽然可以通过编译,但在运行过程中,inserter 不能向 map 中插入元素,会导致从数据库读取完成后得到空的 map。我一直尝试查找这个文章的原文,但是一无所获,对于互联网传播过程中发现这样驴头马嘴的讹误事件,本人表示非常痛心疾首(虽然我不是很懂,但你也不能坑我啊)……

好了,话归正题,有了 map_inserter 后,我们就可以这样声明了:

typedef std::map_inserter > inserter_t;

template intWorkEngine::db_fetch_server_msg(inserter_t);

对于这个 map_inserter 实现,我们需要传递 map 的三个模板参数,而不是 map 本身这个参数,我不太清楚是一种进步、还是一种退步,反正这个 map_inserter 有点儿怪,没有封装成 map_insert_iterator + map_inserter 的形式,和标准库的实现水平还是有差异的,大家将就看吧。调用方也需要进行一些微调:

db_fetch_server_msg(std::map_inserter >(m_svrmsgs));

看看,没有标准库实现的简洁吧,到底是山寨货啊~ 幸好我们已经封装了 inserter_t 类型,可以改写成这样:

db_fetch_server_msg(inserter_t(m_svrmsgs));

简洁多了。现在我们再看下项目的文件组成:

1

2

3

4

5

+ map_inserter.hpp: map_inserter 声明+实现

+ engine.h: WorkEngine 声明 (包含 map_inserter.hpp)

+ engine.cpp:WorkEngine 实现 (包含 engine.h)

+ engine_db.cpp:WorkEngine::db_xxx 模板实现 (包含 engine.h)

……

这里为了降低复杂度,将 map_inserter 放在头文件中进行共享,类似于标准库头文件的使用方式。

使用普通模板函数代替类成员模板函数

本文的最后,我们再回头看一下上面例子中的两个成员模板函数,发现它们并没有使用到类中的其它成员,其实完全可以将它们独立成两个普通模板函数去调用,例如改成这样:

正在上传... 取消

1namespaceGCM {2classserver_msg_t3{4public:5void dump(charconst*prompt);67        std::stringappname;8        std::stringuid;9        std::stringmsgid;10        time_t recv_first =0;11        time_t recv_last =0;12int recv_cnt =0;13};1415classWorkEngine16{17public:18WorkEngine();19        ~WorkEngine();2021private:22//to avoid server push duplicate messages to same client.23//note this instance is only accessed when single connection to server arrives message, so no lock needed..24        std::vectorm_svrmsgs;25};2627    template 28intdb_store_server_msg(InputIterator beg, InputIterator end);29    template 30intdb_fetch_server_msg(OutputIterator it);3132    typedef std::map ::iterator iterator_t;33    typedef std::map_inserter >inserter_t;34 }

正在上传... 取消

将模板函数声明从类中移到类外(line 27-30),同时修改 engine_db.cpp 中两个类的定义和显示实例化语句,去掉类限制(WorkEngine::):

templateintdb_fetch_server_msg(inserter_t);

template intdb_store_server_msg(iterator_t, iterator_t);

调用处不需要修改。再次编译报错:

1>engine_db.cpp(16): warning C4667: “int GCM::db_fetch_server_msg(GCM::inserter_t)”: 未定义与强制实例化匹配的函数模板

1>engine_db.cpp(17): warning C4667: “int GCM::db_store_server_msg(GCM::iterator_t,GCM::iterator_t)”: 未定义与强制实例化匹配的函数模板

1>    正在创建库 F:\gdpclient\src\gcm\Release\gcmsvc.lib 和对象 F:\gdpclient\src\gcm\Release\gcmsvc.exp

1>workengine.obj : error LNK2001: 无法解析的外部符号 "int __cdecl GCM::db_fetch_server_msg<class std::map_inserter<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> >,class GCM::server_msg_t,struct std::less<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > > > >(class std::map_inserter<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> >,class GCM::server_msg_t,struct std::less<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > > >)" (??$db_fetch_server_msg@V?$map_inserter@V?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@Vserver_msg_t@GCM@@U?$less@V?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@@2@@std@@@GCM@@YAHV?$map_inserter@V?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@Vserver_msg_t@GCM@@U?$less@V?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@@2@@std@@@Z)

1>workengine.obj : error LNK2001: 无法解析的外部符号 "int __cdecl GCM::db_store_server_msg<class std::_Tree_iterator<class std::_Tree_val<struct std::_Tree_simple_types<struct std::pair<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > const ,class GCM::server_msg_t> > > > >(class std::_Tree_iterator<class std::_Tree_val<struct std::_Tree_simple_types<struct std::pair<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > const ,class GCM::server_msg_t> > > >,class std::_Tree_iterator<class std::_Tree_val<struct std::_Tree_simple_types<struct std::pair<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > const ,class GCM::server_msg_t> > > >)" (??$db_store_server_msg@V?$_Tree_iterator@V?$_Tree_val@U?$_Tree_simple_types@U?$pair@$$CBV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@Vserver_msg_t@GCM@@@std@@@std@@@std@@@std@@@GCM@@YAHV?$_Tree_iterator@V?$_Tree_val@U?$_Tree_simple_types@U?$pair@$$CBV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@Vserver_msg_t@GCM@@@std@@@std@@@std@@@std@@0@Z)

前两个 warning 是因为由成员函数变为普通函数后,显示实例化需要放在函数实现后面,我们将这两条语句调整到文件末尾就好了。对于后面两个链接 error,百思不得其解,后来使用一个非常简单的 test 模板函数做试验,发现是命名空间搞的鬼,需要在每个函数的定义和显示实例化语句前加上命名空间限定(GCM::):

templateintGCM::db_fetch_server_msg(inserter_t);

template intGCM::db_store_server_msg(iterator_t, iterator_t);

亚马逊测评 www.yisuping.com

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,056评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,842评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,938评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,296评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,292评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,413评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,824评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,493评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,686评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,502评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,553评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,281评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,820评论 3 305
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,873评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,109评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,699评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,257评论 2 341

推荐阅读更多精彩内容