当前位置: 首页 > article >正文

qt - 隐式共享与d-pointer技术

文章目录

  • 前言
  • 1. 隐式共享
  • 2. d-pointer在隐式共享中的应用
  • 3. 二进制代码兼容
  • 4. d-pointer模式的实现
  • 5. QObject中的d-pointer


前言

一般情况下,一个类的多个对象所占用的内存是相互独立的。如果其中某些对象数据成员的取值完全相同,我们可以令它们共享一块内存以节省空间。只有当程序需要修改其中某个对象的数据成员时,我们再为该对象分配新的内存。这种技术被称为隐式共享(implicit sharing)。该技术被Qt库广泛使用,接下来,介绍该技术,并剖析QString的部分源代码以演示该技术的具体实现。

通常情况下,与一个类密切相关的数据会被作为数据成员直接定义在该类中。然而,在某些场合下,我们会将这些数据从该类(被称为公类)分离出来,定义在一个单独的类中(被称为私类)。公类中会定义一个指针,指向私类的对象。在计算机的发展历史中,这种模式被称为pointer to implementation (pimpl),handle/body或者cheshire cat。这种模式起初由Trolltech公司的职员Arnt Gulbrandsen引入到Qt中。由于这个指针实质上指向了一个类的数据,Qt程序员常将其命名为d_ptr或者d。Qt文档以及本书将其称为d-pointer。

d-pointer模式具有多种用途。第2节讨论d-pointer在隐式共享中的作用。第3节介绍如何使用d-pointer维持Qt库的二进制兼容性。第4节给出d-pointer模式的具体实现。QObject的许多派生类都利用了d-pointer模式,第5介绍如何在QObject中实现该模式,以供其所有的派生类共享这个功能。

1. 隐式共享

隐式共享(implicit sharing)的目的在于节省内存、提高程序运行速度。如图1所示,设对象O1、O2、O3的部分数据成员具有相同的取值。为了节省内存,我们用一个内存块A来存放这些数据成员,每个对象内部有一个指针,指向这个内存块。设此时有一个O3来复制构造O4的操作。由于O4和O3此时具有相同的数据成员,所以O4也可以和O1~O3共享内存块A。此后,程序请求修改O4的数据成员。由于逻辑上O1、O2、O3、O4是相互独立的对象,所以我们不能够直接修改内存块A中的数据,否则,会影响O1~O3的数据。内存块A中的数据被复制到一个新的内存块B,对O4的修改会施加到内存块B中存放的数据。这就是所谓“写时复制(copy-on-write)”名称的来源。这种技术能够在逻辑上保证各个对象是相互独立的。同时,在物理实现上,只要某些对象的数据成员值相同,则它们就会共享内存,以节省内存资源。

在这里插入图片描述
图1 隐式共享技术
除了能够节省内存之外,这种技术还可以提高程序运行速度。设想我们需要用O3来构造一个对象O5。如果O3、O5各自使用独立的内存块来存放数据,则这个构造操作需要将O3中的数据完全复制到O5中,这需要较长的运行时间。而采用了隐式共享技术后,只需要设置O5中的一个指针,以指向被共享的数据块,这个操作的执行速度会很快。

当然,采用这种技术需要一些额外的内存管理工作。在图1中,设想客户要析构对象O3。我们不能够简单地将O3所用的内存块A释放,因为O1、O2还在使用这个内存块。当只有一个对象使用内存块A时,在析构该对象时才可以释放内存块A。为此,我们需要维护一个引用计数(reference counter)。每当有一个新的对象需要共享该内存块时,该内存块对应的引用计数被加1,每当共享该内存块的一个对象被析构时,该引用计数被减1。如果减1后为0,则说明已经没有任何对象需要使用该内存块,该内存块被释放

我们以QString为例来剖析隐式共享的实现。QString有一个成员函数toCaseFolded(),能够将一个字符串中的所有字符变为小写的。设有下面的语句:

      QString s1 ="HELLO world!";
      QString s2 = s1.toCaseFolded();

这两行执行完后,s2中的内容为“hello world!”,而s1的内容不变,仍然为“HELLO world!”。由于s1和s2具有不同的数据内容,因而必须为s2分配独立的存储空间来存放修改后的字符数据。然而,如果s1中的字符串本身已经为小写的“hello world!”,转换后的字符串不变,我们就可以让s2和s1共享一个内部数据区。

