IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> C++知识库 -> C++模板编程之变长参数模板 -> 正文阅读

[C++知识库]C++模板编程之变长参数模板

参考文章:

  • 模板:https://zh.cppreference.com/w/cpp/language/templates
  • 变量模板:https://zh.cppreference.com/w/cpp/language/variable_template
  • 类型别名,别名模版:https://zh.cppreference.com/w/cpp/language/type_alias
template<class T>
struct Alloc { };
template<class T>
using Vec = vector<T, Alloc<T>>; // 类型标识为 vector<T, Alloc<T>>
Vec<int> v; // Vec<int> 等同于 vector<int, Alloc<int>>
  • 变长模板(可变参数模板、形参包):https://zh.cppreference.com/w/cpp/language/parameter_pack

在C语言中的<stdarg.h>文件中定义了va_list、va_start、va_arg、va_end来实现可变参数编程,例如著名 printf() 函数就是由此方式编写的。而在C语言的泛型编程中,宏中也有__VA_ARGS__参数用于多参数编程。

作为cplusplus的C++而言,在C++11标准以后,也有了自己的可变参数编程,它集成在模板中,称之为变长模板。

一、形参包

模板头用法:

类型 ... 包名(可选)	(1)	
typename|class ... 包名(可选)	(2)	
类型约束 ... 包名(可选)	(3)	(C++20)
template < 形参列表 > class ... 包名(可选)	(4)	(C++17)
template < 形参列表 > typename|class ... 包名(可选)	(4)	(C++17)

变参函数模板可以用任意数量的函数实参调用(模板实参通过模板实参推导推导):

template<class... Types> void f(Types... args);
f();       // OK:args 不包含实参
f(1);      // OK:args 包含一个实参:int
f(2, 1.0); // OK:args 包含两个实参:int 与 double

在主类模板中,模板形参包必须是模板形参列表的最后一个形参。在函数模板中,模板参数包可以在列表中更早出现,只要其后的所有形参都可以从函数实参推导或拥有默认实参即可:

template<typename... Ts, typename U> struct Invalid; // 错误:Ts... 不在结尾
 
template<typename... Ts, typename U, typename=void>
void valid(U, Ts...);    // OK:能推导出 U
// void valid(Ts..., U); // 不能使用:Ts... 在此位置是不推导语境
 
valid(1.0, 1, 2, 3);     // OK:推导出 U 为 double,Ts 为 {int, int, int}

二、包展开

2.1 函数模板:引用传参、指针传参

后随省略号且其中至少有一个形参包的名字至少出现了一次的模式会被展开成零个或更多个逗号分隔的模式实例,其中形参包的名字按顺序被替换成包中的各个元素:

template<class... Us> void f(Us... pargs) {}
template<class... Ts> void g(Ts... args)
{
    f(&args...); // “&args...” 是包展开
                 // “&args” 是它的模式
}
g(1, 0.2, "a"); // Ts... args 会展开成 int E1, double E2, const char* E3
                // &args... 会展开成 &E1, &E2, &E3
                // Us... 会展开成 int* E1, double* E2, const char** E3

为了明确 f(&args…) 与 f(args…) 的差别,我们在函数内部打印一些提示信息查看。

void f() { 
	cout << "pargs size = 0\t Do nothing, quit." << endl;
}
template<class Ts,class... Us> void f(Ts value, Us... pargs) {
	cout << "pargs size = " << sizeof...(pargs) + 1		// value + {pargs...}
		<< "\tcurrent value type = " << typeid(Ts).name()
		<< "\tcarrent value = " << value << "\n";
	f(pargs...);		// 递归调用,每次调用后 value 取代 pargs 第一个参数,直至为空
}

template<class... Ts> void g(Ts... args)
{
	cout << "g:\n\targs size = " << sizeof...(args) 
		<< " \tCall f(&args..):\n";
	f(&args...); // “&args...” f接收到的参数是 原类型取地址

	cout << "\n\targs size = " << sizeof...(args)
		<< " \tCall f(args..):\n";
	f(args...); // “args...”  f接收到的参数是 原类型
}

