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++17(3) -> 正文阅读

[C++知识库]C++17(3)

类模板实参推导

  • C++17支持类模板类型推导(class template argument deduction,在下面的文章中,我叫做CTAD)。

  • 而我们在很久之前就有了template argument deduction,但是只能用于函数,这多少有点不公平。

  • 此篇博客的内容来自cppcon2018_CTAD

//before C++17
std::pair<int, string> p1(3, "string");
auto p2 = make_pair(3, "string");
//deduction pair<int, const char*>

//C++17 or late
std::pair p3(3, string("hello")); // nice !

CTAD是如何工作的?

  • CTAD工作的具体细节是很复杂的。而且有些细节是给编译器实现者使用的。
  • 这里,我将介绍CTAD工作的最重要的两步。
template <class T, class U>
struct pair{
	T first;
	U second;
	pair(const T& first_, const U& second_)
	:first(first_), second(second_)
	{}

	pair(T&& first_, U&& second_)
	:first(std::forward<T>(first_))
	,second(std::forward<U>(second_))
	{}
	//...
};

std::pair p(3, string("hello")); //how does it work ?
  • 上面是粗略的std::pair的实现,我们使用它来进行讲解。

第一步:

  • 当编译器看到你尝试去初始化一个p,编译器又看见了pair是一个模板的名字。但是你没有显式传入模板实参,而且pair没有默认值,所以编译器需要CTAD。
  • 编译器会去查看pair的构造函数,它会假装构造函数是普通的函数模板,像下面这样,
	template <class T, class U>  //来自pair类
	pair(const T& first_, const U& second_)
	:first(first_), second(second_)
	{}
	
	template <class T, class U> //来自pair类
	pair(T&& first_, U&& second_) //右值引用,不是转发引用
	:first(std::forward<T>(first_))
	,second(std::forward<U>(second_))
	{}
  • 编译器会假装synthesis(合成,函数重载的术语)两个上面的函数,将类的模板参数列表加到构造函数的头部。
  • 然后编译器就使用模板实参推导,overload resolutions等一系列方法,去分辨函数重载中最合适的那一个。
  • 最终,匹配了第二个右值引用的pair,然后编译器就会推导出p的类型为pair< int, string >。

第二步:

  • 注意,第一步中,没有进行任何的实例化,仅仅是推导出模板参数。
  • 实例化发生在第二步。
  • 编译器现在有了p的类型,pair<int, string >,然后就可以调用第二个构造函数实例化出该对象。

CTAD && STL

  • CTAD不仅仅适用于pair,也适用于任何类,因为CTAD是语言特性。
  • 有了CTAD之后,你不在需要那些make函数,比如make_pair,make_tuple,CTAD完全可以替代它们,除了某些边缘情况。
tuple t1{3, 3.14, "string"};
auto t2 = make_tuple(1, 1.11, "hello"); //该退休了。

zero or all

  • 其中一个CTAD和make函数的区别就是,CTAD要么有全部的显式实参,要么一个没有。
std::tuple t1<std::string>{"string", 3, 3.14}; //error !
auto t2 = make_tuple<std::string>("string", 3, 3.14); // ok !
  • 据我的测试,在C++20中,CTAD依然不支持这种语法格式。

vector

  • vector与CTAD也搭配的很好。
std::vector v1{1, 2, 3, 4}; //ok, vector<int>
std::vector v2{1, 2 ,3 ,4.0f}; // error !
  • CTAD和花括号也互动的很好,可以推导出vector< int >。
  • 你不能将在花括号中放入不同类型的常量,这样CTAD无法推导出类型。
  • 这里有一个陷阱,
std::vector<int> v1{3}; //一个元素,3
std::vector<int> v2(3); //3个元素,0,0,0
  • 这实际上是C++14之前的一个古老的陷阱。当你用花括号去构造vector,调用初始化列表的构造函数,构造一个size为1,含有一个3的vector。
  • 当你使用小括号时,vector回去构造一个size为3的数组,每个位置使用初始值0。
