• Qt扫盲-Qt Concurrent概述


    一、概述

    QtConcurrent 命名空间提供了一些高级api,可以在不使用互斥锁、读写锁、等待条件或信号量等低级线程原语的情况下编写多线程程序。使用QtConcurrent编写的程序会根据可用的处理器核数自动调整所使用的线程数。这意味着今天编写的应用程序在将来部署到多核系统上时将继续扩展。

    QtConcurrent包括用于并行列表处理的函数式编程风格api,包括用于共享内存(非分布式)系统的 MapReduce 和 FilterReduce 实现,以及用于管理GUI应用程序中的异步计算的类:

    • Concurrent Map 和 Map- reduce
      QtConcurrent::map() 对容器中的每个项应用一个函数,就地修改这些项 (不返回,原地修改)
      QtConcurrent::mapped() 类似于map(),不同之处在于它返回一个带有修改的新容器 (返回修改)
      QtConcurrent::mappedReduced() 类似于map(),不同之处在于修改后的结果被简化或折叠为单个结果。
    • Concurrent Filter和Filter- reduce
      QtConcurrent::filter() 根据过滤器函数的结果从容器中删除所有项。
      QtConcurrent::filtered() 类似于filter(),不同之处在于它返回一个包含过滤结果的新容器。
      QtConcurrent::filteredReduced() 类似于filtered(),不同之处在于过滤后的结果被简化或折叠为单个结果。
    • 并发运行 (Concurrent Run)
      QtConcurrent::run() 在另一个线程中运行函数。
    • QFuture 表示异步计算的结果。
    • QFutureIterator 允许通过QFuture得到的结果进行迭代。
    • QFutureWatcher 允许使用信号和插槽监控QFuture。
    • QFutureSynchronizer 是一个方便的类,可以自动同步多个QFutures。

    Qt Concurrent 支持几种与 stl 兼容的容器和迭代器类型,但最适合具有随机访问迭代器的Qt容器,如 QList 或 QVector。map 和 filter 函数接受容器和 begin/end 迭代器。

    STL迭代器支持概述:

    迭代器类型示例类支持状态
    输入迭代器不支持
    输出迭代器不支持
    前向迭代器std:: slist支持
    双向迭代器QLinkedList, std::list支持
    随机存取迭代器QList, QVector, std::vector支持和推荐

    在Qt Concurrent迭代大量轻量级项的情况下,随机访问迭代器可以更快,因为它们允许跳转到容器中的任何点。此外,使用随机访问迭代器允许 Qt Concurrent 通过 QFuture::progressValue() 和 QFutureWatcher::progressValueChanged() 提供进度信息。

    非就地修改函数,如 mapped() 和 filtered(),在调用时生成一个容器的副本。如果使用STL容器,此复制操作可能需要一些时间,在这种情况下,Qt 建议为容器指定 开始和结束 迭代器。

    二、Concurrent Map 和 Map- reduce

    QtConcurrent::map()、QtConcurrent::mapped()和QtConcurrent::mappedReduced()函数对序列(如QList或QVector)中的项并行运行计算。QtConcurrent::map()就地修改序列,QtConcurrent::mapped()返回一个包含修改内容的新序列,而QtConcurrent::mappedReduced()返回一个结果。

    这些函数是Qt Concurrent框架的一部分。

    上面的每个函数都有一个阻塞变量,它返回最终结果而不是 QFuture。我们可以像使用异步变量一样使用它们。

      QList<QImage> images = ...;
    
      // Each call blocks until the entire operation is finished.
      QList<QImage> future = QtConcurrent::blockingMapped(images, scaled);
    
      QtConcurrent::blockingMap(images, scale);
    
      QImage collage = QtConcurrent::blockingMappedReduced(images, scaled, addToCollage);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    注意,上面的结果类型不是QFuture对象,而是真正的结果类型(在本例中是QList和QImage)。

    1. 并发 Map

    QtConcurrent::mapped() 接受输入序列和映射函数。然后对序列中的每一项调用该映射函数,并返回一个包含映射函数返回值的新序列。

    map函数的格式必须是:

     U function(const T &t);
    
    • 1

    T 和 U可以是任何类型(它们甚至可以是相同的类型),但是T必须匹配存储在序列中的类型。函数返回修改或映射的内容。

    这个例子展示了如何将一个比例函数应用于一个序列中的所有项:

      QImage scaled(const QImage &image)
      {
          return image.scaled(100, 100);
      }
    
      QList<QImage> images = ...;
      QFuture<QImage> thumbnails = QtConcurrent::mapped(images, scaled);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    该 Map 的结果可通过QFuture获得。有关如何在应用程序中使用QFuture的更多信息,请参阅QFuture和QFutureWatcher文档。

    如果我们想就地修改序列,请使用QtConcurrent::map()。map函数必须是这样的形式:

     U function(T &t);
    
    • 1

    注意,没有使用map函数的返回值和返回类型。

    使用QtConcurrent::map()类似于使用QtConcurrent::mapped():

      void scale(QImage &image)
      {
          image = image.scaled(100, 100);
      }
    
      QList<QImage> images = ...;
      QFuture<void> future = QtConcurrent::map(images, scale);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    由于序列被就地修改,QtConcurrent::map()不会通过QFuture返回任何结果。但是,我们仍然可以使用QFuture和QFutureWatcher来监视 Map 的状态。

    2. 并发 Map-Reduce

    QtConcurrent::mappedReduced()类似于QtConcurrent::mapped(),但不是返回带有新结果的序列,而是使用reduce函数将结果组合成单个值。

    QFuture<ResultType> QtConcurrent:: mapappedreduced (const Sequence &sequence, MapFunctor mapFunction, ReduceFunctor reduceFunction, QtConcurrent::ReduceOptions ReduceOptions = ReduceOptions(UnorderedReduce | SequentialReduce))
    
    • 1

    含义:按顺序为每个Item 调用mapFunction一次。每个mapFunction的返回值传递给reduceFunction,最后获得一个结果。

    reduce函数的形式必须是:

    V function(T &result, const U &intermediate)
    
    • 1

    T是最终结果的类型,U是映射函数的返回类型。注意,这里没有使用reduce函数的返回值和返回类型。

    像这样调用QtConcurrent::mappedReduced():

      void addToCollage(QImage &collage, const QImage &thumbnail)
      {
          QPainter p(&collage);
          static QPoint offset = QPoint(0, 0);
          p.drawImage(offset, thumbnail);
          offset += ...;
      }
    
      QList<QImage> images = ...;
      QFuture<QImage> collage = QtConcurrent::mappedReduced(images, scaled, addToCollage);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    对于map函数返回的每个结果,reduce函数将被调用一次,并且应该将中间值合并到result变量中。

    QtConcurrent::mappedReduced() 保证一次只有一个线程调用reduce,所以没有必要使用互斥锁来锁定结果变量。

    ReduceOptions enum提供了一种方法来控制执行缩减的顺序。如果使用QtConcurrent::UnorderedReduce(默认值),则顺序是未定义的,而QtConcurrent::OrderedReduce确保按原始序列的顺序进行缩减。

    3. 其他API特性

    1. 使用迭代器而不是Sequence

    以上每个函数都有一个变体,它接受迭代器范围而不是序列。使用它们的方式与序列变体相同:

    QList<QImage> images = ...;
    
    QFuture<QImage> thumbnails = QtConcurrent::mapped(images.constBegin(), images.constEnd(), scaled);
    
    // Map in-place only works on non-const iterators.
    QFuture<void> future = QtConcurrent::map(images.begin(), images.end(), scale);
    
    QFuture<QImage> collage = QtConcurrent::mappedReduced(images.constBegin(), images.constEnd(), scaled, addToCollage);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    3. 阻塞变量

    上面的每个函数都有一个阻塞变量,它返回最终结果而不是QFuture。我们可以像使用异步变量一样使用它们。

      QList<QImage> images = ...;
    
      // Each call blocks until the entire operation is finished.
      QList<QImage> future = QtConcurrent::blockingMapped(images, scaled);
    
      QtConcurrent::blockingMap(images, scale);
    
      QImage collage = QtConcurrent::blockingMappedReduced(images, scaled, addToCollage);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    注意,上面的结果类型不是QFuture对象,而是真正的结果类型(在本例中是QList和QImage)。

    4. 使用成员函数

    QtConcurrent::map()、QtConcurrent::mapped()和QtConcurrent::mappedReduced()接受指向成员函数的指针。成员函数类类型必须与序列中存储的类型匹配:

      // Squeeze all strings in a QStringList.
      QStringList strings = ...;
      QFuture<void> squeezedStrings = QtConcurrent::map(strings, &QString::squeeze);
    
      // Swap the rgb values of all pixels on a list of images.
      QList<QImage> images = ...;
      QFuture<QImage> bgrImages = QtConcurrent::mapped(images, &QImage::rgbSwapped);
    
      // Create a set of the lengths of all strings in a list.
      QStringList strings = ...;
      QFuture<QSet<int> > wordLengths = QtConcurrent::mappedReduced(strings, &QString::length, &QSet<int>::insert);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    注意,当使用QtConcurrent::mappedReduced()时,你可以自由地混合使用普通函数和成员函数:

      // Can mix normal functions and member functions with QtConcurrent::mappedReduced().
    
      // Compute the average length of a list of strings.
      extern void computeAverage(int &average, int length);
      QStringList strings = ...;
      QFuture<int> averageWordLength = QtConcurrent::mappedReduced(strings, &QString::length, computeAverage);
    
      // Create a set of the color distribution of all images in a list.
      extern int colorDistribution(const QImage &string);
      QList<QImage> images = ...;
      QFuture<QSet<int> > totalColorDistribution = QtConcurrent::mappedReduced(images, colorDistribution, QSet<int>::insert);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    5. 使用函数对象

    QtConcurrent::map()、QtConcurrent::mapped()和QtConcurrent::mappedReduced()接受map函数的函数对象。这些函数对象可用于向函数调用添加状态。result_type typepedef必须定义函数调用操作符的结果类型:

      struct Scaled
      {
          Scaled(int size)
          : m_size(size) { }
    
          typedef QImage result_type;
    
          QImage operator()(const QImage &image)
          {
              return image.scaled(m_size, m_size);
          }
    
          int m_size;
      };
    
      QList<QImage> images = ...;
      QFuture<QImage> thumbnails = QtConcurrent::mapped(images, Scaled(100));
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    对于reduce函数,不直接支持函数对象。但是,当显式指定了缩减结果的类型时,可以使用函数对象:

      struct ImageTransform
      {
          void operator()(QImage &result, const QImage &value);
      };
    
      QFuture<QImage> thumbNails =
        QtConcurrent::mappedReduced<QImage>(images,
                                            Scaled(100),
                                            ImageTransform(),
                                            QtConcurrent::SequentialReduce);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    6. 包装接受多个参数的函数

    如果我们想使用接受多个参数的map函数,可以使用lambda函数或std::bind()将其转换为接受一个参数的函数。
    作为示例,我们将使用QImage::scaledToWidth():

      QImage QImage::scaledToWidth(int width, Qt::TransformationMode) const;
    
    • 1

    scaledToWidth接受三个参数(包括“this”指针),不能直接与QtConcurrent::mapped()一起使用,因为QtConcurrent::mapped()期望一个函数接受一个参数。为了使用QImage::scaledToWidth()和QtConcurrent::mapped(),我们必须提供一个宽度和转换模式的值:

      QList<QImage> images = ...;
      std::function<QImage(const QImage &)> scale = [](const QImage &img) {
          return img.scaledToWidth(100, Qt::SmoothTransformation);
      };
      QFuture<QImage> thumbnails = QtConcurrent::mapped(images, scale);
    
    • 1
    • 2
    • 3
    • 4
    • 5

    三、Concurrent Filter and Filter-Reduce

    QtConcurrent::filter()、QtConcurrent::filtered()和QtConcurrent::filteredReduced()函数对序列中的项进行并行过滤,比如QList或QVector。QtConcurrent::filter()就地修改序列,QtConcurrent::filtered()返回包含过滤内容的新序列,QtConcurrent::filteredReduced()返回单个结果。
    这些函数是Qt Concurrent框架的一部分。
    上面的每个函数都有一个阻塞变量,它返回最终结果而不是QFuture。您可以像使用异步变体一样使用它们。

      QStringList strings = ...;
    
      // each call blocks until the entire operation is finished
      QStringList lowerCaseStrings = QtConcurrent::blockingFiltered(strings, allLowerCase);
    
      QtConcurrent::blockingFilter(strings, allLowerCase);
    
      QSet<QString> dictionary = QtConcurrent::blockingFilteredReduced(strings, allLowerCase, addToDictionary);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    注意,上面的结果类型不是QFuture对象,而是真正的结果类型(在本例中是QStringList和QSet)。

    1. 并发过滤器

    QtConcurrent::filtered()接受一个输入序列和一个过滤函数。然后对序列中的每个项调用此筛选函数,并返回一个包含筛选值的新序列。

    过滤器函数必须是这样的:

    bool function(const T &t);
    
    • 1

    T必须匹配存储在序列中的类型。如果应该保留该项,则返回true;如果应该丢弃该项,则返回false。

    这个例子展示了如何从QStringList中保留所有小写的字符串:

      bool allLowerCase(const QString &string)
      {
          return string.lowered() == string;
      }
    
      QStringList strings = ...;
      QFuture<QString> lowerCaseStrings = QtConcurrent::filtered(strings, allLowerCase);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    过滤器的结果可以通过QFuture获得。有关如何在应用程序中使用QFuture的更多信息,请参阅QFuture和QFutureWatcher文档。

    如果你想就地修改一个序列,使用QtConcurrent::filter():

      QStringList strings = ...;
      QFuture<void> future = QtConcurrent::filter(strings, allLowerCase);
    
    • 1
    • 2

    由于序列被就地修改,QtConcurrent::filter()不会通过QFuture返回任何结果。但是,您仍然可以使用QFuture和QFutureWatcher来监视过滤器的状态。

    2. 并发Filter-Reduce

    QtConcurrent::filteredReduced()类似于QtConcurrent::filtered(),但不是返回一个包含过滤结果的序列,而是使用reduce函数将结果组合成一个值。

    reduce函数的形式必须是:

    V function(T &result, const U &intermediate)
    
    • 1

    T是最终结果的类型,U是被过滤项目的类型。注意,这里没有使用reduce函数的返回值和返回类型。

    像这样调用QtConcurrent::filteredReduced():

      void addToDictionary(QSet<QString> &dictionary, const QString &string)
      {
          dictionary.insert(string);
      }
    
      QStringList strings = ...;
      QFuture<QSet<QString> > dictionary = QtConcurrent::filteredReduced(strings, allLowerCase, addToDictionary);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    对于过滤器函数保存的每个结果,reduce函数将被调用一次,并且应该将中间结果合并到结果变量中。

    QtConcurrent::filteredReduced()保证一次只有一个线程调用reduce,所以没有必要使用互斥锁来锁定结果变量。ReduceOptions enum提供了一种方法来控制执行缩减的顺序。

    3. 其他API特性

    1. 使用迭代器而不是Sequence

    以上每个函数都有一个变体,它接受迭代器范围而不是序列。使用它们的方式与序列变体相同:

      QStringList strings = ...;
      QFuture<QString> lowerCaseStrings = QtConcurrent::filtered(strings.constBegin(), strings.constEnd(), allLowerCase);
    
      // filter in-place only works on non-const iterators
      QFuture<void> future = QtConcurrent::filter(strings.begin(), strings.end(), allLowerCase);
    
      QFuture<QSet<QString> > dictionary = QtConcurrent::filteredReduced(strings.constBegin(), strings.constEnd(), allLowerCase, addToDictionary);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    2. 使用成员函数

    QtConcurrent::filter()、QtConcurrent::filtered()和QtConcurrent::filteredReduced()接受指向成员函数的指针。成员函数类类型必须与序列中存储的类型匹配:

      // keep only images with an alpha channel
      QList<QImage> images = ...;
      QFuture<void> alphaImages = QtConcurrent::filter(images, &QImage::hasAlphaChannel);
    
      // retrieve gray scale images
      QList<QImage> images = ...;
      QFuture<QImage> grayscaleImages = QtConcurrent::filtered(images, &QImage::isGrayscale);
    
      // create a set of all printable characters
      QList<QChar> characters = ...;
      QFuture<QSet<QChar> > set = QtConcurrent::filteredReduced(characters, &QChar::isPrint, &QSet<QChar>::insert);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    注意,当使用QtConcurrent::filteredReduced()时,你可以自由地混合使用普通函数和成员函数:

      // can mix normal functions and member functions with QtConcurrent::filteredReduced()
    
      // create a dictionary of all lower cased strings
      extern bool allLowerCase(const QString &string);
      QStringList strings = ...;
      QFuture<QSet<int> > averageWordLength = QtConcurrent::filteredReduced(strings, allLowerCase, QSet<QString>::insert);
    
      // create a collage of all gray scale images
      extern void addToCollage(QImage &collage, const QImage &grayscaleImage);
      QList<QImage> images = ...;
      QFuture<QImage> collage = QtConcurrent::filteredReduced(images, &QImage::isGrayscale, addToCollage);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    3. 使用函数对象

    QtConcurrent::filter()、QtConcurrent::filtered()和QtConcurrent::filteredReduced()接受过滤函数的函数对象。这些函数对象可用于向函数调用添加状态。result_type typepedef必须定义函数调用操作符的结果类型:

      struct StartsWith
      {
          StartsWith(const QString &string)
          : m_string(string) { }
    
          typedef bool result_type;
    
          bool operator()(const QString &testString)
          {
              return testString.startsWith(m_string);
          }
    
          QString m_string;
      };
    
      QList<QString> strings = ...;
      QFuture<QString> fooString = QtConcurrent::filtered(strings, StartsWith(QLatin1String("Foo")));
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    对于reduce函数,不直接支持函数对象。但是,当显式指定了缩减结果的类型时,可以使用函数对象:

      struct StringTransform
      {
          void operator()(QString &result, const QString &value);
      };
    
      QFuture<QString> fooString =
        QtConcurrent::filteredReduced<QString>(strings,
                                               StartsWith(QLatin1String("Foo")),
                                               StringTransform());
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    4. 包装接受多个参数的函数

    如果您想使用接受多个参数的过滤器函数,可以使用lambda函数或std::bind()将其转换为接受一个参数的函数。
    作为一个例子,我们使用QString::contains():

    bool QString::contains(const QRegularExpression &regexp) const;
    
    • 1

    QString::contains()接受2个参数(包括“this”指针),不能直接与QtConcurrent::filtered()一起使用,因为QtConcurrent::filtered()期望一个函数接受一个参数。要将QString::contains()与QtConcurrent::filtered()一起使用,我们必须为regexp参数提供一个值:

      QStringList strings = ...;
      
      QFuture<QString> future = QtConcurrent::filtered(list, [](const QString &str) {
          return str.contains(QRegularExpression("^\\S+$")); // matches strings without whitespace
      });
    
    • 1
    • 2
    • 3
    • 4
    • 5

    四、Concurrent Run

    QtConcurrent::run()函数在一个单独的线程中运行一个函数。函数的返回值可以通过QFuture API获得。

    这个函数是Qt Concurrent框架的一部分。

    1. 在单独的线程中运行函数

    要在另一个线程中运行一个函数,使用QtConcurrent::run():

      extern void aFunction();
      QFuture<void> future = QtConcurrent::run(aFunction);
    
    • 1
    • 2

    这将在从默认QThreadPool获得的单独线程中运行function。您可以使用QFuture和QFutureWatcher类来监视函数的状态。

    要使用专用线程池,你可以将QThreadPool作为第一个参数:

      extern void aFunction();
      QThreadPool pool;
      QFuture<void> future = QtConcurrent::run(&pool, aFunction);
    
    • 1
    • 2
    • 3

    2. 向函数传递参数

    向函数传递参数是通过将参数添加到函数名后面的QtConcurrent::run()调用中来完成的。例如:

      extern void aFunctionWithArguments(int arg1, double arg2, const QString &string);
    
      int integer = ...;
      double floatingPoint = ...;
      QString string = ...;
    
      QFuture<void> future = QtConcurrent::run(aFunctionWithArguments, integer, floatingPoint, string);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    在调用QtConcurrent::run()时生成每个参数的副本,并在线程开始执行函数时将这些值传递给线程。

    调用QtConcurrent::run()后对参数所做的更改对线程是不可见的。

    3. 从函数返回值

    函数的任何返回值都可以通过QFuture获得:

      extern QString functionReturningAString();
      QFuture<QString> future = QtConcurrent::run(functionReturningAString);
      ...
      QString result = future.result();
    
    • 1
    • 2
    • 3
    • 4

    如上所述,传递参数是这样做的:

      extern QString someFunction(const QByteArray &input);
    
      QByteArray bytearray = ...;
    
      QFuture<QString> future = QtConcurrent::run(someFunction, bytearray);
      ...
      QString result = future.result();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    注意,QFuture::result()函数阻塞并等待结果可用。当函数完成执行并且结果可用时,使用QFutureWatcher获取通知。

    4. 其他API特性

    1. 使用成员函数

    QtConcurrent::run()也接受成员函数的指针。第一个实参必须是const引用或指向类实例的指针。在调用const成员函数时,通过const引用传递是有用的;指针传递对于调用修改实例的非const成员函数很有用。

    例如,在单独的线程中调用QByteArray::split()(一个const成员函数)是这样做的:

      // call 'QList  QByteArray::split(char sep) const' in a separate thread
      QByteArray bytearray = "hello world";
      QFuture<QList<QByteArray> > future = QtConcurrent::run(bytearray, &QByteArray::split, ',');
      ...
      QList<QByteArray> result = future.result();
    
    • 1
    • 2
    • 3
    • 4
    • 5

    调用非const成员函数是这样做的:

      // call 'void QImage::invertPixels(InvertMode mode)' in a separate thread
      QImage image = ...;
      QFuture<void> future = QtConcurrent::run(&image, &QImage::invertPixels, QImage::InvertRgba);
      ...
      future.waitForFinished();
      // At this point, the pixels in 'image' have been inverted
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    2. 使用Lambda函数

    调用lambda函数是这样做的:

      QFuture<void> future = QtConcurrent::run([=]() {
          // Code in this block will run in another thread
      });
      ...
    
    • 1
    • 2
    • 3
    • 4
  • 相关阅读:
    C++(17):使用visit访问variant
    短视频不火怎么办?加上配音试试看|教你制作最近超火的配音旁白
    雷神MixBook Air笔记本系统故障怎么重装?
    基于jsp+mysql+ssm协同办公系统-计算机毕业设计
    静态代码块和代码块的执行顺序解说
    使用 millis() 函数作为延迟的替代方法(电位器控制延迟时间)
    ECMAScript 2021 (es2020)
    Open CASCADE学习|视图
    1688API接入说明(商品详情数据示例)
    qt设计界面的属性编辑器不见了,如何恢复显示
  • 原文地址:https://blog.csdn.net/qq_43680827/article/details/133955717