从运行结果可以看出,加 & 传参,相当于对每个参数取地址传参。因此,函数以指针的形式接收到参数。
在这里插入图片描述
通过以上思考,既然传参的时候可以以指针传参,那么我们时候可以在函数内部以引用的形式接收呢。

在f()的参数中,通过 & 定义引用接收参数

void fun() {
	cout << "pargs size = 0\t Do nothing, quit." << endl;
}
template<class Ts, class... Us> void fun(Ts value, Us... pargs) {
	cout << "pargs size = " << sizeof...(pargs) + 1		// value + {pargs...}
		<< "\tcurrent value type = " << typeid(Ts).name()
		<< "\tcarrent value = " << value
		<< "\targs address = " << static_cast<void*>(&value)
		<< "\n";
	fun(pargs...);		// 递归调用,每次调用后 value 取代 pargs 第一个参数,直至为空
}
void fun_ref() {
	cout << "pargs size = 0\t Do nothing, quit." << endl;
}
template<class Ts, class... Us> void fun_ref(Ts& value, Us&... pargs) {
	cout << "pargs size = " << sizeof...(pargs) + 1		// value + {pargs...}
		<< "\tcurrent value type = " << typeid(Ts).name()
		<< "\tcarrent value = " << value
		<< "\targs address = " << static_cast<void*>(&value)
		<< "\n";
	fun_ref(pargs...);		// 递归调用,每次调用后 value 取代 pargs 第一个参数,直至为空
}

// main:
	int  ia = 10;
	float fa = 1.1f;
	double da = 0.01;
	char ca = 'a';
	char str[] = "abcd";

	cout << "\t普通方式接收参数传参" << "\n";
	fun(ia, fa, da, ca, str);
	cout << "\t引用方式接收参数传参" << "\n";
	fun_ref(ia, fa, da, ca, str);
		
	// 打印实参地址,作为对照组							    
	cout << typeid(ia).name() << " \t: " << static_cast<void*>(&ia) << "\n"
		<< typeid(fa).name() << " \t: " << static_cast<void*>(&fa) << "\n"
		<< typeid(da).name() << " \t: " << static_cast<void*>(&da) << "\n"
		<< typeid(ca).name() << " \t: " << static_cast<void*>(&ca) << "\n"
		<< typeid(str).name() << " \t: " << static_cast<void*>(&str) << "\n";

通过结果可以发现,在变长模板中也是可以使用引用传参的,并且使用方式与普通函数一样。
在这里插入图片描述

2.2 确保变长模板至少有一个参数

对于 template<class... Us> void func(Us... args) {} 这种,参数列表全部由变长参数组成的,它的参数个数是不确定,即可能包含0个或多个。而我们在使用这种函数可能会造成循环递归的情况。

例如:对于此模板函数,不论我们怎样调用都会造成函数的持续递归调用,这与我们普通函数的套娃递归一样,最终都会因为栈资源溢出而导致程序崩溃。

template<class... Us> void func(Us... args) {
	func(args...);		// 无限递归调用,直至栈溢出
}

/* 调用
func();		// 崩溃
func(1,2,3); // 崩溃
...
*/

当然,如果我们指定了某个特例化版本时,它就会去执行这个特例化版本函数。例如我们设计了一个无参的func void func() {} ,这样我们调用 func(); 时就不会调用这个模板函数,而是调用特例版本。

因此,对于一般的变长模板函数,建议采用以下方式使用。当然参数列表的 const 和 & 按照实际需求选填即可。

void func() {}	// 特殊处理,递归终点。参数递归完后要做的事 
template<class Ts, class... Us> void func(const Ts& value, const Us&... args) {
	cout << value << ", ";
	func(args...);		// 递归调用
}

2.3 嵌套:包展开规则

如果包展开内嵌于另一个包展开中,那么它所展开的是在最内层包展开出现的形参包,并且在外围(而非最内层)的包展开中必须提及其它形参包:

template<class... Args>
void g(Args... args)
{
    f(const_cast<const Args*>(&args)...); 
    // const_cast<const Args*>(&args) 是模式,它同时展开两个包(Args 与 args)
    // 这里将非const的&args,转换成const指针作为参数调用f()。
    // 因此在f()内部,这些参数是 const type * 
 
    f(h(args...) + args...); // 嵌套包展开:
    // 内层包展开是 “args...”,它首先展开
    // 外层包展开是 h(E1, E2, E3) + args 它其次被展开
    // (成为 h(E1, E2, E3) + E1, h(E1, E2, E3) + E2, h(E1, E2, E3) + E3)
}
2.3.1 关于const_cast传递常量指针

首先,我们可以分析 const_cast<const Args*>(&args) 这段代码,它将 args 参数的地址,转换成 const 类型的地址。再调用
f() 函数,可以预见的是,f() 函数中接收到的参数都是被const类型修饰过的指针。

而我们将原表达式 *const_cast<const Args*>(&args) 解引用后,他就可以形参的方式被 f() 接收,而不是当前变量的地址。f(*const_cast<const Args*>(&args)...);

为了验证我们的猜想,我们设计 f() 函数。

// 注,这里的type_name为自实现函数,可参考:https://blog.csdn.net/weixin_43919932/article/details/113186595
// 打印变量类型,可打印引用,cv限定。打印结果比比自带的typeid()函数更全面
template <typename T>
constexpr auto type_name() noexcept {
	std::string_view name = "Error: unsupported compiler", prefix, suffix;
#ifdef __clang__
	name = __PRETTY_FUNCTION__;
	prefix = "auto type_name() [T = ";
	suffix = "]";
#elif defined(__GNUC__)
	name = __PRETTY_FUNCTION__;
	prefix = "constexpr auto type_name() [with T = ";
	suffix = "]";
#elif defined(_MSC_VER)
	name = __FUNCSIG__;
	prefix = "auto __cdecl type_name<";
	suffix = ">(void) noexcept";
#endif
	name.remove_prefix(prefix.size());
	name.remove_suffix(suffix.size());
	return name;
}

// f() 函数原型,仅供参考。
void f() { cout << "\n"; }
template<class Ts, class... Us> void f(Ts const value, Us const ... pargs) {
    cout << type_name<decltype(value)>()<<":" << value << ",\n";
    //value += 1;	// 验证指针时,是否可以改变指向,验证参数原型时,是否可以改变值
    //*value = 10;	// 只用于指针,验证指针指向之值是否可以被修改
    f(pargs...);		// 递归调用,每次调用后 value 取代 pargs 第一个参数,直至为空
}

以下皆省略模板前缀:

  • 调用:f(*const_cast<const Args*>(&args)...); // 以常量实参原型调用
    • 如果 void f( Ts value, Us ... pargs),则我们在函数体内修改 value = 10,将不会引发报错。
      (因为行参传递过程,是另外开辟的一片空间,其f()的参数列表未规定其const属性)
    • 如果 void f(const Ts value, const Us ... pargs),则我们在函数体内修改 value = 10,会引发报错。
      (因为函数形参列表限定参数为常量,不可修改)
    • 如果 void f(Ts& value, Us& ... pargs),则我们在函数体内修改 value = 10,会引发报错。
      (因为是以引用传递,而原实参是const的,因此在函数内引用的参数也是cosnt的不允许修改)
    • 其他:例如常量左值引用const Type&、非常量右值引用Type&&、常量右值引用const Type&& 这里不再列举
  • 调用:f(const_cast<const Args*>(&args)...); // 以常量实参指针调用 注:f() 接收到的参数是指针
    • 如果 void f(Ts value, Us ... pargs),则 value += 1; 成功, *value = 10;失败
      (因为,const_cast<const Args*>() 将 type 强转为 const type *,因此指针值不可变。而并没有限制指针的指向,因此value += 1成功 )
    • 如果 void f(Ts const value, Us const ... pargs),则 value += 1; 失败, *value = 10;失败
      (因为,原实参是 const type *, f() 函数中限定了参数类型为 type * const,最终参数被叠加为 const type*const。 既不可改变指向,又不可改变指向的值)
    • 其他:同理这里可以传入引用或多级指针测试。有关变量类型的输出参考:https://blog.csdn.net/weixin_43919932/article/details/113186595