std::vector v1{3};  //ok,一个元素,3
std::vector v2(3);  //error !
  • 但是当你使用CTAD时,v1依然可以推断出vector< int >,然后构造出size为1的vector。
  • 但是v2的推断失败了。因为编译器只知道v2的size是3,但是它不知道你想用什么类型的vector。它会说,“哦,你想要vector的大小为3,你想用T类型的,但是T是什么,我不知道。”

deduction guides

  • vector还有一个非常好用的构造函数,
std::vector range{1, 2, 3, 4};
std::vector v(range.begin(), range.end()); //ok, vector<int>
  • CTAD依然奏效。根据迭代器类型推导出v 类型为vector< int >。
  • 这是如何工作的?
template <class T>
class vector{
	template <class Iter>
	vector(Iter first, Iter last);
	//...
};
  • 上面是vector关于此构造函数的简单写法。按照CTAD的第一步,编译器会去合成一些模板函数,
template <class T, class Iter>
vector(Iter first, Iter last);
  • 现在你去调用此构造函数,编译器会说,“ok,我可以推导出Iter的类型,因为你给了我两个迭代器。但是我无法推导出T的类型。”
  • 仅仅依靠CTAD是不够的,我们需要别的语法,这就是deduction guides。
template <class T>
class vector{
	template <class Iter>
	vector(Iter first, Iter last);
	//...
};
template <class Iter> //deduction guides
vector(Iter, Iter)->vector<typename iterator_traits<Iter>::value_type>;
  • 当编译器无法自己完成CTAD时,需要你显式的提供deduction guides。
  • 前面类似该构造函数的签名式,然后是一个箭头,然后是你想设置的类型。这告诉编译器,如果你调用该构造函数,那么该类的模板实参为typename iterator_traits< Iter >::value_type。
  • 从这里可以看到deduction guides不需要形参的名字
  • deduction guides必须与该类处在相同的作用范围内,或者说相同的命名空间内。
  • deduction guides类似一种新的对象,它会在overload resolution的第一步被加入到候选人列表中,如果该构造函数被选中,那么T可以被正确推导了。
std::vector v1(range.begin(), range.end()); //ok, 可以推导
std::vector v2(1, 2); //不会调用关于迭代器的构造函数,因为int没有iterator_traits

顺序很重要:

  • 另外一个陷阱就是,deduction guides必须紧跟在类的后边,如果deduction guides和调用语句互换,那么CTAD不会奏效。
template <class T>
class vector{
	template <class Iter>
	vector(Iter first, Iter last);
	//...
};

std::vector range{1, 2, 3, 4};
std::vector v(range.begin(), range.end()); //error !

template <class Iter> //deduction guides
vector(Iter, Iter)->vector<typename iterator_traits<Iter>::value_type>;
  • 当某些库缺少deduction guides时,你确实可以打开命名空间,为其编写deduction guides。但是请避免这样做,因为这可能与库维护者的编写产生冲突。
  • 例如vector就在std中,因为deduction guides必须与该类处在同一范围中,所以deduction guides必须在std中,但是C++规定禁止向std中添加额外的东西。

陷阱2:花括号有优先级。

vector range{1, 2, 3};
vector v1(range.begin(), range.end()); //vector<int>
vector v2{range.begin(), range.end()}; //vector<vector<int>::iterator>
  • 当采用花括号时,编译器会优先调用初始化列表那个构造函数,将v2推导为类型为迭代器的vector,而非int。(这点与auto的推导规则一样,初始化列表具有优先级。)

other containers:

  • CTAD还可以与其他容器配合。
vector range{1, 2, 3, 4};
list l(range.begin(), range.end());          //list<int>
forward_list fl(range.begin(), range.end()); //forward_list<int>
deque d(range.begin(), range.end());
  • 与vector相同,不要使用花括号。使用小括号。

set:

  • set也可以使用CTAD,但是set有些特殊的构造函数。
set s1{1, 2, 3}; //ok, set<int>
set s2(s1.begin(), s1.end()); //ok, set<int>
set s3({1, 2, 3}, [](int lhs, int rhs)->bool{
	return lhs > rhs ;
}); //ok

set s4(s1.begin(), s1.end(), [](int lhs, int rhs){
	return lhs > rhs;
});
  • set有一个构造函数支持初始化列表和一个比较方法,CTAD依然可以。
  • set有一个函数支持一对迭代器和一个比较方法,CTAD依然可以。
  • it is cool!!