成员函数toCaseFolded()采用了这一技术,其源代码如代码段1所示。行①的d是QString的成员变量,指向一块内存,其中存放着QString对象所有的属性信息。也就是说,QString并没有直接使用它的成员变量来存放其属性信息,这种技术被称为d-pointer模式,第2节将详细讨论这种技术。d->size表示字符串的长度。值为0表示空串,无须转换,直接将当前对象返回。

代码段1,采用隐式共享技术的QString::toCaseFolded( )

      QString QString::toCaseFolded() const
      {
            if (!d->size)return *this;const ushort *p = d->data;
            if (!p) return *this;
            const ushort *e = d->data + d->size;
            uint last = 0;
            while (p < e) {
              ushort folded = foldCase(*p, last);if (folded != *p) {                 ③
                    QString s(*this);            ④
                    s.detach();                   ⑤
                    ushort *pp = s.d->data + (p - d->data);
                    const ushort *ppe = s.d->data + s.d->size;
                    last = pp > s.d->data ? *(pp -1) : 0;
                    while (pp < ppe) {
            			*pp = foldCase(*pp, last);
            			++pp;
                    }
                    return s;}
              p++;
            }
            return *this;
      }

行②求取当前对象中某一个字符的小写形式,行③判断该小写形式是否和原字符相同。如果不同,则说明成员函数toCaseFolded()将要返回的对象和当前对象具有不同的字符数据,因而无法再和当前对象共享内存块。

行④用当前对象创建一个新的对象s,这调用了QString的复制构造函数,如代码段8-2所示。行⑦使得当前对象和新对象的d-pointer指向同一块内存,导致两个对象共享相同的字符串数据区。而行⑧增加一个引用计数,表示有了一个新对象s需要引用字符串数据区。

代码段2,QString的复制构造函数

      inline QString::QString(const QString &other) : d(other.d)  (7)
      {
      	Q_ASSERT(&other != this);
      	d->ref.ref();        (8)
      }

但是,由于新对象s将要存放和当前对象相异的字符数据,故这种共享是暂时的。行⑤的detach()调用立即切断了s和当前对象之间的共享关系,使得s拥有了自己的内存块。此后的几行代码对当前对象的后续字符串做大小写转换操作,将操作结果存放在s的新内存块中。行⑥返回该对象s。

2. d-pointer在隐式共享中的应用

直接定义一个类的数据成员无法实现隐式共享。设想我们要定义一个类来实现3x3矩阵的存储与操作,传统的定义方式如代码段3所示。矩阵存放在类Matrix的私有数据成员data中。每个Matrix的对象被创建时就拥有这样一个数据区域。即使多个对象具有完全相同的矩阵元素,它们也无法共享一块内存区域。

代码段3,类Matrix的传统定义方式

      class Matrix {
      public:
            Matrix() {
                  memset(data, 0, sizeof(data));
            }
            //…
      private:
            double data[3][3];
      }

为了能够共享数据,我们必须将类中的数据分离出来,定义在一个单独的类中,再定义一个指针指向这个新类。这个指针就被称为d-pointer,这个模式就被称为d-pointer模式。包含有d-pointer的那个类被称为主类,d-pointer所指的那个类被称为从类。Qt常在主类的名字后面加上后缀“Private”或者“Data”形成从类的名字。应用了d-pointer模式后,类Matrix的定义与实现参见代码段4。

矩阵数据实际存放在从类MatrixData中。如果多个主类对象具有完全相同的矩阵元素,这些对象共享一个从类对象。从类对象的数据成员refCount记录着有多少个主类对象共享该从类对象。

主类的复制构造函数令新创建的主类对象共享已有的从类对象,如行①的构造函数所示。只有当用户使用形如“matrix(row,col)”方式对矩阵第row行、第col列元素进行访问时,重载后的“()”运算符(行③)才会调用detach函数为一个主类对象创建新的从类对象。

当某个主类对象被析构时,会将该计数器减1。如果此时的计数器仍大于0,表示还有其他主类对象引用该从类对象,因而从类对象不被析构。反之,如果此时计数器变为0,表示该从类对象不被任何主类对象引用,应该被析构,如行②的析构函数所示。