需要知道的是,const_cast<const Args*>() 这个转换是无法代替的。即,我们无法通过在 f() 函数内限定参数的方式保护原数据不被修改。因为模板参数的存在,我们只能在template<class T> 的存在,我们只能在函数的形参类型前后添加cv限定。

我们都只到,const int a , 与 int const a 实际上是一种类型。而这里的 int 是一种基础类型,如果类比模板参数template<class T>, 则表示 const T a , 与 int T a 实际上是一种类型。因为从const修饰的角度而言,从始至终cosnt修饰的都是最后面的变量 a。

同理换成指针而言(这里我们不传 T* 而是以 T 本身作为指针),对于 const T p 而言,const 修饰的是指针 p ,换言之在这里cosnt只能限定指针的指向不变

类比上述结论,在模板参数 T 上加cosnt 修饰,分两种情况:

  • 如果我们在模板类型前加const

    • f( const T p ) 注意这里const 修饰的是指针 p 本身 。
    • 因此将 char* 参数 与模板参数 T 解析后的函数为 f( const (char*) p ) ,这里 const 仍然修饰的是 p 本身,而不是 p 的所指之物。
    • 因此,最终 p 的类型是 char* const p。限定指向。
  • 如果我们在模板类型后加const

    • f( T const p ) ==》char* ==》f( char* const p )
    • 很明显,这里的 p 仍然是被限定了指向。

综上,使用 T 方式传参(以指针的形式),如果不想原数据被改变,可以使用 const_cast<const type*>(&arg) 进行强制类型转换,或者使用引用的方式。

2.3.2 嵌套执行的变长模板函数
template<class... Args>
void g(Args... args)
{
    f(h(args...) + args...); // 嵌套包展开:
    // 内层包展开是 “args...”,它首先展开
    // 外层包展开是 h(E1, E2, E3) + args 它其次被展开
    // (成为 h(E1, E2, E3) + E1, h(E1, E2, E3) + E2, h(E1, E2, E3) + E3)
}

首先,对于 f(1, args...) 的函数调用,f() 函数最终接收到的参数是 args 中每个参数都加上1的结果。即示例中函数 h() 的返回值 与 args 的每个元素相加。

  • 例如,对于 args… = {1,2,3,4}, h(args…) => 10
    f(h(args…) + args…) => f(10 + args…) ==> f({11,12,13,14})

关于 f(x, args…) 参考下列程序:

void f() { cout << "\n"; }
template<class Ts, class... Us> void f(Ts value, Us... pargs) {
    cout << value << ", ";
    f(pargs...);
}

template<class... Args>
void g(Args... args)
{
    f(100 + args...);
}


// main
 g(1, 2, 3, 4);
 // output: 101, 102, 103, 104,

而对于示例中给的程序,如果我们将函数 f(),函数 h() 补充完整。就能验证我们的猜想。

void f() { cout << endl; }
template<class Ts, class... Us> void f(Ts value, Us... pargs) {
    cout << value << ", ";	// 打印结果
    f(pargs...);			// 递归取参
}

int h() { return 0; }
template<class Ts, class... Us> auto h(Ts value, Us... pargs) {
    return value + h(pargs...);		// 将所有参数求和
}

template<class... Args>
void g(Args... args)
{
    f(h(args...) + args...);	// h()的返回值与 args 分别相加后的新序列作为参数
}

// main
  g(1, 2, 3, 4); // output: 11, 12, 13, 14,

2.4 类模板嵌套

对于类模板,这里提供了一个示例:

//如果两个形参包在同一模式中出现,那么它们同时展开而且长度必须相同:

template<typename...> struct Tuple {};
template<typename T1, typename T2> struct Pair {};
 
template<class... Args1> struct zip
{
    template<class... Args2> struct with
    {
        typedef Tuple<Pair<Args1, Args2>...> type;
        // Pair<Args1, Args2>... 是包展开
        // Pair<Args1, Args2> 是模式
    };
};
 