陷阱3, map:

  • map有一点不同。
std::map m{{1, 3.14}, {2, 6.66}}; // error
  • 你想要上面的CTAD起效果,推导出m的类型为map<int, double >,但是这不行。
  • 编译器不认识里面的东西,{1, 3.14}不是任何类型,编译器无法推断出这是什么。
  • CTAD不认识内嵌的初始化列表。
std::map m{std::pair{1, 3.14}, std::pair{2, 6.66}}; //ok,map<int, double>
  • 你需要显式告诉编译器,这种类型是一个pair。你可以只为第一个显式说明是pair,编译器会推导剩下的。

CTAD 和拷贝构造函数

vector v{1, 2, 3};
vector v1{v}; //ok
list l{1, 2, 3};
list l1{l};
  • 拷贝构造函数也可以搭配CTAD。但是这里也有一个陷阱。

copy wins:

vector v{1, 2, 3};
vector v1{v};  //调用拷贝构造
vector v2{v, v1}; //调用初始化列表的构造函数
  • 拷贝构造函数比起初始化列表,有更高的优先级。
  • v1回去调用拷贝构造函数,从而推断出v1的类型为vector< int >
  • v2的类型为vector<< int >>;

其他与CTAD的搭配

locks && mutexes

before C++17
std::shared_timed_mutex mtx;
std::lock_guard<std::shared_timed_mutex> lock(mtx);

//now
std::lock_guard lock(mtx); //CTAD !
//or,
std::scoped_lock lock(mtx);    //more better !
  • 现在不需要为lock_guard写一长串的类型,CTAD会推导。
  • 在C++17中,我们还有scoped_lock替代lock_guard,这是更好的。

CTAD && 无参构造

  • CTAD也会考虑默认值。
std::less<>{}; //before C++17
std::less{}; //now
  • less有一个默认的模板参数,你可以不给参数,CTAD就会采用默认的参数。你可以省略尖括号。
  • 如果模板没有默认参数,那么CTAD就会出错。

CTAD && more

std::optional o{10};       // optional<int>
std::complex c{1.0, 3.14}; // complex<double>
  • CTAD也适用于这些值包装器,optional,complex。

CTAD要注意的点

std::complex c{1, 2};  //推导出complex<int>
  • oh,这可能不是你想要的,你想要的是fioat,或者double。
  • 但是CTAD推断的类型是完美符合你给它的类型。所以推断出int。但是C++已经指出complex的模板参数如果是float, double, long double之外的,那么结果是未定义的。
  • 所以,要注意,CTAD会默默的拓展你的类模板的接口。

智能指针:

  • 你可能会想在智能指针上使用CTAD。
class Person{
public:
	Person(std::string name, int id);
	//...
};

std::shared_ptr sp(new Person("zzh", 1)); // error
auto sp1 = make_shared<Person>("zzh", 1);  //ok

std::unique_ptr up(new Person("zzh", 1)); // error
auto up = make_unique<Person>("zzh", 1);  //ok
  • CTAD竟然不能用于指针指针,你依然得使用make函数。但是这是好的!CTAD不能用于智能指针是好的!
  • make函数创建智能指针更高效,make函数直接分配内存,构造一次。
  • make函数是异常安全函数,如果创建智能指针抛出异常,make函数保证异常不会扩散。
  • 第三个关于智能指针不能使用CTAD的原因才是主要原因,这跟数组有关。
std::unique_ptr up(new int[10]); // 推断出什么?
  • 如果CTAD可以使用,上面会为up推出什么类型?
  • 我的天,由于数组到指针的退化,new会返回一个int指针类型,不是数组类型! 然后up的类型就是unique_ptr< int >,而非unique_ptr< int[] >。
  • 然后你的若干操作,包括delete都会调用普通版本的,而非数组版本的。everything都是坏的。
  • 所以,当你构造智能指针的时候,不能使用CTAD。你需要显式声明你的类型。
std::unique_ptr<int[]> up(new int[10]); //ok !