代码段4,应用d-pointer模式的类Matrix

      #include <iostream>
      using namespace std;
      class Matrix;
      class MatrixData {
            int refCount;
            double data[3][3];
            friend Matrix;
      public:
            MatrixData(){
              memset(data, 0, sizeof(data));
            }
      };
      class Matrix {
      public:
            Matrix() {
              d = new MatrixData;
              d->refCount = 1;
            }
            Matrix(const Matrix & other ) {     ①
              d = other.d;
              d->refCount++;
            }
            ~Matrix() {if ( --d->refCount == 0 )
                        delete d;
            }
            double & operator()(int row, int col){detach();
              return d->data[row][col];
            };
            void detach(){
              if ( d->refCount <= 1 ) return;
              d->refCount--;
              d = new MatrixData( *d );
              d->refCount = 1;
            }
      private:
            MatrixData * d;
      };
      int main()
      {
            Matrix m1;
            Matrix m2(m1),m3(m1);
            m3(0,0)=10;
      }

3. 二进制代码兼容

一个Qt应用程序可以使用静态链接或者动态链接方式和Qt库链接。所谓静态链接,是指所有被调用的Qt函数的代码都会被包含在应用程序的可执行代码文件中。应用程序可以直接运行,不再需要其他软件模块。而动态链接是指被调用的Qt函数的代码被封装在一些共享库中。当应用程序运行时,将当前被调用的Qt函数的代码从该共享库中读取出来,加载到内存中再执行。在Windows操作系统中,共享库常常以动态链接库(Dynamically Linked Library,DLL)形式出现。以默认参数编译Qt源代码得到的是以动态链接方式的Qt库。所生成的DLL存放在q:\lib目录下。

采用动态链接方式有以下优点。

(1)节省存储空间。如果一个计算机系统中含有多个Qt应用程序,这些应用程序可以共享动态链接库中的函数代码,不必在每个应用程序的可执行文件中重复存放Qt函数代码。另外,仅当某个Qt函数被执行时,其代码才会被调入到内存中,这也节省了内存空间。

(2)有利于Qt库的部署。如果采用静态链接方式,每当Qt库的源代码更新时,需要重新编译每个Qt应用程序,再将应用程序部署到客户机器上。而采用动态链接方式时,客户只需要更新Qt的动态链接库,不需要重新编译、部署Qt应用程序。如果在这种情形下这些Qt应用程序仍然能够正常工作,我们称这个动态链接库是二进制兼容的(binary compatible)。

C++程序员需要遵守很多规则才可以保证一个动态链接库是二进制兼容的。其中一条规则如下。程序员在更新一个库的源代码时,可以自由地更改私类的数据成员,包括添加新的数据成员,删除原有的数据成员,调整数据成员的定义顺序,更改数据成员的数据类型,这些更改不会破坏该库的二进制兼容性。但是,对于公类,程序员只可以添加新的静态数据成员(static data members),不可以更改非静态的数据成员(non-static data members)。也就是说,程序员不可以添加、删除非静态数据成员,不可以更改非静态数据成员的定义顺序、类型。

我们以Qt中的类QLocale为例来说明这个规则。该类能够将一个日期时间信息转换为各种自然语言中表示该信息的字符串。比如,2010年12月8日早上11点49分这个信息转换到英文为“Wednesday, December 8, 2010 11:49:00 AM”,转换到德文为“Mittwoch, 8. Dezember 2010 11:49:00”,转换到日文为“2010年12月8日水曜日11時49分00秒”。

在Qt 4.5版本中,类QLocale的定义如代码段5所示。枚举类型Language定义了所能够处理的自然语言的名称,枚举类型Country定义了所能够处理的国家的名称。成员函数toString()将QDateTime对象中的日期、时间信息转换为一个字符串,以QString对象的形式返回。该类使用了一个d-pointer指向私类QLocalePrivate的一个对象。其实现细节比一般d-pointer的要复杂,读者可参考qlocale.h以及qlocale.cpp了解其细节。无论该d-pointer是如何实现的,行①的成员函数d()能够返回指向私类对象的指针。由于采用了d-pointer模式,类QLocale没有定义其他任何私有数据成员,这些数据成员都被放置在私类QLocalePrivate中。

代码段5,Qt 4.5版本中类QLocale的定义

      struct QLocalePrivate;
      class QLocale
      {   ……
      public:
          enum Language{……,Chinese=25,……Chewa=165,  LastLanguage=Chewa};
          enum Country { AnyCountry=0,Afghanistan = 1,……,  China = 44,……
              SerbiaAndMontenegro = 241, LastCountry = SerbiaAndMontenegro
          }
          ……
          QLocale(Language language, Country country = AnyCountry);……
          QString toString(const QDateTime &dateTime,
                            FormatType format = LongFormat) const;……
      private:
          ……
          const QLocalePrivate *d() const;};