typedef zip<short, int>::with<unsigned short, unsigned>::type T1;
// Pair<Args1, Args2>... 会展开成
// Pair<short, unsigned short>, Pair<int, unsigned int> 
// T1 是 Tuple<Pair<short, unsigned short>, Pair<int, unsigned>>
 
typedef zip<short>::with<unsigned short, unsigned>::type T2;
// 错误:包展开中的形参包包含不同长度

三、包展开的位置

一览:

函数实参列表
有括号初始化器
花括号包围的初始化器
模板实参列表
函数形参列表
模板形参列表
基类说明符与成员初始化器列表
Lambda 捕获
sizeof… 运算符
动态异常说明
using 声明

函数实参列表

包展开可以在函数调用运算符的括号内出现,此时省略号左侧的最大表达式或花括号初始化器列表是被展开的模式:

f(&args...);             // 展开成 f(&E1, &E2, &E3)
f(n, ++args...);         // 展开成 f(n, ++E1, ++E2, ++E3);
f(++args..., n);         // 展开成 f(++E1, ++E2, ++E3, n);
f(const_cast<const Args*>(&args)...);	// 展开成常量指针(指向常量的指针,指针所指之物不可修改)
// f(const_cast<const E1*>(&X1), const_cast<const E2*>(&X2), const_cast<const E3*>(&X3))
f(h(args...) + args...); // h() 的返回值与 args 所有元素加和的结果,作为新的参数
// 展开成 f(h(E1, E2, E3) + E1, h(E1, E2, E3) + E2, h(E1, E2, E3) + E3)

正式而言,函数调用表达式中的表达式列表被归类为初始化器列表,它的模式是初始化器子句,它是赋值表达式和花括号初始化器列表其中之一。

有括号初始化器

包展开可以在直接初始化器函数式转型及其他语境(成员初始化器new 表达式等)的括号内出现,这种情况下的规则与适用于上述函数调用表达式的规则相同:

Class c1(&args...);             // 调用 Class::Class(&E1, &E2, &E3)
Class c2 = Class(n, ++args...); // 调用 Class::Class(n, ++E1, ++E2, ++E3);
::new((void *)p) U(std::forward<Args>(args)...) // std::allocator::allocate

相关名词:

  • 直接初始化:从明确的构造函数实参的集合初始化对象。https://zh.cppreference.com/w/cpp/language/direct_initialization
  • 显式类型转换:用显式和隐式转换的组合进行类型之间的转换。https://zh.cppreference.com/w/cpp/language/explicit_cast
  • 构造函数与成员初始化器列表:构造函数是类的一种特殊的非静态成员函数,用于初始化该类类型的对象。
    在类的构造函数定义中,成员初始化器列表指定各个直接基类、虚基类和非静态数据成员的初始化器
    https://zh.cppreference.com/w/cpp/language/constructor
  • new 表达式:创建并初始化拥有动态存储期的对象,这些对象的生存期不受它们创建时所在的作用域限制。
    https://zh.cppreference.com/w/cpp/language/new
花括号包围的初始化器

在花括号初始化器列表(花括号包围的初始化器和其他花括号初始化器列表的列表,用于列表初始化和其他一些语境中)中,也可以出现包展开:

template<typename... Ts> void func(Ts... args)
{
    const int size = sizeof...(args) + 2;
    int res[size] = {1, args..., 2};	
    // 因为初始化器列表保证顺序,所以这可以用来对包的每个元素按顺序调用函数:
    int dummy[sizeof...(Ts)] = {(std::cout << args, 0)...};
}

这里分析一下最后一句代码:
()..., 括号中是一个逗号表达式,因此执行完括号内的语句后,返回的是最后一个逗号后面的值。因此,dummy最后获得一个全0的数组。

除此之外,也可以使用这种方法输出 args 中的元素数据:

template<typename ...Args>
void print(Args && ...args)
{
    (std::cout << ... << args) << "\n";
}
模板实参列表

包展开可以在模板实参列表任何位置使用,前提是模板拥有与该展开相匹配的形参:

template<class A, class B, class... C> void func(A arg1, B arg2, C...arg3)
{
    container<A, B, C...> t1; // 展开成 container<A, B, E1, E2, E3> 
    container<C..., A, B> t2; // 展开成 container<E1, E2, E3, A, B> 
    container<A, C..., B> t3; // 展开成 container<A, E1, E2, E3, B> 
}
函数形参列表

在函数形参列表中,如果省略号在某个形参声明中(无论它是否指名函数形参包(例如在 Args ... args中))出现,那么该形参声明是模式:

template<typename... Ts> void f(Ts...) {}
f('a', 1); // Ts... 会展开成 void f(char, int)
f(0.1);    // Ts... 会展开成 void f(double)
 
template<typename... Ts, int... N> void g(Ts (&...arr)[N]) {}
int n[1];
g<const char, int>("a", n); // Ts (&...arr)[N] 会展开成 
                            // const char (&)[2], int(&)[1]

注意:在模式 Ts (&…arr)[N] 中,省略号是最内层的元素,而不是像所有其他包展开中一样是最后的元素。
注意:不能用 Ts (&…)[N],因为 C++11 语法要求带括号的省略号形参拥有名字 。

关于第一条示例,因为函数形参没有定义形参名,只声明了可变参数,因此在函数内部,无法获取到参数。类似我们定义的 void func(int,int)
在这里插入图片描述

模板形参列表

包展开可以在模板形参列表中出现:

template<typename... T> struct value_holder
{
    template<T... Values> // 会展开成非类型模板形参列表,
    struct apply {};      // 例如 <int, char, int(&)[5]>
};
基类说明符与成员初始化器列表

包展开可以用于指定类声明中的基类列表。通常这也意味着它的构造函数也需要在成员初始化器列表中使用包展开,以调用这些基类的构造函数:

template<class... Mixins>
class X : public Mixins...
{
public:
    X(const Mixins&... mixins) : Mixins(mixins)... {}
};

演示:

template<class Ty>
class Base
{
public:
    Base(Ty _d)
        :data(_d)
    {}
    ~Base() {}

    void show() {
        cout << __FUNCSIG__ << "data:" << data << endl;
    }
private:
    Ty data;
};


template<class... Mixins>
class X : public Mixins...
{
public:
    X(const Mixins&... mixins) : Mixins(mixins)... {}

    void show() {
        //this->Base<int>::show() ;
        int x[sizeof...(Mixins)] = { (Mixins::show(),0)... };
    }

};

int main()
{;
    Base<int> iBase(10);
    Base<float> fBase(0.1f);
    Base<double> dBase(3.14);
    Base<char> cBase('a');
    Base<const char*> sBase("hello...!");
    X x(iBase,fBase,dBase,cBase,sBase );
    x.show();
    
	return 0;
}

在这里插入图片描述

Lambda 捕获

包展开可以在 lambda 表达式的捕获子句中出现:

template<class... Args>
void f(Args... args)
{
    auto lm = [&, args...] { return g(args...); };
    lm();
}
sizeof… 运算符

用于计算模板参数个数。

template<class... Types>
struct count
{
    static const std::size_t value = sizeof...(Types);
};
动态异常说明

动态异常说明中的异常列表也可以是包展开:

template<class...X> void func(int arg) throw(X...)
{
    // ... 在不同情形下抛出不同的 X
}
using 声明

在 using 声明中,省略号可以在声明器列表内出现,这对于从一个形参包进行派生时有用:

template <typename... bases>
struct X : bases...
{
    using bases::g...;
};
X<B, D> x; // OK:引入 B::g 与 D::g
  C++知识库 最新文章
【C++】友元、嵌套类、异常、RTTI、类型转换
通讯录的思路与实现(C语言)
C++PrimerPlus 第七章 函数-C++的编程模块(
Problem C: 算法9-9~9-12:平衡二叉树的基本
MSVC C++ UTF-8编程
C++进阶 多态原理
简单string类c++实现
我的年度总结
【C语言】以深厚地基筑伟岸高楼-基础篇(六
c语言常见错误合集
上一篇文章      下一篇文章      查看所有文章
加:2022-03-08 22:10:02  更:2022-03-08 22:10:58 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/24 4:17:22-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码