何时 && 如何 禁用CTAD

  • 何时禁用CTAD?
  1. 当CTAD会导致错误或者危险的代码
  2. 当CTAD不会实例化你想要的东西
  3. 当CTAD会降低效率
  4. 当CTAD不会提供一些make函数的特性,例如异常安全
  • 此时,禁用CTAD。
  • so,如何禁用CTAD?

type_identity技法:

template <class T>
class my_smart_ptr{
public:
	my_smart_ptr(T* ptr)
	:ptr_(ptr){}
	//...
T* ptr_;
};

my_smart_ptr msp(new int[10]); //compiler, bad
  • 上面是一个非常简陋的智能指针,如果我们不禁用CTAD,那么就会将数组变成普通指针,bad。
  • 我们的手法是利用type_identity,
template <class T>
struct type_identity{
	using type = T;
};
template <class T>
using type_identity_t = typename type_identity<T>::type;
  • type_identity是C++20才引入的一个模板元函数,将你传入的类型返回给你。very easy。
template <class T>
class my_smart_ptr{
public:
	my_smart_ptr(type_identity_t<T>* ptr)
	:ptr_(ptr){}
	//...
T* ptr_;
};

my_smart_ptr msp(new int[10]); //error ,not compile
  • 现在该调用不会编译。

  • 当编译器尝试去编译时,采取CTAD,但是它发现了type_identity_t也是一个模板,CTAD不会去实例化另外的模板来推断当前的模板。所以,编译器会停下来,告诉你“sorry,type_identity_t是个模板,我无法实例化这个来推断T的类型,所以,error。”

  • 这就是我们常说的“non-deduction情况”(此术语来自《C++template》一书)。

  • 显式的调用则会起效果,因为type_identity_t< T >就是T。

  • type_identity是相当厉害的手法,它还适用于以下情况:

  1. 普通函数的限制
template <class T> //普通函数
void fun(type_identity_t<T> t){}

fun(1);     //error !
fun<int>(1) // ok!
  1. 它还可以选择参数限制,就是只限制某些参数的推断。
template <class T, class U> //普通函数
void fun(type_identity_t<T> t, U d){}

fun(1, 3.14);     //error !
fun<int>(1, 3.14) // ok!
fun<int, double>(1, 3.14); //ok !

typedef手法:

template <class T>
class my_smart_ptr{
public:
	using pointer = T*;
	my_smart_ptr(pointer ptr)
	:ptr_(ptr){}
	//...
T* ptr_;
};

my_smart_ptr msp(new int[10]); //bad, compile
  • 简单的typedef是不管用的,因为using指代的东西仍然是类内部的类型。
template <class T>
class my_smart_ptr{
public:
	using pointer = add_pointer_t<T>*;
	my_smart_ptr(pointer ptr)
	:ptr_(ptr){}
	//...
T* ptr_;
};

my_smart_ptr msp(new int[10]); //ok, not compile
  • 仅仅需要一步简单的变换,就又变成了non-deduction情况。此时,符合我们的要求。

template member手法:

template <class T>
class my_smart_ptr{
public:
	template <class U> //模板成员
	my_smart_ptr(U* ptr)
	:ptr_(ptr){}
	//...
T* ptr_;
};

my_smart_ptr msp(new int[10]); //ok, not compile
  • 编译器使用CTAD时,它会说,ok,我能推断出U的类型,但是我无法推断出T的类型,因为你有两个模板参数。虽然我们知道U和T是有关系的,但是在CTAD期间,编译器不会去考虑。

模板元编程技法

std::function

void fun();

struct Test{
void operator(){}
};

std::function f1(&fun);
std::function f2(Test());
std::function f3([](){});

template <class Ret, class ... Args> //函数指针
function(Ret(*)(Args...))->function<Ret(Args)>;
  • std的function提供了这个deduction guide,可以看到,deduction guides的模板可以和该类不同,甚至比该类还要多。
  • 如果传入的类型是函数指针,那么推断为函数类型。

deduction guides && SFINAE

  • 另外一个关于deduction guide非常重要的就是,它支持SFINAE。
template <class T>
class vector{
	template <class Iter>
	vector(Iter first, Iter last);
	
	vector(size_t n, const T& value = T());
	//...
};

template <class Iter> //deduction guides
vector(Iter, Iter)->vector<typename iterator_traits<Iter>::value_type>;