Qt 4.5版本中,私类QLocalePrivate的定义如代码段6所示。行②之后定义了一些类型为quint32的成员变量。但是,在Qt 4.8.1版本中,这些quint32类型都被更改为quint16类型,其中quint32表示无符号32位整数,quint16表示无符号16位整数。这个更改是合理的。以成员变量m_language_id为例,新版本中的quint16类型已经能够表示65536个不同的language id,远大于目前世界上自然语言的数量,没有必要使用老版本的quint32类型。

代码段6,Qt 4.5版本中类QLocalePrivate的定义

      struct  QLocalePrivate
      {    ……
      public:
            QString dateTimeToString(const QString &format, const QDate *date,
                          const QTime *time, const QLocale *q) const;
            quint32  m_language_id, m_country_id;                 ②
            quint16  m_decimal, m_group, m_list, m_percent,
                      m_zero, m_minus, m_plus, m_exponential;
            /* 以下省略36个类型为quint32的成员变量*/
            ……
      }

这个变更不会破坏Qt库的二进制兼容性,最根本的原因是Qt应用程序从来不会直接在其源代码中创建、访问QLocalePrivate对象,该对象的构造、访问、析构都是在Qt的动态链接库中进行的。Qt库的开发者修改了QLocalePrivate的定义之后,会重新编译Qt库并发布新的动态链接库。新的动态链接库会以统一的、新的方式构造、访问、析构QLocalePrivate对象。应用程序通过公类QLocale间接地使用QLocalePrivate对象,因而无须重新编译应用程序,确保了Qt库的二进制兼容性。

总之,C++库的开发者可以利用d-pointer技术,将本应属于公类的数据成员剥离出来放置到另外一个私类中。更新库源代码时,开发者可以自由变更私类中的数据而不必担心破坏库的二进制兼容性。

4. d-pointer模式的实现

实现d-pointer模式时还需要考虑其他一些因素。公类使用d_pointer可以访问私类的成员。有时,私类的成员函数需要访问公类的成员,比如公类中定义的信号。这需要在私类中定义一个逆向的指针,Qt将这个指针称为q-pointer。

代码段9给出了一个具体例子。公类MyClass定义了一个信号,而私类的成员函数foobar()需要找到对应的公类对象,并触发这个信号。为此,私类MyClassPrivate在行①定义了一个指针q_ptr,指向该公类对象。

代码段9,实现d-pointer模式的一个例子

      class MyClassPrivate;
      class MyClass: public QObject  {
      public:
          MyClass(QObject *parent = 0);
          virtual ~MyClass();
          void dummyFunc();
      signal:
          void dummySignal();
      private:
          MyClassPrivate * const d_ptr;
          Q_DECLARE_PRIVATE(MyClass);       (2)
      };
      
      class MyClassPrivate{
      public:
          MyClassPrivate(MyClass *parent): q_ptr(parent) { }
          void foobar(){
          Q_Q(MyClass);
          emit q->dummySignal();
      	 }
      private:
          MyClass * const q_ptr;                (1)
          Q_DECLARE_PUBLIC(MyClass);
      };
      
      MyClass::MyClass(QObject *parent)
      : QObject(parent), d_ptr(new MyClassPrivate(this))
      {
      }
      
      MyClass::~MyClass()
      {
          Q_D(MyClass);
          delete d;
      }
      
      void MyClass::dummyFunc()
      {
          Q_D(MyClass);
          d->foobar();
      }

在这段代码中,公类MyClass定义了一个指针d_ptr来访问私类MyClassPrivate,而私类定义了一个指针q_ptr来访问公类。在这个简单的例子中,公类和私类本可以通过这两个指针非常方便地访问对方的数据。但是,在一些复杂的场合下,这两个指针并不是直接定义在公类、私类中的,而是被定义在它们的基类中。此时,就需要用到Qt定义的4个宏:Q_DECLARE_PRIVATE、Q_DECLARE_PUBLIC、Q_D以及Q_Q。它们的定义如代码段10所示。