vector v(1, 2);
  • 当编译器尝试去初始化v时,它如果采用deduction guide,但是int没有iterator_traits,触发SFINAE,编译器不会报错,而是默默的将该函数丢弃,去寻找下一下。
  • 最终构造了一个大小为1,元素为2的vector。

让我们更细致的考虑:

  • 实际上vector的构造函数只适用于Input迭代器,如果你传入一个output迭代器,那么不会奏效。
  • 我们也可以使用SFINAE来实现。
template <class Iter>
vector(Iter first, Iter last)
->vector<
	 enable_if_t<
	 	  is_base_if_v<
	 	    	input_iterator_tag,
	 	    		typename iterator_traits<Iter>::iterator_catagory>,
	 	    			typename iterator_traits<Iter>::value_type>>;
  • 这是常见的enable_if技术,如果Iter时input_iterator,那么就可以使用该构造函数。如果不是的,那么触发SFINAE。

std::array

  • 另外一个跟SFINAE有联动的就是array。
template <class T, size_t N>
class array{
	T data_[N];
	//...
};

array arr{1, 2, 3, 4}; //ok, array<int, 4>;
  • array的简化实现类似上面,有一个类型模板参数,有一个非类型模板参数,表示array的大小。
  • 我们依然可以推导出arr的类型为array<int, 4 >,so,CTAD不可能独立完成这一点,因为它无法推导N为多少,我们需要deduction guide。
template <class T, class ... Args>
array(T, Args...)->array<T, 1 + sizeof...(Args)>;

array arr{1, 2, 3, 4}; //well !
array arr1{1, 2.0}; // bad !
  • 这样我们就能推导出N的大小.
  • 但是,如果我们传入不同的类型,CTAD会推导出T为int,然后将2.0转换成int,但是这不是我们想要的。我们想让它和初始化列表有一样的行为,让这个失败。
  • 怎么样让这个失败?
template <class T, class...Args>
array(T, Args...)->array<
	enable_if_t<
	conjunction_v<is_same<T, Args>...>, T>, 1 + sizeof...(Args)>;

or

template <class T, class...Args>
array(T, Args...)->array<
	enable_if_t<(is_same_v<T, Args> && ...), T>, 1 + sizeof...(Args)>;

trap:

  • CTAD的第一步只会考虑主模板。
template <class T>
struct Foo{};

template <>
struct Foo<int>{
	Foo(int){}
};

Foo f(1);  //error !
  • 在CTAD的第一阶段,类型推导阶段,编译器只会去考虑主模板。so,即使你的特化模板有一个关于int的构造函数,它也不会去考虑。
  • 但是,这不一样是该对象实例化的地方。
template <class T>
struct Foo{
	Foo(T){}
};

template <>
sruct Foo<int>{
	Foo(const double&){}  //这个版本将会被调用
};

Foo f(1);  //Foo<int>
  • 在CTAD的第一阶段,f被推导为Foo< int >。但是在第二步,编译器已经知道了你的类型。他会发现Foo有对int的特化模板,所以它会调用特化模板的const double&来初始化f !!

  • 建议,特化模板的构造函数的标签,也要与主模板保持一致。

more

  • CTAD不仅仅在声明中可以使用,
auto* p = new std::pair{3, 3.14}; // new表达式, ok

std::mutex mtx;
auto lock = lock_guard(mtx); //函数式的类型转换, ok
  • CTAD不会和指针或者引用一起工作,
std::pair p{3, 3.14};
std::pair& pref{p}; //error !

auto& pref{p};  //ok, 使用auto
  • CTAD不能用于raw的智能指针初始化,但是其他的构造函数依然可以使用CTAD。
auto uptr1 = std::make_unique<Person>("zzh", 1);

//ok, unique_ptr<Person>
std::unique_ptr uptr2{std::move(uptr1)};
//ok, shared_ptr<Person>
std::shared_ptr sptr2{std::move(uptr2)};
//...
  • deduction guides 不必匹配任何构造函数。
  • deduction guides仅仅用于CTAD的类型推导,它可以和构造函数不一样。因为deduction guides永远不会像函数一样被调用。
//deduction guide:
template <class T, class Deleter>
shared_ptr(std::unique_ptr<T, Deleter>)->shared_ptr<T>;
// constructor:
template <class T, class Deleter>
shared_ptr(std::unique_ptr<T, Deleter>&& uptr);
  • deduction guide会获取一个unique_ptr的拷贝,但是unique_ptr不支持拷贝构造。真正的构造函数是右值引用,但是deduction guide仅仅用于类型推断,它永远也不会调用unique_ptr的拷贝构造。
  • 实际上这是一种实现手法,当你有一个构造函数是const 引用,一个是右值引用。
  • 那么你不必实现两个deduction guides,一个是const 引用,一个是右值引用。
  • 可以只实现一个deduction guide,使用值传递,这个deduction guide适用两种情况。然后推导出类型之后再决定调用哪一个。
  • 这样写,更方便。

deduction guides可以不是模板:

template <class T>
struct Foo{
T name_;
Foo(T name) : name_(name) {}
//...
};

Foo f("zzh"); //推导出Foo<const char*>,但是我们想要Foo<string_view>
  • 上面是一个简单的Foo类,如果你给我一种const char*,我不想要这种类型,我想要string_view,因为string_view更好一些,那么就可以这样
template <class T>
struct Foo{
T name_;
Foo(T name) : name_(name) {}
//...
};
Foo(const char*)->Foo<string_view>; //非模板

Foo f("zzh"); //推导出Foo<string_view>;
  • 但是不推荐这样写,因为这样的调用不明显。
  • 当我们能够从调用语句中明显的看出来将会推导出什么类型时,才会适用deduction guides。

C++20可能对CTAD的修改

1. aggregates需要显式的deduction guide。

template <class T>
struct Name{
	T first, last;
};

Name<std::string> name{"zz", "zh"}; //ok,初始化
Name name{"zz", "zh"}; // error !
template <class T>
struct Name{
	T first, last;
};
template <class T>
Name(T, T)->Name<T>; //这是必须的

Name name("zz", "zh");  //ok
  • aggregates是C++一种特殊的对象,没有显式构造函数,所以成员变量都是public的,还有很多限制。aggregates支持花括号初始化,但是这不是构造函数。
  • 没有显式的deduction guides,CTAD不会工作。因为在第一阶段,编译器找不到构造函数。用花括号包起来的不是构造函数,只是一种初始化方式!!所以不会被放入到重载集中。
Name name("zz", "zh"); //error!
  • 即使有显式的deduction guides,也不支持小括号。
  • 在第一阶段,编译器推导出name的类型为const char*。
  • 在第二阶段,编译器发现Name没有构造函数,你使用的是小括号,不是花括号,不是aggregate初始化,所以error。

经过我的测试,在C++20中,VS和GCC都支持了aggregate的小括号初始化方式,但是clang仍然不支持。这三者都不支持没有显式deduction guides的花括号初始化。

2,CTAD不支持模板别名

template <class T>
struct Foo{
	Foo(T t) {
		cout << t;
	}
};

template <class T>
using Foo_ = Foo<T>;  //template alias


int main() {
	Foo f(1);    //ok
	Foo_ f1(1); //error
	return 0;
}
  • 经过我的测试,在C++20中,gcc竟然支持了CTAD的模板别名。
  • clang和VS都不支持,clang的错误信息非常的明显,
    错误信息

3,CTAD看不到继承的构造函数,你需要显式提供deduction guides。

  • 我也不知道这点是什么意思.

不支持部分的CTAD,C++20仍不支持。

std::tuple<std::string> t{"zzh", 1, 3.14}; //error
  C++知识库 最新文章
【C++】友元、嵌套类、异常、RTTI、类型转换
通讯录的思路与实现(C语言)
C++PrimerPlus 第七章 函数-C++的编程模块(
Problem C: 算法9-9~9-12:平衡二叉树的基本
MSVC C++ UTF-8编程
C++进阶 多态原理
简单string类c++实现
我的年度总结
【C语言】以深厚地基筑伟岸高楼-基础篇(六
c语言常见错误合集
上一篇文章      下一篇文章      查看所有文章
加:2022-02-24 15:05:50  更:2022-02-24 15:06:18 
 
开发: 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 7:49:08-

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