代码段8-10,Qt中与d-pointer模式相关的宏

      #define Q_DECLARE_PRIVATE(Class) \
          inline Class##Private* d_func() {  \      (3)
                      return reinterpret_cast<Class##Private *>(d_ptr); } \
          inline const Class##Private* d_func() const { \
                      return reinterpret_cast<const Class##Private *>(d_ptr); } \
          friend class Class##Private;
      #define Q_DECLARE_PUBLIC(Class)  \
          inline Class* q_func() { \
                      return static_cast<Class *>(q_ptr); } \
          inline const Class* q_func() const { \
                      return static_cast<const Class *>(q_ptr); } \
          friend class Class;
      #define Q_D(Class) Class##Private * const d = d_func()
      #define Q_Q(Class) Class * const q = q_func()

我们首先来分析Q_DECLARE_PRIVATE的作用。代码段9行②以“Q_DECLARE_PRIVATE(MyClass)”形式调用这个宏,宏扩展后的结果如代码段11所示。其作用是在公类中定义了一个成员函数d_func,返回一个指针,指向对应的私类。本例中,公类本身定义了一个指向私类的指针d_ptr,所以Qt应用程序可以直接使用这个指针,而不必调用d_func来获取这个指针。但是,在本节后续部分我们将看到,Qt中大部分公类具有共同的基类QObject,这些公类对应的私类具有共同的基类QObjectPrivate。QObject定义了d_ptr指针,其他公类继承这个指针。所继承的指针在语法上指向QObjectPrivate,所以需要在成员函数d_func()中将这个指针转换为指向对应私类的指针。注意代码段10行③中的“##”表示宏调用时,该行的“Class##”将被替换为实际宏参数的名字。宏Q_DECLARE_PUBLIC具有类似的作用,不再赘述。

代码段8-11,宏Q_DECLARE_PRIVATE展开后的结果

      inline MyClassPrivate* d_func() {
            return reinterpret_cast<MyClassPrivate *>(d_ptr);
      }
      inline const MyClassPrivate* d_func() const {
            return reinterpret_cast<const MyClassPrivate *>(d_ptr);
      }
      friend class MyClassPrivate;

当公类对象需要访问私类对象中的数据时,可以使用代码段8-10的宏Q_D,将成员函数d_func()返回的指针重新命名为更加简洁的“d”。而宏Q_Q具有类似的功能,也就是将成员函数q_func()返回的指针重新命名为更加简洁的“q”。

总之,一方面,Qt在公类中定义了一个指针d_ptr指向私类,在宏Q_DECLARE_PRIVATE中定义了一个函数获取这个指针,用宏Q_D将这个指针重新命名为d,以便于访问私类对象。另一方面,Qt在私类中定义了一个指针q_ptr指向公类,在宏Q_DECLARE_PUBLIC中定义了一个函数获取这个指针,用宏Q_Q将这个指针重新命名为q,以便于访问公类对象。

5. QObject中的d-pointer

QObject的多个派生类都使用了d-pointer模式。与其在每个类中实现一次d-pointer模式,不如在基类QObject中实现一次,再让这些类继承这个功能。以类QWidget为例,如图3所示,类QObject用指针d_ptr指向类QObjectData,而后者用指针q_ptr指回类QObject。作为QObjectData的子类,QObjectPrivate继承了q_ptr,因而QObject和QObjectPrivate就形成了d-pointer模式中公类与私类之间的关系。由于QObject的特殊性,其私类的部分数据需要公开给Qt应用程序。这部分数据被放置在QObjectData中,该类被公开给Qt应用程序,而其他数据仍然被放置在QObjectPrivate中。
在这里插入图片描述
图3 用QObject实现d-pointer模式

具体实现如代码段12所示。行③定义的指针d_ptr语法上指向QObjectData。行①扩展后所得的函数d_func()将会令这个指针指向QObjectPrivate。行②用一个私类对象来构造对应的公类对象,某些QObject的子类(如下文将要讨论的QWidget)会使用这种方式。由于QObjectPrivate对应用程序来说是不可见的,所以这个构造函数被定义为protected。

代码段12,QObject及QObjectData的定义

      class QObjectData {
      public:
          virtual ~QObjectData() = 0;
          QObject *q_ptr;
          ……
      }
      class QObject {
          Q_DECLARE_PRIVATE(QObject)   (1)
      public:
          QObject(QObject *parent=0);
          protected:
          QObject(QObjectPrivate &dd, QObject *parent = 0);     (2)
          QObjectData *d_ptr;           (3)
          ……
      }

公类QObject和私类QObjectPrivate使用相关的宏来使用d_ptr以及q_ptr,如代码段13所示。行①的宏扩展后,成员函数q_func()返回一个指针,指向公类QObject。行②的构造函数创建一个私类对象以及一个公类对象,并设置两者的d_ptr以及q_ptr指向对方。行③的构造函数不同,它只构造公类对象,而私类对象已经存在。该函数也会修改这两个对象的d_ptr以及q_ptr以指向对方。

代码段13,类QObject及QObjectPrivate对d-pointer的使用

      /* 取自S:\corelib\kernel\qobject_p.h */
      class QObjectPrivate : public QObjectData
      {
      Q_DECLARE_PUBLIC(QObject)        (1)
      ……
      }
      /* 取自S:\corelib\kernel\qobject.cpp */
      QObject::QObject(QObject *parent) : d_ptr(new QObjectPrivate)      (2)
      {
      Q_D(QObject);
      …d_ptr->q_ptr = this…
      ……
      }
      QObject::QObject(QObjectPrivate &dd, QObject *parent) : d_ptr(&dd) (3)
      {
      Q_D(QObject);
      …d_ptr->q_ptr = this…
      ……
      }

作为QObject/QObjectPrivate的派生类,QWidget及QWidgetPrivate自动具有公类/私类关系,不必重复实现d-pointer模式。如代码段14所示,行①的宏展开后,成员函数d_func()实际上将基类QObject中定义的d_ptr转换为一个类型为QWidgetPrivate的指针,而行②的宏展开后,成员函数q_func()将基类QObjectData中定义的q_ptr转换为一个类型为QWidget的指针。行③的构造函数显示调用QObject的构造函数,以正确设置QWidget及QWidgetPrivate中的d_ptr及q_ptr,使两者相互指向对方。

代码段14,QWidget继承了QObject的d-pointer模式

      /*  取自S:\gui\kernel\qwidget.h   */
      class QWidgetPrivate;
      class QWidget : public QObject, public QPaintDevice
      {
      Q_DECLARE_PRIVATE(QWidget)       (1)
      explicit QWidget(QWidget* parent = 0, Qt::WindowFlags f = 0);
      ……
      }
      /*  取自S:\gui\kernel\qwidget_p.h   */
      class Q_GUI_EXPORT QWidgetPrivate : public QObjectPrivate
      {
      Q_DECLARE_PUBLIC(QWidget)        (2)
      ……
      }
      /*  取自S:\gui\kernel\qwidget.cpp   */
      QWidget::QWidget(QWidget *parent, Qt::WindowFlags f)
      : QObject(*new QWidgetPrivate, 0), QPaintDevice()     (3)
      {
      ……
      }

http://www.kler.cn/a/10272.html

相关文章:

  • Snort的配置与使用
  • 双十一云服务器抢购后,用SD-WAN连通多云网络
  • Redis五种数据类型剖析
  • 漏洞挖掘 | 某医院小程序支付漏洞+越权
  • 基于STM32的智能充电桩:集成RTOS、MQTT与SQLite的先进管理系统设计思路
  • Axure是什么软件?全方位解读助力设计入门
  • Android 11.0 原生SystemUI下拉通知栏UI背景设置为圆角背景的定制(二)
  • ptuning v2 的 chatglm垂直领域训练记录
  • 银行数字化转型导师坚鹏:金融科技与数字化转型成功案例
  • 腾讯云服务器TencentOS系统安装宝塔Linux面板命令
  • 软件管理 基础配置(运维笔记)
  • 所有知识付费都可以用 ChatGPT 再割一次?
  • 设计师都在用的6个免费设计素材网站~
  • 【C++】内联函数理解
  • d修复导入c的问题
  • MySQL日志
  • 如何驯化生成式AI,从提示工程 Prompt Engineering 开始
  • Day939.如何小步安全地升级数据库框架 -系统重构实战
  • 银行数字化转型导师坚鹏:ChatGPT解密与银行应用案例
  • 鸟哥的Linux私房菜 学习 Shell Scripts
  • 【CSS】实现梯形
  • C语言实例:求一个整数的所有因数,创建各类三角形图案(代码+思路)
  • AB测试基本原理
  • t-SNE进行分类可视化
  • 【SpringMVC】7—文件上传
  • 详细讲讲Java线程的状态